ソースを参照

Cdk ldk node (#904)

* feat: add LDK Node Lightning backend with comprehensive integration

- Add new cdk-ldk-node crate implementing Lightning backend using LDK Node
- Extend MintPayment trait with start/stop methods for processor lifecycle management
- Add LDK Node configuration support to cdk-mintd with chain source and gossip options
- Enhance mint startup/shutdown to properly manage payment processor lifecycle

---------

Co-authored-by: Erik <78821053+swedishfrenchpress@users.noreply.github.com>
thesimplekid 2 ヶ月 前
コミット
9ab86fabfe
71 ファイル変更7351 行追加401 行削除
  1. 3 1
      .github/workflows/ci.yml
  2. 20 0
      .github/workflows/nutshell_itest.yml
  3. 5 0
      CHANGELOG.md
  4. 5 1
      Cargo.toml
  5. 17 0
      README.md
  6. 1 5
      crates/cdk-cln/src/lib.rs
  7. 3 0
      crates/cdk-common/src/database/mod.rs
  8. 5 0
      crates/cdk-common/src/error.rs
  9. 23 0
      crates/cdk-common/src/payment.rs
  10. 4 1
      crates/cdk-integration-tests/Cargo.toml
  11. 6 2
      crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs
  12. 6 2
      crates/cdk-integration-tests/src/bin/start_fake_mint.rs
  13. 30 2
      crates/cdk-integration-tests/src/bin/start_regtest.rs
  14. 331 118
      crates/cdk-integration-tests/src/bin/start_regtest_mints.rs
  15. 1 2
      crates/cdk-integration-tests/src/cli.rs
  16. 114 4
      crates/cdk-integration-tests/src/init_regtest.rs
  17. 94 9
      crates/cdk-integration-tests/src/lib.rs
  18. 43 18
      crates/cdk-integration-tests/src/shared.rs
  19. 63 0
      crates/cdk-integration-tests/tests/bolt12.rs
  20. 3 9
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  21. 63 0
      crates/cdk-integration-tests/tests/ldk_node.rs
  22. 73 50
      crates/cdk-integration-tests/tests/regtest.rs
  23. 1 3
      crates/cdk-integration-tests/tests/test_fees.rs
  24. 34 0
      crates/cdk-ldk-node/Cargo.toml
  25. 165 0
      crates/cdk-ldk-node/NETWORK_GUIDE.md
  26. 84 0
      crates/cdk-ldk-node/README.md
  27. 89 0
      crates/cdk-ldk-node/src/error.rs
  28. 994 0
      crates/cdk-ldk-node/src/lib.rs
  29. 515 0
      crates/cdk-ldk-node/src/web/handlers/channels.rs
  30. 276 0
      crates/cdk-ldk-node/src/web/handlers/dashboard.rs
  31. 293 0
      crates/cdk-ldk-node/src/web/handlers/invoices.rs
  32. 171 0
      crates/cdk-ldk-node/src/web/handlers/lightning.rs
  33. 17 0
      crates/cdk-ldk-node/src/web/handlers/mod.rs
  34. 479 0
      crates/cdk-ldk-node/src/web/handlers/onchain.rs
  35. 615 0
      crates/cdk-ldk-node/src/web/handlers/payments.rs
  36. 91 0
      crates/cdk-ldk-node/src/web/handlers/utils.rs
  37. 6 0
      crates/cdk-ldk-node/src/web/mod.rs
  38. 78 0
      crates/cdk-ldk-node/src/web/server.rs
  39. 47 0
      crates/cdk-ldk-node/src/web/static_files.rs
  40. 36 0
      crates/cdk-ldk-node/src/web/templates/components.rs
  41. 150 0
      crates/cdk-ldk-node/src/web/templates/formatters.rs
  42. 1326 0
      crates/cdk-ldk-node/src/web/templates/layout.rs
  43. 10 0
      crates/cdk-ldk-node/src/web/templates/mod.rs
  44. 105 0
      crates/cdk-ldk-node/src/web/templates/payments.rs
  45. 75 0
      crates/cdk-ldk-node/static/css/globe.css
  46. BIN
      crates/cdk-ldk-node/static/favicon.ico
  47. 6 0
      crates/cdk-ldk-node/static/favicon.svg
  48. BIN
      crates/cdk-ldk-node/static/images/bg-dark.jpg
  49. BIN
      crates/cdk-ldk-node/static/images/bg.jpg
  50. BIN
      crates/cdk-ldk-node/static/images/nut.png
  51. 4 2
      crates/cdk-mintd/Cargo.toml
  52. 2 1
      crates/cdk-mintd/build.rs
  53. 42 1
      crates/cdk-mintd/example.config.toml
  54. 113 44
      crates/cdk-mintd/src/config.rs
  55. 103 0
      crates/cdk-mintd/src/env_vars/ldk_node.rs
  56. 8 0
      crates/cdk-mintd/src/env_vars/mod.rs
  57. 40 9
      crates/cdk-mintd/src/lib.rs
  58. 23 27
      crates/cdk-mintd/src/main.rs
  59. 149 0
      crates/cdk-mintd/src/setup.rs
  60. 4 0
      crates/cdk-payment-processor/src/proto/mod.rs
  61. 1 0
      crates/cdk-payment-processor/src/proto/payment_processor.proto
  62. 12 0
      crates/cdk-sql-common/src/mint/mod.rs
  63. 9 55
      crates/cdk/src/mint/issue/mod.rs
  64. 76 8
      crates/cdk/src/mint/mod.rs
  65. 2 2
      crates/cdk/src/wallet/issue/issue_bolt12.rs
  66. 1 1
      justfile
  67. 62 4
      misc/interactive_regtest_mprocs.sh
  68. 69 19
      misc/itests.sh
  69. 2 1
      misc/mintd_payment_processor.sh
  70. 34 0
      misc/regtest_helper.sh
  71. 19 0
      misc/scripts/filtered_ldk_node_log.sh

+ 3 - 1
.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,

+ 20 - 0
.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

+ 5 - 0
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<u32>` 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]).

+ 5 - 1
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"] }

+ 17 - 0
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

+ 1 - 5
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);
             }

+ 3 - 0
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)]

+ 5 - 0
crates/cdk-common/src/error.rs

@@ -542,6 +542,11 @@ impl From<Error> 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()),

+ 23 - 0
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<Error> + From<Error>;
 
+    /// 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<serde_json::Value, Self::Err>;
 

+ 4 - 1
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"] }

+ 6 - 2
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);
     }

+ 6 - 2
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);
     }

+ 30 - 2
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");
     });

+ 331 - 118
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();
-
-    // Initialize logging based on CLI arguments
-    shared::setup_logging(&args.common);
-
-    let temp_dir = shared::init_working_directory(&args.work_dir)?;
+/// Start regtest LDK mint using the library
+async fn start_ldk_mint(
+    temp_dir: &Path,
+    port: u16,
+    shutdown: Arc<Notify>,
+    runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+) -> Result<tokio::task::JoinHandle<()>> {
+    let ldk_work_dir = temp_dir.join("ldk_mint");
 
-    // 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 work directory for LDK mint
+    fs::create_dir_all(&ldk_work_dir)?;
 
-    shared::write_env_file(&temp_dir, &env_vars)?;
+    // 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
+    };
 
-    // Start regtest
-    println!("Starting regtest...");
+    // Create settings struct for LDK mint using a new shared function
+    let settings = create_ldk_settings(port, ldk_config, Mnemonic::generate(12)?.to_string());
 
-    let shutdown_regtest = shared::create_shutdown_handler();
-    let shutdown_clone = shutdown_regtest.clone();
-    let (tx, rx) = oneshot::channel();
+    println!("Starting LDK mintd on port {port}");
 
-    let shutdown_clone_one = Arc::clone(&shutdown_clone);
+    let ldk_work_dir = ldk_work_dir.clone();
+    let shutdown_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");
-    });
+    // 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");
+        };
 
-    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");
+        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}"),
         }
-    }
-
-    // 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?;
+    Ok(handle)
+}
 
-    // 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);
+/// 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,
     }
+}
 
-    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...");
+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");
             }
-            _ = sigterm.recv() => {
-                tracing::info!("Received SIGTERM, shutting down mints...");
+            Err(_) => {
+                tracing::error!("regtest setup timed out after 5 minutes");
+                anyhow::bail!("Could not set up regtest");
             }
         }
-        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;
+        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 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...");
+        if cancel_token.is_cancelled() {
+            bail!("Token canceled");
         }
-        _ = monitor_mints => {
-            println!("One or more mints terminated unexpectedly");
+
+        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!(cln_handle, lnd_handle) {
-        eprintln!("Error waiting for mints to shut down: {e}");
-    }
+        // 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");
+        println!("All services shut down successfully");
 
-    Ok(())
+        Ok(())
+    })
 }

+ 1 - 2
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

+ 114 - 4
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<Notify>,
+    ldk_node: Option<Arc<Node>>,
 ) -> 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::<Result<Vec<u8>, _>>()?
+                        .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");
 

+ 94 - 9
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<u64>) -> Result<String> {
+pub async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
     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<u64>) ->
         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<dyn LightningClient> {
+    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;
+            }
+        }
+    }
+}

+ 43 - 18
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<CancellationToken>,
+) -> 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()
-                    );
+        if shutdown_notify.is_cancelled() {
+            return Err(anyhow::anyhow!("Canceled waiting for {}", port));
+        }
+
+        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}");
+                    }
                 }
             }
-            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
+                ));
             }
-        }
 
-        tokio::time::sleep(Duration::from_secs(2)).await;
+
+
+        }
     }
 }
 
@@ -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,

+ 63 - 0
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");
+        }
+    }
+}

+ 3 - 9
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();
 

+ 63 - 0
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(&quote_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(())
+}

+ 73 - 50
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");
+        }
+    }
+}

+ 1 - 3
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();
 

+ 34 - 0
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"
+
+
+

+ 165 - 0
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**: <https://mutinynet.com>
+- **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

+ 84 - 0
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: <http://127.0.0.1:8085>
+- LDK management interface: <http://127.0.0.1:8091>
+- 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).**

+ 89 - 0
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<Error> for cdk_common::payment::Error {
+    fn from(e: Error) -> Self {
+        Self::Lightning(Box::new(e))
+    }
+}

+ 994 - 0
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<Node>,
+    fee_reserve: FeeReserve,
+    wait_invoice_cancel_token: CancellationToken,
+    wait_invoice_is_active: Arc<AtomicBool>,
+    sender: tokio::sync::broadcast::Sender<WaitPaymentResponse>,
+    receiver: Arc<tokio::sync::broadcast::Receiver<WaitPaymentResponse>>,
+    events_cancel_token: CancellationToken,
+    runtime: Option<Arc<Runtime>>,
+    web_addr: Option<SocketAddr>,
+}
+
+/// 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<SocketAddress>,
+        runtime: Option<Arc<Runtime>>,
+    ) -> Result<Self, Error> {
+        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<SocketAddr>) {
+        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<Node>,
+        sender: &tokio::sync::broadcast::Sender<WaitPaymentResponse>,
+        payment_id: Option<PaymentId>,
+        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<Node> {
+        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<serde_json::Value, Self::Err> {
+        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<CreateIncomingPaymentResponse, Self::Err> {
+        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<PaymentQuoteResponse, Self::Err> {
+        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<MakePaymentResponse, Self::Err> {
+        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<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + 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<Vec<WaitPaymentResponse>, 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<MakePaymentResponse, Self::Err> {
+        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");
+    }
+}

+ 515 - 0
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<u64>,
+}
+
+#[derive(Deserialize)]
+pub struct CloseChannelForm {
+    channel_id: String,
+    node_id: String,
+}
+
+pub async fn channels_page(State(_state): State<AppState>) -> Result<Response, StatusCode> {
+    // 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<AppState>) -> Result<Html<String>, 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<AppState>,
+    Form(form): Form<OpenChannelForm>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    query: Query<HashMap<String, String>>,
+) -> Result<Html<String>, 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<AppState>,
+    query: Query<HashMap<String, String>>,
+) -> Result<Html<String>, 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<AppState>,
+    Form(form): Form<CloseChannelForm>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    Form(form): Form<CloseChannelForm>,
+) -> Result<Response, StatusCode> {
+    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())
+}

+ 276 - 0
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<AppState>) -> Result<Html<String>, 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<String> = 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()))
+}

+ 293 - 0
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<String>,
+    #[serde(deserialize_with = "deserialize_optional_u32")]
+    expiry_seconds: Option<u32>,
+}
+
+#[derive(Deserialize)]
+pub struct CreateBolt12Form {
+    #[serde(deserialize_with = "deserialize_optional_f64")]
+    amount_btc: Option<f64>,
+    description: Option<String>,
+    #[serde(deserialize_with = "deserialize_optional_u32")]
+    expiry_seconds: Option<u32>,
+}
+
+pub async fn invoices_page(State(_state): State<AppState>) -> Result<Html<String>, 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<AppState>,
+    Form(form): Form<CreateBolt11Form>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    Form(form): Form<CreateBolt12Form>,
+) -> Result<Response, StatusCode> {
+    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())
+}

+ 171 - 0
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<AppState>) -> Result<Html<String>, 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()))
+}

+ 17 - 0
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;

+ 479 - 0
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<u64>,
+    send_action: String,
+}
+
+#[derive(Deserialize)]
+pub struct ConfirmOnchainForm {
+    address: String,
+    amount_sat: Option<u64>,
+    send_action: String,
+    confirmed: Option<String>,
+}
+
+pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, 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<AppState>,
+    query: Query<HashMap<String, String>>,
+) -> Result<Html<String>, 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<AppState>,
+    Form(form): Form<SendOnchainActionForm>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    query: Query<ConfirmOnchainForm>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    form: ConfirmOnchainForm,
+) -> Result<Response, StatusCode> {
+    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())
+}

+ 615 - 0
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<String>,
+    page: Option<u32>,
+    per_page: Option<u32>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct PayBolt11Form {
+    invoice: String,
+    #[serde(deserialize_with = "deserialize_optional_u64")]
+    amount_btc: Option<u64>,
+}
+
+#[derive(Deserialize)]
+pub struct PayBolt12Form {
+    offer: String,
+    #[serde(deserialize_with = "deserialize_optional_u64")]
+    amount_btc: Option<u64>,
+}
+
+pub async fn payments_page(
+    State(state): State<AppState>,
+    query: Query<PaymentsQuery>,
+) -> Result<Html<String>, 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 &current_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::<String>, "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::<String>, "BOLT12", preimage.map(|p| p.to_string()))
+                        },
+                        PaymentKind::Bolt12Refund { hash, preimage, .. } => {
+                            (hash.map(|h| h.to_string()), None::<String>, "BOLT12", preimage.map(|p| p.to_string()))
+                        },
+                        PaymentKind::Spontaneous { hash, preimage, .. } => {
+                            (Some(hash.to_string()), None::<String>, "Spontaneous", preimage.map(|p| p.to_string()))
+                        },
+                        PaymentKind::Onchain { txid, .. } => {
+                            (Some(txid.to_string()), None::<String>, "On-chain", None)
+                        },
+                        PaymentKind::Bolt11Jit { hash, .. } => {
+                            (Some(hash.to_string()), None::<String>, "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<AppState>,
+) -> Result<Html<String>, 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<AppState>,
+    Form(form): Form<PayBolt11Form>,
+) -> Result<Response, StatusCode> {
+    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<AppState>,
+    Form(form): Form<PayBolt12Form>,
+) -> Result<Response, StatusCode> {
+    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())
+}

+ 91 - 0
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<CdkLdkNode>,
+}
+
+// Custom deserializer for optional u32 that handles empty strings
+pub fn deserialize_optional_u32<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let opt = Option::<String>::deserialize(deserializer)?;
+    match opt.as_deref() {
+        None | Some("") => Ok(None),
+        Some(s) => s.parse::<u32>().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<Option<u64>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let opt = Option::<String>::deserialize(deserializer)?;
+    match opt.as_deref() {
+        None | Some("") => Ok(None),
+        Some(s) => s.parse::<u64>().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<Option<f64>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let opt = Option::<String>::deserialize(deserializer)?;
+    match opt.as_deref() {
+        None | Some("") => Ok(None),
+        Some(s) => s.parse::<f64>().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<ldk_node::payment::PaymentDetails>, 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)
+}

+ 6 - 0
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;

+ 78 - 0
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<CdkLdkNode>,
+}
+
+impl WebServer {
+    pub fn new(node: Arc<CdkLdkNode>) -> 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<dyn std::error::Error>> {
+        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(())
+    }
+}

+ 47 - 0
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<String>) -> 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()
+        }
+    }
+}

+ 36 - 0
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) }
+    }
+}

+ 150 - 0
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<char> = 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<char> = 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");
+    }
+}

+ 1326 - 0
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)
+                }
+            }
+        }
+    }
+}

+ 10 - 0
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::*;

+ 105 - 0
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<u64>,
+    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))
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 75 - 0
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; }
+}

BIN
crates/cdk-ldk-node/static/favicon.ico


+ 6 - 0
crates/cdk-ldk-node/static/favicon.svg

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="12 2 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <!-- Simplified crown icon -->
+  <path d="M22.5522 4V2" stroke="#a480ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
+  <path d="M15.5522 10V14C15.5524 15.5569 16.0713 17.0693 17.0269 18.2984C17.9825 19.5275 19.3204 20.4031 20.8292 20.787C21.2412 20.891 21.6312 21.079 21.9312 21.379L22.5522 22L23.1732 21.379C23.4732 21.079 23.8632 20.891 24.2752 20.787C25.7841 20.4033 27.1221 19.5277 28.0778 18.2986C29.0334 17.0694 29.5523 15.5569 29.5522 14V10" stroke="#a480ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
+  <path d="M22.5522 4C18.5522 4 15.0522 6 14.5522 8C14.3092 8.97 13.6332 9.952 12.5522 11C13.8622 10.918 14.5242 10.71 15.5522 10C16.0922 10.92 16.5342 11.356 17.5522 12C19.0042 11.353 19.5062 10.902 20.0522 10C20.6472 10.995 21.2032 11.427 22.5522 12C23.8622 11.379 24.4142 10.942 25.0522 10C25.6812 10.977 26.2142 11.423 27.5522 12C28.7612 11.452 29.2322 11.033 29.5522 10C30.5842 10.916 31.2352 11.157 32.5522 11C31.2552 9.964 30.7942 8.97 30.5522 8C30.0522 6 26.5522 4 22.5522 4Z" stroke="#a480ff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
+</svg>

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


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


BIN
crates/cdk-ldk-node/static/images/nut.png


+ 4 - 2
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

+ 2 - 1
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!(

+ 42 - 1
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

+ 113 - 44
crates/cdk-mintd/src/config.rs

@@ -45,7 +45,7 @@ pub struct LoggingConfig {
     pub file_level: Option<String>,
 }
 
-#[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<bool>,
 }
 
+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<String>,
+    /// Chain source type (esplora or bitcoinrpc)
+    pub chain_source_type: Option<String>,
+    /// Esplora URL (when chain_source_type = "esplora")
+    pub esplora_url: Option<String>,
+    /// Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc")
+    pub bitcoind_rpc_host: Option<String>,
+    pub bitcoind_rpc_port: Option<u16>,
+    pub bitcoind_rpc_user: Option<String>,
+    pub bitcoind_rpc_password: Option<String>,
+    /// Storage directory path
+    pub storage_dir_path: Option<String>,
+    /// LDK node listening host
+    pub ldk_node_host: Option<String>,
+    /// LDK node listening port
+    pub ldk_node_port: Option<u16>,
+    /// Gossip source type (p2p or rgs)
+    pub gossip_source_type: Option<String>,
+    /// Rapid Gossip Sync URL (when gossip_source_type = "rgs")
+    pub rgs_url: Option<String>,
+    /// Webserver host (defaults to 127.0.0.1)
+    #[serde(default = "default_webserver_host")]
+    pub webserver_host: Option<String>,
+    /// Webserver port
+    #[serde(default = "default_webserver_port")]
+    pub webserver_port: Option<u16>,
+}
+
+#[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<String> {
+    Some("127.0.0.1".to_string())
+}
+
+#[cfg(feature = "ldk-node")]
+fn default_webserver_port() -> Option<u16> {
+    Some(8091)
+}
+
 #[cfg(feature = "fakewallet")]
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct FakeWallet {
@@ -337,6 +440,8 @@ pub struct Settings {
     pub lnbits: Option<LNbits>,
     #[cfg(feature = "lnd")]
     pub lnd: Option<Lnd>,
+    #[cfg(feature = "ldk-node")]
+    pub ldk_node: Option<LdkNode>,
     #[cfg(feature = "fakewallet")]
     pub fake_wallet: Option<FakeWallet>,
     pub grpc_processor: Option<GrpcProcessor>,
@@ -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("<hashed: "));
     }
-
-    #[test]
-    fn test_logging_output_from_str() {
-        assert_eq!(
-            LoggingOutput::from_str("stderr").unwrap(),
-            LoggingOutput::Stderr
-        );
-        assert_eq!(
-            LoggingOutput::from_str("file").unwrap(),
-            LoggingOutput::File
-        );
-        assert_eq!(
-            LoggingOutput::from_str("both").unwrap(),
-            LoggingOutput::Both
-        );
-
-        // Test case insensitive
-        assert_eq!(
-            LoggingOutput::from_str("STDERR").unwrap(),
-            LoggingOutput::Stderr
-        );
-        assert_eq!(
-            LoggingOutput::from_str("File").unwrap(),
-            LoggingOutput::File
-        );
-        assert_eq!(
-            LoggingOutput::from_str("BOTH").unwrap(),
-            LoggingOutput::Both
-        );
-
-        // Test invalid input
-        assert!(LoggingOutput::from_str("invalid").is_err());
-    }
-
-    #[test]
-    fn test_logging_config_defaults() {
-        let config = LoggingConfig::default();
-        assert_eq!(config.output, LoggingOutput::Both);
-        assert_eq!(config.console_level, None);
-        assert_eq!(config.file_level, None);
-    }
 }

+ 103 - 0
crates/cdk-mintd/src/env_vars/ldk_node.rs

@@ -0,0 +1,103 @@
+//! LDK Node environment variables
+
+use std::env;
+
+use crate::config::LdkNode;
+
+// LDK Node Environment Variables
+pub const LDK_NODE_FEE_PERCENT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_FEE_PERCENT";
+pub const LDK_NODE_RESERVE_FEE_MIN_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN";
+pub const LDK_NODE_BITCOIN_NETWORK_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIN_NETWORK";
+pub const LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_CHAIN_SOURCE_TYPE";
+pub const LDK_NODE_ESPLORA_URL_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_ESPLORA_URL";
+pub const LDK_NODE_BITCOIND_RPC_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_HOST";
+pub const LDK_NODE_BITCOIND_RPC_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_PORT";
+pub const LDK_NODE_BITCOIND_RPC_USER_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER";
+pub const LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD";
+pub const LDK_NODE_STORAGE_DIR_PATH_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH";
+pub const LDK_NODE_LDK_NODE_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_HOST";
+pub const LDK_NODE_LDK_NODE_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_PORT";
+pub const LDK_NODE_GOSSIP_SOURCE_TYPE_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE";
+pub const LDK_NODE_RGS_URL_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_RGS_URL";
+pub const LDK_NODE_WEBSERVER_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_HOST";
+pub const LDK_NODE_WEBSERVER_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_PORT";
+
+impl LdkNode {
+    pub fn from_env(mut self) -> Self {
+        if let Ok(fee_percent) = env::var(LDK_NODE_FEE_PERCENT_ENV_VAR) {
+            if let Ok(fee_percent) = fee_percent.parse::<f32>() {
+                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::<u64>() {
+                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::<u16>() {
+                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::<u16>() {
+                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::<u16>() {
+                self.webserver_port = Some(webserver_port);
+            }
+        }
+
+        self
+    }
+}

+ 8 - 0
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 =

+ 40 - 9
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<std::sync::Arc<tokio::runtime::Runtime>>,
+    work_dir: &Path,
 ) -> Result<(MintBuilder, Vec<Router>)> {
     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<Router>,
+    _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+    work_dir: &Path,
 ) -> Result<MintBuilder> {
     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<String>,
     enable_logging: bool,
+    runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
 ) -> 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<Output = ()> + Send + 'static,
     db_password: Option<String>,
+    runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
 ) -> 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?;
 

+ 23 - 27
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
+    })
 }

+ 149 - 0
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<Router>,
         settings: &Settings,
         unit: CurrencyUnit,
+        runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        work_dir: &Path,
     ) -> anyhow::Result<impl MintPayment>;
 }
 
@@ -41,6 +45,8 @@ impl LnBackendSetup for config::Cln {
         _routers: &mut Vec<Router>,
         _settings: &Settings,
         _unit: CurrencyUnit,
+        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        _work_dir: &Path,
     ) -> anyhow::Result<cdk_cln::Cln> {
         let cln_socket = expand_path(
             self.rpc_path
@@ -68,6 +74,8 @@ impl LnBackendSetup for config::LNbits {
         _routers: &mut Vec<Router>,
         _settings: &Settings,
         _unit: CurrencyUnit,
+        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        _work_dir: &Path,
     ) -> anyhow::Result<cdk_lnbits::LNbits> {
         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<Router>,
         _settings: &Settings,
         _unit: CurrencyUnit,
+        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        _work_dir: &Path,
     ) -> anyhow::Result<cdk_lnd::Lnd> {
         let address = &self.address;
         let cert_file = &self.cert_file;
@@ -130,6 +140,8 @@ impl LnBackendSetup for config::FakeWallet {
         _router: &mut Vec<Router>,
         _settings: &Settings,
         unit: CurrencyUnit,
+        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        _work_dir: &Path,
     ) -> anyhow::Result<cdk_fake_wallet::FakeWallet> {
         let fee_reserve = FeeReserve {
             min_fee_reserve: self.reserve_fee_min,
@@ -160,6 +172,8 @@ impl LnBackendSetup for config::GrpcProcessor {
         _routers: &mut Vec<Router>,
         _settings: &Settings,
         _unit: CurrencyUnit,
+        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        _work_dir: &Path,
     ) -> anyhow::Result<cdk_payment_processor::PaymentProcessorClient> {
         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<Router>,
+        _settings: &Settings,
+        _unit: CurrencyUnit,
+        runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+        work_dir: &Path,
+    ) -> anyhow::Result<cdk_ldk_node::CdkLdkNode> {
+        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)
+    }
+}

+ 4 - 0
crates/cdk-payment-processor/src/proto/mod.rs

@@ -37,6 +37,10 @@ impl From<CdkPaymentIdentifier> 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))),
+            },
         }
     }
 }

+ 1 - 0
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 {

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

@@ -546,6 +546,11 @@ where
         amount_paid: Amount,
         payment_id: String,
     ) -> Result<Amount, Self::Err> {
+        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#"

+ 9 - 55
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

+ 76 - 8
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<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
         localstore: Arc<dyn MintDatabase<database::Error> + 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<dyn MintDatabase<database::Error> + Send + Sync>,
         pubsub_manager: &Arc<PubSubManager>,
@@ -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?;

+ 2 - 2
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 {

+ 1 - 1
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

+ 62 - 4
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

+ 69 - 19
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

+ 2 - 1
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=$!
 

+ 34 - 0
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() {

+ 19 - 0
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"