diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a584fe01..ddba0daf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,8 +112,10 @@ jobs: -p cdk-lnbits, -p cdk-fake-wallet, -p cdk-payment-processor, - -p cdk-mint-rpc, + -p cdk-ldk-node, + -p cdk-signatory, + -p cdk-mint-rpc, # Binaries --bin cdk-cli, diff --git a/.github/workflows/nutshell_itest.yml b/.github/workflows/nutshell_itest.yml index 4e331d89..bac6ae65 100644 --- a/.github/workflows/nutshell_itest.yml +++ b/.github/workflows/nutshell_itest.yml @@ -10,6 +10,16 @@ jobs: steps: - name: checkout uses: actions/checkout@v4 + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 - name: Nix Cache @@ -29,6 +39,16 @@ jobs: steps: - name: checkout uses: actions/checkout@v4 + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Pull Nutshell Docker image run: docker pull cashubtc/nutshell:latest - name: Install Nix diff --git a/CHANGELOG.md b/CHANGELOG.md index 985daf14..f36bbc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ - cdk-integration-tests: Shared utilities module for common integration test functionality ([thesimplekid]). - cdk-redb: Database migration to increment keyset counters by 1 for existing keysets with counter > 0 ([thesimplekid]). - cdk-sql-common: Database migration to increment keyset counters by 1 for existing keysets with counter > 0 ([thesimplekid]). +- cdk-ldk-node: New Lightning backend implementation using LDK Node for improved Lightning Network functionality ([thesimplekid]). +- cdk-common: Added `start()` and `stop()` methods to `MintPayment` trait for payment processor lifecycle management ([thesimplekid]). +- cdk-mintd: Added LDK Node backend support with comprehensive configuration options ([thesimplekid]). ### Changed - cdk-common: Modified `Database::get_keyset_counter` trait method to return `u32` instead of `Option` for simpler keyset counter handling ([thesimplekid]). @@ -31,6 +34,8 @@ - cdk: Enhanced keyset management with better offline/online operation separation ([thesimplekid]). - cdk: Updated method documentation to clarify storage vs network operations ([thesimplekid]). - cdk: Refactored invoice payment monitoring to use centralized lifecycle management instead of manual task spawning ([thesimplekid]). +- cdk: Enhanced mint startup to initialize payment processors before starting background services ([thesimplekid]). +- cdk: Improved mint shutdown to gracefully stop payment processors alongside background services ([thesimplekid]). - cdk-mintd: Updated to use new mint lifecycle methods for improved service management ([thesimplekid]). - cdk-integration-tests: Updated test utilities to use new mint lifecycle management ([thesimplekid]). - cdk-sqlite: Introduce `cdk-sql-common` crate for shared SQL storage codebase ([crodas]). diff --git a/Cargo.toml b/Cargo.toml index 62c534db..3049fe22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0 cdk-cln = { path = "./crates/cdk-cln", version = "=0.11.0" } cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.11.0" } cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.11.0" } +cdk-ldk-node = { path = "./crates/cdk-ldk-node", version = "=0.11.0" } cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.11.0" } cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.11.0" } cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.11.0" } @@ -62,15 +63,18 @@ cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.11.0", default-features clap = { version = "4.5.31", features = ["derive"] } ciborium = { version = "0.2.2", default-features = false, features = ["std"] } cbor-diag = "0.1.12" +config = { version = "0.15.11", features = ["toml"] } futures = { version = "0.3.28", default-features = false, features = ["async-await"] } lightning-invoice = { version = "0.33.0", features = ["serde", "std"] } lightning = { version = "0.1.2", default-features = false, features = ["std"]} +ldk-node = "0.6.2" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = { version = "2" } tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync"] } tokio-util = { version = "0.7.11", default-features = false } -tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace"] } +tower = "0.5.2" +tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace", "fs"] } tokio-tungstenite = { version = "0.26.0", default-features = false } tokio-stream = "0.1.15" tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } diff --git a/README.md b/README.md index e04d41b7..191679cc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The project is split up into several crates in the `crates/` directory: * [**cdk-cln**](./crates/cdk-cln/): CLN Lightning backend for mint. * [**cdk-lnd**](./crates/cdk-lnd/): Lnd Lightning backend for mint. * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. **Note: Only LNBits v1 API is supported.** + * [**cdk-ldk-node**](./crates/cdk-ldk-node/): LDK Node Lightning backend for mint. * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled. * [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli. * Binaries: @@ -35,6 +36,22 @@ The project is split up into several crates in the `crates/` directory: For a guide to settings up a development environment see [DEVELOPMENT.md](./DEVELOPMENT.md) +## LDK Node Network Configuration + +For detailed configuration examples for running CDK with LDK Node on different Bitcoin networks (Mutinynet, Testnet, Mainnet), see [LDK Node Network Guide](./crates/cdk-ldk-node/NETWORK_GUIDE.md). + +**Quick Start with Mutinynet (Recommended for Testing):** +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" +esplora_url = "https://mutinynet.com/api" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +gossip_source_type = "rgs" +``` + ## Implemented [NUTs](https://github.com/cashubtc/nuts/): ### Mandatory diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 1605bb57..66e142bd 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -647,11 +647,7 @@ impl MintPayment for Cln { .await .map_err(Error::from)? } - PaymentIdentifier::CustomId(_) => { - tracing::error!("Unsupported payment id for CLN"); - return Err(payment::Error::UnknownPaymentState); - } - PaymentIdentifier::Bolt12PaymentHash(_) => { + _ => { tracing::error!("Unsupported payment id for CLN"); return Err(payment::Error::UnknownPaymentState); } diff --git a/crates/cdk-common/src/database/mod.rs b/crates/cdk-common/src/database/mod.rs index ccf13797..2860feb6 100644 --- a/crates/cdk-common/src/database/mod.rs +++ b/crates/cdk-common/src/database/mod.rs @@ -106,6 +106,9 @@ pub enum Error { /// Amount overflow #[error("Amount overflow")] AmountOverflow, + /// Amount zero + #[error("Amount zero")] + AmountZero, /// DHKE error #[error(transparent)] diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index b1c7e27e..0009bcc6 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -542,6 +542,11 @@ impl From for ErrorResponse { error: Some(err.to_string()), detail: None, }, + Error::UnpaidQuote => ErrorResponse { + code: ErrorCode::QuoteNotPaid, + error: Some(err.to_string()), + detail: None + }, _ => ErrorResponse { code: ErrorCode::Unknown(9999), error: Some(err.to_string()), diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index c9c7a920..afd07ee2 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -91,6 +91,8 @@ pub enum PaymentIdentifier { PaymentHash([u8; 32]), /// Bolt12 payment hash Bolt12PaymentHash([u8; 32]), + /// Payment id + PaymentId([u8; 32]), /// Custom Payment ID CustomId(String), } @@ -112,6 +114,11 @@ impl PaymentIdentifier { .map_err(|_| Error::InvalidHash)?, )), "custom" => Ok(Self::CustomId(identifier.to_string())), + "payment_id" => Ok(Self::PaymentId( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), _ => Err(Error::UnsupportedPaymentOption), } } @@ -123,6 +130,7 @@ impl PaymentIdentifier { Self::OfferId(_) => "offer_id".to_string(), Self::PaymentHash(_) => "payment_hash".to_string(), Self::Bolt12PaymentHash(_) => "bolt12_payment_hash".to_string(), + Self::PaymentId(_) => "payment_id".to_string(), Self::CustomId(_) => "custom".to_string(), } } @@ -135,6 +143,7 @@ impl std::fmt::Display for PaymentIdentifier { Self::OfferId(o) => write!(f, "{o}"), Self::PaymentHash(h) => write!(f, "{}", hex::encode(h)), Self::Bolt12PaymentHash(h) => write!(f, "{}", hex::encode(h)), + Self::PaymentId(h) => write!(f, "{}", hex::encode(h)), Self::CustomId(c) => write!(f, "{c}"), } } @@ -245,6 +254,20 @@ pub trait MintPayment { /// Mint Lightning Error type Err: Into + From; + /// Start the payment processor + /// Called when the mint starts up to initialize the payment processor + async fn start(&self) -> Result<(), Self::Err> { + // Default implementation - do nothing + Ok(()) + } + + /// Stop the payment processor + /// Called when the mint shuts down to gracefully stop the payment processor + async fn stop(&self) -> Result<(), Self::Err> { + // Default implementation - do nothing + Ok(()) + } + /// Base Settings async fn get_settings(&self) -> Result; diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index febe40ca..7a257603 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -23,12 +23,13 @@ cashu = { workspace = true, features = ["mint", "wallet"] } cdk = { workspace = true, features = ["mint", "wallet", "auth"] } cdk-cln = { workspace = true } cdk-lnd = { workspace = true } +cdk-ldk-node = { workspace = true } cdk-axum = { workspace = true, features = ["auth"] } cdk-sqlite = { workspace = true } cdk-redb = { workspace = true } cdk-fake-wallet = { workspace = true } cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] } -cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres"] } +cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node"] } futures = { workspace = true, default-features = false, features = [ "executor", ] } @@ -39,11 +40,13 @@ serde_json.workspace = true # ln-regtest-rs = { path = "../../../../ln-regtest-rs" } ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "df81424" } lightning-invoice.workspace = true +ldk-node.workspace = true tracing.workspace = true tracing-subscriber.workspace = true tokio-tungstenite.workspace = true tower-http = { workspace = true, features = ["cors"] } tower-service = "0.3.3" +tokio-util.workspace = true reqwest.workspace = true bitcoin = "0.32.0" clap = { workspace = true, features = ["derive"] } diff --git a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs index b0c22881..0fe84fd8 100644 --- a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs +++ b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs @@ -20,6 +20,7 @@ use cdk_integration_tests::shared; use cdk_mintd::config::AuthType; use clap::Parser; use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; #[derive(Parser)] #[command(name = "start-fake-auth-mint")] @@ -100,7 +101,8 @@ async fn start_fake_auth_mint( println!("Fake auth mint shutdown signal received"); }; - match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None).await + match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None, None) + .await { Ok(_) => println!("Fake auth mint exited normally"), Err(e) => eprintln!("Fake auth mint exited with error: {e}"), @@ -132,8 +134,10 @@ async fn main() -> Result<()> { ) .await?; + let cancel_token = Arc::new(CancellationToken::new()); + // Wait for fake auth mint to be ready - if let Err(e) = shared::wait_for_mint_ready(args.port, 100).await { + if let Err(e) = shared::wait_for_mint_ready_with_shutdown(args.port, 100, cancel_token).await { eprintln!("Error waiting for fake auth mint: {e}"); return Err(e); } diff --git a/crates/cdk-integration-tests/src/bin/start_fake_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs index d5bad2fe..89d25301 100644 --- a/crates/cdk-integration-tests/src/bin/start_fake_mint.rs +++ b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs @@ -19,6 +19,7 @@ use cdk_integration_tests::cli::CommonArgs; use cdk_integration_tests::shared; use clap::Parser; use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; #[derive(Parser)] #[command(name = "start-fake-mint")] @@ -99,7 +100,8 @@ async fn start_fake_mint( println!("Fake mint shutdown signal received"); }; - match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None).await + match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None, None) + .await { Ok(_) => println!("Fake mint exited normally"), Err(e) => eprintln!("Fake mint exited with error: {e}"), @@ -141,8 +143,10 @@ async fn main() -> Result<()> { ) .await?; + let cancel_token = Arc::new(CancellationToken::new()); + // Wait for fake mint to be ready - if let Err(e) = shared::wait_for_mint_ready(args.port, 100).await { + if let Err(e) = shared::wait_for_mint_ready_with_shutdown(args.port, 100, cancel_token).await { eprintln!("Error waiting for fake mint: {e}"); return Err(e); } diff --git a/crates/cdk-integration-tests/src/bin/start_regtest.rs b/crates/cdk-integration-tests/src/bin/start_regtest.rs index cec63d88..3ceec058 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest.rs @@ -6,9 +6,12 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use cashu::Amount; use cdk_integration_tests::cli::{init_logging, CommonArgs}; use cdk_integration_tests::init_regtest::start_regtest_end; +use cdk_ldk_node::CdkLdkNode; use clap::Parser; +use ldk_node::lightning::ln::msgs::SocketAddress; use tokio::signal; use tokio::sync::{oneshot, Notify}; use tokio::time::timeout; @@ -42,15 +45,40 @@ async fn main() -> Result<()> { init_logging(args.common.enable_logging, args.common.log_level); let temp_dir = PathBuf::from_str(&args.work_dir)?; - let temp_dir_clone = temp_dir.clone(); let shutdown_regtest = Arc::new(Notify::new()); let shutdown_clone = Arc::clone(&shutdown_regtest); let shutdown_clone_two = Arc::clone(&shutdown_regtest); + let ldk_work_dir = temp_dir.join("ldk_mint"); + let cdk_ldk = CdkLdkNode::new( + bitcoin::Network::Regtest, + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host: "127.0.0.1".to_string(), + port: 18443, + user: "testuser".to_string(), + password: "testpass".to_string(), + }), + cdk_ldk_node::GossipSource::P2P, + ldk_work_dir.to_string_lossy().to_string(), + cdk_common::common::FeeReserve { + min_fee_reserve: Amount::ZERO, + percent_fee_reserve: 0.0, + }, + vec![SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: 8092, + }], + None, + )?; + + let inner_node = cdk_ldk.node(); + + let temp_dir_clone = temp_dir.clone(); + let (tx, rx) = oneshot::channel(); tokio::spawn(async move { - start_regtest_end(&temp_dir_clone, tx, shutdown_clone) + start_regtest_end(&temp_dir_clone, tx, shutdown_clone, Some(inner_node)) .await .expect("Error starting regtest"); }); diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index ccef911b..5f6cca28 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -16,15 +16,22 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{bail, Result}; +use bip39::Mnemonic; +use cashu::Amount; use cdk_integration_tests::cli::CommonArgs; use cdk_integration_tests::init_regtest::start_regtest_end; use cdk_integration_tests::shared; +use cdk_ldk_node::CdkLdkNode; +use cdk_mintd::config::LoggingConfig; use clap::Parser; +use ldk_node::lightning::ln::msgs::SocketAddress; +use tokio::runtime::Runtime; +use tokio::signal; use tokio::signal::unix::SignalKind; -use tokio::signal::{self}; use tokio::sync::{oneshot, Notify}; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; #[derive(Parser)] #[command(name = "start-regtest-mints")] @@ -50,6 +57,10 @@ struct Args { /// LND port (default: 8087) #[arg(default_value_t = 8087)] lnd_port: u16, + + /// LDK port (default: 8089) + #[arg(default_value_t = 8089)] + ldk_port: u16, } /// Start regtest CLN mint using the library @@ -96,7 +107,8 @@ async fn start_cln_mint( println!("CLN mint shutdown signal received"); }; - match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None).await + match cdk_mintd::run_mintd_with_shutdown(&temp_dir, &settings, shutdown_future, None, None) + .await { Ok(_) => println!("CLN mint exited normally"), Err(e) => eprintln!("CLN mint exited with error: {e}"), @@ -154,8 +166,14 @@ async fn start_lnd_mint( println!("LND mint shutdown signal received"); }; - match cdk_mintd::run_mintd_with_shutdown(&lnd_work_dir, &settings, shutdown_future, None) - .await + match cdk_mintd::run_mintd_with_shutdown( + &lnd_work_dir, + &settings, + shutdown_future, + None, + None, + ) + .await { Ok(_) => println!("LND mint exited normally"), Err(e) => eprintln!("LND mint exited with error: {e}"), @@ -165,141 +183,336 @@ async fn start_lnd_mint( Ok(handle) } -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); +/// Start regtest LDK mint using the library +async fn start_ldk_mint( + temp_dir: &Path, + port: u16, + shutdown: Arc, + runtime: Option>, +) -> Result> { + let ldk_work_dir = temp_dir.join("ldk_mint"); - // Initialize logging based on CLI arguments - shared::setup_logging(&args.common); + // Create work directory for LDK mint + fs::create_dir_all(&ldk_work_dir)?; - let temp_dir = shared::init_working_directory(&args.work_dir)?; + // Configure LDK node for regtest + let ldk_config = cdk_mintd::config::LdkNode { + fee_percent: 0.0, + reserve_fee_min: 0.into(), + bitcoin_network: Some("regtest".to_string()), + // Use bitcoind RPC for regtest + chain_source_type: Some("bitcoinrpc".to_string()), + bitcoind_rpc_host: Some("127.0.0.1".to_string()), + bitcoind_rpc_port: Some(18443), + bitcoind_rpc_user: Some("testuser".to_string()), + bitcoind_rpc_password: Some("testpass".to_string()), + esplora_url: None, + storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()), + ldk_node_host: Some("127.0.0.1".to_string()), + ldk_node_port: Some(port + 10), // Use a different port for the LDK node P2P connections + gossip_source_type: None, + rgs_url: None, + webserver_host: Some("127.0.0.1".to_string()), + webserver_port: Some(port + 1), // Use next port for web interface + }; - // Write environment variables to a .env file in the temp_dir - let mint_url_1 = format!("http://{}:{}", args.mint_addr, args.cln_port); - let mint_url_2 = format!("http://{}:{}", args.mint_addr, args.lnd_port); - let env_vars: Vec<(&str, &str)> = vec![ - ("CDK_TEST_MINT_URL", &mint_url_1), - ("CDK_TEST_MINT_URL_2", &mint_url_2), - ]; + // Create settings struct for LDK mint using a new shared function + let settings = create_ldk_settings(port, ldk_config, Mnemonic::generate(12)?.to_string()); - shared::write_env_file(&temp_dir, &env_vars)?; + println!("Starting LDK mintd on port {port}"); - // Start regtest - println!("Starting regtest..."); + let ldk_work_dir = ldk_work_dir.clone(); + let shutdown_clone = shutdown.clone(); - let shutdown_regtest = shared::create_shutdown_handler(); - let shutdown_clone = shutdown_regtest.clone(); - let (tx, rx) = oneshot::channel(); + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("LDK mint shutdown signal received"); + }; - let shutdown_clone_one = Arc::clone(&shutdown_clone); - - let temp_dir_clone = temp_dir.clone(); - tokio::spawn(async move { - start_regtest_end(&temp_dir_clone, tx, shutdown_clone_one) - .await - .expect("Error starting regtest"); + match cdk_mintd::run_mintd_with_shutdown( + &ldk_work_dir, + &settings, + shutdown_future, + None, + runtime, + ) + .await + { + Ok(_) => println!("LDK mint exited normally"), + Err(e) => eprintln!("LDK mint exited with error: {e}"), + } }); - match timeout(Duration::from_secs(300), rx).await { - Ok(_) => { - tracing::info!("Regtest set up"); - } - Err(_) => { - tracing::error!("regtest setup timed out after 5 minutes"); - anyhow::bail!("Could not set up regtest"); - } - } - - // Start CLN mint - let cln_handle = start_cln_mint(&temp_dir, args.cln_port, shutdown_clone.clone()).await?; - - // Wait for CLN mint to be ready - if let Err(e) = shared::wait_for_mint_ready(args.cln_port, 100).await { - eprintln!("Error waiting for CLN mint: {e}"); - return Err(e); - } - - // Start LND mint - let lnd_handle = start_lnd_mint(&temp_dir, args.lnd_port, shutdown_clone.clone()).await?; - - // Wait for LND mint to be ready - if let Err(e) = shared::wait_for_mint_ready(args.lnd_port, 100).await { - eprintln!("Error waiting for LND mint: {e}"); - return Err(e); - } - - println!("All regtest mints started successfully!"); - println!("CLN mint: http://{}:{}", args.mint_addr, args.cln_port); - println!("LND mint: http://{}:{}", args.mint_addr, args.lnd_port); - shared::display_mint_info(args.cln_port, &temp_dir, &args.database_type); // Using CLN port for display - println!(); - println!("Environment variables set:"); - println!( - " CDK_TEST_MINT_URL=http://{}:{}", - args.mint_addr, args.cln_port - ); - println!( - " CDK_TEST_MINT_URL_2=http://{}:{}", - args.mint_addr, args.lnd_port - ); - println!(" CDK_ITESTS_DIR={}", temp_dir.display()); - println!(); - println!("You can now run integration tests with:"); - println!(" cargo test -p cdk-integration-tests --test regtest"); - println!(" cargo test -p cdk-integration-tests --test happy_path_mint_wallet"); - println!(" etc."); - println!(); - - println!("Press Ctrl+C to stop the mints..."); - - // Create a future to wait for either Ctrl+C signal or unexpected mint termination - let shutdown_future = async { - // Wait for either SIGINT (Ctrl+C) or SIGTERM - let mut sigterm = signal::unix::signal(SignalKind::terminate()) - .expect("Failed to create SIGTERM signal handler"); - tokio::select! { - _ = signal::ctrl_c() => { - tracing::info!("Received SIGINT (Ctrl+C), shutting down mints..."); - } - _ = sigterm.recv() => { - tracing::info!("Received SIGTERM, shutting down mints..."); - } - } - println!("\nShutdown signal received, shutting down mints..."); - shutdown_clone.notify_waiters(); - }; - - // Monitor mint handles for unexpected termination - let monitor_mints = async { - loop { - if cln_handle.is_finished() { - println!("CLN mint finished unexpectedly"); - return; - } - if lnd_handle.is_finished() { - println!("LND mint finished unexpectedly"); - return; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }; - - // Wait for either shutdown signal or mint termination - tokio::select! { - _ = shutdown_future => { - println!("Shutdown signal received, waiting for mints to stop..."); - } - _ = monitor_mints => { - println!("One or more mints terminated unexpectedly"); - } - } - - // Wait for mints to finish gracefully - if let Err(e) = tokio::try_join!(cln_handle, lnd_handle) { - eprintln!("Error waiting for mints to shut down: {e}"); - } - - println!("All services shut down successfully"); - - Ok(()) + Ok(handle) +} + +/// Create settings for an LDK mint +fn create_ldk_settings( + port: u16, + ldk_config: cdk_mintd::config::LdkNode, + mnemonic: String, +) -> cdk_mintd::config::Settings { + cdk_mintd::config::Settings { + info: cdk_mintd::config::Info { + url: format!("http://127.0.0.1:{port}"), + listen_host: "127.0.0.1".to_string(), + listen_port: port, + mnemonic: Some(mnemonic), + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cdk_axum::cache::Config::default(), + enable_swagger_ui: None, + logging: LoggingConfig::default(), + }, + mint_info: cdk_mintd::config::MintInfo::default(), + ln: cdk_mintd::config::Ln { + ln_backend: cdk_mintd::config::LnBackend::LdkNode, + invoice_description: None, + min_mint: 1.into(), + max_mint: 500_000.into(), + min_melt: 1.into(), + max_melt: 500_000.into(), + }, + cln: None, + lnbits: None, + lnd: None, + ldk_node: Some(ldk_config), + fake_wallet: None, + grpc_processor: None, + database: cdk_mintd::config::Database::default(), + mint_management_rpc: None, + auth: None, + } +} + +fn main() -> Result<()> { + let rt = Arc::new(Runtime::new()?); + + let rt_clone = Arc::clone(&rt); + + rt.block_on(async { + let args = Args::parse(); + + // Initialize logging based on CLI arguments + shared::setup_logging(&args.common); + + let temp_dir = shared::init_working_directory(&args.work_dir)?; + + // Write environment variables to a .env file in the temp_dir + let mint_url_1 = format!("http://{}:{}", args.mint_addr, args.cln_port); + let mint_url_2 = format!("http://{}:{}", args.mint_addr, args.lnd_port); + let mint_url_3 = format!("http://{}:{}", args.mint_addr, args.ldk_port); + let env_vars: Vec<(&str, &str)> = vec![ + ("CDK_TEST_MINT_URL", &mint_url_1), + ("CDK_TEST_MINT_URL_2", &mint_url_2), + ("CDK_TEST_MINT_URL_3", &mint_url_3), + ]; + + shared::write_env_file(&temp_dir, &env_vars)?; + + // Start regtest + println!("Starting regtest..."); + + let shutdown_regtest = shared::create_shutdown_handler(); + let shutdown_clone = shutdown_regtest.clone(); + let (tx, rx) = oneshot::channel(); + + let shutdown_clone_one = Arc::clone(&shutdown_clone); + + let ldk_work_dir = temp_dir.join("ldk_mint"); + let cdk_ldk = CdkLdkNode::new( + bitcoin::Network::Regtest, + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host: "127.0.0.1".to_string(), + port: 18443, + user: "testuser".to_string(), + password: "testpass".to_string(), + }), + cdk_ldk_node::GossipSource::P2P, + ldk_work_dir.to_string_lossy().to_string(), + cdk_common::common::FeeReserve { + min_fee_reserve: Amount::ZERO, + percent_fee_reserve: 0.0, + }, + vec![SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: 8092, + }], + Some(Arc::clone(&rt_clone)), + )?; + + let inner_node = cdk_ldk.node(); + + let temp_dir_clone = temp_dir.clone(); + let shutdown_clone_two = Arc::clone(&shutdown_clone); + tokio::spawn(async move { + start_regtest_end(&temp_dir_clone, tx, shutdown_clone_two, Some(inner_node)) + .await + .expect("Error starting regtest"); + }); + + match timeout(Duration::from_secs(300), rx).await { + Ok(k) => { + k?; + tracing::info!("Regtest set up"); + } + Err(_) => { + tracing::error!("regtest setup timed out after 5 minutes"); + anyhow::bail!("Could not set up regtest"); + } + } + + println!("lnd port: {}", args.ldk_port); + + // Start LND mint + let lnd_handle = start_lnd_mint(&temp_dir, args.lnd_port, shutdown_clone.clone()).await?; + + // Start LDK mint + let ldk_handle = start_ldk_mint( + &temp_dir, + args.ldk_port, + shutdown_clone.clone(), + Some(rt_clone), + ) + .await?; + + // Start CLN mint + let cln_handle = start_cln_mint(&temp_dir, args.cln_port, shutdown_clone.clone()).await?; + + let cancel_token = Arc::new(CancellationToken::new()); + + // Set up Ctrl+C handler before waiting for mints to be ready + let ctrl_c_token = Arc::clone(&cancel_token); + + let s_u = shutdown_clone.clone(); + tokio::spawn(async move { + signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + tracing::info!("Shutdown signal received during mint setup"); + println!("\nReceived Ctrl+C, shutting down..."); + ctrl_c_token.cancel(); + s_u.notify_waiters(); + }); + + match tokio::try_join!( + shared::wait_for_mint_ready_with_shutdown( + args.lnd_port, + 100, + Arc::clone(&cancel_token) + ), + shared::wait_for_mint_ready_with_shutdown( + args.ldk_port, + 100, + Arc::clone(&cancel_token) + ), + shared::wait_for_mint_ready_with_shutdown( + args.cln_port, + 100, + Arc::clone(&cancel_token) + ), + ) { + Ok(_) => println!("All mints are ready!"), + Err(e) => { + if cancel_token.is_cancelled() { + bail!("Startup canceled by user"); + } + eprintln!("Error waiting for mints to be ready: {e}"); + return Err(e); + } + } + + if cancel_token.is_cancelled() { + bail!("Token canceled"); + } + + println!("All regtest mints started successfully!"); + println!("CLN mint: http://{}:{}", args.mint_addr, args.cln_port); + println!("LND mint: http://{}:{}", args.mint_addr, args.lnd_port); + println!("LDK mint: http://{}:{}", args.mint_addr, args.ldk_port); + shared::display_mint_info(args.cln_port, &temp_dir, &args.database_type); // Using CLN port for display + println!(); + println!("Environment variables set:"); + println!( + " CDK_TEST_MINT_URL=http://{}:{}", + args.mint_addr, args.cln_port + ); + println!( + " CDK_TEST_MINT_URL_2=http://{}:{}", + args.mint_addr, args.lnd_port + ); + println!( + " CDK_TEST_MINT_URL_3=http://{}:{}", + args.mint_addr, args.ldk_port + ); + println!(" CDK_ITESTS_DIR={}", temp_dir.display()); + println!(); + println!("You can now run integration tests with:"); + println!(" cargo test -p cdk-integration-tests --test regtest"); + println!(" cargo test -p cdk-integration-tests --test happy_path_mint_wallet"); + println!(" etc."); + println!(); + + println!("Press Ctrl+C to stop the mints..."); + + // Create a future to wait for either Ctrl+C signal or unexpected mint termination + let shutdown_future = async { + // Wait for either SIGINT (Ctrl+C) or SIGTERM + let mut sigterm = signal::unix::signal(SignalKind::terminate()) + .expect("Failed to create SIGTERM signal handler"); + tokio::select! { + _ = signal::ctrl_c() => { + tracing::info!("Received SIGINT (Ctrl+C), shutting down mints..."); + } + _ = sigterm.recv() => { + tracing::info!("Received SIGTERM, shutting down mints..."); + } + } + println!("\nShutdown signal received, shutting down mints..."); + shutdown_clone.notify_waiters(); + }; + + // Monitor mint handles for unexpected termination + let monitor_mints = async { + loop { + if cln_handle.is_finished() { + println!("CLN mint finished unexpectedly"); + return; + } + if lnd_handle.is_finished() { + println!("LND mint finished unexpectedly"); + return; + } + if ldk_handle.is_finished() { + println!("LDK mint finished unexpectedly"); + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + + // Wait for either shutdown signal or mint termination + tokio::select! { + _ = shutdown_clone_one.notified() => { + println!("Shutdown signal received, waiting for mints to stop..."); + } + _ = monitor_mints => { + println!("One or more mints terminated unexpectedly"); + } + _ = shutdown_future => () + } + + // Wait for mints to finish gracefully + if let Err(e) = tokio::try_join!(ldk_handle, cln_handle, lnd_handle) { + eprintln!("Error waiting for mints to shut down: {e}"); + } + + println!("All services shut down successfully"); + + Ok(()) + }) } diff --git a/crates/cdk-integration-tests/src/cli.rs b/crates/cdk-integration-tests/src/cli.rs index 22c5fdcd..0c2ea30e 100644 --- a/crates/cdk-integration-tests/src/cli.rs +++ b/crates/cdk-integration-tests/src/cli.rs @@ -24,7 +24,6 @@ pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { let default_filter = log_level.to_string(); // Common filters to reduce noise - let sqlx_filter = "sqlx=warn"; let hyper_filter = "hyper=warn"; let h2_filter = "h2=warn"; let rustls_filter = "rustls=warn"; @@ -32,7 +31,7 @@ pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { let tower_filter = "tower_http=warn"; let env_filter = EnvFilter::new(format!( - "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter},{tower_filter}" + "{default_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter},{tower_filter}" )); // Ok if successful, Err if already initialized diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 538bd869..faa61b58 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -1,4 +1,5 @@ use std::env; +use std::net::Ipv4Addr; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -6,6 +7,8 @@ use anyhow::Result; use cdk::types::FeeReserve; use cdk_cln::Cln as CdkCln; use cdk_lnd::Lnd as CdkLnd; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::Node; use ln_regtest_rs::bitcoin_client::BitcoinClient; use ln_regtest_rs::bitcoind::Bitcoind; use ln_regtest_rs::cln::Clnd; @@ -145,6 +148,9 @@ pub async fn init_lnd( pub fn generate_block(bitcoin_client: &BitcoinClient) -> Result<()> { let mine_to_address = bitcoin_client.get_new_address()?; + let blocks = 10; + tracing::info!("Mining {blocks} blocks to {mine_to_address}"); + bitcoin_client.generate_blocks(&mine_to_address, 10)?; Ok(()) @@ -225,6 +231,7 @@ pub async fn start_regtest_end( work_dir: &Path, sender: Sender<()>, notify: Arc, + ldk_node: Option>, ) -> anyhow::Result<()> { let mut bitcoind = init_bitcoind(work_dir); bitcoind.start_bitcoind()?; @@ -285,6 +292,13 @@ pub async fn start_regtest_end( lnd_client.wait_chain_sync().await.unwrap(); + if let Some(node) = ldk_node.as_ref() { + tracing::info!("Starting ldk node"); + node.start()?; + let addr = node.onchain_payment().new_address().unwrap(); + bitcoin_client.send_to_address(&addr.to_string(), 5_000_000)?; + } + fund_ln(&bitcoin_client, &lnd_client).await.unwrap(); // create second lnd node @@ -336,12 +350,108 @@ pub async fn start_regtest_end( tracing::info!("Opened channel between cln and lnd two"); generate_block(&bitcoin_client)?; - cln_client.wait_channels_active().await?; - cln_two_client.wait_channels_active().await?; - lnd_client.wait_channels_active().await?; - lnd_two_client.wait_channels_active().await?; + if let Some(node) = ldk_node { + let pubkey = node.node_id(); + let listen_addr = node.listening_addresses(); + let listen_addr = listen_addr.as_ref().unwrap().first().unwrap(); + + let (listen_addr, port) = match listen_addr { + SocketAddress::TcpIpV4 { addr, port } => (Ipv4Addr::from(*addr).to_string(), port), + _ => panic!(), + }; + + tracing::info!("Opening channel from cln to ldk"); + + cln_client + .connect_peer(pubkey.to_string(), listen_addr.clone(), *port) + .await?; + + cln_client + .open_channel(1_500_000, &pubkey.to_string(), Some(750_000)) + .await + .unwrap(); + + generate_block(&bitcoin_client)?; + + let cln_two_info = cln_two_client.get_connect_info().await?; + + cln_client + .connect_peer(cln_two_info.pubkey, listen_addr.clone(), cln_two_info.port) + .await?; + + tracing::info!("Opening channel from lnd to ldk"); + + let cln_info = cln_client.get_connect_info().await?; + + node.connect( + cln_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: cln_info + .address + .split('.') + .map(|part| part.parse()) + .collect::, _>>()? + .try_into() + .unwrap(), + port: cln_info.port, + }, + true, + )?; + + let lnd_info = lnd_client.get_connect_info().await?; + + node.connect( + lnd_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: lnd_info.port, + }, + true, + )?; + + // lnd_client + // .open_channel(1_500_000, &pubkey.to_string(), Some(750_000)) + // .await + // .unwrap(); + + generate_block(&bitcoin_client)?; + lnd_client.wait_chain_sync().await?; + + node.open_announced_channel( + lnd_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: lnd_info.port, + }, + 1_000_000, + Some(500_000_000), + None, + )?; + + generate_block(&bitcoin_client)?; + + tracing::info!("Ldk channels opened"); + + node.sync_wallets()?; + + tracing::info!("Ldk wallet synced"); + + cln_client.wait_channels_active().await?; + + lnd_client.wait_channels_active().await?; + + node.stop()?; + } else { + cln_client.wait_channels_active().await?; + + lnd_client.wait_channels_active().await?; + + generate_block(&bitcoin_client)?; + } } + tracing::info!("Regtest channels active"); + // Send notification that regtest set up is complete sender.send(()).expect("Could not send oneshot"); diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 2f4dc70d..675e9402 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -17,7 +17,7 @@ //! - Proof state management utilities use std::env; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, bail, Result}; @@ -27,9 +27,11 @@ use cdk::nuts::State; use cdk::Wallet; use cdk_fake_wallet::create_fake_invoice; use init_regtest::{get_lnd_dir, LND_RPC_ADDR}; -use ln_regtest_rs::ln_client::{LightningClient, LndClient}; +use ln_regtest_rs::ln_client::{ClnClient, LightningClient, LndClient}; use tokio::time::Duration; +use crate::init_regtest::get_cln_dir; + pub mod cli; pub mod init_auth_mint; pub mod init_pure_tests; @@ -118,11 +120,18 @@ pub async fn init_lnd_client(work_dir: &Path) -> LndClient { /// /// This is useful for tests that need to pay invoices in regtest mode but /// should be skipped in other environments. -pub async fn pay_if_regtest(work_dir: &Path, invoice: &Bolt11Invoice) -> Result<()> { +pub async fn pay_if_regtest(_work_dir: &Path, invoice: &Bolt11Invoice) -> Result<()> { // Check if the invoice is for the regtest network if invoice.network() == bitcoin::Network::Regtest { - let lnd_client = init_lnd_client(work_dir).await; - lnd_client.pay_invoice(invoice.to_string()).await?; + let client = get_test_client().await; + let mut tries = 0; + while let Err(err) = client.pay_invoice(invoice.to_string()).await { + println!("Could not pay invoice.retrying {err}"); + tries += 1; + if tries > 10 { + bail!("Could not pay invoice"); + } + } Ok(()) } else { // Not a regtest invoice, just return Ok @@ -149,11 +158,10 @@ pub fn is_regtest_env() -> bool { /// /// Uses the is_regtest_env() function to determine whether to /// create a real regtest invoice or a fake one for testing. -pub async fn create_invoice_for_env(work_dir: &Path, amount_sat: Option) -> Result { +pub async fn create_invoice_for_env(amount_sat: Option) -> Result { if is_regtest_env() { - // In regtest mode, create a real invoice - let lnd_client = init_lnd_client(work_dir).await; - lnd_client + let client = get_test_client().await; + client .create_invoice(amount_sat) .await .map_err(|e| anyhow!("Failed to create regtest invoice: {}", e)) @@ -166,3 +174,80 @@ pub async fn create_invoice_for_env(work_dir: &Path, amount_sat: Option) -> Ok(fake_invoice.to_string()) } } + +// This is the ln wallet we use to send/receive ln payements as the wallet +async fn _get_lnd_client() -> LndClient { + let temp_dir = get_work_dir(); + + // The LND mint uses the second LND node (LND_TWO_RPC_ADDR = localhost:10010) + let lnd_dir = get_lnd_dir(&temp_dir, "one"); + let cert_file = lnd_dir.join("tls.cert"); + let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); + + println!("Looking for LND cert file: {cert_file:?}"); + println!("Looking for LND macaroon file: {macaroon_file:?}"); + println!("Connecting to LND at: https://{LND_RPC_ADDR}"); + + // Connect to LND + LndClient::new( + format!("https://{LND_RPC_ADDR}"), + cert_file.clone(), + macaroon_file.clone(), + ) + .await + .expect("Could not connect to lnd rpc") +} + +/// Returns a Lightning client based on the CDK_TEST_LIGHTNING_CLIENT environment variable. +/// +/// Reads the CDK_TEST_LIGHTNING_CLIENT environment variable: +/// - "cln" or "CLN": returns a CLN client +/// - Anything else (or unset): returns an LND client (default) +pub async fn get_test_client() -> Box { + match env::var("CDK_TEST_LIGHTNING_CLIENT") { + Ok(val) => { + let val = val.to_lowercase(); + match val.as_str() { + "cln" => Box::new(create_cln_client_with_retry().await), + _ => Box::new(_get_lnd_client().await), + } + } + Err(_) => Box::new(_get_lnd_client().await), // Default to LND + } +} + +fn get_work_dir() -> PathBuf { + match env::var("CDK_ITESTS_DIR") { + Ok(dir) => { + let path = PathBuf::from(dir); + println!("Using temp directory from CDK_ITESTS_DIR: {path:?}"); + path + } + Err(_) => { + panic!("Unknown temp dir"); + } + } +} + +// Helper function to create CLN client with retries +async fn create_cln_client_with_retry() -> ClnClient { + let mut retries = 0; + let max_retries = 10; + + let cln_dir = get_cln_dir(&get_work_dir(), "one"); + loop { + match ClnClient::new(cln_dir.clone(), None).await { + Ok(client) => return client, + Err(e) => { + retries += 1; + if retries >= max_retries { + panic!("Could not connect to CLN client after {max_retries} retries: {e}"); + } + println!( + "Failed to connect to CLN (attempt {retries}/{max_retries}): {e}. Retrying in 7 seconds..." + ); + tokio::time::sleep(tokio::time::Duration::from_secs(7)).await; + } + } + } +} diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 900e36a0..85efef28 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -14,6 +14,7 @@ use cdk_axum::cache; use cdk_mintd::config::{Database, DatabaseEngine}; use tokio::signal; use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; use crate::cli::{init_logging, CommonArgs}; @@ -26,8 +27,12 @@ const DEFAULT_MIN_MELT: u64 = 1; /// Default maximum melt amount for test mints const DEFAULT_MAX_MELT: u64 = 500_000; -/// Wait for mint to be ready by checking its info endpoint -pub async fn wait_for_mint_ready(port: u16, timeout_secs: u64) -> Result<()> { +/// Wait for mint to be ready by checking its info endpoint, with optional shutdown signal +pub async fn wait_for_mint_ready_with_shutdown( + port: u16, + timeout_secs: u64, + shutdown_notify: Arc, +) -> Result<()> { let url = format!("http://127.0.0.1:{port}/v1/info"); let start_time = std::time::Instant::now(); @@ -39,26 +44,43 @@ pub async fn wait_for_mint_ready(port: u16, timeout_secs: u64) -> Result<()> { return Err(anyhow::anyhow!("Timeout waiting for mint on port {}", port)); } - // Try to make a request to the mint info endpoint - match reqwest::get(&url).await { - Ok(response) => { - if response.status().is_success() { - println!("Mint on port {port} is ready"); - return Ok(()); - } else { - println!( - "Mint on port {} returned status: {}", - port, - response.status() - ); - } - } - Err(e) => { - println!("Error connecting to mint on port {port}: {e}"); - } + if shutdown_notify.is_cancelled() { + return Err(anyhow::anyhow!("Canceled waiting for {}", port)); } - tokio::time::sleep(Duration::from_secs(2)).await; + tokio::select! { + // Try to make a request to the mint info endpoint + result = reqwest::get(&url) => { + match result { + Ok(response) => { + if response.status().is_success() { + println!("Mint on port {port} is ready"); + return Ok(()); + } else { + println!( + "Mint on port {} returned status: {}", + port, + response.status() + ); + } + } + Err(e) => { + println!("Error connecting to mint on port {port}: {e}"); + } + } + } + + // Check for shutdown signal + _ = shutdown_notify.cancelled() => { + return Err(anyhow::anyhow!( + "Shutdown requested while waiting for mint on port {}", + port + )); + } + + + + } } } @@ -187,6 +209,7 @@ pub fn create_fake_wallet_settings( cln: None, lnbits: None, lnd: None, + ldk_node: None, fake_wallet: fake_wallet_config, grpc_processor: None, database: Database { @@ -234,6 +257,7 @@ pub fn create_cln_settings( cln: Some(cln_config), lnbits: None, lnd: None, + ldk_node: None, fake_wallet: None, grpc_processor: None, database: cdk_mintd::config::Database::default(), @@ -276,6 +300,7 @@ pub fn create_lnd_settings( }, cln: None, lnbits: None, + ldk_node: None, lnd: Some(lnd_config), fake_wallet: None, grpc_processor: None, diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 8a900a80..68e34027 100644 --- a/crates/cdk-integration-tests/tests/bolt12.rs +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -385,3 +385,66 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_attempt_to_mint_unpaid() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + let state = wallet + .mint_bolt12_quote_state(&mint_quote.id) + .await + .unwrap(); + + assert!(state.amount_paid == Amount::ZERO); + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } +} 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 63046092..1372c09d 100644 --- a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs +++ b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs @@ -123,9 +123,7 @@ async fn test_happy_mint_melt_round_trip() { assert!(mint_amount == 100.into()); - let invoice = create_invoice_for_env(&get_test_temp_dir(), Some(50)) - .await - .unwrap(); + let invoice = create_invoice_for_env(Some(50)).await.unwrap(); let melt = wallet.melt_quote(invoice, None).await.unwrap(); @@ -371,9 +369,7 @@ async fn test_fake_melt_change_in_quote() { .await .unwrap(); - let invoice = create_invoice_for_env(&get_test_temp_dir(), Some(9)) - .await - .unwrap(); + let invoice = create_invoice_for_env(Some(9)).await.unwrap(); let proofs = wallet.get_unspent_proofs().await.unwrap(); @@ -447,9 +443,7 @@ async fn test_pay_invoice_twice() { assert_eq!(mint_amount, 100.into()); - let invoice = create_invoice_for_env(&get_test_temp_dir(), Some(25)) - .await - .unwrap(); + let invoice = create_invoice_for_env(Some(25)).await.unwrap(); let melt_quote = wallet.melt_quote(invoice.clone(), None).await.unwrap(); diff --git a/crates/cdk-integration-tests/tests/ldk_node.rs b/crates/cdk-integration-tests/tests/ldk_node.rs new file mode 100644 index 00000000..51f5327c --- /dev/null +++ b/crates/cdk-integration-tests/tests/ldk_node.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use cdk_integration_tests::get_mint_url_from_env; + +#[tokio::test] +async fn test_ldk_node_mint_info() -> Result<()> { + // This test just verifies that the LDK-Node mint is running and responding + let mint_url = get_mint_url_from_env(); + + // Create an HTTP client + let client = reqwest::Client::new(); + + // Make a request to the info endpoint + let response = client.get(&format!("{}/v1/info", mint_url)).send().await?; + + // Check that we got a successful response + assert_eq!(response.status(), 200); + + // Try to parse the response as JSON + let info: serde_json::Value = response.json().await?; + + // Verify that we got some basic fields + assert!(info.get("name").is_some()); + assert!(info.get("version").is_some()); + assert!(info.get("description").is_some()); + + println!("LDK-Node mint info: {:?}", info); + + Ok(()) +} + +#[tokio::test] +async fn test_ldk_node_mint_quote() -> Result<()> { + // This test verifies that we can create a mint quote with the LDK-Node mint + let mint_url = get_mint_url_from_env(); + + // Create an HTTP client + let client = reqwest::Client::new(); + + // Create a mint quote request + let quote_request = serde_json::json!({ + "amount": 1000, + "unit": "sat" + }); + + // Make a request to create a mint quote + let response = client + .post(&format!("{}/v1/mint/quote/bolt11", mint_url)) + .json("e_request) + .send() + .await?; + + // Print the response for debugging + let status = response.status(); + let text = response.text().await?; + println!("Mint quote response status: {}", status); + println!("Mint quote response body: {}", text); + + // For now, we'll just check that we get a response (even if it's an error) + // In a real test, we'd want to verify the quote was created correctly + assert!(status.is_success() || status.as_u16() < 500); + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index fad09655..0061423c 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -13,8 +13,6 @@ //! - Requires properly configured LND nodes with TLS certificates and macaroons //! - Uses real Bitcoin transactions in regtest mode -use std::env; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -26,49 +24,16 @@ use cdk::nuts::{ NotificationPayload, PreMintSecrets, }; use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription}; -use cdk_integration_tests::init_regtest::{get_lnd_dir, LND_RPC_ADDR}; -use cdk_integration_tests::{get_mint_url_from_env, get_second_mint_url_from_env}; +use cdk_integration_tests::{get_mint_url_from_env, get_second_mint_url_from_env, get_test_client}; use cdk_sqlite::wallet::{self, memory}; use futures::join; -use ln_regtest_rs::ln_client::{LightningClient, LndClient}; use tokio::time::timeout; -// This is the ln wallet we use to send/receive ln payements as the wallet -async fn init_lnd_client() -> LndClient { - // Try to get the temp directory from environment variable first (from .env file) - let temp_dir = match env::var("CDK_ITESTS_DIR") { - Ok(dir) => { - let path = PathBuf::from(dir); - println!("Using temp directory from CDK_ITESTS_DIR: {:?}", path); - path - } - Err(_) => { - panic!("Unknown temp dir"); - } - }; - - // The LND mint uses the second LND node (LND_TWO_RPC_ADDR = localhost:10010) - let lnd_dir = get_lnd_dir(&temp_dir, "one"); - let cert_file = lnd_dir.join("tls.cert"); - let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); - - println!("Looking for LND cert file: {:?}", cert_file); - println!("Looking for LND macaroon file: {:?}", macaroon_file); - println!("Connecting to LND at: https://{}", LND_RPC_ADDR); - - // Connect to LND - LndClient::new( - format!("https://{}", LND_RPC_ADDR), - cert_file.clone(), - macaroon_file.clone(), - ) - .await - .expect("Could not connect to lnd rpc") -} +const LDK_URL: &str = "http://127.0.0.1:8089"; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_internal_payment() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), @@ -81,7 +46,7 @@ async fn test_internal_payment() { let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - lnd_client + ln_client .pay_invoice(mint_quote.request.clone()) .await .expect("failed to pay invoice"); @@ -208,8 +173,8 @@ async fn test_websocket_connection() { _ => panic!("Unexpected notification type"), } - let lnd_client = init_lnd_client().await; - lnd_client + let ln_client = get_test_client().await; + ln_client .pay_invoice(mint_quote.request) .await .expect("failed to pay invoice"); @@ -231,7 +196,11 @@ async fn test_websocket_connection() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_multimint_melt() { - let lnd_client = init_lnd_client().await; + if get_mint_url_from_env() == LDK_URL { + return; + } + + let ln_client = get_test_client().await; let db = Arc::new(memory::empty().await.unwrap()); let wallet1 = Wallet::new( @@ -257,7 +226,7 @@ async fn test_multimint_melt() { // Fund the wallets let quote = wallet1.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); @@ -271,7 +240,7 @@ async fn test_multimint_melt() { .unwrap(); let quote = wallet2.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); @@ -285,7 +254,7 @@ async fn test_multimint_melt() { .unwrap(); // Get an invoice - let invoice = lnd_client.create_invoice(Some(50)).await.unwrap(); + let invoice = ln_client.create_invoice(Some(50)).await.unwrap(); // Get multi-part melt quotes let melt_options = MeltOptions::Mpp { @@ -318,7 +287,7 @@ async fn test_multimint_melt() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_cached_mint() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, @@ -331,7 +300,7 @@ async fn test_cached_mint() { let mint_amount = Amount::from(100); let quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); @@ -366,7 +335,7 @@ async fn test_cached_mint() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_regtest_melt_amountless() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), @@ -383,7 +352,7 @@ async fn test_regtest_melt_amountless() { assert_eq!(mint_quote.amount, Some(mint_amount)); - lnd_client + ln_client .pay_invoice(mint_quote.request) .await .expect("failed to pay invoice"); @@ -397,7 +366,7 @@ async fn test_regtest_melt_amountless() { assert!(mint_amount == amount); - let invoice = lnd_client.create_invoice(None).await.unwrap(); + let invoice = ln_client.create_invoice(None).await.unwrap(); let options = MeltOptions::new_amountless(5_000); @@ -410,3 +379,57 @@ async fn test_regtest_melt_amountless() { assert!(melt.amount == 5.into()); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_attempt_to_mint_unpaid() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } + + let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); + + let state = wallet.mint_quote_state(&mint_quote.id).await.unwrap(); + + assert!(state.state == MintQuoteState::Unpaid); + + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } +} diff --git a/crates/cdk-integration-tests/tests/test_fees.rs b/crates/cdk-integration-tests/tests/test_fees.rs index a2352f6c..458c3495 100644 --- a/crates/cdk-integration-tests/tests/test_fees.rs +++ b/crates/cdk-integration-tests/tests/test_fees.rs @@ -108,9 +108,7 @@ async fn test_fake_melt_change_in_quote() { let invoice_amount = 9; - let invoice = create_invoice_for_env(&get_temp_dir(), Some(invoice_amount)) - .await - .unwrap(); + let invoice = create_invoice_for_env(Some(invoice_amount)).await.unwrap(); let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); diff --git a/crates/cdk-ldk-node/Cargo.toml b/crates/cdk-ldk-node/Cargo.toml new file mode 100644 index 00000000..94e3ca0b --- /dev/null +++ b/crates/cdk-ldk-node/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cdk-ldk-node" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK ln backend for cdk-ldk-node" +readme = "README.md" + +[dependencies] +async-trait.workspace = true +axum.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true +ldk-node.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +serde.workspace = true +serde_json.workspace = true +maud = "0.27.0" +tower.workspace = true +tower-http.workspace = true +rust-embed = "8.5.0" +serde_urlencoded = "0.7" +urlencoding = "2.1" + + + diff --git a/crates/cdk-ldk-node/NETWORK_GUIDE.md b/crates/cdk-ldk-node/NETWORK_GUIDE.md new file mode 100644 index 00000000..50488db4 --- /dev/null +++ b/crates/cdk-ldk-node/NETWORK_GUIDE.md @@ -0,0 +1,165 @@ +# LDK Node Network Configuration Guide + +This guide provides configuration examples for running CDK LDK Node on different Bitcoin networks. + +## Table of Contents + +- [Mutinynet (Recommended for Testing)](#mutinynet-recommended-for-testing) +- [Bitcoin Testnet](#bitcoin-testnet) +- [Bitcoin Mainnet](#bitcoin-mainnet) +- [Regtest (Development)](#regtest-development) +- [Docker Deployment](#docker-deployment) +- [Troubleshooting](#troubleshooting) + +## Mutinynet (Recommended for Testing) + +**Mutinynet** is a Bitcoin signet-based test network designed specifically for Lightning Network development with fast block times and reliable infrastructure. + +### Configuration + +```toml +[info] +url = "http://127.0.0.1:8085/" +listen_host = "127.0.0.1" +listen_port = 8085 + +[database] +engine = "sqlite" + +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" +chain_source_type = "esplora" +esplora_url = "https://mutinynet.com/api" +gossip_source_type = "rgs" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +storage_dir_path = "~/.cdk-ldk-node/mutinynet" +webserver_port = 8091 +``` + +### Environment Variables + +```bash +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="signet" +export CDK_MINTD_LDK_NODE_ESPLORA_URL="https://mutinynet.com/api" +export CDK_MINTD_LDK_NODE_RGS_URL="https://rgs.mutinynet.com/snapshot/0" +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="rgs" + +cdk-mintd +``` + +### Resources +- **Explorer/Faucet**: +- **Esplora API**: `https://mutinynet.com/api` +- **RGS Endpoint**: `https://rgs.mutinynet.com/snapshot/0` + +## Bitcoin Testnet + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "testnet" +esplora_url = "https://blockstream.info/testnet/api" +rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +gossip_source_type = "rgs" +storage_dir_path = "~/.cdk-ldk-node/testnet" +``` + +**Resources**: [Explorer](https://blockstream.info/testnet) | API: `https://blockstream.info/testnet/api` + +## Bitcoin Mainnet + +⚠️ **WARNING**: Uses real Bitcoin! + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "mainnet" +esplora_url = "https://blockstream.info/api" +rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +gossip_source_type = "rgs" +storage_dir_path = "/var/lib/cdk-ldk-node/mainnet" # Use absolute path +webserver_host = "127.0.0.1" # CRITICAL: Never bind to 0.0.0.0 in production +webserver_port = 8091 +``` + +**Resources**: [Explorer](https://blockstream.info) | API: `https://blockstream.info/api` + +### Production Security + +🔒 **CRITICAL SECURITY CONSIDERATIONS**: + +1. **Web Interface Security**: The LDK management interface has **NO AUTHENTICATION** and allows sending funds/managing channels. + - **NEVER** bind to `0.0.0.0` or expose publicly + - Only use `127.0.0.1` (localhost) + - Use VPN, SSH tunneling, or reverse proxy with authentication for remote access + +## Regtest (Development) + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "regtest" +chain_source_type = "bitcoinrpc" +bitcoind_rpc_host = "127.0.0.1" +bitcoind_rpc_port = 18443 +bitcoind_rpc_user = "testuser" +bitcoind_rpc_password = "testpass" +gossip_source_type = "p2p" +``` + +For complete regtest environment: `just regtest` (see [REGTEST_GUIDE.md](../../REGTEST_GUIDE.md)) + +## Docker Deployment + +⚠️ **SECURITY WARNING**: The examples below expose ports for testing. For production, **DO NOT expose port 8091** publicly as the web interface has no authentication and allows sending funds. + +```bash +# Mutinynet example (testing only - web interface exposed) +docker run -d \ + --name cdk-mintd \ + -p 8085:8085 -p 8091:8091 \ + -e CDK_MINTD_LN_BACKEND=ldk-node \ + -e CDK_MINTD_LDK_NODE_BITCOIN_NETWORK=signet \ + -e CDK_MINTD_LDK_NODE_ESPLORA_URL=https://mutinynet.com/api \ + -e CDK_MINTD_LDK_NODE_RGS_URL=https://rgs.mutinynet.com/snapshot/0 \ + -e CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE=rgs \ + cashubtc/cdk-mintd:latest + +# Production example (web interface not exposed) +docker run -d \ + --name cdk-mintd \ + -p 8085:8085 \ + --network host \ + -e CDK_MINTD_LN_BACKEND=ldk-node \ + -e CDK_MINTD_LDK_NODE_BITCOIN_NETWORK=mainnet \ + -e CDK_MINTD_LDK_NODE_WEBSERVER_HOST=127.0.0.1 \ + cashubtc/cdk-mintd:latest +``` + +## Troubleshooting + +### Common Issues +- **RGS sync fails**: Try `gossip_source_type = "p2p"` +- **Connection errors**: Verify API endpoints with curl +- **Port conflicts**: Use `netstat -tuln` to check ports +- **Permissions**: Ensure storage directory is writable + +### Debug Logging +```bash +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +``` + +### Performance Tips +- Use RGS for faster gossip sync +- PostgreSQL for production +- Monitor initial sync resources diff --git a/crates/cdk-ldk-node/README.md b/crates/cdk-ldk-node/README.md new file mode 100644 index 00000000..db7dcaa0 --- /dev/null +++ b/crates/cdk-ldk-node/README.md @@ -0,0 +1,84 @@ +# CDK LDK Node + +CDK lightning backend for ldk-node, providing Lightning Network functionality for CDK with support for Cashu operations. + +## Features + +- Lightning Network payments (Bolt11 and Bolt12) +- Channel management +- Payment processing for Cashu mint operations +- Web management interface +- Support for multiple Bitcoin networks (Mainnet, Testnet, Signet/Mutinynet, Regtest) +- RGS (Rapid Gossip Sync) and P2P gossip support + +## Quick Start + +### Mutinynet (Recommended for Testing) + +```bash +# Using environment variables (simplest) +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="signet" +export CDK_MINTD_LDK_NODE_ESPLORA_URL="https://mutinynet.com/api" +export CDK_MINTD_LDK_NODE_RGS_URL="https://rgs.mutinynet.com/snapshot/0" +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="rgs" + +cdk-mintd +``` + +After starting: +- Mint API: +- LDK management interface: +- Get test sats: [mutinynet.com](https://mutinynet.com) + +**For complete network configuration examples, Docker setup, and production deployment, see [NETWORK_GUIDE.md](./NETWORK_GUIDE.md).** + +## Web Management Interface + +The CDK LDK Node includes a built-in web management interface accessible at `http://127.0.0.1:8091` by default. + +⚠️ **SECURITY WARNING**: The web management interface has **NO AUTHENTICATION** and allows sending funds and managing channels. **NEVER expose it publicly** without proper authentication/authorization in front of it. Only bind to localhost (`127.0.0.1`) for security. + +### Key Features +- **Dashboard**: Node status, balance, and recent activity +- **Channel Management**: Open and close Lightning channels +- **Payment Management**: Create invoices, send payments, view history with pagination +- **On-chain Operations**: View balances and manage transactions + +### Configuration + +```toml +[ldk_node] +webserver_host = "127.0.0.1" # IMPORTANT: Only localhost for security +webserver_port = 8091 # 0 = auto-assign port +``` + +Or via environment variables: +- `CDK_MINTD_LDK_NODE_WEBSERVER_HOST` +- `CDK_MINTD_LDK_NODE_WEBSERVER_PORT` + +## Basic Configuration + +### Config File Example + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" # mainnet, testnet, signet, regtest +esplora_url = "https://mutinynet.com/api" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +gossip_source_type = "rgs" # rgs or p2p +webserver_port = 8091 +``` + +### Environment Variables + +All options can be set with `CDK_MINTD_LDK_NODE_` prefix: +- `CDK_MINTD_LDK_NODE_BITCOIN_NETWORK` +- `CDK_MINTD_LDK_NODE_ESPLORA_URL` +- `CDK_MINTD_LDK_NODE_RGS_URL` +- `CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE` + +**For detailed network configurations, Docker setup, production deployment, and troubleshooting, see [NETWORK_GUIDE.md](./NETWORK_GUIDE.md).** diff --git a/crates/cdk-ldk-node/src/error.rs b/crates/cdk-ldk-node/src/error.rs new file mode 100644 index 00000000..4464d278 --- /dev/null +++ b/crates/cdk-ldk-node/src/error.rs @@ -0,0 +1,89 @@ +//! LDK Node Errors + +use thiserror::Error; + +/// LDK Node Error +#[derive(Debug, Error)] +pub enum Error { + /// LDK Node error + #[error("LDK Node error: {0}")] + LdkNode(#[from] ldk_node::NodeError), + + /// LDK Build error + #[error("LDK Build error: {0}")] + LdkBuild(#[from] ldk_node::BuildError), + + /// Invalid description + #[error("Invalid description")] + InvalidDescription, + + /// Invalid payment hash + #[error("Invalid payment hash")] + InvalidPaymentHash, + + /// Invalid payment hash length + #[error("Invalid payment hash length")] + InvalidPaymentHashLength, + + /// Invalid payment ID length + #[error("Invalid payment ID length")] + InvalidPaymentIdLength, + + /// Unknown invoice amount + #[error("Unknown invoice amount")] + UnknownInvoiceAmount, + + /// Could not send bolt11 payment + #[error("Could not send bolt11 payment")] + CouldNotSendBolt11, + + /// Could not send bolt11 without amount + #[error("Could not send bolt11 without amount")] + CouldNotSendBolt11WithoutAmount, + + /// Payment not found + #[error("Payment not found")] + PaymentNotFound, + + /// Could not get amount spent + #[error("Could not get amount spent")] + CouldNotGetAmountSpent, + + /// Could not get payment amount + #[error("Could not get payment amount")] + CouldNotGetPaymentAmount, + + /// Unexpected payment kind + #[error("Unexpected payment kind")] + UnexpectedPaymentKind, + + /// Unsupported payment identifier type + #[error("Unsupported payment identifier type")] + UnsupportedPaymentIdentifierType, + + /// Invalid payment direction + #[error("Invalid payment direction")] + InvalidPaymentDirection, + + /// Hex decode error + #[error("Hex decode error: {0}")] + HexDecode(#[from] cdk_common::util::hex::Error), + + /// JSON error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Amount conversion error + #[error("Amount conversion error: {0}")] + AmountConversion(#[from] cdk_common::amount::Error), + + /// Invalid hex + #[error("Invalid hex")] + InvalidHex, +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-ldk-node/src/lib.rs b/crates/cdk-ldk-node/src/lib.rs new file mode 100644 index 00000000..dde3b5ed --- /dev/null +++ b/crates/cdk-ldk-node/src/lib.rs @@ -0,0 +1,994 @@ +//! CDK lightning backend for ldk-node + +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use cdk_common::amount::to_unit; +use cdk_common::common::FeeReserve; +use cdk_common::payment::{self, *}; +use cdk_common::util::{hex, unix_time}; +use cdk_common::{Amount, CurrencyUnit, MeltOptions, MeltQuoteState}; +use futures::{Stream, StreamExt}; +use ldk_node::bitcoin::hashes::Hash; +use ldk_node::bitcoin::Network; +use ldk_node::lightning::ln::channelmanager::PaymentId; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; +use ldk_node::lightning_types::payment::PaymentHash; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus, SendingParameters}; +use ldk_node::{Builder, Event, Node}; +use tokio::runtime::Runtime; +use tokio_stream::wrappers::BroadcastStream; +use tokio_util::sync::CancellationToken; +use tracing::instrument; + +use crate::error::Error; + +mod error; +mod web; + +/// CDK Lightning backend using LDK Node +/// +/// Provides Lightning Network functionality for CDK with support for Cashu operations. +/// Handles payment creation, processing, and event management using the Lightning Development Kit. +#[derive(Clone)] +pub struct CdkLdkNode { + inner: Arc, + fee_reserve: FeeReserve, + wait_invoice_cancel_token: CancellationToken, + wait_invoice_is_active: Arc, + sender: tokio::sync::broadcast::Sender, + receiver: Arc>, + events_cancel_token: CancellationToken, + runtime: Option>, + web_addr: Option, +} + +/// Configuration for connecting to Bitcoin RPC +/// +/// Contains the necessary connection parameters for Bitcoin Core RPC interface. +#[derive(Debug, Clone)] +pub struct BitcoinRpcConfig { + /// Bitcoin RPC server hostname or IP address + pub host: String, + /// Bitcoin RPC server port number + pub port: u16, + /// Username for Bitcoin RPC authentication + pub user: String, + /// Password for Bitcoin RPC authentication + pub password: String, +} + +/// Source of blockchain data for the Lightning node +/// +/// Specifies how the node should connect to the Bitcoin network to retrieve +/// blockchain information and broadcast transactions. +#[derive(Debug, Clone)] +pub enum ChainSource { + /// Use an Esplora server for blockchain data + /// + /// Contains the URL of the Esplora server endpoint + Esplora(String), + /// Use Bitcoin Core RPC for blockchain data + /// + /// Contains the configuration for connecting to Bitcoin Core + BitcoinRpc(BitcoinRpcConfig), +} + +/// Source of Lightning network gossip data +/// +/// Specifies how the node should learn about the Lightning Network topology +/// and routing information. +#[derive(Debug, Clone)] +pub enum GossipSource { + /// Learn gossip through peer-to-peer connections + /// + /// The node will connect to other Lightning nodes and exchange gossip data directly + P2P, + /// Use Rapid Gossip Sync for efficient gossip updates + /// + /// Contains the URL of the RGS server for compressed gossip data + RapidGossipSync(String), +} + +impl CdkLdkNode { + /// Create a new CDK LDK Node instance + /// + /// # Arguments + /// * `network` - Bitcoin network (mainnet, testnet, regtest, signet) + /// * `chain_source` - Source of blockchain data (Esplora or Bitcoin RPC) + /// * `gossip_source` - Source of Lightning network gossip data + /// * `storage_dir_path` - Directory path for node data storage + /// * `fee_reserve` - Fee reserve configuration for payments + /// * `listening_address` - Socket addresses for peer connections + /// * `runtime` - Optional Tokio runtime to use for starting the node + /// + /// # Returns + /// A new `CdkLdkNode` instance ready to be started + /// + /// # Errors + /// Returns an error if the LDK node builder fails to create the node + pub fn new( + network: Network, + chain_source: ChainSource, + gossip_source: GossipSource, + storage_dir_path: String, + fee_reserve: FeeReserve, + listening_address: Vec, + runtime: Option>, + ) -> Result { + let mut builder = Builder::new(); + builder.set_network(network); + tracing::info!("Storage dir of node is {}", storage_dir_path); + builder.set_storage_dir_path(storage_dir_path); + + match chain_source { + ChainSource::Esplora(esplora_url) => { + builder.set_chain_source_esplora(esplora_url, None); + } + ChainSource::BitcoinRpc(BitcoinRpcConfig { + host, + port, + user, + password, + }) => { + builder.set_chain_source_bitcoind_rpc(host, port, user, password); + } + } + + match gossip_source { + GossipSource::P2P => { + builder.set_gossip_source_p2p(); + } + GossipSource::RapidGossipSync(rgs_url) => { + builder.set_gossip_source_rgs(rgs_url); + } + } + + builder.set_listening_addresses(listening_address)?; + + builder.set_node_alias("cdk-ldk-node".to_string())?; + + let node = builder.build()?; + + tracing::info!("Creating tokio channel for payment notifications"); + let (sender, receiver) = tokio::sync::broadcast::channel(8); + + let id = node.node_id(); + + let adr = node.announcement_addresses(); + + tracing::info!( + "Created node {} with address {:?} on network {}", + id, + adr, + network + ); + + Ok(Self { + inner: node.into(), + fee_reserve, + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + sender, + receiver: Arc::new(receiver), + events_cancel_token: CancellationToken::new(), + runtime, + web_addr: None, + }) + } + + /// Set the web server address for the LDK node management interface + /// + /// # Arguments + /// * `addr` - Socket address for the web server. If None, no web server will be started. + pub fn set_web_addr(&mut self, addr: Option) { + self.web_addr = addr; + } + + /// Get a default web server address using an unused port + /// + /// Returns a SocketAddr with localhost and port 0, which will cause + /// the system to automatically assign an available port + pub fn default_web_addr() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 8091)) + } + + /// Start the CDK LDK Node + /// + /// Starts the underlying LDK node and begins event processing. + /// Sets up event handlers to listen for Lightning events like payment received. + /// + /// # Returns + /// Returns `Ok(())` on successful start, error otherwise + /// + /// # Errors + /// Returns an error if the LDK node fails to start or event handling setup fails + pub fn start_ldk_node(&self) -> Result<(), Error> { + match &self.runtime { + Some(runtime) => { + tracing::info!("Starting cdk-ldk node with existing runtime"); + self.inner.start_with_runtime(Arc::clone(runtime))? + } + None => { + tracing::info!("Starting cdk-ldk-node with new runtime"); + self.inner.start()? + } + }; + let node_config = self.inner.config(); + + tracing::info!("Starting node with network {}", node_config.network); + + tracing::info!("Node status: {:?}", self.inner.status()); + + self.handle_events()?; + + Ok(()) + } + + /// Start the web server for the LDK node management interface + /// + /// Starts a web server that provides a user interface for managing the LDK node. + /// The web interface allows users to view balances, manage channels, create invoices, + /// and send payments. + /// + /// # Arguments + /// * `web_addr` - The socket address to bind the web server to + /// + /// # Returns + /// Returns `Ok(())` on successful start, error otherwise + /// + /// # Errors + /// Returns an error if the web server fails to start + pub fn start_web_server(&self, web_addr: SocketAddr) -> Result<(), Error> { + let web_server = crate::web::WebServer::new(Arc::new(self.clone())); + + tokio::spawn(async move { + if let Err(e) = web_server.serve(web_addr).await { + tracing::error!("Web server error: {}", e); + } + }); + + Ok(()) + } + + /// Stop the CDK LDK Node + /// + /// Gracefully stops the node by cancelling all active tasks and event handlers. + /// This includes: + /// - Cancelling the event handler task + /// - Cancelling any active wait_invoice streams + /// - Stopping the underlying LDK node + /// + /// # Returns + /// Returns `Ok(())` on successful shutdown, error otherwise + /// + /// # Errors + /// Returns an error if the underlying LDK node fails to stop + pub fn stop_ldk_node(&self) -> Result<(), Error> { + tracing::info!("Stopping CdkLdkNode"); + // Cancel all tokio tasks + tracing::info!("Cancelling event handler"); + self.events_cancel_token.cancel(); + + // Cancel any wait_invoice streams + if self.is_wait_invoice_active() { + tracing::info!("Cancelling wait_invoice stream"); + self.wait_invoice_cancel_token.cancel(); + } + + // Stop the LDK node + tracing::info!("Stopping LDK node"); + self.inner.stop()?; + tracing::info!("CdkLdkNode stopped successfully"); + Ok(()) + } + + /// Handle payment received event + async fn handle_payment_received( + node: &Arc, + sender: &tokio::sync::broadcast::Sender, + payment_id: Option, + payment_hash: PaymentHash, + amount_msat: u64, + ) { + tracing::info!( + "Received payment for hash={} of amount={} msat", + payment_hash, + amount_msat + ); + + let payment_id = match payment_id { + Some(id) => id, + None => { + tracing::warn!("Received payment without payment_id"); + return; + } + }; + + let payment_id_hex = hex::encode(payment_id.0); + + if amount_msat == 0 { + tracing::warn!("Payment of no amount"); + return; + } + + tracing::info!( + "Processing payment notification: id={}, amount={} msats", + payment_id_hex, + amount_msat + ); + + let payment_details = match node.payment(&payment_id) { + Some(details) => details, + None => { + tracing::error!("Could not find payment details for id={}", payment_id_hex); + return; + } + }; + + let (payment_identifier, payment_id) = match payment_details.kind { + PaymentKind::Bolt11 { hash, .. } => { + (PaymentIdentifier::PaymentHash(hash.0), hash.to_string()) + } + PaymentKind::Bolt12Offer { hash, offer_id, .. } => match hash { + Some(h) => ( + PaymentIdentifier::OfferId(offer_id.to_string()), + h.to_string(), + ), + None => { + tracing::error!("Bolt12 payment missing hash"); + return; + } + }, + k => { + tracing::warn!("Received payment of kind {:?} which is not supported", k); + return; + } + }; + + let wait_payment_response = WaitPaymentResponse { + payment_identifier, + payment_amount: amount_msat.into(), + unit: CurrencyUnit::Msat, + payment_id, + }; + + match sender.send(wait_payment_response) { + Ok(_) => tracing::info!("Successfully sent payment notification to stream"), + Err(err) => tracing::error!( + "Could not send payment received notification on channel: {}", + err + ), + } + } + + /// Set up event handling for the node + pub fn handle_events(&self) -> Result<(), Error> { + let node = self.inner.clone(); + let sender = self.sender.clone(); + let cancel_token = self.events_cancel_token.clone(); + + tracing::info!("Starting event handler task"); + + tokio::spawn(async move { + tracing::info!("Event handler loop started"); + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + tracing::info!("Event handler cancelled"); + break; + } + event = node.next_event_async() => { + match event { + Event::PaymentReceived { + payment_id, + payment_hash, + amount_msat, + custom_records: _ + } => { + Self::handle_payment_received( + &node, + &sender, + payment_id, + payment_hash, + amount_msat + ).await; + } + event => { + tracing::debug!("Received other ldk node event: {:?}", event); + } + } + + if let Err(err) = node.event_handled() { + tracing::error!("Error handling node event: {}", err); + } else { + tracing::debug!("Successfully handled node event"); + } + } + } + } + tracing::info!("Event handler loop terminated"); + }); + + tracing::info!("Event handler task spawned"); + Ok(()) + } + + /// Get Node used + pub fn node(&self) -> Arc { + Arc::clone(&self.inner) + } +} + +/// Mint payment trait +#[async_trait] +impl MintPayment for CdkLdkNode { + type Err = payment::Error; + + /// Start the payment processor + /// Starts the LDK node and begins event processing + async fn start(&self) -> Result<(), Self::Err> { + self.start_ldk_node().map_err(|e| { + tracing::error!("Failed to start CdkLdkNode: {}", e); + e + })?; + + tracing::info!("CdkLdkNode payment processor started successfully"); + + // Start web server if configured + if let Some(web_addr) = self.web_addr { + tracing::info!("Starting LDK Node web interface on {}", web_addr); + self.start_web_server(web_addr).map_err(|e| { + tracing::error!("Failed to start web server: {}", e); + e + })?; + } else { + tracing::info!("No web server address configured, skipping web interface"); + } + + Ok(()) + } + + /// Stop the payment processor + /// Gracefully stops the LDK node and cancels all background tasks + async fn stop(&self) -> Result<(), Self::Err> { + self.stop_ldk_node().map_err(|e| { + tracing::error!("Failed to stop CdkLdkNode: {}", e); + e.into() + }) + } + + /// Base Settings + async fn get_settings(&self) -> Result { + let settings = Bolt11Settings { + mpp: false, + unit: CurrencyUnit::Msat, + invoice_description: true, + amountless: true, + bolt12: true, + }; + Ok(serde_json::to_value(settings)?) + } + + /// Create a new invoice + #[instrument(skip(self))] + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = to_unit(bolt11_options.amount, unit, &CurrencyUnit::Msat)?; + let description = bolt11_options.description.unwrap_or_default(); + let time = bolt11_options + .unix_expiry + .map(|t| t - unix_time()) + .unwrap_or(36000); + + let description = Bolt11InvoiceDescription::Direct( + Description::new(description).map_err(|_| Error::InvalidDescription)?, + ); + + let payment = self + .inner + .bolt11_payment() + .receive(amount_msat.into(), &description, time as u32) + .map_err(Error::LdkNode)?; + + let payment_hash = payment.payment_hash().to_string(); + let payment_identifier = PaymentIdentifier::PaymentHash( + hex::decode(&payment_hash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHashLength)?, + ); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: payment.to_string(), + expiry: Some(unix_time() + time), + }) + } + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry, + } = *bolt12_options; + + let time = unix_expiry.map(|t| (t - unix_time()) as u32); + + let offer = match amount { + Some(amount) => { + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; + + self.inner + .bolt12_payment() + .receive( + amount_msat.into(), + &description.unwrap_or("".to_string()), + time, + None, + ) + .map_err(Error::LdkNode)? + } + None => self + .inner + .bolt12_payment() + .receive_variable_amount(&description.unwrap_or("".to_string()), time) + .map_err(Error::LdkNode)?, + }; + let payment_identifier = PaymentIdentifier::OfferId(offer.id().to_string()); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: offer.to_string(), + expiry: time.map(|a| a as u64), + }) + } + } + } + + /// Get payment quote + /// Used to get fee and amount required for a payment request + #[instrument(skip_all)] + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + + let amount_msat = match bolt11_options.melt_options { + Some(melt_options) => melt_options.amount_msat(), + None => bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; + + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + let payment_hash = bolt11.payment_hash().to_string(); + let payment_hash_bytes = hex::decode(&payment_hash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHashLength)?; + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash(payment_hash_bytes)), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat = match bolt12_options.melt_options { + Some(melt_options) => melt_options.amount_msat(), + None => { + let amount = offer.amount().ok_or(payment::Error::AmountMismatch)?; + + match amount { + ldk_node::lightning::offers::offer::Amount::Bitcoin { + amount_msats, + } => amount_msats.into(), + _ => return Err(payment::Error::AmountMismatch), + } + } + }; + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + Ok(PaymentQuoteResponse { + request_lookup_id: None, + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + } + } + + /// Pay request + #[instrument(skip(self, options))] + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + + let send_params = match bolt11_options + .max_fee_amount + .map(|f| { + to_unit(f, unit, &CurrencyUnit::Msat).map(|amount_msat| SendingParameters { + max_total_routing_fee_msat: Some(Some(amount_msat.into())), + max_channel_saturation_power_of_half: None, + max_total_cltv_expiry_delta: None, + max_path_count: None, + }) + }) + .transpose() + { + Ok(params) => params, + Err(err) => { + tracing::error!("Failed to convert fee amount: {}", err); + return Err(payment::Error::Custom(format!("Invalid fee amount: {err}"))); + } + }; + + let payment_id = match bolt11_options.melt_options { + Some(MeltOptions::Amountless { amountless }) => self + .inner + .bolt11_payment() + .send_using_amount(&bolt11, amountless.amount_msat.into(), send_params) + .map_err(|err| { + tracing::error!("Could not send send amountless bolt11: {}", err); + Error::CouldNotSendBolt11WithoutAmount + })?, + None => self + .inner + .bolt11_payment() + .send(&bolt11, send_params) + .map_err(|err| { + tracing::error!("Could not send bolt11 {}", err); + Error::CouldNotSendBolt11 + })?, + _ => return Err(payment::Error::UnsupportedPaymentOption), + }; + + // Check payment status for up to 10 seconds + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let (status, payment_details) = loop { + let details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + match details.status { + PaymentStatus::Succeeded => break (MeltQuoteState::Paid, details), + PaymentStatus::Failed => { + tracing::error!("Failed to pay bolt11 payment."); + break (MeltQuoteState::Failed, details); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Paying bolt11 exceeded timeout 10 seconds no longer waitning." + ); + break (MeltQuoteState::Pending, details); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)?; + + let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + bolt11.payment_hash().to_byte_array(), + ), + payment_proof, + status, + total_spent, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let payment_id = match bolt12_options.melt_options { + Some(MeltOptions::Amountless { amountless }) => self + .inner + .bolt12_payment() + .send_using_amount(&offer, amountless.amount_msat.into(), None, None) + .map_err(Error::LdkNode)?, + None => self + .inner + .bolt12_payment() + .send(&offer, None, None) + .map_err(Error::LdkNode)?, + _ => return Err(payment::Error::UnsupportedPaymentOption), + }; + + // Check payment status for up to 10 seconds + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let (status, payment_details) = loop { + let details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + match details.status { + PaymentStatus::Succeeded => break (MeltQuoteState::Paid, details), + PaymentStatus::Failed => { + tracing::error!("Payment with id {} failed.", payment_id); + break (MeltQuoteState::Failed, details); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Payment has been being for 10 seconds. No longer waiting" + ); + break (MeltQuoteState::Pending, details); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt12Offer { + hash: _, + preimage, + secret: _, + offer_id: _, + payer_note: _, + quantity: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)?; + + let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentId(payment_id.0), + payment_proof, + status, + total_spent, + unit: unit.clone(), + }) + } + } + } + + /// 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( + &self, + ) -> Result + Send>>, Self::Err> { + tracing::info!("Starting stream for invoices - wait_any_incoming_payment called"); + + // Set active flag to indicate stream is active + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + tracing::debug!("wait_invoice_is_active set to true"); + + let receiver = self.receiver.clone(); + + tracing::info!("Receiver obtained successfully, creating response stream"); + + // Transform the String stream into a WaitPaymentResponse stream + let response_stream = BroadcastStream::new(receiver.resubscribe()); + + // Map the stream to handle BroadcastStreamRecvError + let response_stream = response_stream.filter_map(|result| async move { + match result { + Ok(payment) => Some(payment), + Err(err) => { + tracing::warn!("Error in broadcast stream: {}", err); + None + } + } + }); + + // Create a combined stream that also handles cancellation + let cancel_token = self.wait_invoice_cancel_token.clone(); + let is_active = self.wait_invoice_is_active.clone(); + + let stream = Box::pin(response_stream); + + // Set up a task to clean up when the stream is dropped + tokio::spawn(async move { + cancel_token.cancelled().await; + tracing::info!("wait_invoice stream cancelled"); + is_active.store(false, Ordering::SeqCst); + }); + + tracing::info!("wait_any_incoming_payment returning stream"); + Ok(stream) + } + + /// Is wait invoice active + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + /// Cancel wait invoice + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + /// Check the status of an incoming payment + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let payment_id_str = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hex::encode(hash), + PaymentIdentifier::CustomId(id) => id.clone(), + _ => return Err(Error::UnsupportedPaymentIdentifierType.into()), + }; + + let payment_id = PaymentId( + hex::decode(&payment_id_str)? + .try_into() + .map_err(|_| Error::InvalidPaymentIdLength)?, + ); + + let payment_details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + if payment_details.direction == PaymentDirection::Outbound { + return Err(Error::InvalidPaymentDirection.into()); + } + + let amount = if payment_details.status == PaymentStatus::Succeeded { + payment_details + .amount_msat + .ok_or(Error::CouldNotGetPaymentAmount)? + } else { + return Ok(vec![]); + }; + + let response = WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: amount.into(), + unit: CurrencyUnit::Msat, + payment_id: payment_id_str, + }; + + Ok(vec![response]) + } + + /// Check the status of an outgoing payment + async fn check_outgoing_payment( + &self, + request_lookup_id: &PaymentIdentifier, + ) -> Result { + let payment_details = match request_lookup_id { + PaymentIdentifier::PaymentHash(id_hash) => self + .inner + .list_payments_with_filter( + |p| matches!(&p.kind, PaymentKind::Bolt11 { hash, .. } if &hash.0 == id_hash), + ) + .first() + .cloned(), + PaymentIdentifier::PaymentId(id) => self.inner.payment(&PaymentId( + hex::decode(id)? + .try_into() + .map_err(|_| payment::Error::Custom("Invalid hex".to_string()))?, + )), + _ => { + return Ok(MakePaymentResponse { + payment_lookup_id: request_lookup_id.clone(), + status: MeltQuoteState::Unknown, + payment_proof: None, + total_spent: Amount::ZERO, + unit: CurrencyUnit::Msat, + }); + } + } + .ok_or(Error::PaymentNotFound)?; + + // This check seems reversed in the original code, so I'm fixing it here + if payment_details.direction != PaymentDirection::Outbound { + return Err(Error::InvalidPaymentDirection.into()); + } + + let status = match payment_details.status { + PaymentStatus::Pending => MeltQuoteState::Pending, + PaymentStatus::Succeeded => MeltQuoteState::Paid, + PaymentStatus::Failed => MeltQuoteState::Failed, + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)?; + + Ok(MakePaymentResponse { + payment_lookup_id: request_lookup_id.clone(), + payment_proof, + status, + total_spent: total_spent.into(), + unit: CurrencyUnit::Msat, + }) + } +} + +impl Drop for CdkLdkNode { + fn drop(&mut self) { + tracing::info!("Drop called on CdkLdkNode"); + self.wait_invoice_cancel_token.cancel(); + tracing::debug!("Cancelled wait_invoice token in drop"); + } +} diff --git a/crates/cdk-ldk-node/src/web/handlers/channels.rs b/crates/cdk-ldk-node/src/web/handlers/channels.rs new file mode 100644 index 00000000..238f9365 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/channels.rs @@ -0,0 +1,515 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::UserChannelId; +use maud::html; +use serde::Deserialize; + +use crate::web::handlers::utils::deserialize_optional_u64; +use crate::web::handlers::AppState; +use crate::web::templates::{ + error_message, form_card, format_sats_as_btc, info_card, layout, success_message, +}; + +#[derive(Deserialize)] +pub struct OpenChannelForm { + node_id: String, + address: String, + port: u32, + amount_sats: u64, + #[serde(deserialize_with = "deserialize_optional_u64")] + push_btc: Option, +} + +#[derive(Deserialize)] +pub struct CloseChannelForm { + channel_id: String, + node_id: String, +} + +pub async fn channels_page(State(_state): State) -> Result { + // Redirect to the balance page since channels are now part of the Lightning section + Ok(Response::builder() + .status(StatusCode::FOUND) + .header("Location", "/balance") + .body(Body::empty()) + .unwrap()) +} + +pub async fn open_channel_page(State(_state): State) -> Result, StatusCode> { + let content = form_card( + "Open New Channel", + html! { + form method="post" action="/channels/open" { + div class="form-group" { + label for="node_id" { "Node Public Key" } + input type="text" id="node_id" name="node_id" required placeholder="02..." {} + } + div class="form-group" { + label for="address" { "Node Address" } + input type="text" id="address" name="address" required placeholder="127.0.0.1" {} + } + div class="form-group" { + label for="port" { "Port" } + input type="number" id="port" name="port" required value="9735" {} + } + div class="form-group" { + label for="amount_btc" { "Channel Size" } + input type="number" id="amount_sats" name="amount_sats" required placeholder="₿0" step="1" {} + } + div class="form-group" { + label for="push_btc" { "Push Amount (optional)" } + input type="number" id="push_btc" name="push_btc" placeholder="₿0" step="1" {} + } + button type="submit" { "Open Channel" } + " " + a href="/balance" { button type="button" { "Cancel" } } + } + }, + ); + + Ok(Html(layout("Open Channel", content).into_string())) +} + +pub async fn post_open_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to open channel to node_id={}, address={}:{}, amount_sats={}, push_btc={:?}", + form.node_id, + form.address, + form.port, + form.amount_sats, + form.push_btc + ); + + let pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!("Web interface: Invalid node public key provided: {}", e); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Open Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + let socket_addr = match SocketAddress::from_str(&format!("{}:{}", form.address, form.port)) { + Ok(addr) => addr, + Err(e) => { + tracing::warn!("Web interface: Invalid address:port combination: {}", e); + let content = html! { + (error_message(&format!("Invalid address:port combination: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Open Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + // First connect to the peer + tracing::info!( + "Web interface: Connecting to peer {} at {}", + pubkey, + socket_addr + ); + if let Err(e) = state.node.inner.connect(pubkey, socket_addr.clone(), true) { + tracing::error!("Web interface: Failed to connect to peer {}: {}", pubkey, e); + let content = html! { + (error_message(&format!("Failed to connect to peer: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from( + layout("Open Channel Error", content).into_string(), + )) + .unwrap()); + } + + // Then open the channel + tracing::info!( + "Web interface: Opening announced channel to {} with amount {} sats and push amount {:?} msats", + pubkey, + form.amount_sats, + form.push_btc.map(|a| a * 1000) + ); + let channel_result = state.node.inner.open_announced_channel( + pubkey, + socket_addr, + form.amount_sats, + form.push_btc.map(|a| a * 1000), + None, + ); + + let content = match channel_result { + Ok(user_channel_id) => { + tracing::info!( + "Web interface: Successfully initiated channel opening with user_channel_id={} to {}", + user_channel_id.0, + pubkey + ); + html! { + (success_message("Channel opening initiated successfully!")) + (info_card( + "Channel Details", + vec![ + ("Temporary Channel ID", user_channel_id.0.to_string()), + ("Node ID", form.node_id), + ("Amount", format_sats_as_btc(form.amount_sats)), + ("Push Amount", form.push_btc.map(format_sats_as_btc).unwrap_or_else(|| "₿ 0".to_string())), + ] + )) + div class="card" { + p { "The channel is now being opened. It may take some time for the channel to become active." } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to open channel to {}: {}", pubkey, e); + html! { + (error_message(&format!("Failed to open channel: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("Open Channel Result", content).into_string(), + )) + .unwrap()) +} + +pub async fn close_channel_page( + State(_state): State, + query: Query>, +) -> Result, StatusCode> { + let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone(); + let node_id = query.get("node_id").unwrap_or(&"".to_string()).clone(); + + if channel_id.is_empty() || node_id.is_empty() { + let content = html! { + (error_message("Missing channel ID or node ID")) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + }; + return Ok(Html(layout("Close Channel Error", content).into_string())); + } + + let content = form_card( + "Close Channel", + html! { + p { "Are you sure you want to close this channel?" } + div class="info-item" { + span class="info-label" { "User Channel ID:" } + span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (channel_id) } + } + div class="info-item" { + span class="info-label" { "Node ID:" } + span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) } + } + form method="post" action="/channels/close" style="margin-top: 1rem;" { + 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" } } + } + }, + ); + + Ok(Html(layout("Close Channel", content).into_string())) +} + +pub async fn force_close_channel_page( + State(_state): State, + query: Query>, +) -> Result, StatusCode> { + let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone(); + let node_id = query.get("node_id").unwrap_or(&"".to_string()).clone(); + + if channel_id.is_empty() || node_id.is_empty() { + let content = html! { + (error_message("Missing channel ID or node ID")) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + }; + return Ok(Html( + layout("Force Close Channel Error", content).into_string(), + )); + } + + 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;" { + "Force close should NOT be used if normal close is preferred. " + "Force close will immediately broadcast the latest commitment transaction and may result in delayed fund recovery. " + "Only use this if the channel counterparty is unresponsive or there are other issues preventing normal closure." + } + } + p { "Are you sure you want to force close this channel?" } + div class="info-item" { + span class="info-label" { "User Channel ID:" } + span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (channel_id) } + } + div class="info-item" { + span class="info-label" { "Node ID:" } + span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) } + } + form method="post" action="/channels/force-close" style="margin-top: 1rem;" { + 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" } } + } + }, + ); + + Ok(Html(layout("Force Close Channel", content).into_string())) +} + +pub async fn post_close_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to close channel_id={} with node_id={}", + form.channel_id, + form.node_id + ); + + let node_pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + "Web interface: Invalid node public key for channel close: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Close Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + let channel_id: u128 = match form.channel_id.parse() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Web interface: Invalid channel ID for channel close: {}", e); + let content = html! { + (error_message(&format!("Invalid channel ID: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Close Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + let user_channel_id = UserChannelId(channel_id); + tracing::info!( + "Web interface: Initiating cooperative close for channel {} with {}", + channel_id, + node_pubkey + ); + let close_result = state + .node + .inner + .close_channel(&user_channel_id, node_pubkey); + + let content = match close_result { + Ok(()) => { + tracing::info!( + "Web interface: Successfully initiated cooperative close for channel {} with {}", + channel_id, + node_pubkey + ); + html! { + (success_message("Channel closing initiated successfully!")) + div class="card" { + p { "The channel is now being closed. It may take some time for the closing transaction to be confirmed." } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!( + "Web interface: Failed to close channel {} with {}: {}", + channel_id, + node_pubkey, + e + ); + html! { + (error_message(&format!("Failed to close channel: {e}"))) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("Close Channel Result", content).into_string(), + )) + .unwrap()) +} + +pub async fn post_force_close_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to FORCE CLOSE channel_id={} with node_id={}", + form.channel_id, + form.node_id + ); + + let node_pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + "Web interface: Invalid node public key for force close: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Force Close Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + let channel_id: u128 = match form.channel_id.parse() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Web interface: Invalid channel ID for force close: {}", e); + let content = html! { + (error_message(&format!("Invalid channel ID: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Force Close Channel Error", content).into_string(), + )) + .unwrap()); + } + }; + + let user_channel_id = UserChannelId(channel_id); + tracing::warn!("Web interface: Initiating FORCE CLOSE for channel {} with {} - this will broadcast the latest commitment transaction", channel_id, node_pubkey); + let force_close_result = + state + .node + .inner + .force_close_channel(&user_channel_id, node_pubkey, None); + + let content = match force_close_result { + Ok(()) => { + tracing::info!( + "Web interface: Successfully initiated force close for channel {} with {}", + channel_id, + node_pubkey + ); + html! { + (success_message("Channel force close initiated successfully!")) + div class="card" style="border: 1px solid #d63384; background-color: rgba(214, 51, 132, 0.1);" { + h4 style="color: #d63384;" { "Force Close Complete" } + p { "The channel has been force closed. The latest commitment transaction has been broadcast to the network." } + p style="color: #d63384; font-size: 0.9rem;" { + "Note: Your funds may be subject to a time delay before they can be spent. " + "This delay depends on the channel configuration and may be several blocks." + } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!( + "Web interface: Failed to force close channel {} with {}: {}", + channel_id, + node_pubkey, + e + ); + html! { + (error_message(&format!("Failed to force close channel: {e}"))) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("Force Close Channel Result", content).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/dashboard.rs b/crates/cdk-ldk-node/src/web/handlers/dashboard.rs new file mode 100644 index 00000000..25aacaf3 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/dashboard.rs @@ -0,0 +1,276 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use maud::html; + +use crate::web::handlers::AppState; +use crate::web::templates::{format_sats_as_btc, layout}; + +#[derive(Debug)] +pub struct UsageMetrics { + pub lightning_inflow_24h: u64, + pub lightning_outflow_24h: u64, + pub lightning_inflow_all_time: u64, + pub lightning_outflow_all_time: u64, + pub onchain_inflow_24h: u64, + pub onchain_outflow_24h: u64, + pub onchain_inflow_all_time: u64, + pub onchain_outflow_all_time: u64, +} + +/// Calculate usage metrics from payment history +fn calculate_usage_metrics(payments: &[ldk_node::payment::PaymentDetails]) -> UsageMetrics { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let twenty_four_hours_ago = now.saturating_sub(24 * 60 * 60); + + let mut metrics = UsageMetrics { + lightning_inflow_24h: 0, + lightning_outflow_24h: 0, + lightning_inflow_all_time: 0, + lightning_outflow_all_time: 0, + onchain_inflow_24h: 0, + onchain_outflow_24h: 0, + onchain_inflow_all_time: 0, + onchain_outflow_all_time: 0, + }; + + for payment in payments { + if payment.status != PaymentStatus::Succeeded { + continue; + } + + let amount_sats = payment.amount_msat.unwrap_or(0) / 1000; + let is_recent = payment.latest_update_timestamp >= twenty_four_hours_ago; + + match &payment.kind { + PaymentKind::Bolt11 { .. } + | PaymentKind::Bolt12Offer { .. } + | PaymentKind::Bolt12Refund { .. } + | PaymentKind::Spontaneous { .. } + | PaymentKind::Bolt11Jit { .. } => match payment.direction { + PaymentDirection::Inbound => { + metrics.lightning_inflow_all_time += amount_sats; + if is_recent { + metrics.lightning_inflow_24h += amount_sats; + } + } + PaymentDirection::Outbound => { + metrics.lightning_outflow_all_time += amount_sats; + if is_recent { + metrics.lightning_outflow_24h += amount_sats; + } + } + }, + PaymentKind::Onchain { .. } => match payment.direction { + PaymentDirection::Inbound => { + metrics.onchain_inflow_all_time += amount_sats; + if is_recent { + metrics.onchain_inflow_24h += amount_sats; + } + } + PaymentDirection::Outbound => { + metrics.onchain_outflow_all_time += amount_sats; + if is_recent { + metrics.onchain_outflow_24h += amount_sats; + } + } + }, + } + } + + metrics +} + +pub async fn dashboard(State(state): State) -> Result, StatusCode> { + let node = &state.node.inner; + + let _node_id = node.node_id().to_string(); + let alias = node + .node_alias() + .map(|a| a.to_string()) + .unwrap_or_else(|| "No alias set".to_string()); + + let listening_addresses: Vec = state + .node + .inner + .announcement_addresses() + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|a| a.to_string()) + .collect(); + + let (num_peers, num_connected_peers) = + node.list_peers() + .iter() + .fold((0, 0), |(mut peers, mut connected), p| { + if p.is_connected { + connected += 1; + } + peers += 1; + (peers, connected) + }); + + let (num_active_channels, num_inactive_channels) = + node.list_channels() + .iter() + .fold((0, 0), |(mut active, mut inactive), c| { + if c.is_usable { + active += 1; + } else { + inactive += 1; + } + (active, inactive) + }); + + let balances = node.list_balances(); + + // Calculate payment metrics for dashboard + let all_payments = node.list_payments_with_filter(|_| true); + let metrics = calculate_usage_metrics(&all_payments); + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Dashboard" } + + // Balance Summary as metric cards + div class="card" { + h2 { "Balance Summary" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) } + div class="metric-label" { "Lightning Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } + div class="metric-label" { "On-chain Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats + balances.total_onchain_balance_sats)) } + div class="metric-label" { "Combined Total" } + } + } + } + + // Node Information - new layout based on Figma design + section class="node-info-section" { + div class="node-info-main-container" { + // Left side - Node avatar and info + div class="node-info-left" { + div class="node-avatar" { + img src="/static/images/nut.png" alt="Node Avatar" class="avatar-image"; + } + div class="node-details" { + h2 class="node-name" { (alias.clone()) } + p class="node-address" { + "Listening Address: " + (listening_addresses.first().unwrap_or(&"127.0.0.1:8090".to_string())) + } + } + } + + // Middle - Gray container with spinning globe animation + div class="node-content-box" { + div class="globe-container" { + svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" { + defs { + symbol id="icon-world" viewBox="0 0 216 100" { + title { "world" } + g fill-rule="nonzero" { + path d="M48 94l-3-4-2-14c0-3-1-5-3-8-4-5-6-9-4-11l1-4 1-3c2-1 9 0 11 1l3 2 2 3 1 2 8 2c1 1 2 2 0 7-1 5-2 7-4 7l-2 3-2 4-2 3-2 1c-2 2-2 9 0 10v1l-3-2zM188 90l3-2h1l-4 2zM176 87h2l-1 1-1-1zM195 86l3-2-2 2h-1zM175 83l-1-2-2-1-6 1c-5 1-5 1-5-2l1-4 2-2 4-3c5-4 9-5 9-3 0 3 3 3 4 1s1-2 1 0l3 4c2 4 1 6-2 10-4 3-7 4-8 1zM100 80c-2-4-4-11-3-14l-1-6c-1-1-2-3-1-4 0-2-4-3-9-3-4 0-5 0-7-3-1-2-2-4-1-7l3-6 3-3c1-2 10-4 11-2l6 3 5-1c3 1 4 0 5-1s-1-2-2-2l-4-1c0-1 3-3 6-2 3 0 3 0 2-2-2-2-6-2-7 0l-2 2-1 2-3-2-3-3c-1 0-1 1 1 2l1 2-2-1c-4-3-6-2-8 1-2 2-4 3-5 1-1-1 0-4 2-4l2-2 1-2 3-2 3-2 2 1c3 0 7-3 5-4l-1-3h-1l-1 3-2 2h-1l-2-1c-2-1-2-1 1-4 5-4 6-4 11-3 4 1 4 1 2 2v1l3-1 6-1c5 0 6-1 5-2l2 1c1 2 2 2 2 1-2-4 12-7 14-4l11 1 29 3 1 2-3 3c-2 0-2 0-1 1l1 3h-2c-1-1-2-3-1-4h-4l-6 2c-1 1-1 1 2 2 3 2 4 6 1 8v3c1 3 0 3-3 0s-4-1-2 3c3 4 3 7-2 8-5 2-4 1-2 5 2 3 0 5-3 4l-2-1-2-2-1-1-1-1-2-2c-1-2-1-2-4 0-2 1-3 4-3 5-1 3-1 3-3 1l-2-4c0-2-1-3-2-3l-1-1-4-2-6-1-4-2c-1 1 3 4 5 4h2c1 1 0 2-1 4-3 2-7 4-8 3l-7-10 5 10c2 2 3 3 5 2 3 0 2 1-2 7-4 4-4 5-4 8 1 3 1 4-1 6l-2 3c0 2-6 9-8 9l-3-2zm22-51l-2-3-1-1v-1c-2 0-2 2-1 4 2 3 4 4 4 1z" {} + path d="M117 75c-1-2 0-6 2-7h2l-2 5c0 2-1 3-2 1zM186 64h-3c-2 0-6-3-5-5 1-1 6 1 7 3l2 3-2-1zM160 62h2c1 1 0 1-1 1l-1-1zM154 57l-1-2c2 2 3 1 2-2l-2-3 2 2 1 4 1 3v2l-3-4zM161 59c-1-1-1-2 1-4 3-3 4-3 4 0 0 4-2 6-5 4zM167 59l1-1 1 1-1 1-1-1zM176 59l1-1v2l-1-1zM141 52l1-1v2l-1-1zM170 52l1-1v2l-1-1zM32 50c-1-2-4-3-6-4-4-1-5-3-7-6l-3-5-2-2c-1-3-1-6 2-9 1-1 2-3 1-5 0-4-3-5-8-4H4l2-2 1-1 1-1 2-1c1-2 7-2 23-1 12 1 12 1 12-1h1c1 1 2 2 3 1l1 1-3 1c-2 0-8 4-8 5l2 1 2 3 4-3c3-4 4-4 5-3l3 1 1 2 1 2c3 0-1 2-4 2-2 0-2 0-2 2 1 1 0 2-2 2-4 1-12 9-12 12 0 2 0 2-1 1 0-2-2-3-6-2-3 0-4 1-4 3-2 4 0 6 3 4 3-1 3-1 2 1s-1 2 1 2l1 2 1 3 1 1-3-2zm8-24l1-1c0-1-4-3-5-2l1 1v2c-1 1-1 1 0 0h3zM167 47v-3l1 2c1 2 0 3-1 1z" {} + path d="M41 43h2l-1 1-1-1zM37 42v-1l2 1h-2zM16 38l1-1v2l-1-1zM172 32l2-3h1c1 2 0 4-3 4v-1zM173 26h2l-1 1-1-1zM56 22h2l-2 1v-1zM87 19l1-2 1 3-1 1-1-2zM85 19l1-1v1l-1 1v-1zM64 12l1-3c2 0-1-4-3-4s-2 0 0-1V3l-6 2c-3 1-3 1-2-1 2-1 4-2 15-2h14c0 2-6 7-10 9l-5 2-2 1-2-2zM53 12l1-1c2 0-1-3-3-3-2-1-1-1 1-1l4 2c2 1 2 1 1 3-2 1-4 2-4 0zM80 12l1-1 1 1-1 1-1-1zM36 8h-2V7c1-1 7 0 7 1h-5zM116 7l1-1v1l-1 1V7zM50 5h2l-1 1-1-1zM97 5l2-1c0-1 1-1 0 0l-2 1z" {} + } + } + symbol id="icon-repeated-world" viewBox="0 0 432 100" { + use href="#icon-world" x="0" {} + use href="#icon-world" x="189" {} + } + } + } + span class="world" { + span class="images" { + svg { use href="#icon-repeated-world" {} } + } + } + } + } + } + + // Right side - Connections metrics + aside class="node-metrics" { + div class="card" { + h3 { "Connections" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format!("{}/{}", num_connected_peers, num_peers)) } + div class="metric-label" { "Connected Peers" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}/{}", num_active_channels, num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Active Channels" } + } + } + } + } + } + + // Lightning Network Activity as metric cards + div class="card" { + h2 { "Lightning Network Activity" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) } + div class="metric-label" { "24h LN Inflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) } + div class="metric-label" { "24h LN Outflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) } + div class="metric-label" { "All-time LN Inflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) } + div class="metric-label" { "All-time LN Outflow" } + } + } + } + + // On-chain Activity as metric cards + div class="card" { + h2 { "On-chain Activity" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) } + div class="metric-label" { "24h On-chain Inflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) } + div class="metric-label" { "24h On-chain Outflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) } + div class="metric-label" { "All-time On-chain Inflow" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) } + div class="metric-label" { "All-time On-chain Outflow" } + } + } + + } + }; + + Ok(Html(layout("Dashboard", content).into_string())) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/invoices.rs b/crates/cdk-ldk-node/src/web/handlers/invoices.rs new file mode 100644 index 00000000..7d181d54 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/invoices.rs @@ -0,0 +1,293 @@ +use axum::body::Body; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; +use maud::html; +use serde::Deserialize; + +use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32}; +use crate::web::handlers::AppState; +use crate::web::templates::{ + error_message, form_card, format_sats_as_btc, info_card, layout, success_message, +}; + +#[derive(Deserialize)] +pub struct CreateBolt11Form { + amount_btc: u64, + description: Option, + #[serde(deserialize_with = "deserialize_optional_u32")] + expiry_seconds: Option, +} + +#[derive(Deserialize)] +pub struct CreateBolt12Form { + #[serde(deserialize_with = "deserialize_optional_f64")] + amount_btc: Option, + description: Option, + #[serde(deserialize_with = "deserialize_optional_u32")] + expiry_seconds: Option, +} + +pub async fn invoices_page(State(_state): State) -> Result, StatusCode> { + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" } + div class="grid" { + (form_card( + "Create BOLT11 Invoice", + html! { + form method="post" action="/invoices/bolt11" { + div class="form-group" { + label for="amount_btc" { "Amount" } + input type="number" id="amount_btc" name="amount_btc" required placeholder="₿0" step="0.00000001" {} + } + div class="form-group" { + label for="description" { "Description (optional)" } + input type="text" id="description" name="description" placeholder="Payment for..." {} + } + div class="form-group" { + label for="expiry_seconds" { "Expiry (seconds, optional)" } + input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {} + } + button type="submit" { "Create BOLT11 Invoice" } + } + } + )) + + (form_card( + "Create BOLT12 Offer", + html! { + form method="post" action="/invoices/bolt12" { + div class="form-group" { + label for="amount_btc" { "Amount (optional for variable amount)" } + input type="number" id="amount_btc" name="amount_btc" placeholder="₿0" step="0.00000001" {} + } + div class="form-group" { + label for="description" { "Description (optional)" } + input type="text" id="description" name="description" placeholder="Payment for..." {} + } + div class="form-group" { + label for="expiry_seconds" { "Expiry (seconds, optional)" } + input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {} + } + button type="submit" { "Create BOLT12 Offer" } + } + } + )) + } + }; + + Ok(Html(layout("Create Invoices", content).into_string())) +} + +pub async fn post_create_bolt11( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Creating BOLT11 invoice for amount={} sats, description={:?}, expiry={}s", + form.amount_btc, + form.description, + form.expiry_seconds.unwrap_or(3600) + ); + + // Handle optional description + let description_text = form.description.clone().unwrap_or_else(|| "".to_string()); + let description = if description_text.is_empty() { + // Use empty description for empty or missing description + match Description::new("".to_string()) { + Ok(desc) => Bolt11InvoiceDescription::Direct(desc), + Err(_) => { + // Fallback to a minimal valid description + let desc = Description::new(" ".to_string()).unwrap(); + Bolt11InvoiceDescription::Direct(desc) + } + } + } else { + match Description::new(description_text.clone()) { + Ok(desc) => Bolt11InvoiceDescription::Direct(desc), + Err(e) => { + tracing::warn!( + "Web interface: Invalid description for BOLT11 invoice: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid description: {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Create Invoice Error", content).into_string(), + )) + .unwrap()); + } + } + }; + + // Convert Bitcoin to millisatoshis + let amount_msats = form.amount_btc * 1_000; + + let expiry_seconds = form.expiry_seconds.unwrap_or(3600); + let invoice_result = + state + .node + .inner + .bolt11_payment() + .receive(amount_msats, &description, expiry_seconds); + + let content = match invoice_result { + Ok(invoice) => { + tracing::info!( + "Web interface: Successfully created BOLT11 invoice with payment_hash={}", + invoice.payment_hash() + ); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let description_display = if description_text.is_empty() { + "None".to_string() + } else { + description_text.clone() + }; + + html! { + (success_message("BOLT11 Invoice created successfully!")) + (info_card( + "Invoice Details", + vec![ + ("Payment Hash", invoice.payment_hash().to_string()), + ("Amount", format_sats_as_btc(form.amount_btc)), + ("Description", description_display), + ("Expires At", format!("{}", current_time + expiry_seconds as u64)), + ] + )) + div class="card" { + h3 { "Invoice (copy this to share)" } + textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" { + (invoice.to_string()) + } + } + div class="card" { + a href="/invoices" { button { "← Create Another Invoice" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to create BOLT11 invoice: {}", e); + html! { + (error_message(&format!("Failed to create invoice: {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("BOLT11 Invoice Created", content).into_string(), + )) + .unwrap()) +} + +pub async fn post_create_bolt12( + State(state): State, + Form(form): Form, +) -> Result { + let expiry_seconds = form.expiry_seconds.unwrap_or(3600); + let description_text = form.description.unwrap_or_else(|| "".to_string()); + + tracing::info!( + "Web interface: Creating BOLT12 offer for amount={:?} btc, description={:?}, expiry={}s", + form.amount_btc, + description_text, + expiry_seconds + ); + + let offer_result = if let Some(amount_btc) = form.amount_btc { + // Convert Bitcoin to millisatoshis (1 BTC = 100,000,000,000 msats) + let amount_msats = (amount_btc * 100_000_000_000.0) as u64; + state.node.inner.bolt12_payment().receive( + amount_msats, + &description_text, + Some(expiry_seconds), + None, + ) + } else { + state + .node + .inner + .bolt12_payment() + .receive_variable_amount(&description_text, Some(expiry_seconds)) + }; + + let content = match offer_result { + Ok(offer) => { + tracing::info!( + "Web interface: Successfully created BOLT12 offer with offer_id={}", + offer.id() + ); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let amount_display = form + .amount_btc + .map(|a| format_sats_as_btc((a * 100_000_000.0) as u64)) + .unwrap_or_else(|| "Variable amount".to_string()); + + let description_display = if description_text.is_empty() { + "None".to_string() + } else { + description_text + }; + + html! { + (success_message("BOLT12 Offer created successfully!")) + (info_card( + "Offer Details", + vec![ + ("Offer ID", offer.id().to_string()), + ("Amount", amount_display), + ("Description", description_display), + ("Expires At", format!("{}", current_time + expiry_seconds as u64)), + ] + )) + div class="card" { + h3 { "Offer (copy this to share)" } + textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" { + (offer.to_string()) + } + } + div class="card" { + a href="/invoices" { button { "← Create Another Offer" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to create BOLT12 offer: {}", e); + html! { + (error_message(&format!("Failed to create offer: {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("BOLT12 Offer Created", content).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/lightning.rs b/crates/cdk-ldk-node/src/web/handlers/lightning.rs new file mode 100644 index 00000000..344b78b3 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/lightning.rs @@ -0,0 +1,171 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; +use maud::html; + +use crate::web::handlers::AppState; +use crate::web::templates::{format_sats_as_btc, layout}; + +pub async fn balance_page(State(state): State) -> Result, StatusCode> { + let balances = state.node.inner.list_balances(); + let channels = state.node.inner.list_channels(); + + let (num_active_channels, num_inactive_channels) = + channels + .iter() + .fold((0, 0), |(mut active, mut inactive), c| { + if c.is_usable { + active += 1; + } else { + inactive += 1; + } + (active, inactive) + }); + + let content = if channels.is_empty() { + html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" } + + // Quick Actions section - matching dashboard style + 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" } + } + a href="/invoices" style="text-decoration: none; flex: 1; min-width: 200px;" { + button class="button-primary" style="width: 100%;" { "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" } + } + } + } + + // Balance Information as metric cards + div class="card" { + h2 { "Balance Information" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) } + div class="metric-label" { "Lightning Balance" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Total Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels)) } + div class="metric-label" { "Active Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_inactive_channels)) } + div class="metric-label" { "Inactive Channels" } + } + } + } + + div class="card" { + p { "No channels found. Create your first channel to start using Lightning Network." } + } + } + } else { + html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" } + + // Quick Actions section - matching dashboard style + 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" } + } + a href="/invoices" style="text-decoration: none; flex: 1; min-width: 200px;" { + button class="button-primary" style="width: 100%;" { "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" } + } + } + } + + // Balance Information as metric cards + div class="card" { + h2 { "Balance Information" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) } + div class="metric-label" { "Lightning Balance" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Total Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels)) } + div class="metric-label" { "Active Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_inactive_channels)) } + div class="metric-label" { "Inactive Channels" } + } + } + } + + div class="card" { + h2 { "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()) } + @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()) } + } + @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-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" } + } + } + @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" } + } + } + } + } + } + + } + } + }; + + Ok(Html(layout("Lightning", content).into_string())) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/mod.rs b/crates/cdk-ldk-node/src/web/handlers/mod.rs new file mode 100644 index 00000000..c7794800 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/mod.rs @@ -0,0 +1,17 @@ +pub mod channels; +pub mod dashboard; +pub mod invoices; +pub mod lightning; +pub mod onchain; +pub mod payments; +pub mod utils; + +// Re-export commonly used items +// Re-export handler functions +pub use channels::*; +pub use dashboard::*; +pub use invoices::*; +pub use lightning::*; +pub use onchain::*; +pub use payments::*; +pub use utils::AppState; diff --git a/crates/cdk-ldk-node/src/web/handlers/onchain.rs b/crates/cdk-ldk-node/src/web/handlers/onchain.rs new file mode 100644 index 00000000..ee34076f --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/onchain.rs @@ -0,0 +1,479 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::bitcoin::Address; +use maud::html; +use serde::{Deserialize, Serialize}; + +use crate::web::handlers::utils::deserialize_optional_u64; +use crate::web::handlers::AppState; +use crate::web::templates::{ + error_message, form_card, format_sats_as_btc, info_card, layout, success_message, +}; + +#[derive(Deserialize, Serialize)] +pub struct SendOnchainActionForm { + address: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_sat: Option, + send_action: String, +} + +#[derive(Deserialize)] +pub struct ConfirmOnchainForm { + address: String, + amount_sat: Option, + send_action: String, + confirmed: Option, +} + +pub async fn get_new_address(State(state): State) -> Result, StatusCode> { + let address_result = state.node.inner.onchain_payment().new_address(); + + let content = match address_result { + Ok(address) => { + html! { + (success_message(&format!("New address generated: {address}"))) + div class="card" { + h2 { "Bitcoin Address" } + div class="info-item" { + span class="info-label" { "Address:" } + span class="info-value" style="font-family: monospace; font-size: 0.9rem;" { (address.to_string()) } + } + } + div class="card" { + a href="/onchain" { button { "← Back to On-chain" } } + " " + a href="/onchain/new-address" { button { "Generate Another Address" } } + } + } + } + Err(e) => { + html! { + (error_message(&format!("Failed to generate address: {e}"))) + div class="card" { + a href="/onchain" { button { "← Back to On-chain" } } + } + } + } + }; + + Ok(Html(layout("New Address", content).into_string())) +} + +pub async fn onchain_page( + State(state): State, + query: Query>, +) -> Result, StatusCode> { + let balances = state.node.inner.list_balances(); + let action = query + .get("action") + .map(|s| s.as_str()) + .unwrap_or("overview"); + + let mut content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // Quick Actions section - matching dashboard style + 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" } + } + a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" { + button class="button-primary" style="width: 100%;" { "Send Bitcoin" } + } + } + } + + // On-chain Balance as metric cards + div class="card" { + h2 { "On-chain Balance" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } + div class="metric-label" { "Total Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + + match action { + "send" => { + content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // Quick Actions section - matching dashboard style + 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" } + } + a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" { + button class="button-primary" style="width: 100%;" { "Send Bitcoin" } + } + } + } + + // Send form above balance + (form_card( + "Send On-chain Payment", + html! { + form method="post" action="/onchain/send" { + div class="form-group" { + label for="address" { "Recipient Address" } + input type="text" id="address" name="address" required placeholder="bc1..." {} + } + div class="form-group" { + label for="amount_sat" { "Amount (sats)" } + input type="number" id="amount_sat" name="amount_sat" placeholder="0" {} + } + 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" } } + 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" } + } + } + } + } + )) + + // On-chain Balance as metric cards + div class="card" { + h2 { "On-chain Balance" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } + div class="metric-label" { "Total Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + } + "receive" => { + content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // Quick Actions section - matching dashboard style + 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" } + } + a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" { + button class="button-primary" style="width: 100%;" { "Send Bitcoin" } + } + } + } + + // Generate address form above balance + (form_card( + "Generate New Address", + html! { + 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" } } + button class="button-primary" type="submit" { "Generate New Address" } + } + } + } + )) + + // On-chain Balance as metric cards + div class="card" { + h2 { "On-chain Balance" } + div class="metrics-container" { + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } + div class="metric-label" { "Total Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + } + _ => { + // Show overview with just the balance and quick actions at the top + } + } + + Ok(Html(layout("On-chain", content).into_string())) +} + +pub async fn post_send_onchain( + State(_state): State, + Form(form): Form, +) -> Result { + let encoded_form = + serde_urlencoded::to_string(&form).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Response::builder() + .status(StatusCode::FOUND) + .header("Location", format!("/onchain/confirm?{}", encoded_form)) + .body(Body::empty()) + .unwrap()) +} + +pub async fn onchain_confirm_page( + State(state): State, + query: Query, +) -> Result { + let form = query.0; + + // If user confirmed, execute the transaction + if form.confirmed.as_deref() == Some("true") { + return execute_onchain_transaction(State(state), form).await; + } + + // Validate address + let _address = match Address::from_str(&form.address) { + Ok(addr) => addr, + Err(e) => { + let content = html! { + (error_message(&format!("Invalid address: {e}"))) + div class="card" { + a href="/onchain?action=send" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Send On-chain Error", content).into_string(), + )) + .unwrap()); + } + }; + + let balances = state.node.inner.list_balances(); + let spendable_balance = balances.spendable_onchain_balance_sats; + + // Calculate transaction details + let (amount_to_send, is_send_all) = if form.send_action == "send_all" { + (spendable_balance, true) + } else { + let amount = form.amount_sat.unwrap_or(0); + if amount > spendable_balance { + let content = html! { + (error_message(&format!("Insufficient funds. Requested: {}, Available: {}", + format_sats_as_btc(amount), format_sats_as_btc(spendable_balance)))) + div class="card" { + a href="/onchain?action=send" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Send On-chain Error", content).into_string(), + )) + .unwrap()); + } + (amount, false) + }; + + let confirmation_url = if form.send_action == "send_all" { + format!( + "/onchain/confirm?address={}&send_action={}&confirmed=true", + urlencoding::encode(&form.address), + form.send_action + ) + } else { + format!( + "/onchain/confirm?address={}&amount_sat={}&send_action={}&confirmed=true", + urlencoding::encode(&form.address), + form.amount_sat.unwrap_or(0), + form.send_action + ) + }; + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" } + + div class="card" style="border: 2px solid hsl(var(--primary)); background-color: hsl(var(--primary) / 0.05);" { + h2 { "⚠️ Transaction Confirmation" } + p style="color: hsl(var(--muted-foreground)); margin-bottom: 1.5rem;" { + "Please review the transaction details carefully before proceeding. This action cannot be undone." + } + } + + (info_card( + "Transaction Details", + vec![ + ("Recipient Address", form.address.clone()), + ("Amount to Send", if is_send_all { + format!("{} (All available funds)", format_sats_as_btc(amount_to_send)) + } else { + format_sats_as_btc(amount_to_send) + }), + ("Current Spendable Balance", format_sats_as_btc(spendable_balance)), + ] + )) + + @if is_send_all { + div class="card" style="border: 1px solid hsl(32.6 75.4% 55.1%); background-color: hsl(32.6 75.4% 55.1% / 0.1);" { + h3 style="color: hsl(32.6 75.4% 55.1%);" { "Send All Notice" } + p style="color: hsl(32.6 75.4% 55.1%);" { + "This transaction will send all available funds to the recipient address. " + "Network fees will be deducted from the total amount automatically." + } + } + } + + 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" } + } + div style="display: flex; gap: 0.5rem;" { + a href=(confirmation_url) { + button class="button-primary" { + "✓ Confirm & Send Transaction" + } + } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("Confirm Transaction", content).into_string(), + )) + .unwrap()) +} + +async fn execute_onchain_transaction( + State(state): State, + form: ConfirmOnchainForm, +) -> Result { + tracing::info!( + "Web interface: Executing on-chain transaction to address={}, send_action={}, amount_sat={:?}", + form.address, + form.send_action, + form.amount_sat + ); + + let address = match Address::from_str(&form.address) { + Ok(addr) => addr, + Err(e) => { + tracing::warn!( + "Web interface: Invalid address for on-chain transaction: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid address: {e}"))) + div class="card" { + a href="/onchain" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout("Send On-chain Error", content).into_string(), + )) + .unwrap()); + } + }; + + // Handle send all action + let txid_result = if form.send_action == "send_all" { + tracing::info!( + "Web interface: Sending all available funds to {}", + form.address + ); + state.node.inner.onchain_payment().send_all_to_address( + address.assume_checked_ref(), + false, + None, + ) + } else { + let amount_sats = form.amount_sat.ok_or(StatusCode::BAD_REQUEST)?; + tracing::info!( + "Web interface: Sending {} sats to {}", + amount_sats, + form.address + ); + state.node.inner.onchain_payment().send_to_address( + address.assume_checked_ref(), + amount_sats, + None, + ) + }; + + let content = match txid_result { + Ok(txid) => { + if form.send_action == "send_all" { + tracing::info!( + "Web interface: Successfully sent all available funds, txid={}", + txid + ); + } else { + tracing::info!( + "Web interface: Successfully sent {} sats, txid={}", + form.amount_sat.unwrap_or(0), + txid + ); + } + let amount = form.amount_sat; + html! { + (success_message("Transaction sent successfully!")) + (info_card( + "Transaction Details", + vec![ + ("Transaction ID", txid.to_string()), + ("Amount", if form.send_action == "send_all" { + format!("{} (All available funds)", format_sats_as_btc(amount.unwrap_or(0))) + } else { + format_sats_as_btc(form.amount_sat.unwrap_or(0)) + }), + ("Recipient", form.address), + ] + )) + div class="card" { + a href="/onchain" { button { "← Back to On-chain" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to send on-chain transaction: {}", e); + html! { + (error_message(&format!("Failed to send payment: {e}"))) + div class="card" { + a href="/onchain" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout("Send On-chain Result", content).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/payments.rs b/crates/cdk-ldk-node/src/web/handlers/payments.rs new file mode 100644 index 00000000..343c7999 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/payments.rs @@ -0,0 +1,615 @@ +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use cdk_common::util::hex; +use ldk_node::lightning::offers::offer::Offer; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use maud::html; +use serde::Deserialize; + +use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming}; +use crate::web::handlers::AppState; +use crate::web::templates::{ + error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, layout, + payment_list_item, success_message, +}; + +#[derive(Deserialize)] +pub struct PaymentsQuery { + filter: Option, + page: Option, + per_page: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PayBolt11Form { + invoice: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_btc: Option, +} + +#[derive(Deserialize)] +pub struct PayBolt12Form { + offer: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_btc: Option, +} + +pub async fn payments_page( + State(state): State, + query: Query, +) -> Result, StatusCode> { + let filter = query.filter.as_deref().unwrap_or("all"); + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(25).clamp(10, 100); // Limit between 10-100 items per page + + // Use efficient pagination function + let (current_page_payments, total_count) = get_paginated_payments_streaming( + &state.node.inner, + filter, + ((page - 1) * per_page) as usize, + per_page as usize, + ); + + // Calculate pagination + let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as u32; + let start_index = ((page - 1) * per_page) as usize; + let end_index = (start_index + per_page as usize).min(total_count); + + // Helper function to build URL with pagination params + let build_url = |new_page: u32, new_filter: &str, new_per_page: u32| -> String { + let mut params = vec![]; + if new_filter != "all" { + params.push(format!("filter={}", new_filter)); + } + if new_page != 1 { + params.push(format!("page={}", new_page)); + } + if new_per_page != 25 { + params.push(format!("per_page={}", new_per_page)); + } + + if params.is_empty() { + "/payments".to_string() + } else { + format!("/payments?{}", params.join("&")) + } + }; + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Payments" } + div class="card" { + div class="payment-list-header" { + div { + h2 { "Payment History" } + @if total_count > 0 { + p style="margin: 0.25rem 0 0 0; color: #666; font-size: 0.9rem;" { + "Showing " (start_index + 1) " to " (end_index) " of " (total_count) " payments" + } + } + } + div class="payment-filter-tabs" { + a href=(build_url(1, "all", per_page)) class=(if filter == "all" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "All" } + a href=(build_url(1, "incoming", per_page)) class=(if filter == "incoming" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Incoming" } + a href=(build_url(1, "outgoing", per_page)) class=(if filter == "outgoing" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Outgoing" } + } + } + + // Payment list (no metrics here) + @if current_page_payments.is_empty() { + @if total_count == 0 { + p { "No payments found." } + } @else { + p { "No payments found on this page. " + a href=(build_url(1, filter, per_page)) { "Go to first page" } + } + } + } @else { + @for payment in ¤t_page_payments { + @let direction_str = match payment.direction { + PaymentDirection::Inbound => "Inbound", + PaymentDirection::Outbound => "Outbound", + }; + + @let status_str = match payment.status { + PaymentStatus::Pending => "Pending", + PaymentStatus::Succeeded => "Succeeded", + PaymentStatus::Failed => "Failed", + }; + + @let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string()); + + @let (payment_hash, description, payment_type, preimage) = match &payment.kind { + PaymentKind::Bolt11 { hash, preimage, .. } => { + (Some(hash.to_string()), None::, "BOLT11", preimage.map(|p| p.to_string())) + }, + PaymentKind::Bolt12Offer { hash, offer_id, preimage, .. } => { + // For BOLT12, we can use either the payment hash or offer ID + let identifier = hash.map(|h| h.to_string()).unwrap_or_else(|| offer_id.to_string()); + (Some(identifier), None::, "BOLT12", preimage.map(|p| p.to_string())) + }, + PaymentKind::Bolt12Refund { hash, preimage, .. } => { + (hash.map(|h| h.to_string()), None::, "BOLT12", preimage.map(|p| p.to_string())) + }, + PaymentKind::Spontaneous { hash, preimage, .. } => { + (Some(hash.to_string()), None::, "Spontaneous", preimage.map(|p| p.to_string())) + }, + PaymentKind::Onchain { txid, .. } => { + (Some(txid.to_string()), None::, "On-chain", None) + }, + PaymentKind::Bolt11Jit { hash, .. } => { + (Some(hash.to_string()), None::, "BOLT11 JIT", None) + }, + }; + + (payment_list_item( + &payment.id.to_string(), + direction_str, + status_str, + &amount_str, + payment_hash.as_deref(), + description.as_deref(), + Some(payment.latest_update_timestamp), // Use the actual timestamp + payment_type, + preimage.as_deref(), + )) + } + } + + // Pagination controls (bottom) + @if total_pages > 1 { + div class="pagination-controls" style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee;" { + div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;" { + // Previous page + @if page > 1 { + a href=(build_url(page - 1, filter, per_page)) class="pagination-btn" { "← Previous" } + } @else { + span class="pagination-btn disabled" { "← Previous" } + } + + // Page numbers + @let start_page = (page.saturating_sub(2)).max(1); + @let end_page = (page + 2).min(total_pages); + + @if start_page > 1 { + a href=(build_url(1, filter, per_page)) class="pagination-number" { "1" } + @if start_page > 2 { + span class="pagination-ellipsis" { "..." } + } + } + + @for p in start_page..=end_page { + @if p == page { + span class="pagination-number active" { (p) } + } @else { + a href=(build_url(p, filter, per_page)) class="pagination-number" { (p) } + } + } + + @if end_page < total_pages { + @if end_page < total_pages - 1 { + span class="pagination-ellipsis" { "..." } + } + a href=(build_url(total_pages, filter, per_page)) class="pagination-number" { (total_pages) } + } + + // Next page + @if page < total_pages { + a href=(build_url(page + 1, filter, per_page)) class="pagination-btn" { "Next →" } + } @else { + span class="pagination-btn disabled" { "Next →" } + } + } + } + } + + // Compact per-page selector integrated with pagination + @if total_count > 0 { + div class="per-page-selector" { + label for="per-page" { "Show:" } + select id="per-page" onchange="changePage()" { + option value="10" selected[per_page == 10] { "10" } + option value="25" selected[per_page == 25] { "25" } + option value="50" selected[per_page == 50] { "50" } + option value="100" selected[per_page == 100] { "100" } + } + span { "per page" } + } + } + } + + // JavaScript for per-page selector + script { + "function changePage() { + const perPageSelect = document.getElementById('per-page'); + const newPerPage = perPageSelect.value; + const currentUrl = new URL(window.location); + currentUrl.searchParams.set('per_page', newPerPage); + currentUrl.searchParams.set('page', '1'); // Reset to first page when changing per_page + window.location.href = currentUrl.toString(); + }" + } + }; + + Ok(Html(layout("Payment History", content).into_string())) +} + +pub async fn send_payments_page( + State(_state): State, +) -> Result, StatusCode> { + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" } + div class="grid" { + (form_card( + "Pay BOLT11 Invoice", + html! { + form method="post" action="/payments/bolt11" { + div class="form-group" { + label for="invoice" { "BOLT11 Invoice" } + textarea id="invoice" name="invoice" required placeholder="lnbc..." style="height: 120px;" {} + } + div class="form-group" { + label for="amount_btc" { "Amount Override (optional)" } + input type="number" id="amount_btc" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {} + } + button type="submit" { "Pay BOLT11 Invoice" } + } + } + )) + + (form_card( + "Pay BOLT12 Offer", + html! { + form method="post" action="/payments/bolt12" { + div class="form-group" { + label for="offer" { "BOLT12 Offer" } + textarea id="offer" name="offer" required placeholder="lno..." style="height: 120px;" {} + } + div class="form-group" { + label for="amount_btc" { "Amount (required for variable amount offers)" } + input type="number" id="amount_btc" name="amount_btc" placeholder="Required for variable amount offers, ignored for fixed amount offers" step="1" {} + } + button type="submit" { "Pay BOLT12 Offer" } + } + } + )) + } + + div class="card" { + h3 { "Payment History" } + a href="/payments" { button { "View All Payments" } } + } + }; + + Ok(Html(layout("Send Payments", content).into_string())) +} + +pub async fn post_pay_bolt11( + State(state): State, + Form(form): Form, +) -> Result { + let invoice = match Bolt11Invoice::from_str(form.invoice.trim()) { + Ok(inv) => inv, + Err(e) => { + tracing::warn!("Web interface: Invalid BOLT11 invoice provided: {}", e); + let content = html! { + (error_message(&format!("Invalid BOLT11 invoice: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from(layout("Payment Error", content).into_string())) + .unwrap()); + } + }; + + tracing::info!( + "Web interface: Attempting to pay BOLT11 invoice payment_hash={}, amount_override={:?}", + invoice.payment_hash(), + form.amount_btc + ); + + let payment_id = if let Some(amount_btc) = form.amount_btc { + // Convert Bitcoin to millisatoshis + let amount_msats = amount_btc * 1000; + state + .node + .inner + .bolt11_payment() + .send_using_amount(&invoice, amount_msats, None) + } else { + state.node.inner.bolt11_payment().send(&invoice, None) + }; + + let payment_id = match payment_id { + Ok(id) => { + tracing::info!( + "Web interface: BOLT11 payment initiated with payment_id={}", + hex::encode(id.0) + ); + id + } + Err(e) => { + tracing::error!("Web interface: Failed to initiate BOLT11 payment: {}", e); + let content = html! { + (error_message(&format!("Failed to initiate payment: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from(layout("Payment Error", content).into_string())) + .unwrap()); + } + }; + + // Wait for payment to complete (max 10 seconds) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let payment_result = loop { + if let Some(details) = state.node.inner.payment(&payment_id) { + match details.status { + PaymentStatus::Succeeded => { + tracing::info!( + "Web interface: BOLT11 payment succeeded for payment_hash={}", + invoice.payment_hash() + ); + break Ok(details); + } + PaymentStatus::Failed => { + tracing::error!( + "Web interface: BOLT11 payment failed for payment_hash={}", + invoice.payment_hash() + ); + break Err("Payment failed".to_string()); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Web interface: BOLT11 payment timeout for payment_hash={}", + invoice.payment_hash() + ); + break Err("Payment is still pending after timeout".to_string()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + } else { + break Err("Payment not found".to_string()); + } + }; + + let content = match payment_result { + Ok(details) => { + let (preimage, fee_msats) = match details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => ( + preimage.map(|p| p.to_string()).unwrap_or_default(), + details.fee_paid_msat.unwrap_or(0), + ), + _ => (String::new(), 0), + }; + + html! { + (success_message("Payment succeeded!")) + (info_card( + "Payment Details", + vec![ + ("Payment Hash", invoice.payment_hash().to_string()), + ("Payment Preimage", preimage), + ("Fee Paid", format_msats_as_btc(fee_msats)), + ("Amount", form.amount_btc.map(|_a| format_sats_as_btc(details.amount_msat.unwrap_or(1000) / 1000)).unwrap_or_default()), + ] + )) + div class="card" { + a href="/payments" { button { "← Make Another Payment" } } + } + } + } + Err(error) => { + html! { + (error_message(&format!("Payment failed: {error}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from(layout("Payment Result", content).into_string())) + .unwrap()) +} + +pub async fn post_pay_bolt12( + State(state): State, + Form(form): Form, +) -> Result { + let offer = match Offer::from_str(form.offer.trim()) { + Ok(offer) => offer, + Err(e) => { + tracing::warn!("Web interface: Invalid BOLT12 offer provided: {:?}", e); + let content = html! { + (error_message(&format!("Invalid BOLT12 offer: {e:?}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from(layout("Payment Error", content).into_string())) + .unwrap()); + } + }; + + tracing::info!( + "Web interface: Attempting to pay BOLT12 offer offer_id={}, amount_override={:?}", + offer.id(), + form.amount_btc + ); + + // Determine payment method based on offer type and user input + let payment_id = match offer.amount() { + Some(_) => { + // Fixed amount offer - use send() method, ignore user input amount + state.node.inner.bolt12_payment().send(&offer, None, None) + } + None => { + // Variable amount offer - requires user to specify amount via send_using_amount() + let amount_btc = match form.amount_btc { + Some(amount) => amount, + None => { + tracing::warn!("Web interface: Amount required for variable amount BOLT12 offer but not provided"); + let content = html! { + (error_message("Amount is required for variable amount offers. This offer does not have a fixed amount, so you must specify how much you want to pay.")) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from(layout("Payment Error", content).into_string())) + .unwrap()); + } + }; + let amount_msats = amount_btc * 1_000; + state + .node + .inner + .bolt12_payment() + .send_using_amount(&offer, amount_msats, None, None) + } + }; + + let payment_id = match payment_id { + Ok(id) => { + tracing::info!( + "Web interface: BOLT12 payment initiated with payment_id={}", + hex::encode(id.0) + ); + id + } + Err(e) => { + tracing::error!("Web interface: Failed to initiate BOLT12 payment: {}", e); + let content = html! { + (error_message(&format!("Failed to initiate payment: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from(layout("Payment Error", content).into_string())) + .unwrap()); + } + }; + + // Wait for payment to complete (max 10 seconds) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let payment_result = loop { + if let Some(details) = state.node.inner.payment(&payment_id) { + match details.status { + PaymentStatus::Succeeded => { + tracing::info!( + "Web interface: BOLT12 payment succeeded for offer_id={}", + offer.id() + ); + break Ok(details); + } + PaymentStatus::Failed => { + tracing::error!( + "Web interface: BOLT12 payment failed for offer_id={}", + offer.id() + ); + break Err("Payment failed".to_string()); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Web interface: BOLT12 payment timeout for offer_id={}", + offer.id() + ); + break Err("Payment is still pending after timeout".to_string()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + } else { + break Err("Payment not found".to_string()); + } + }; + + let content = match payment_result { + Ok(details) => { + let (payment_hash, preimage, fee_msats) = match details.kind { + PaymentKind::Bolt12Offer { + hash, + preimage, + secret: _, + offer_id: _, + payer_note: _, + quantity: _, + } => ( + hash.map(|h| h.to_string()).unwrap_or_default(), + preimage.map(|p| p.to_string()).unwrap_or_default(), + details.fee_paid_msat.unwrap_or(0), + ), + _ => (String::new(), String::new(), 0), + }; + + html! { + (success_message("Payment succeeded!")) + (info_card( + "Payment Details", + vec![ + ("Payment Hash", payment_hash), + ("Payment Preimage", preimage), + ("Fee Paid", format_msats_as_btc(fee_msats)), + ("Amount Paid", form.amount_btc.map(format_sats_as_btc).unwrap_or_else(|| { + // If no amount was specified in the form, show the actual amount from the payment details + details.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string()) + })), + ] + )) + div class="card" { + a href="/payments" { button { "← Make Another Payment" } } + } + } + } + Err(error) => { + html! { + (error_message(&format!("Payment failed: {error}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from(layout("Payment Result", content).into_string())) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/utils.rs b/crates/cdk-ldk-node/src/web/handlers/utils.rs new file mode 100644 index 00000000..c4f2dbed --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/utils.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use ldk_node::payment::PaymentDirection; +use serde::Deserialize; + +use crate::CdkLdkNode; + +#[derive(Clone)] +pub struct AppState { + pub node: Arc, +} + +// Custom deserializer for optional u32 that handles empty strings +pub fn deserialize_optional_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +// Custom deserializer for optional u64 that handles empty strings +pub fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +// Custom deserializer for optional f64 that handles empty strings +pub fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +/// Get paginated payments with efficient filtering and sorting +pub fn get_paginated_payments_streaming( + node: &ldk_node::Node, + filter: &str, + skip: usize, + take: usize, +) -> (Vec, usize) { + // Create filter predicate - note LDK expects &&PaymentDetails + let filter_fn = match filter { + "incoming" => { + |p: &&ldk_node::payment::PaymentDetails| p.direction == PaymentDirection::Inbound + } + "outgoing" => { + |p: &&ldk_node::payment::PaymentDetails| p.direction == PaymentDirection::Outbound + } + _ => |_: &&ldk_node::payment::PaymentDetails| true, + }; + + // Get filtered payments from LDK + let filtered_payments = node.list_payments_with_filter(filter_fn); + + // Create sorted index to avoid cloning payments during sort + let mut time_indexed: Vec<_> = filtered_payments + .iter() + .enumerate() + .map(|(idx, payment)| (payment.latest_update_timestamp, idx)) + .collect(); + + // Sort by timestamp (newest first) + time_indexed.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + + let total_count = time_indexed.len(); + + // Extract only the payments we need for this page + let page_payments: Vec<_> = time_indexed + .into_iter() + .skip(skip) + .take(take) + .map(|(_, idx)| filtered_payments[idx].clone()) + .collect(); + + (page_payments, total_count) +} diff --git a/crates/cdk-ldk-node/src/web/mod.rs b/crates/cdk-ldk-node/src/web/mod.rs new file mode 100644 index 00000000..f182a9a9 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/mod.rs @@ -0,0 +1,6 @@ +pub mod handlers; +pub mod server; +pub mod static_files; +pub mod templates; + +pub use server::WebServer; diff --git a/crates/cdk-ldk-node/src/web/server.rs b/crates/cdk-ldk-node/src/web/server.rs new file mode 100644 index 00000000..300db0d4 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/server.rs @@ -0,0 +1,78 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::routing::{get, post}; +use axum::Router; +use tower::ServiceBuilder; +use tower_http::cors::CorsLayer; + +use crate::web::handlers::{ + balance_page, channels_page, close_channel_page, dashboard, force_close_channel_page, + get_new_address, invoices_page, onchain_confirm_page, onchain_page, open_channel_page, + payments_page, post_close_channel, post_create_bolt11, post_create_bolt12, + post_force_close_channel, post_open_channel, post_pay_bolt11, post_pay_bolt12, + post_send_onchain, send_payments_page, AppState, +}; +use crate::web::static_files::static_handler; +use crate::CdkLdkNode; + +pub struct WebServer { + pub node: Arc, +} + +impl WebServer { + pub fn new(node: Arc) -> Self { + Self { node } + } + + pub fn create_router(&self) -> Router { + let state = AppState { + node: self.node.clone(), + }; + + tracing::debug!("Serving static files from embedded assets"); + + Router::new() + // Dashboard + .route("/", get(dashboard)) + // Balance and onchain operations + .route("/balance", get(balance_page)) + .route("/onchain", get(onchain_page)) + .route("/onchain/send", post(post_send_onchain)) + .route("/onchain/confirm", get(onchain_confirm_page)) + .route("/onchain/new-address", post(get_new_address)) + // Channel management + .route("/channels", get(channels_page)) + .route("/channels/open", get(open_channel_page)) + .route("/channels/open", post(post_open_channel)) + .route("/channels/close", get(close_channel_page)) + .route("/channels/close", post(post_close_channel)) + .route("/channels/force-close", get(force_close_channel_page)) + .route("/channels/force-close", post(post_force_close_channel)) + // Invoice creation + .route("/invoices", get(invoices_page)) + .route("/invoices/bolt11", post(post_create_bolt11)) + .route("/invoices/bolt12", post(post_create_bolt12)) + // Payment sending and history + .route("/payments", get(payments_page)) + .route("/payments/send", get(send_payments_page)) + .route("/payments/bolt11", post(post_pay_bolt11)) + .route("/payments/bolt12", post(post_pay_bolt12)) + // Static files - now embedded + .route("/static/{*file}", get(static_handler)) + .layer(ServiceBuilder::new().layer(CorsLayer::permissive())) + .with_state(state) + } + + pub async fn serve(&self, addr: SocketAddr) -> Result<(), Box> { + let app = self.create_router(); + + tracing::info!("Starting web server on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + + tracing::info!("Web interface available at: http://{}", addr); + axum::serve(listener, app).await?; + + Ok(()) + } +} diff --git a/crates/cdk-ldk-node/src/web/static_files.rs b/crates/cdk-ldk-node/src/web/static_files.rs new file mode 100644 index 00000000..666c4089 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/static_files.rs @@ -0,0 +1,47 @@ +use axum::extract::Path; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "static/"] +pub struct Assets; + +fn get_content_type(path: &str) -> &'static str { + if let Some(extension) = path.rsplit('.').next() { + match extension.to_lowercase().as_str() { + "css" => "text/css", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + _ => "application/octet-stream", + } + } else { + "application/octet-stream" + } +} + +pub async fn static_handler(Path(path): Path) -> impl IntoResponse { + let cleaned_path = path.trim_start_matches('/'); + + match Assets::get(cleaned_path) { + Some(content) => { + let content_type = get_content_type(cleaned_path); + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + + // Add cache headers for static assets + headers.insert( + header::CACHE_CONTROL, + "public, max-age=31536000".parse().unwrap(), + ); + + (headers, content.data).into_response() + } + None => { + tracing::warn!("Static file not found: {}", cleaned_path); + (StatusCode::NOT_FOUND, "404 Not Found").into_response() + } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/components.rs b/crates/cdk-ldk-node/src/web/templates/components.rs new file mode 100644 index 00000000..bbada534 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/components.rs @@ -0,0 +1,36 @@ +use maud::{html, Markup}; + +pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup { + html! { + div class="card" { + h2 { (title) } + @for (label, value) in items { + div class="info-item" { + span class="info-label" { (label) ":" } + span class="info-value" { (value) } + } + } + } + } +} + +pub fn form_card(title: &str, form_content: Markup) -> Markup { + html! { + div class="card" { + h2 { (title) } + (form_content) + } + } +} + +pub fn success_message(message: &str) -> Markup { + html! { + div class="success" { (message) } + } +} + +pub fn error_message(message: &str) -> Markup { + html! { + div class="error" { (message) } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/formatters.rs b/crates/cdk-ldk-node/src/web/templates/formatters.rs new file mode 100644 index 00000000..97630a13 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/formatters.rs @@ -0,0 +1,150 @@ +/// Format satoshis as a whole number with Bitcoin symbol (BIP177) +pub fn format_sats_as_btc(sats: u64) -> String { + let sats_str = sats.to_string(); + let formatted_sats = if sats_str.len() > 3 { + let mut result = String::new(); + let chars: Vec = sats_str.chars().collect(); + let len = chars.len(); + + for (i, ch) in chars.iter().enumerate() { + // Add comma before every group of 3 digits from right to left + if i > 0 && (len - i) % 3 == 0 { + result.push(','); + } + result.push(*ch); + } + result + } else { + sats_str + }; + + format!("₿{formatted_sats}") +} + +/// Format millisatoshis as satoshis (whole number) with Bitcoin symbol (BIP177) +pub fn format_msats_as_btc(msats: u64) -> String { + let sats = msats / 1000; + let sats_str = sats.to_string(); + let formatted_sats = if sats_str.len() > 3 { + let mut result = String::new(); + let chars: Vec = sats_str.chars().collect(); + let len = chars.len(); + + for (i, ch) in chars.iter().enumerate() { + // Add comma before every group of 3 digits from right to left + if i > 0 && (len - i) % 3 == 0 { + result.push(','); + } + result.push(*ch); + } + result + } else { + sats_str + }; + + format!("₿{formatted_sats}") +} + +/// Format a Unix timestamp as a human-readable date and time +pub fn format_timestamp(timestamp: u64) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let diff = now.saturating_sub(timestamp); + + match diff { + 0..=60 => "Just now".to_string(), + 61..=3600 => format!("{} min ago", diff / 60), + _ => { + // For timestamps older than 1 hour, show UTC time + // Convert to a simple UTC format + let total_seconds = timestamp; + let seconds = total_seconds % 60; + let total_minutes = total_seconds / 60; + let minutes = total_minutes % 60; + let total_hours = total_minutes / 60; + let hours = total_hours % 24; + let days = total_hours / 24; + + // Calculate year, month, day from days since epoch (1970-01-01) + let mut year = 1970; + let mut remaining_days = days; + + // Simple year calculation + loop { + let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let days_in_year = if is_leap_year { 366 } else { 365 }; + + if remaining_days >= days_in_year { + remaining_days -= days_in_year; + year += 1; + } else { + break; + } + } + + // Calculate month and day + let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let days_in_months = if is_leap_year { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut month = 1; + let mut day = remaining_days + 1; + + for &days_in_month in &days_in_months { + if day > days_in_month { + day -= days_in_month; + month += 1; + } else { + break; + } + } + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, month, day, hours, minutes, seconds + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_timestamp() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Test "Just now" (30 seconds ago) + let recent = now - 30; + assert_eq!(format_timestamp(recent), "Just now"); + + // Test minutes ago (30 minutes ago) + let minutes_ago = now - (30 * 60); + assert_eq!(format_timestamp(minutes_ago), "30 min ago"); + + // Test UTC format for older timestamps (2 hours ago) + let hours_ago = now - (2 * 60 * 60); + let result = format_timestamp(hours_ago); + assert!(result.ends_with(" UTC")); + assert!(result.contains("-")); + assert!(result.contains(":")); + + // Test known timestamp: January 1, 2020 00:00:00 UTC + let timestamp_2020 = 1577836800; // 2020-01-01 00:00:00 UTC + let result = format_timestamp(timestamp_2020); + assert_eq!(result, "2020-01-01 00:00:00 UTC"); + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/layout.rs b/crates/cdk-ldk-node/src/web/templates/layout.rs new file mode 100644 index 00000000..d055390d --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/layout.rs @@ -0,0 +1,1326 @@ +use maud::{html, Markup, DOCTYPE}; + +pub fn layout(title: &str, content: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + link rel="icon" type="image/svg+xml" href="/static/favicon.svg"; + link rel="stylesheet" type="text/css" href="/static/css/globe.css"; + title { (title) " - CDK LDK Node" } + style { + " + :root { + /* Light mode (default) */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --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 */ + @media (prefers-color-scheme: dark) { + :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%; + --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; + + /* Header text colors for dark mode */ + --header-title: #ffffff; + --header-subtitle: #e2e8f0; + } + } + + * { + 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; + line-height: 1.5; + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); + font-feature-settings: 'rlig' 1, 'calt' 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + 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; + background-image: url('/static/images/bg.jpg?v=3'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 3rem; + text-align: center; + width: 100%; + height: 400px; /* Fixed height for better proportion */ + display: flex; + align-items: center; + justify-content: center; + } + + /* 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%); + z-index: 2; + width: 100%; + max-width: 1200px; + padding: 0 2rem; + } + + h1 { + font-size: 3rem; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--header-title); + margin-bottom: 1rem; + } + + .subtitle { + font-size: 1.25rem; + color: var(--header-subtitle); + font-weight: 400; + max-width: 600px; + margin: 0 auto; + line-height: 1.6; + } + + @media (max-width: 768px) { + header { + height: 300px; /* 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)); + border-top: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); + border-left: none; + border-right: none; + border-radius: 0; + padding: 0.75rem; + margin-bottom: 2rem; + } + + nav .container { + padding: 0; + display: flex; + justify-content: center; + } + + nav ul { + list-style: none; + display: flex; + gap: 0.5rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0; + padding: 0; + justify-content: center; + } + + nav li { + flex-shrink: 0; + } + + nav a { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + text-decoration: none; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + padding: 1rem 1.5rem; + border-radius: calc(var(--radius) - 2px); + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + 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)); + border-radius: var(--radius); + padding: 1.5rem; + 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; + gap: 1rem; + margin: 1rem 0; + flex-wrap: wrap; + } + + .metric-card { + flex: 1; + min-width: 200px; + text-align: center; + padding: 1rem; + background-color: hsl(var(--muted) / 0.3); + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + } + + .metric-value { + font-size: 1.5rem; + font-weight: 600; + color: hsl(var(--foreground)); + 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 { + font-size: var(--fs-title); + line-height: var(--lh-tight); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + text-transform: none; + margin: 0 0 12px; + } + + h3 { + font-size: var(--fs-title); + line-height: var(--lh-tight); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + text-transform: none; + margin: 0 0 12px; + } + + .form-group { + margin-bottom: 1.5rem; + } + + label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; + } + + input, textarea, select { + flex: 1; + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--input)); + border-radius: calc(var(--radius) - 2px); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25; + color: hsl(var(--foreground)); + 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; + justify-content: center; + white-space: nowrap; + border-radius: calc(var(--radius) - 2px); + font-size: 0.875rem; + font-weight: 600; + transition: all 150ms ease-in-out; + border: 1px solid transparent; + cursor: pointer; + padding: 0.5rem 1rem; + height: 2.25rem; + 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)); + } + + .button-destructive:hover { + background-color: hsl(var(--destructive) / 0.9); + } + + .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 { + font-size: var(--fs-label); + line-height: var(--lh-normal); + font-weight: var(--fw-medium); + color: var(--fg-muted); + text-transform: none; + letter-spacing: 0.02em; + flex-shrink: 0; + } + + .info-value { + font-size: 0.875rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: var(--fg-primary); + text-align: right; + word-break: break-all; + overflow-wrap: break-word; + hyphens: auto; + min-width: 0; + } + + .info-item { + display: flex; + gap: 0.5rem; + align-items: baseline; + margin: 8px 0; + padding: 1rem 0; + border-bottom: 1px solid hsl(var(--border)); + 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; + color: hsl(var(--foreground)); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 200px; + } + + .copy-button { + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 4px); + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; + transition: all 150ms ease-in-out; + height: auto; + 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, + .balance-label { + display: block; + margin-bottom: 6px; + font-size: var(--fs-label); + line-height: var(--lh-normal); + font-weight: var(--fw-medium); + color: var(--fg-muted); + letter-spacing: 0.02em; + text-transform: none; + } + + .balance-item .balance-amount, + .balance-item-container .balance-value, + .balance-amount, + .balance-amount-value, + .balance-value { + display: block; + font-size: var(--fs-value); + line-height: var(--lh-tight); + font-weight: var(--fw-bold); + color: var(--fg-primary); + 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%); + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 76.2% 36.3%); + border: 1px solid hsl(142.1 76.2% 36.3%); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; + } + + .error { + border-color: hsl(var(--destructive)); + background-color: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); + border: 1px solid hsl(var(--destructive)); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; + } + + .badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + 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 { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + } + + .status-active { + background-color: hsl(142.1 70.6% 45.3%); + color: hsl(355.7 78% 98.4%); + } + + .status-inactive { + background-color: hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); + } + + .channel-item { + 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; + margin-bottom: 1rem; + gap: 1rem; + } + + .channel-id { + 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; + } + + .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; + background-color: hsl(var(--muted) / 0.3); + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + } + + .balance-amount { + font-weight: 600; + font-size: 1.125rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: hsl(var(--foreground)); + line-height: 1.2; + } + + + + .payment-item { + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1.5rem; + } + + .payment-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; + } + + @media (max-width: 640px) { + .payment-header { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + } + + .payment-direction { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: hsl(var(--foreground)); + 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; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + 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; + align-items: flex-start; + 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; + font-size: 0.875rem; + word-break: break-all; + min-width: 0; + } + + .payment-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); + } + + @media (max-width: 640px) { + .payment-list-header { + flex-direction: column; + align-items: stretch; + 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; + justify-content: center; + white-space: nowrap; + padding: 0.5rem 1rem; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + border-radius: calc(var(--radius) - 2px); + text-decoration: none; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + font-weight: 600; + 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)); + } + + .payment-type-badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.125rem 0.5rem; + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + margin-left: 0.5rem; + 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; + justify-content: center; + 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; + justify-content: center; + white-space: nowrap; + border-radius: calc(var(--radius) - 2px); + font-size: 0.875rem; + font-weight: 600; + transition: all 150ms ease-in-out; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + text-decoration: none; + cursor: pointer; + height: 2.25rem; + 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)); + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + } + + .pagination-ellipsis { + display: flex; + align-items: center; + justify-content: center; + height: 2.25rem; + width: 2.25rem; + 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; + gap: 0.75rem; + 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; + } + + .node-info-main-container { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + } + + .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); + 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; + } + + .avatar-image { + width: 48px; + height: 48px; + border-radius: calc(var(--radius) - 2px); + object-fit: cover; + display: block; + } + + .node-details { + flex: 1; + min-width: 0; + } + + .node-name { + font-size: var(--fs-title); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + margin: 0 0 0.25rem 0; + line-height: var(--lh-tight); + word-wrap: break-word; + 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)); + border-radius: var(--radius); + min-height: 200px; + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + overflow: hidden; + } + + .node-metrics { + flex-shrink: 0; + width: 280px; + display: flex; + flex-direction: column; + } + + .node-metrics .card { + margin-bottom: 0; + flex: 1; + display: flex; + flex-direction: column; + } + + .node-metrics .metrics-container { + flex-direction: column; + margin: 1rem 0 0 0; + flex: 1; + } + + .node-metrics .metric-card { + min-width: auto; + } + + /* 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; + } + } + + /* Responsive typography adjustments */ + @media (max-width: 640px) { + :root { + --fs-value: 1.45rem; + } + + .node-name { + font-size: 0.875rem; + } + } + + @media (max-width: 480px) { + .node-name { + font-size: 0.8125rem; + } + } + + /* Dark mode adjustments for globe animation */ + @media (prefers-color-scheme: dark) { + .node-content-box .world { + border-color: rgba(156, 163, 175, 0.4); + fill: rgba(156, 163, 175, 0.2); + } + } + " + } + } + body { + header { + div class="container" { + h1 { "CDK LDK Node" } + p class="subtitle" { "Lightning Network Node Management" } + } + } + + 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" } } + } + } + } + + main class="container" { + (content) + } + } + } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/mod.rs b/crates/cdk-ldk-node/src/web/templates/mod.rs new file mode 100644 index 00000000..d0166085 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/mod.rs @@ -0,0 +1,10 @@ +pub mod components; +pub mod formatters; +pub mod layout; +pub mod payments; + +// Re-export commonly used functions +pub use components::*; +pub use formatters::*; +pub use layout::*; +pub use payments::*; diff --git a/crates/cdk-ldk-node/src/web/templates/payments.rs b/crates/cdk-ldk-node/src/web/templates/payments.rs new file mode 100644 index 00000000..84455bd4 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/payments.rs @@ -0,0 +1,105 @@ +use maud::{html, Markup}; + +use crate::web::templates::formatters::format_timestamp; + +#[allow(clippy::too_many_arguments)] +pub fn payment_list_item( + _payment_id: &str, + direction: &str, + status: &str, + amount: &str, + payment_hash: Option<&str>, + description: Option<&str>, + timestamp: Option, + payment_type: &str, + preimage: Option<&str>, +) -> Markup { + let status_class = match status { + "Succeeded" => "status-active", + "Failed" => "status-inactive", + "Pending" => "status-badge", + _ => "status-badge", + }; + + let direction_icon = match direction { + "Inbound" => "↓", + "Outbound" => "↑", + _ => "•", + }; + + let type_class = match payment_type { + "BOLT11" => "payment-type-bolt11", + "BOLT12" => "payment-type-bolt12", + "On-chain" => "payment-type-onchain", + "Spontaneous" => "payment-type-spontaneous", + "BOLT11 JIT" => "payment-type-bolt11-jit", + _ => "payment-type-unknown", + }; + + html! { + div class="payment-item" { + div class="payment-header" { + div class="payment-direction" { + span class="direction-icon" { (direction_icon) } + span { (direction) " Payment" } + span class=(format!("payment-type-badge {}", type_class)) { (payment_type) } + } + span class=(format!("status-badge {}", status_class)) { (status) } + } + + div class="payment-details" { + div class="payment-amount" { (amount) } + + @if let Some(hash) = payment_hash { + div class="payment-info" { + span class="payment-label" { + @if payment_type == "BOLT11" || payment_type == "BOLT12" || payment_type == "Spontaneous" || payment_type == "BOLT11 JIT" { "Payment Hash:" } + @else { "Transaction ID:" } + } + span class="payment-value" title=(hash) { + (&hash[..std::cmp::min(16, hash.len())]) "..." + } + button class="copy-button" data-copy=(hash) + onclick="navigator.clipboard.writeText(this.getAttribute('data-copy')).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); })" { + "Copy" + } + } + } + + // Show preimage for successful outgoing BOLT11 or BOLT12 payments + @if let Some(preimage_str) = preimage { + @if !preimage_str.is_empty() && direction == "Outbound" && status == "Succeeded" && (payment_type == "BOLT11" || payment_type == "BOLT12") { + div class="payment-info" { + span class="payment-label" { "Preimage:" } + span class="payment-value" title=(preimage_str) { + (&preimage_str[..std::cmp::min(16, preimage_str.len())]) "..." + } + button class="copy-button" data-copy=(preimage_str) + onclick="navigator.clipboard.writeText(this.getAttribute('data-copy')).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); })" { + "Copy" + } + } + } + } + + @if let Some(desc) = description { + @if !desc.is_empty() { + div class="payment-info" { + span class="payment-label" { "Description:" } + span class="payment-value" { (desc) } + } + } + } + + @if let Some(ts) = timestamp { + div class="payment-info" { + span class="payment-label" { "Last Update:" } + span class="payment-value" { + (format_timestamp(ts)) + } + } + } + } + } + } +} diff --git a/crates/cdk-ldk-node/static/css/globe.css b/crates/cdk-ldk-node/static/css/globe.css new file mode 100644 index 00000000..6cdca043 --- /dev/null +++ b/crates/cdk-ldk-node/static/css/globe.css @@ -0,0 +1,75 @@ +/* Spinning Globe Animation CSS - Replaces radar */ +:root { + --globe-hue: 220deg; + --globe-base-bg-sat: 20%; + --globe-base-bg-lum: 12%; + --globe-base-bg: hsl(var(--globe-hue), var(--globe-base-bg-sat), var(--globe-base-bg-lum)); + --globe-base-fg-sat: 50%; + --globe-base-fg-lum: 80%; + --globe-base-fg: hsl(var(--globe-hue), var(--globe-base-fg-sat), var(--globe-base-fg-lum)); + --globe-filter-fg: saturate(100%) brightness(100%); + --globe-module-bg-sat: 18%; + --globe-module-bg-lum: 27%; + --globe-module-bg: hsl(var(--globe-hue), var(--globe-module-bg-sat), var(--globe-module-bg-lum)); +} + +/* Dark mode adjustments for globe */ +@media (prefers-color-scheme: dark) { + :root { + --globe-hue: 220deg; + --globe-base-bg-sat: 25%; + --globe-base-bg-lum: 15%; + --globe-base-fg-sat: 60%; + --globe-base-fg-lum: 85%; + --globe-filter-fg: saturate(120%) brightness(110%); + --globe-module-bg-sat: 22%; + --globe-module-bg-lum: 30%; + } +} + +/* Globe Container - fits inside the gray content box */ +.globe-container { + display: block; + width: 100%; + height: 200px; + position: relative; + overflow: hidden; +} + +.world { + fill: rgba(107, 114, 128, 0.1); /* Gray color with reduced opacity */ + width: 40em; + height: 40em; + position: absolute; + left: 50%; + top: 0%; + transform: translateX(-50%); + border-radius: 50%; + overflow: hidden; + white-space: nowrap; + border: 2px solid rgba(156, 163, 175, 0.2); /* Light gray border with reduced opacity */ + box-sizing: border-box; + background-image: url(#icon-world); + filter: var(--globe-filter-fg); +} + +/* Dark mode globe styling */ +@media (prefers-color-scheme: dark) { + .world { + fill: rgba(156, 163, 175, 0.2); + border-color: rgba(156, 163, 175, 0.4); + } +} + +.world svg { + width: 160em; + height: 40em; + margin-top: calc(-2px + -0.05em); + display: inline; + animation: world-scroll 8s linear infinite; +} + +@keyframes world-scroll { + from { margin-left: -110em; } + to { margin-left: -40em; } +} diff --git a/crates/cdk-ldk-node/static/favicon.ico b/crates/cdk-ldk-node/static/favicon.ico new file mode 100644 index 00000000..cb970476 Binary files /dev/null and b/crates/cdk-ldk-node/static/favicon.ico differ diff --git a/crates/cdk-ldk-node/static/favicon.svg b/crates/cdk-ldk-node/static/favicon.svg new file mode 100644 index 00000000..be884b03 --- /dev/null +++ b/crates/cdk-ldk-node/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cdk-ldk-node/static/images/bg-dark.jpg b/crates/cdk-ldk-node/static/images/bg-dark.jpg new file mode 100644 index 00000000..8cd9b7c2 Binary files /dev/null and b/crates/cdk-ldk-node/static/images/bg-dark.jpg differ diff --git a/crates/cdk-ldk-node/static/images/bg.jpg b/crates/cdk-ldk-node/static/images/bg.jpg new file mode 100644 index 00000000..0c165114 Binary files /dev/null and b/crates/cdk-ldk-node/static/images/bg.jpg differ diff --git a/crates/cdk-ldk-node/static/images/nut.png b/crates/cdk-ldk-node/static/images/nut.png new file mode 100644 index 00000000..70c32d3e Binary files /dev/null and b/crates/cdk-ldk-node/static/images/nut.png differ diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index c55882d1..240e791c 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -21,6 +21,7 @@ cln = ["dep:cdk-cln"] lnd = ["dep:cdk-lnd"] lnbits = ["dep:cdk-lnbits"] fakewallet = ["dep:cdk-fake-wallet"] +ldk-node = ["dep:cdk-ldk-node"] grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] # MSRV is not committed to with swagger enabled @@ -42,12 +43,13 @@ cdk-postgres = { workspace = true, features = ["mint"], optional = true } cdk-cln = { workspace = true, optional = true } cdk-lnbits = { workspace = true, optional = true } cdk-lnd = { workspace = true, optional = true } +cdk-ldk-node = { workspace = true, optional = true } cdk-fake-wallet = { workspace = true, optional = true } cdk-axum.workspace = true cdk-signatory.workspace = true cdk-mint-rpc = { workspace = true, optional = true } cdk-payment-processor = { workspace = true, optional = true } -config = { version = "0.15.11", features = ["toml"] } +config.workspace = true clap.workspace = true bitcoin.workspace = true tokio = { workspace = true, default-features = false, features = ["signal"] } @@ -58,7 +60,7 @@ futures.workspace = true serde.workspace = true bip39.workspace = true tower-http = { workspace = true, features = ["compression-full", "decompression-full"] } -tower = "0.5.2" +tower.workspace = true lightning-invoice.workspace = true home.workspace = true url.workspace = true diff --git a/crates/cdk-mintd/build.rs b/crates/cdk-mintd/build.rs index 3f833b77..43ea2a29 100644 --- a/crates/cdk-mintd/build.rs +++ b/crates/cdk-mintd/build.rs @@ -15,7 +15,8 @@ fn main() { || cfg!(feature = "lnd") || cfg!(feature = "lnbits") || cfg!(feature = "fakewallet") - || cfg!(feature = "grpc-processor"); + || cfg!(feature = "grpc-processor") + || cfg!(feature = "ldk-node"); if !has_lightning_backend { panic!( diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 30983d1e..2b103175 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -64,7 +64,7 @@ max_connections = 20 connection_timeout_seconds = 10 [ln] -# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits' +# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode' ln_backend = "fakewallet" # min_mint=1 # max_mint=500000 @@ -89,6 +89,47 @@ reserve_fee_min = 4 # fee_percent=0.04 # reserve_fee_min=4 +# [ldk_node] +# fee_percent = 0.04 +# reserve_fee_min = 4 +# bitcoin_network = "signet" # mainnet, testnet, signet, regtest +# chain_source_type = "esplora" # esplora, bitcoinrpc +# +# # Mutinynet configuration (recommended for testing) +# esplora_url = "https://mutinynet.com/api" +# gossip_source_type = "rgs" # Use RGS for better performance +# rgs_url = "https://rgs.mutinynet.com/snapshot/0" +# storage_dir_path = "~/.cdk-ldk-node/mutinynet" +# +# # Testnet configuration +# # bitcoin_network = "testnet" +# # esplora_url = "https://blockstream.info/testnet/api" +# # rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +# # storage_dir_path = "~/.cdk-ldk-node/testnet" +# +# # Mainnet configuration (CAUTION: Real Bitcoin!) +# # bitcoin_network = "mainnet" +# # esplora_url = "https://blockstream.info/api" +# # rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +# # storage_dir_path = "~/.cdk-ldk-node/mainnet" +# +# # Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc") +# bitcoind_rpc_host = "127.0.0.1" +# bitcoind_rpc_port = 18443 +# bitcoind_rpc_user = "testuser" +# bitcoind_rpc_password = "testpass" +# +# # Node configuration +# ldk_node_host = "127.0.0.1" +# ldk_node_port = 8090 +# +# # Gossip source configuration +# gossip_source_type = "p2p" # p2p (direct peer-to-peer) or rgs (rapid gossip sync) +# +# # Webserver configuration for LDK node management interface +# webserver_host = "127.0.0.1" # Default: 127.0.0.1 +# webserver_port = 0 # 0 = auto-assign available port + # [fake_wallet] # supported_units = ["sat"] # fee_percent = 0.02 diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 60290165..56bdecd1 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -45,7 +45,7 @@ pub struct LoggingConfig { pub file_level: Option, } -#[derive(Clone, Serialize, Deserialize, Default)] +#[derive(Clone, Serialize, Deserialize)] pub struct Info { pub url: String, pub listen_host: String, @@ -68,6 +68,23 @@ pub struct Info { pub enable_swagger_ui: Option, } +impl Default for Info { + fn default() -> Self { + Info { + url: String::new(), + listen_host: "127.0.0.1".to_string(), + listen_port: 8091, // Default to port 8091 instead of 0 + mnemonic: None, + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cache::Config::default(), + enable_swagger_ui: None, + logging: LoggingConfig::default(), + } + } +} + impl std::fmt::Debug for Info { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Use a fallback approach that won't panic @@ -106,6 +123,8 @@ pub enum LnBackend { FakeWallet, #[cfg(feature = "lnd")] Lnd, + #[cfg(feature = "ldk-node")] + LdkNode, #[cfg(feature = "grpc-processor")] GrpcProcessor, } @@ -123,6 +142,8 @@ impl std::str::FromStr for LnBackend { "fakewallet" => Ok(LnBackend::FakeWallet), #[cfg(feature = "lnd")] "lnd" => Ok(LnBackend::Lnd), + #[cfg(feature = "ldk-node")] + "ldk-node" | "ldknode" => Ok(LnBackend::LdkNode), #[cfg(feature = "grpc-processor")] "grpcprocessor" => Ok(LnBackend::GrpcProcessor), _ => Err(format!("Unknown Lightning backend: {s}")), @@ -183,6 +204,88 @@ pub struct Lnd { pub reserve_fee_min: Amount, } +#[cfg(feature = "ldk-node")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LdkNode { + /// Fee percentage (e.g., 0.02 for 2%) + #[serde(default = "default_ldk_fee_percent")] + pub fee_percent: f32, + /// Minimum reserve fee + #[serde(default = "default_ldk_reserve_fee_min")] + pub reserve_fee_min: Amount, + /// Bitcoin network (mainnet, testnet, signet, regtest) + pub bitcoin_network: Option, + /// Chain source type (esplora or bitcoinrpc) + pub chain_source_type: Option, + /// Esplora URL (when chain_source_type = "esplora") + pub esplora_url: Option, + /// Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc") + pub bitcoind_rpc_host: Option, + pub bitcoind_rpc_port: Option, + pub bitcoind_rpc_user: Option, + pub bitcoind_rpc_password: Option, + /// Storage directory path + pub storage_dir_path: Option, + /// LDK node listening host + pub ldk_node_host: Option, + /// LDK node listening port + pub ldk_node_port: Option, + /// Gossip source type (p2p or rgs) + pub gossip_source_type: Option, + /// Rapid Gossip Sync URL (when gossip_source_type = "rgs") + pub rgs_url: Option, + /// Webserver host (defaults to 127.0.0.1) + #[serde(default = "default_webserver_host")] + pub webserver_host: Option, + /// Webserver port + #[serde(default = "default_webserver_port")] + pub webserver_port: Option, +} + +#[cfg(feature = "ldk-node")] +impl Default for LdkNode { + fn default() -> Self { + Self { + fee_percent: default_ldk_fee_percent(), + reserve_fee_min: default_ldk_reserve_fee_min(), + bitcoin_network: None, + chain_source_type: None, + esplora_url: None, + bitcoind_rpc_host: None, + bitcoind_rpc_port: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + ldk_node_host: None, + ldk_node_port: None, + gossip_source_type: None, + rgs_url: None, + webserver_host: default_webserver_host(), + webserver_port: default_webserver_port(), + } + } +} + +#[cfg(feature = "ldk-node")] +fn default_ldk_fee_percent() -> f32 { + 0.04 +} + +#[cfg(feature = "ldk-node")] +fn default_ldk_reserve_fee_min() -> Amount { + 4.into() +} + +#[cfg(feature = "ldk-node")] +fn default_webserver_host() -> Option { + Some("127.0.0.1".to_string()) +} + +#[cfg(feature = "ldk-node")] +fn default_webserver_port() -> Option { + Some(8091) +} + #[cfg(feature = "fakewallet")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { @@ -337,6 +440,8 @@ pub struct Settings { pub lnbits: Option, #[cfg(feature = "lnd")] pub lnd: Option, + #[cfg(feature = "ldk-node")] + pub ldk_node: Option, #[cfg(feature = "fakewallet")] pub fake_wallet: Option, pub grpc_processor: Option, @@ -443,6 +548,13 @@ impl Settings { "LND backend requires a valid config." ) } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + assert!( + settings.ldk_node.is_some(), + "LDK Node backend requires a valid config." + ) + } #[cfg(feature = "fakewallet")] LnBackend::FakeWallet => assert!( settings.fake_wallet.is_some(), @@ -464,8 +576,6 @@ impl Settings { #[cfg(test)] mod tests { - use std::str::FromStr; - use super::*; #[test] @@ -532,45 +642,4 @@ mod tests { assert!(!debug_output.contains("特殊字符 !@#$%^&*()")); assert!(debug_output.contains(" Self { + if let Ok(fee_percent) = env::var(LDK_NODE_FEE_PERCENT_ENV_VAR) { + if let Ok(fee_percent) = fee_percent.parse::() { + self.fee_percent = fee_percent; + } + } + + if let Ok(reserve_fee_min) = env::var(LDK_NODE_RESERVE_FEE_MIN_ENV_VAR) { + if let Ok(reserve_fee_min) = reserve_fee_min.parse::() { + self.reserve_fee_min = reserve_fee_min.into(); + } + } + + if let Ok(bitcoin_network) = env::var(LDK_NODE_BITCOIN_NETWORK_ENV_VAR) { + self.bitcoin_network = Some(bitcoin_network); + } + + if let Ok(chain_source_type) = env::var(LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR) { + self.chain_source_type = Some(chain_source_type); + } + + if let Ok(esplora_url) = env::var(LDK_NODE_ESPLORA_URL_ENV_VAR) { + self.esplora_url = Some(esplora_url); + } + + if let Ok(bitcoind_rpc_host) = env::var(LDK_NODE_BITCOIND_RPC_HOST_ENV_VAR) { + self.bitcoind_rpc_host = Some(bitcoind_rpc_host); + } + + if let Ok(bitcoind_rpc_port) = env::var(LDK_NODE_BITCOIND_RPC_PORT_ENV_VAR) { + if let Ok(bitcoind_rpc_port) = bitcoind_rpc_port.parse::() { + self.bitcoind_rpc_port = Some(bitcoind_rpc_port); + } + } + + if let Ok(bitcoind_rpc_user) = env::var(LDK_NODE_BITCOIND_RPC_USER_ENV_VAR) { + self.bitcoind_rpc_user = Some(bitcoind_rpc_user); + } + + if let Ok(bitcoind_rpc_password) = env::var(LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR) { + self.bitcoind_rpc_password = Some(bitcoind_rpc_password); + } + + if let Ok(storage_dir_path) = env::var(LDK_NODE_STORAGE_DIR_PATH_ENV_VAR) { + self.storage_dir_path = Some(storage_dir_path); + } + + if let Ok(ldk_node_host) = env::var(LDK_NODE_LDK_NODE_HOST_ENV_VAR) { + self.ldk_node_host = Some(ldk_node_host); + } + + if let Ok(ldk_node_port) = env::var(LDK_NODE_LDK_NODE_PORT_ENV_VAR) { + if let Ok(ldk_node_port) = ldk_node_port.parse::() { + self.ldk_node_port = Some(ldk_node_port); + } + } + + if let Ok(gossip_source_type) = env::var(LDK_NODE_GOSSIP_SOURCE_TYPE_ENV_VAR) { + self.gossip_source_type = Some(gossip_source_type); + } + + if let Ok(rgs_url) = env::var(LDK_NODE_RGS_URL_ENV_VAR) { + self.rgs_url = Some(rgs_url); + } + + if let Ok(webserver_host) = env::var(LDK_NODE_WEBSERVER_HOST_ENV_VAR) { + self.webserver_host = Some(webserver_host); + } + + if let Ok(webserver_port) = env::var(LDK_NODE_WEBSERVER_PORT_ENV_VAR) { + if let Ok(webserver_port) = webserver_port.parse::() { + self.webserver_port = Some(webserver_port); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 2d8c234b..8a4edcde 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -17,6 +17,8 @@ mod cln; mod fake_wallet; #[cfg(feature = "grpc-processor")] mod grpc_processor; +#[cfg(feature = "ldk-node")] +mod ldk_node; #[cfg(feature = "lnbits")] mod lnbits; #[cfg(feature = "lnd")] @@ -38,6 +40,8 @@ pub use database::*; pub use fake_wallet::*; #[cfg(feature = "grpc-processor")] pub use grpc_processor::*; +#[cfg(feature = "ldk-node")] +pub use ldk_node::*; pub use ln::*; #[cfg(feature = "lnbits")] pub use lnbits::*; @@ -111,6 +115,10 @@ impl Settings { LnBackend::Lnd => { self.lnd = Some(self.lnd.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + self.ldk_node = Some(self.ldk_node.clone().unwrap_or_default().from_env()); + } #[cfg(feature = "grpc-processor")] LnBackend::GrpcProcessor => { self.grpc_processor = diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 962705bf..233b2738 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -22,6 +22,7 @@ use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; feature = "cln", feature = "lnbits", feature = "lnd", + feature = "ldk-node", feature = "fakewallet", feature = "grpc-processor" ))] @@ -31,6 +32,7 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "cln", feature = "lnbits", feature = "lnd", + feature = "ldk-node", feature = "fakewallet" ))] use cdk::nuts::CurrencyUnit; @@ -108,9 +110,10 @@ pub fn setup_tracing( let hyper_filter = "hyper=warn,rustls=warn,reqwest=warn"; let h2_filter = "h2=warn"; let tower_http = "tower_http=warn"; + let rustls = "rustls=warn"; let env_filter = EnvFilter::new(format!( - "{default_filter},{hyper_filter},{h2_filter},{tower_http}" + "{default_filter},{hyper_filter},{h2_filter},{tower_http},{rustls}" )); use config::LoggingOutput; @@ -321,6 +324,8 @@ async fn setup_sqlite_database( async fn configure_mint_builder( settings: &config::Settings, mint_builder: MintBuilder, + runtime: Option>, + work_dir: &Path, ) -> Result<(MintBuilder, Vec)> { let mut ln_routers = vec![]; @@ -328,7 +333,9 @@ async fn configure_mint_builder( let mint_builder = configure_basic_info(settings, mint_builder); // Configure lightning backend - let mint_builder = configure_lightning_backend(settings, mint_builder, &mut ln_routers).await?; + let mint_builder = + configure_lightning_backend(settings, mint_builder, &mut ln_routers, runtime, work_dir) + .await?; // Configure caching let mint_builder = configure_cache(settings, mint_builder); @@ -391,6 +398,8 @@ async fn configure_lightning_backend( settings: &config::Settings, mut mint_builder: MintBuilder, ln_routers: &mut Vec, + _runtime: Option>, + work_dir: &Path, ) -> Result { let mint_melt_limits = MintMeltLimits { mint_min: settings.ln.min_mint, @@ -409,7 +418,7 @@ async fn configure_lightning_backend( .clone() .expect("Config checked at load that cln is some"); let cln = cln_settings - .setup(ln_routers, settings, CurrencyUnit::Msat) + .setup(ln_routers, settings, CurrencyUnit::Msat, None, work_dir) .await?; mint_builder = configure_backend_for_unit( @@ -425,7 +434,7 @@ async fn configure_lightning_backend( LnBackend::LNbits => { let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); let lnbits = lnbits_settings - .setup(ln_routers, settings, CurrencyUnit::Sat) + .setup(ln_routers, settings, CurrencyUnit::Sat, None, work_dir) .await?; mint_builder = configure_backend_for_unit( @@ -441,7 +450,7 @@ async fn configure_lightning_backend( LnBackend::Lnd => { let lnd_settings = settings.clone().lnd.expect("Checked at config load"); let lnd = lnd_settings - .setup(ln_routers, settings, CurrencyUnit::Msat) + .setup(ln_routers, settings, CurrencyUnit::Msat, None, work_dir) .await?; mint_builder = configure_backend_for_unit( @@ -460,7 +469,7 @@ async fn configure_lightning_backend( for unit in fake_wallet.clone().supported_units { let fake = fake_wallet - .setup(ln_routers, settings, unit.clone()) + .setup(ln_routers, settings, unit.clone(), None, work_dir) .await?; mint_builder = configure_backend_for_unit( @@ -489,7 +498,7 @@ async fn configure_lightning_backend( for unit in grpc_processor.clone().supported_units { tracing::debug!("Adding unit: {:?}", unit); let processor = grpc_processor - .setup(ln_routers, settings, unit.clone()) + .setup(ln_routers, settings, unit.clone(), None, work_dir) .await?; mint_builder = configure_backend_for_unit( @@ -502,6 +511,24 @@ async fn configure_lightning_backend( .await?; } } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + let ldk_node_settings = settings.clone().ldk_node.expect("Checked at config load"); + tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings); + + let ldk_node = ldk_node_settings + .setup(ln_routers, settings, CurrencyUnit::Sat, _runtime, work_dir) + .await?; + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(ldk_node), + ) + .await?; + } LnBackend::None => { tracing::error!( "Payment backend was not set or feature disabled. {:?}", @@ -953,6 +980,7 @@ pub async fn run_mintd( settings: &config::Settings, db_password: Option, enable_logging: bool, + runtime: Option>, ) -> Result<()> { let _guard = if enable_logging { setup_tracing(work_dir, &settings.info.logging)? @@ -960,7 +988,8 @@ pub async fn run_mintd( None }; - let result = run_mintd_with_shutdown(work_dir, settings, shutdown_signal(), db_password).await; + let result = + run_mintd_with_shutdown(work_dir, settings, shutdown_signal(), db_password, runtime).await; // Explicitly drop the guard to ensure proper cleanup if let Some(guard) = _guard { @@ -981,12 +1010,14 @@ pub async fn run_mintd_with_shutdown( settings: &config::Settings, shutdown_signal: impl std::future::Future + Send + 'static, db_password: Option, + runtime: Option>, ) -> Result<()> { let (localstore, keystore) = initial_setup(work_dir, settings, db_password.clone()).await?; let mint_builder = MintBuilder::new(localstore); - let (mint_builder, ln_routers) = configure_mint_builder(settings, mint_builder).await?; + let (mint_builder, ln_routers) = + configure_mint_builder(settings, mint_builder, runtime, work_dir).await?; #[cfg(feature = "auth")] let mint_builder = setup_authentication(settings, work_dir, mint_builder, db_password).await?; diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 1d3dd64f..435dcb04 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -2,41 +2,37 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -// Ensure at least one lightning backend is enabled at compile time -#[cfg(not(any( - feature = "cln", - feature = "lnbits", - feature = "lnd", - feature = "fakewallet", - feature = "grpc-processor" -)))] -compile_error!( - "At least one lightning backend feature must be enabled: cln, lnbits, lnd, fakewallet, or grpc-processor" -); - -// Ensure at least one database backend is enabled at compile time -#[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!("At least one database backend feature must be enabled: sqlite or postgres"); +use std::sync::Arc; use anyhow::Result; use cdk_mintd::cli::CLIArgs; use cdk_mintd::{get_work_directory, load_settings}; use clap::Parser; -use tokio::main; +use tokio::runtime::Runtime; -#[main] -async fn main() -> Result<()> { - let args = CLIArgs::parse(); +fn main() -> Result<()> { + let rt = Arc::new(Runtime::new()?); - let work_dir = get_work_directory(&args).await?; - let settings = load_settings(&work_dir, args.config)?; + let rt_clone = Arc::clone(&rt); - #[cfg(feature = "sqlcipher")] - let password = Some(CLIArgs::parse().password); + rt.block_on(async { + let args = CLIArgs::parse(); + let work_dir = get_work_directory(&args).await?; + let settings = load_settings(&work_dir, args.config)?; - #[cfg(not(feature = "sqlcipher"))] - let password = None; + #[cfg(feature = "sqlcipher")] + let password = Some(CLIArgs::parse().password); - // Use the main function that handles logging setup and cleanup - cdk_mintd::run_mintd(&work_dir, &settings, password, args.enable_logging).await + #[cfg(not(feature = "sqlcipher"))] + let password = None; + + cdk_mintd::run_mintd( + &work_dir, + &settings, + password, + args.enable_logging, + Some(rt_clone), + ) + .await + }) } diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 507de0e3..ec03fd33 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; #[cfg(feature = "fakewallet")] use std::collections::HashSet; +use std::path::Path; #[cfg(feature = "cln")] use anyhow::anyhow; @@ -15,6 +16,7 @@ use cdk::nuts::CurrencyUnit; feature = "lnbits", feature = "cln", feature = "lnd", + feature = "ldk-node", feature = "fakewallet" ))] use cdk::types::FeeReserve; @@ -30,6 +32,8 @@ pub trait LnBackendSetup { routers: &mut Vec, settings: &Settings, unit: CurrencyUnit, + runtime: Option>, + work_dir: &Path, ) -> anyhow::Result; } @@ -41,6 +45,8 @@ impl LnBackendSetup for config::Cln { _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, ) -> anyhow::Result { let cln_socket = expand_path( self.rpc_path @@ -68,6 +74,8 @@ impl LnBackendSetup for config::LNbits { _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, ) -> anyhow::Result { let admin_api_key = &self.admin_api_key; let invoice_api_key = &self.invoice_api_key; @@ -100,6 +108,8 @@ impl LnBackendSetup for config::Lnd { _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, ) -> anyhow::Result { let address = &self.address; let cert_file = &self.cert_file; @@ -130,6 +140,8 @@ impl LnBackendSetup for config::FakeWallet { _router: &mut Vec, _settings: &Settings, unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, ) -> anyhow::Result { let fee_reserve = FeeReserve { min_fee_reserve: self.reserve_fee_min, @@ -160,6 +172,8 @@ impl LnBackendSetup for config::GrpcProcessor { _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, ) -> anyhow::Result { let payment_processor = cdk_payment_processor::PaymentProcessorClient::new( &self.addr, @@ -171,3 +185,138 @@ impl LnBackendSetup for config::GrpcProcessor { Ok(payment_processor) } } + +#[cfg(feature = "ldk-node")] +#[async_trait] +impl LnBackendSetup for config::LdkNode { + async fn setup( + &self, + _routers: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + runtime: Option>, + work_dir: &Path, + ) -> anyhow::Result { + use std::net::SocketAddr; + + use bitcoin::Network; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + // Parse network from config + let network = match self + .bitcoin_network + .as_ref() + .map(|n| n.to_lowercase()) + .as_deref() + .unwrap_or("regtest") + { + "mainnet" | "bitcoin" => Network::Bitcoin, + "testnet" => Network::Testnet, + "signet" => Network::Signet, + _ => Network::Regtest, + }; + + // Parse chain source from config + let chain_source = match self + .chain_source_type + .as_ref() + .map(|s| s.to_lowercase()) + .as_deref() + .unwrap_or("esplora") + { + "bitcoinrpc" => { + let host = self + .bitcoind_rpc_host + .clone() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = self.bitcoind_rpc_port.unwrap_or(18443); + let user = self + .bitcoind_rpc_user + .clone() + .unwrap_or_else(|| "testuser".to_string()); + let password = self + .bitcoind_rpc_password + .clone() + .unwrap_or_else(|| "testpass".to_string()); + + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host, + port, + user, + password, + }) + } + _ => { + let esplora_url = self + .esplora_url + .clone() + .unwrap_or_else(|| "https://mutinynet.com/api".to_string()); + cdk_ldk_node::ChainSource::Esplora(esplora_url) + } + }; + + // Parse gossip source from config + let gossip_source = match self.rgs_url.clone() { + Some(rgs_url) => cdk_ldk_node::GossipSource::RapidGossipSync(rgs_url), + None => cdk_ldk_node::GossipSource::P2P, + }; + + // Get storage directory path + let storage_dir_path = if let Some(dir_path) = &self.storage_dir_path { + dir_path.clone() + } else { + let mut work_dir = work_dir.to_path_buf(); + work_dir.push("ldk-node"); + work_dir.to_string_lossy().to_string() + }; + + // Get LDK node listen address + let host = self + .ldk_node_host + .clone() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = self.ldk_node_port.unwrap_or(8090); + + let socket_addr = SocketAddr::new(host.parse()?, port); + + // Parse socket address using ldk_node's SocketAddress + // We need to get the actual socket address struct from ldk_node + // For now, let's construct it manually based on the cdk-ldk-node implementation + let listen_address = vec![socket_addr.into()]; + + let mut ldk_node = cdk_ldk_node::CdkLdkNode::new( + network, + chain_source, + gossip_source, + storage_dir_path, + fee_reserve, + listen_address, + runtime, + )?; + + // Configure webserver address if specified + let webserver_addr = if let Some(host) = &self.webserver_host { + let port = self.webserver_port.unwrap_or(8091); + let socket_addr: SocketAddr = format!("{host}:{port}").parse()?; + Some(socket_addr) + } else if self.webserver_port.is_some() { + // If only port is specified, use default host + let port = self.webserver_port.unwrap_or(8091); + let socket_addr: SocketAddr = format!("127.0.0.1:{port}").parse()?; + Some(socket_addr) + } else { + // Use default webserver address if nothing is configured + Some(cdk_ldk_node::CdkLdkNode::default_web_addr()) + }; + + println!("webserver: {:?}", webserver_addr); + + ldk_node.set_web_addr(webserver_addr); + + Ok(ldk_node) + } +} diff --git a/crates/cdk-payment-processor/src/proto/mod.rs b/crates/cdk-payment-processor/src/proto/mod.rs index cf5995d9..229d1c02 100644 --- a/crates/cdk-payment-processor/src/proto/mod.rs +++ b/crates/cdk-payment-processor/src/proto/mod.rs @@ -37,6 +37,10 @@ impl From for PaymentIdentifier { r#type: PaymentIdentifierType::CustomId.into(), value: Some(payment_identifier::Value::Id(id)), }, + CdkPaymentIdentifier::PaymentId(hash) => Self { + r#type: PaymentIdentifierType::PaymentId.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, } } } diff --git a/crates/cdk-payment-processor/src/proto/payment_processor.proto b/crates/cdk-payment-processor/src/proto/payment_processor.proto index 187c6eed..fad00ffa 100644 --- a/crates/cdk-payment-processor/src/proto/payment_processor.proto +++ b/crates/cdk-payment-processor/src/proto/payment_processor.proto @@ -46,6 +46,7 @@ enum PaymentIdentifierType { LABEL = 2; BOLT12_PAYMENT_HASH = 3; CUSTOM_ID = 4; + PAYMENT_ID = 5; } message PaymentIdentifier { diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index aa28df26..385a00cc 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -546,6 +546,11 @@ where amount_paid: Amount, payment_id: String, ) -> Result { + if amount_paid == Amount::ZERO { + tracing::warn!("Amount payments of zero amount should not be recorded."); + return Err(Error::Duplicate); + } + // Check if payment_id already exists in mint_quote_payments let exists = query( r#" @@ -592,6 +597,13 @@ where .checked_add(amount_paid) .ok_or_else(|| database::Error::AmountOverflow)?; + tracing::debug!( + "Mint quote {} amount paid was {} is now {}.", + quote_id, + current_amount_paid, + new_amount_paid + ); + // Update the amount_paid query( r#" diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index b1268719..ee8bc966 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,4 +1,3 @@ -use cdk_common::amount::to_unit; use cdk_common::mint::MintQuote; use cdk_common::payment::{ Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions, @@ -251,14 +250,10 @@ impl Mint { let description = bolt12_request.description; - let mint_ttl = self.localstore.get_quote_ttl().await?.mint_ttl; - - let expiry = unix_time() + mint_ttl; - let bolt12_options = Bolt12IncomingPaymentOptions { description, amount, - unix_expiry: Some(expiry), + unix_expiry: None, }; let incoming_options = IncomingPaymentOptions::Bolt12(Box::new(bolt12_options)); @@ -410,52 +405,8 @@ impl Mint { mint_quote: &MintQuote, wait_payment_response: WaitPaymentResponse, ) -> Result<(), Error> { - tracing::debug!( - "Received payment notification of {} {} for mint quote {} with payment id {}", - wait_payment_response.payment_amount, - wait_payment_response.unit, - mint_quote.id, - wait_payment_response.payment_id.to_string() - ); - - let quote_state = mint_quote.state(); - if !mint_quote - .payment_ids() - .contains(&&wait_payment_response.payment_id) - { - if mint_quote.payment_method == PaymentMethod::Bolt11 - && (quote_state == MintQuoteState::Issued || quote_state == MintQuoteState::Paid) - { - tracing::info!("Received payment notification for already seen payment."); - } else { - let payment_amount_quote_unit = to_unit( - wait_payment_response.payment_amount, - &wait_payment_response.unit, - &mint_quote.unit, - )?; - - tracing::debug!( - "Payment received amount in quote unit {} {}", - mint_quote.unit, - payment_amount_quote_unit - ); - - let total_paid = tx - .increment_mint_quote_amount_paid( - &mint_quote.id, - payment_amount_quote_unit, - wait_payment_response.payment_id, - ) - .await?; - - self.pubsub_manager - .mint_quote_payment(mint_quote, total_paid); - } - } else { - tracing::info!("Received payment notification for already seen payment."); - } - - Ok(()) + Self::handle_mint_quote_payment(tx, mint_quote, wait_payment_response, &self.pubsub_manager) + .await } /// Checks the status of a mint quote and updates it if necessary @@ -477,7 +428,9 @@ impl Mint { .await? .ok_or(Error::UnknownQuote)?; - self.check_mint_quote_paid(&mut quote).await?; + if quote.payment_method == PaymentMethod::Bolt11 { + self.check_mint_quote_paid(&mut quote).await?; + } quote.try_into() } @@ -508,8 +461,9 @@ impl Mint { .get_mint_quote(&mint_request.quote) .await? .ok_or(Error::UnknownQuote)?; - - self.check_mint_quote_paid(&mut mint_quote).await?; + if mint_quote.payment_method == PaymentMethod::Bolt11 { + self.check_mint_quote_paid(&mut mint_quote).await?; + } // get the blind signatures before having starting the db transaction, if there are any // rollbacks this blind_signatures will be lost, and the signature is stateless. It is not a diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index cc11509b..bd93baca 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -199,13 +199,8 @@ impl Mint { /// # Background Services /// /// Currently manages: + /// - Payment processor initialization and startup /// - Invoice payment monitoring across all configured payment processors - /// - /// Future services may include: - /// - Quote cleanup and expiration management - /// - Periodic database maintenance - /// - Health check monitoring - /// - Metrics collection pub async fn start(&self) -> Result<(), Error> { let mut task_state = self.task_state.lock().await; @@ -214,6 +209,33 @@ impl Mint { return Err(Error::Internal); // Already started } + // Start all payment processors first + tracing::info!("Starting payment processors..."); + let mut seen_processors = Vec::new(); + for (key, processor) in &self.payment_processors { + // Skip if we've already spawned a task for this processor instance + if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) { + continue; + } + + seen_processors.push(Arc::clone(processor)); + + tracing::info!("Starting payment wait task for {:?}", key); + + match processor.start().await { + Ok(()) => { + tracing::debug!("Successfully started payment processor for {:?}", key); + } + Err(e) => { + // Log the error but continue with other processors + tracing::error!("Failed to start payment processor for {:?}: {}", key, e); + return Err(e.into()); + } + } + } + + tracing::info!("Payment processor startup completed"); + // Create shutdown signal let shutdown_notify = Arc::new(Notify::new()); @@ -266,7 +288,8 @@ impl Mint { (Some(notify), Some(handle)) => (notify, handle), _ => { tracing::debug!("Stop called but no background services were running"); - return Ok(()); // Nothing to stop + // Still try to stop payment processors + return self.stop_payment_processors().await; } }; @@ -279,7 +302,7 @@ impl Mint { shutdown_notify.notify_waiters(); // Wait for supervisor to complete - match supervisor_handle.await { + let result = match supervisor_handle.await { Ok(result) => { tracing::info!("Mint background services stopped"); result @@ -288,7 +311,39 @@ impl Mint { tracing::error!("Background service task panicked: {:?}", join_error); Err(Error::Internal) } + }; + + // Stop all payment processors + self.stop_payment_processors().await?; + + result + } + + /// Stop all payment processors + async fn stop_payment_processors(&self) -> Result<(), Error> { + tracing::info!("Stopping payment processors..."); + let mut seen_processors = Vec::new(); + + for (key, processor) in &self.payment_processors { + // Skip if we've already spawned a task for this processor instance + if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) { + continue; + } + + seen_processors.push(Arc::clone(processor)); + + match processor.stop().await { + Ok(()) => { + tracing::debug!("Successfully stopped payment processor for {:?}", key); + } + Err(e) => { + // Log the error but continue with other processors + tracing::error!("Failed to stop payment processor for {:?}: {}", key, e); + } + } } + tracing::info!("Payment processor shutdown completed"); + Ok(()) } /// Get the payment processor for the given unit and payment method @@ -460,6 +515,7 @@ impl Mint { } /// Handles payment waiting for a single processor + #[instrument(skip_all)] async fn wait_for_processor_payments( processor: Arc + Send + Sync>, localstore: Arc + Send + Sync>, @@ -498,6 +554,7 @@ impl Mint { /// Handle payment notification without needing full Mint instance /// This is a helper function that can be called with just the required components + #[instrument(skip_all)] async fn handle_payment_notification( localstore: &Arc + Send + Sync>, pubsub_manager: &Arc, @@ -567,6 +624,11 @@ impl Mint { &mint_quote.unit, )?; + if payment_amount_quote_unit == Amount::ZERO { + tracing::error!("Zero amount payments should not be recorded."); + return Err(Error::AmountUndefined); + } + tracing::debug!( "Payment received amount in quote unit {} {}", mint_quote.unit, @@ -737,6 +799,12 @@ impl Mint { let amount = melt_quote.amount; + tracing::info!( + "Mint quote {} paid {} from internal payment.", + mint_quote.id, + amount + ); + let total_paid = tx .increment_mint_quote_amount_paid(&mint_quote.id, amount, melt_quote.id.to_string()) .await?; diff --git a/crates/cdk/src/wallet/issue/issue_bolt12.rs b/crates/cdk/src/wallet/issue/issue_bolt12.rs index cf547798..7df88942 100644 --- a/crates/cdk/src/wallet/issue/issue_bolt12.rs +++ b/crates/cdk/src/wallet/issue/issue_bolt12.rs @@ -91,7 +91,7 @@ impl Wallet { let quote_info = if let Some(quote) = quote_info { if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { - tracing::info!("Minting after expiry"); + tracing::info!("Attempting to mint expired quote."); } quote.clone() @@ -114,7 +114,7 @@ impl Wallet { if amount == Amount::ZERO { tracing::error!("Cannot mint zero amount."); - return Err(Error::InvoiceAmountUndefined); + return Err(Error::UnpaidQuote); } let premint_secrets = match &spending_conditions { diff --git a/justfile b/justfile index f1c305f3..9b2520f1 100644 --- a/justfile +++ b/justfile @@ -53,7 +53,7 @@ format: nixpkgs-fmt $(echo **.nix) # run doc tests -test: build +test: #!/usr/bin/env bash set -euo pipefail if [ ! -f Cargo.toml ]; then diff --git a/misc/interactive_regtest_mprocs.sh b/misc/interactive_regtest_mprocs.sh index 6354c8de..e86215c4 100755 --- a/misc/interactive_regtest_mprocs.sh +++ b/misc/interactive_regtest_mprocs.sh @@ -101,6 +101,7 @@ export CDK_ITESTS_DIR=$(mktemp -d) export CDK_ITESTS_MINT_ADDR="127.0.0.1" export CDK_ITESTS_MINT_PORT_0=8085 export CDK_ITESTS_MINT_PORT_1=8087 +export CDK_ITESTS_MINT_PORT_2=8089 # Check if the temporary directory was created successfully if [[ ! -d "$CDK_ITESTS_DIR" ]]; then @@ -143,7 +144,7 @@ done < "$CDK_ITESTS_DIR/progress_pipe") & # Wait for regtest setup (up to 120 seconds) echo "Waiting for regtest network to be ready..." -for ((i=0; i<120; i++)); do +for ((i=0; i<220; i++)); do if [ -f "$CDK_ITESTS_DIR/signal_received" ]; then break fi @@ -158,16 +159,19 @@ fi # Create work directories for mints mkdir -p "$CDK_ITESTS_DIR/cln_mint" mkdir -p "$CDK_ITESTS_DIR/lnd_mint" +mkdir -p "$CDK_ITESTS_DIR/ldk_node_mint" # Set environment variables for easy access export CDK_TEST_MINT_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0" export CDK_TEST_MINT_URL_2="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1" +export CDK_TEST_MINT_URL_3="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_2" # Create state file for other terminal sessions ENV_FILE="/tmp/cdk_regtest_env" echo "export CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" > "$ENV_FILE" echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" +echo "export CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" >> "$ENV_FILE" echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" # Get the project root directory (where justfile is located) @@ -230,9 +234,50 @@ echo "---" exec cargo run --bin cdk-mintd EOF +cat > "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" << EOF +#!/usr/bin/env bash +cd "$PROJECT_ROOT" +export CDK_MINTD_URL="http://127.0.0.1:8089" +export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/ldk_node_mint" +export CDK_MINTD_LISTEN_HOST="127.0.0.1" +export CDK_MINTD_LISTEN_PORT=8089 +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +export CDK_MINTD_LOGGING_FILE_LEVEL="debug" +export CDK_MINTD_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +export RUST_BACKTRACE=1 +export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" + +# LDK Node specific environment variables +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="regtest" +export CDK_MINTD_LDK_NODE_CHAIN_SOURCE_TYPE="bitcoinrpc" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_HOST="127.0.0.1" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_PORT=18443 +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER="testuser" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD="testpass" +export CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH="$CDK_ITESTS_DIR/ldk_mint" +export CDK_MINTD_LDK_NODE_LDK_NODE_HOST="127.0.0.1" +export CDK_MINTD_LDK_NODE_LDK_NODE_PORT=8090 +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="p2p" +export CDK_MINTD_LDK_NODE_FEE_PERCENT=0.02 +export CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN=2 + +echo "Starting LDK Node Mint on port 8089..." +echo "Project root: $PROJECT_ROOT" +echo "Working directory: \$CDK_MINTD_WORK_DIR" +echo "Bitcoin RPC: 127.0.0.1:18443 (testuser/testpass)" +echo "LDK Node listen: 127.0.0.1:8090" +echo "Storage directory: \$CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH" +echo "Database type: \$CDK_MINTD_DATABASE" +echo "---" + +exec cargo run --bin cdk-mintd --features ldk-node +EOF + # Make scripts executable chmod +x "$CDK_ITESTS_DIR/start_cln_mint.sh" chmod +x "$CDK_ITESTS_DIR/start_lnd_mint.sh" +chmod +x "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" echo echo "==============================================" @@ -247,16 +292,18 @@ echo " • LND Node 1: https://localhost:10009" echo " • LND Node 2: https://localhost:10010" echo echo "CDK Mints (will be managed by mprocs):" -echo " • CLN Mint: $CDK_TEST_MINT_URL" -echo " • LND Mint: $CDK_TEST_MINT_URL_2" +echo " • CLN Mint: $CDK_TEST_MINT_URL" +echo " • LND Mint: $CDK_TEST_MINT_URL_2" +echo " • LDK Node Mint: $CDK_TEST_MINT_URL_3" echo echo "Files and Directories:" echo " • Working Directory: $CDK_ITESTS_DIR" -echo " • Start Scripts: $CDK_ITESTS_DIR/start_{cln,lnd}_mint.sh" +echo " • Start Scripts: $CDK_ITESTS_DIR/start_{cln,lnd,ldk_node}_mint.sh" echo echo "Environment Variables (available in other terminals):" echo " • CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" echo " • CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" +echo " • CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" echo " • CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" echo echo "Starting mprocs with direct process management..." @@ -290,6 +337,13 @@ procs: CDK_ITESTS_DIR: "$CDK_ITESTS_DIR" CDK_MINTD_DATABASE: "$CDK_MINTD_DATABASE" + ldk-node-mint: + shell: "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" + autostart: true + env: + CDK_ITESTS_DIR: "$CDK_ITESTS_DIR" + CDK_MINTD_DATABASE: "$CDK_MINTD_DATABASE" + bitcoind: shell: "while [ ! -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log" autostart: true @@ -309,6 +363,10 @@ procs: lnd-two: shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log" autostart: true + + ldk-node: + shell: "while [ ! -f $CDK_ITESTS_DIR/ldk_mint/ldk_node.log ]; do sleep 1; done && $PROJECT_ROOT/misc/scripts/filtered_ldk_node_log.sh $CDK_ITESTS_DIR/ldk_mint/ldk_node.log" + autostart: true settings: mouse_scroll_speed: 3 diff --git a/misc/itests.sh b/misc/itests.sh index 7fc490f4..dd5f01e8 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -22,11 +22,11 @@ cleanup() { echo "Mint binary terminated" - # Remove the temporary directory - if [ ! -z "$CDK_ITESTS_DIR" ] && [ -d "$CDK_ITESTS_DIR" ]; then - rm -rf "$CDK_ITESTS_DIR" - echo "Temp directory removed: $CDK_ITESTS_DIR" - fi + # # Remove the temporary directory + # if [ ! -z "$CDK_ITESTS_DIR" ] && [ -d "$CDK_ITESTS_DIR" ]; then + # rm -rf "$CDK_ITESTS_DIR" + # echo "Temp directory removed: $CDK_ITESTS_DIR" + # fi # Unset all environment variables unset CDK_ITESTS_DIR @@ -39,6 +39,7 @@ cleanup() { unset CDK_REGTEST_PID unset RUST_BACKTRACE unset CDK_TEST_REGTEST + unset CDK_TEST_LIGHTNING_CLIENT } # Set up trap to call cleanup on script exit @@ -61,7 +62,6 @@ fi echo "Temp directory created: $CDK_ITESTS_DIR" export CDK_MINTD_DATABASE="$1" -cargo build -p cdk-integration-tests cargo build --bin start_regtest_mints echo "Starting regtest and mints" @@ -115,7 +115,7 @@ export CDK_TEST_MINT_URL_2 URL="$CDK_TEST_MINT_URL/v1/info" -TIMEOUT=100 +TIMEOUT=500 START_TIME=$(date +%s) # Loop until the endpoint returns a 200 OK status or timeout is reached while true; do @@ -177,29 +177,30 @@ while true; do done # Run cargo test -echo "Running regtest test with CLN mint" +echo "Running regtest test with CLN mint and CLN client" +export CDK_TEST_LIGHTNING_CLIENT="lnd" cargo test -p cdk-integration-tests --test regtest if [ $? -ne 0 ]; then - echo "regtest test failed, exiting" + echo "regtest test with cln mint failed, exiting" exit 1 fi -echo "Running happy_path_mint_wallet test with CLN mint" +echo "Running happy_path_mint_wallet test with CLN mint and CLN client" cargo test -p cdk-integration-tests --test happy_path_mint_wallet if [ $? -ne 0 ]; then - echo "happy_path_mint_wallet test failed, exiting" + echo "happy_path_mint_wallet with cln mint test failed, exiting" exit 1 fi # Run cargo test with the http_subscription feature -echo "Running regtest test with http_subscription feature" +echo "Running regtest test with http_subscription feature (CLN client)" cargo test -p cdk-integration-tests --test regtest --features http_subscription if [ $? -ne 0 ]; then echo "regtest test with http_subscription failed, exiting" exit 1 fi -echo "Running regtest test with cln mint for bolt12" +echo "Running regtest test with cln mint for bolt12 (CLN client)" cargo test -p cdk-integration-tests --test bolt12 if [ $? -ne 0 ]; then echo "regtest test failed, exiting" @@ -209,24 +210,73 @@ fi # Switch Mints: Run tests with LND mint echo "Switching to LND mint for tests" -echo "Running regtest test with LND mint" +echo "Running regtest test with LND mint and LND client" CDK_TEST_MINT_URL_SWITCHED=$CDK_TEST_MINT_URL_2 CDK_TEST_MINT_URL_2_SWITCHED=$CDK_TEST_MINT_URL export CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL_SWITCHED export CDK_TEST_MINT_URL_2=$CDK_TEST_MINT_URL_2_SWITCHED -cargo test -p cdk-integration-tests --test regtest + cargo test -p cdk-integration-tests --test regtest + if [ $? -ne 0 ]; then + echo "regtest test with LND mint failed, exiting" + exit 1 + fi + + echo "Running happy_path_mint_wallet test with LND mint and LND client" + cargo test -p cdk-integration-tests --test happy_path_mint_wallet + if [ $? -ne 0 ]; then + echo "happy_path_mint_wallet test with LND mint failed, exiting" + exit 1 + fi + + +export CDK_TEST_MINT_URL="http://127.0.0.1:8089" + +TIMEOUT=100 +START_TIME=$(date +%s) +# Loop until the endpoint returns a 200 OK status or timeout is reached +while true; do + # Get the current time + CURRENT_TIME=$(date +%s) + + # Calculate the elapsed time + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + # Check if the elapsed time exceeds the timeout + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + # Make a request to the endpoint and capture the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $CDK_TEST_MINT_URL/v1/info) + + # Check if the HTTP status is 200 OK + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $CDK_TEST_MINT_URL" + break + else + echo "Waiting for 200 OK response, current status: $HTTP_STATUS" + sleep 2 # Wait for 2 seconds before retrying + fi +done + + +echo "Running happy_path_mint_wallet test with LDK mint and CLN client" +export CDK_TEST_LIGHTNING_CLIENT="cln" # Use CLN client for LDK tests +cargo test -p cdk-integration-tests --test happy_path_mint_wallet if [ $? -ne 0 ]; then - echo "regtest test with LND mint failed, exiting" + echo "happy_path_mint_wallet test with LDK mint failed, exiting" exit 1 fi -echo "Running happy_path_mint_wallet test with LND mint" -cargo test -p cdk-integration-tests --test happy_path_mint_wallet +echo "Running regtest test with LDK mint and CLN client" +cargo test -p cdk-integration-tests --test regtest if [ $? -ne 0 ]; then - echo "happy_path_mint_wallet test with LND mint failed, exiting" + echo "regtest test LDK mint failed, exiting" exit 1 fi + echo "All tests passed successfully" exit 0 diff --git a/misc/mintd_payment_processor.sh b/misc/mintd_payment_processor.sh index 5d833155..f43a1034 100755 --- a/misc/mintd_payment_processor.sh +++ b/misc/mintd_payment_processor.sh @@ -129,7 +129,6 @@ cargo run --bin cdk-payment-processor & CDK_PAYMENT_PROCESSOR_PID=$! -sleep 10; export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0"; export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR"; @@ -141,6 +140,8 @@ export CDK_MINTD_GRPC_PAYMENT_PROCESSOR_PORT="8090"; export CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS="sat"; export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal"; +cargo build --bin cdk-mintd --no-default-features --features grpc-processor + cargo run --bin cdk-mintd --no-default-features --features grpc-processor & CDK_MINTD_PID=$! diff --git a/misc/regtest_helper.sh b/misc/regtest_helper.sh index 5e366825..6d858ccb 100755 --- a/misc/regtest_helper.sh +++ b/misc/regtest_helper.sh @@ -12,6 +12,7 @@ elif [ ! -z "$CDK_ITESTS_DIR" ]; then echo "export CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" > "$ENV_FILE" echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" + echo "export CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" >> "$ENV_FILE" echo "export CDK_MINTD_PID=\"$CDK_MINTD_PID\"" >> "$ENV_FILE" echo "export CDK_MINTD_LND_PID=\"$CDK_MINTD_LND_PID\"" >> "$ENV_FILE" echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" @@ -111,6 +112,11 @@ mint_info() { echo echo "LND Mint (Port 8087):" curl -s "$CDK_TEST_MINT_URL_2/v1/info" | jq . 2>/dev/null || curl -s "$CDK_TEST_MINT_URL_2/v1/info" + echo + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "LDK Node Mint (Port 8089):" + curl -s "$CDK_TEST_MINT_URL_3/v1/info" | jq . 2>/dev/null || curl -s "$CDK_TEST_MINT_URL_3/v1/info" + fi } mint_test() { @@ -125,6 +131,9 @@ show_env() { echo "CDK_ITESTS_DIR=$CDK_ITESTS_DIR" echo "CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL" echo "CDK_TEST_MINT_URL_2=$CDK_TEST_MINT_URL_2" + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "CDK_TEST_MINT_URL_3=$CDK_TEST_MINT_URL_3" + fi echo "CDK_MINTD_PID=$CDK_MINTD_PID" echo "CDK_MINTD_LND_PID=$CDK_MINTD_LND_PID" echo "CDK_REGTEST_PID=$CDK_REGTEST_PID" @@ -144,6 +153,15 @@ show_logs() { else echo "Log file not found" fi + echo + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "=== Recent LDK Node Mint Logs ===" + if [ -f "$CDK_ITESTS_DIR/ldk_node_mint/mintd.log" ]; then + tail -10 "$CDK_ITESTS_DIR/ldk_node_mint/mintd.log" + else + echo "Log file not found" + fi + fi } start_mprocs() { @@ -179,6 +197,10 @@ procs: shell: "touch $CDK_ITESTS_DIR/lnd_mint/mintd.log && tail -f $CDK_ITESTS_DIR/lnd_mint/mintd.log" autostart: true + ldk-node-mint: + shell: "touch $CDK_ITESTS_DIR/ldk_node_mint/mintd.log && tail -f $CDK_ITESTS_DIR/ldk_node_mint/mintd.log" + autostart: true + bitcoind: shell: "touch $CDK_ITESTS_DIR/bitcoin/regtest/debug.log && tail -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log" autostart: true @@ -198,6 +220,10 @@ procs: lnd-two: shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log" autostart: true + + ldk-node: + shell: "while [ ! -f $CDK_ITESTS_DIR/ldk_node_mint/ldk_storage/ldk_node.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/ldk_node_mint/ldk_storage/ldk_node.log" + autostart: true settings: mouse_scroll_speed: 3 @@ -248,6 +274,14 @@ show_status() { else echo " ❌ LND Mint not responding" fi + + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + if curl -s "$CDK_TEST_MINT_URL_3/v1/info" >/dev/null 2>&1; then + echo " ✓ LDK Node Mint responding" + else + echo " ❌ LDK Node Mint not responding" + fi + fi } restart_mints() { diff --git a/misc/scripts/filtered_ldk_node_log.sh b/misc/scripts/filtered_ldk_node_log.sh new file mode 100755 index 00000000..10ee9541 --- /dev/null +++ b/misc/scripts/filtered_ldk_node_log.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Filtered log viewer for LDK Node that excludes "falling back to default fee rate" messages +# Usage: ./misc/scripts/filtered_ldk_node_log.sh [log_file_path] + +LOG_FILE="$1" + +# If no log file specified, use the default pattern +if [ -z "$LOG_FILE" ]; then + LOG_FILE="$CDK_ITESTS_DIR/ldk_mint/ldk_node.log" +fi + +# Wait for log file to exist, then tail it with filtering +while [ ! -f "$LOG_FILE" ]; do + sleep 1 +done + +# Tail the log file and filter out fee rate fallback messages +tail -f "$LOG_FILE" | grep -v -E "Falling back to default of 1 sat/vb|Failed to retrieve fee rate estimates"