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>
This commit is contained in:
thesimplekid
2025-08-25 22:06:00 +01:00
committed by GitHub
parent 61411afde0
commit 9ab86fabfe
71 changed files with 7368 additions and 418 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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]).

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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);
}

View File

@@ -106,6 +106,9 @@ pub enum Error {
/// Amount overflow
#[error("Amount overflow")]
AmountOverflow,
/// Amount zero
#[error("Amount zero")]
AmountZero,
/// DHKE error
#[error(transparent)]

View File

@@ -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()),

View File

@@ -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>;

View File

@@ -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"] }

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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");
});

View File

@@ -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,7 +166,13 @@ 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)
match cdk_mintd::run_mintd_with_shutdown(
&lnd_work_dir,
&settings,
shutdown_future,
None,
None,
)
.await
{
Ok(_) => println!("LND mint exited normally"),
@@ -165,8 +183,118 @@ async fn start_lnd_mint(
Ok(handle)
}
#[tokio::main]
async fn main() -> Result<()> {
/// 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");
// Create work directory for LDK mint
fs::create_dir_all(&ldk_work_dir)?;
// Configure LDK node for regtest
let ldk_config = cdk_mintd::config::LdkNode {
fee_percent: 0.0,
reserve_fee_min: 0.into(),
bitcoin_network: Some("regtest".to_string()),
// Use bitcoind RPC for regtest
chain_source_type: Some("bitcoinrpc".to_string()),
bitcoind_rpc_host: Some("127.0.0.1".to_string()),
bitcoind_rpc_port: Some(18443),
bitcoind_rpc_user: Some("testuser".to_string()),
bitcoind_rpc_password: Some("testpass".to_string()),
esplora_url: None,
storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()),
ldk_node_host: Some("127.0.0.1".to_string()),
ldk_node_port: Some(port + 10), // Use a different port for the LDK node P2P connections
gossip_source_type: None,
rgs_url: None,
webserver_host: Some("127.0.0.1".to_string()),
webserver_port: Some(port + 1), // Use next port for web interface
};
// Create settings struct for LDK mint using a new shared function
let settings = create_ldk_settings(port, ldk_config, Mnemonic::generate(12)?.to_string());
println!("Starting LDK mintd on port {port}");
let ldk_work_dir = ldk_work_dir.clone();
let shutdown_clone = shutdown.clone();
// 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 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}"),
}
});
Ok(handle)
}
/// Create settings for an LDK mint
fn create_ldk_settings(
port: u16,
ldk_config: cdk_mintd::config::LdkNode,
mnemonic: String,
) -> cdk_mintd::config::Settings {
cdk_mintd::config::Settings {
info: cdk_mintd::config::Info {
url: format!("http://127.0.0.1:{port}"),
listen_host: "127.0.0.1".to_string(),
listen_port: port,
mnemonic: Some(mnemonic),
signatory_url: None,
signatory_certs: None,
input_fee_ppk: None,
http_cache: cdk_axum::cache::Config::default(),
enable_swagger_ui: None,
logging: LoggingConfig::default(),
},
mint_info: cdk_mintd::config::MintInfo::default(),
ln: cdk_mintd::config::Ln {
ln_backend: cdk_mintd::config::LnBackend::LdkNode,
invoice_description: None,
min_mint: 1.into(),
max_mint: 500_000.into(),
min_melt: 1.into(),
max_melt: 500_000.into(),
},
cln: None,
lnbits: None,
lnd: None,
ldk_node: Some(ldk_config),
fake_wallet: None,
grpc_processor: None,
database: cdk_mintd::config::Database::default(),
mint_management_rpc: None,
auth: None,
}
}
fn main() -> Result<()> {
let rt = Arc::new(Runtime::new()?);
let rt_clone = Arc::clone(&rt);
rt.block_on(async {
let args = Args::parse();
// Initialize logging based on CLI arguments
@@ -177,9 +305,11 @@ async fn main() -> Result<()> {
// 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)?;
@@ -193,15 +323,41 @@ async fn main() -> Result<()> {
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_one)
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(_) => {
Ok(k) => {
k?;
tracing::info!("Regtest set up");
}
Err(_) => {
@@ -210,27 +366,74 @@ async fn main() -> Result<()> {
}
}
// 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);
}
println!("lnd port: {}", args.ldk_port);
// Start LND mint
let lnd_handle = start_lnd_mint(&temp_dir, args.lnd_port, shutdown_clone.clone()).await?;
// Wait for LND mint to be ready
if let Err(e) = shared::wait_for_mint_ready(args.lnd_port, 100).await {
eprintln!("Error waiting for LND mint: {e}");
// Start LDK mint
let ldk_handle = start_ldk_mint(
&temp_dir,
args.ldk_port,
shutdown_clone.clone(),
Some(rt_clone),
)
.await?;
// Start CLN mint
let cln_handle = start_cln_mint(&temp_dir, args.cln_port, shutdown_clone.clone()).await?;
let cancel_token = Arc::new(CancellationToken::new());
// Set up Ctrl+C handler before waiting for mints to be ready
let ctrl_c_token = Arc::clone(&cancel_token);
let s_u = shutdown_clone.clone();
tokio::spawn(async move {
signal::ctrl_c()
.await
.expect("failed to install CTRL+C handler");
tracing::info!("Shutdown signal received during mint setup");
println!("\nReceived Ctrl+C, shutting down...");
ctrl_c_token.cancel();
s_u.notify_waiters();
});
match tokio::try_join!(
shared::wait_for_mint_ready_with_shutdown(
args.lnd_port,
100,
Arc::clone(&cancel_token)
),
shared::wait_for_mint_ready_with_shutdown(
args.ldk_port,
100,
Arc::clone(&cancel_token)
),
shared::wait_for_mint_ready_with_shutdown(
args.cln_port,
100,
Arc::clone(&cancel_token)
),
) {
Ok(_) => println!("All mints are ready!"),
Err(e) => {
if cancel_token.is_cancelled() {
bail!("Startup canceled by user");
}
eprintln!("Error waiting for mints to be ready: {e}");
return Err(e);
}
}
if cancel_token.is_cancelled() {
bail!("Token canceled");
}
println!("All regtest mints started successfully!");
println!("CLN mint: http://{}:{}", args.mint_addr, args.cln_port);
println!("LND mint: http://{}:{}", args.mint_addr, args.lnd_port);
println!("LDK mint: http://{}:{}", args.mint_addr, args.ldk_port);
shared::display_mint_info(args.cln_port, &temp_dir, &args.database_type); // Using CLN port for display
println!();
println!("Environment variables set:");
@@ -242,6 +445,10 @@ async fn main() -> Result<()> {
" 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:");
@@ -280,26 +487,32 @@ async fn main() -> Result<()> {
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_future => {
_ = 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) {
if let Err(e) = tokio::try_join!(ldk_handle, cln_handle, lnd_handle) {
eprintln!("Error waiting for mints to shut down: {e}");
}
println!("All services shut down successfully");
Ok(())
})
}

View File

@@ -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

View File

@@ -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,11 +350,107 @@ pub async fn start_regtest_end(
tracing::info!("Opened channel between cln and lnd two");
generate_block(&bitcoin_client)?;
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?;
cln_two_client.wait_channels_active().await?;
lnd_client.wait_channels_active().await?;
lnd_two_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");

View File

@@ -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;
}
}
}
}

View File

@@ -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,8 +44,14 @@ pub async fn wait_for_mint_ready(port: u16, timeout_secs: u64) -> Result<()> {
return Err(anyhow::anyhow!("Timeout waiting for mint on port {}", port));
}
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
match reqwest::get(&url).await {
result = reqwest::get(&url) => {
match result {
Ok(response) => {
if response.status().is_success() {
println!("Mint on port {port} is ready");
@@ -57,8 +68,19 @@ pub async fn wait_for_mint_ready(port: u16, timeout_secs: u64) -> Result<()> {
println!("Error connecting to mint on port {port}: {e}");
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
// Check for shutdown signal
_ = shutdown_notify.cancelled() => {
return Err(anyhow::anyhow!(
"Shutdown requested while waiting for mint on port {}",
port
));
}
}
}
}
@@ -187,6 +209,7 @@ pub fn create_fake_wallet_settings(
cln: None,
lnbits: None,
lnd: None,
ldk_node: None,
fake_wallet: fake_wallet_config,
grpc_processor: None,
database: Database {
@@ -234,6 +257,7 @@ pub fn create_cln_settings(
cln: Some(cln_config),
lnbits: None,
lnd: None,
ldk_node: None,
fake_wallet: None,
grpc_processor: None,
database: cdk_mintd::config::Database::default(),
@@ -276,6 +300,7 @@ pub fn create_lnd_settings(
},
cln: None,
lnbits: None,
ldk_node: None,
lnd: Some(lnd_config),
fake_wallet: None,
grpc_processor: None,

View File

@@ -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");
}
}
}

View File

@@ -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();

View File

@@ -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(())
}

View File

@@ -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");
}
}
}

View File

@@ -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();

View File

@@ -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"

View File

@@ -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

View File

@@ -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).**

View File

@@ -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))
}
}

View File

@@ -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");
}
}

View File

@@ -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())
}

View File

@@ -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()))
}

View File

@@ -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())
}

View File

@@ -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()))
}

View File

@@ -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;

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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)
}

View File

@@ -0,0 +1,6 @@
pub mod handlers;
pub mod server;
pub mod static_files;
pub mod templates;
pub use server::WebServer;

View File

@@ -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(())
}
}

View File

@@ -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()
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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::*;

View File

@@ -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))
}
}
}
}
}
}
}

View File

@@ -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; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

View File

@@ -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>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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

View File

@@ -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!(

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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 =

View File

@@ -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?;

View File

@@ -2,32 +2,21 @@
#![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<()> {
fn main() -> Result<()> {
let rt = Arc::new(Runtime::new()?);
let rt_clone = Arc::clone(&rt);
rt.block_on(async {
let args = CLIArgs::parse();
let work_dir = get_work_directory(&args).await?;
let settings = load_settings(&work_dir, args.config)?;
@@ -37,6 +26,13 @@ async fn main() -> Result<()> {
#[cfg(not(feature = "sqlcipher"))]
let password = None;
// Use the main function that handles logging setup and cleanup
cdk_mintd::run_mintd(&work_dir, &settings, password, args.enable_logging).await
cdk_mintd::run_mintd(
&work_dir,
&settings,
password,
args.enable_logging,
Some(rt_clone),
)
.await
})
}

View File

@@ -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)
}
}

View File

@@ -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))),
},
}
}
}

View File

@@ -46,6 +46,7 @@ enum PaymentIdentifierType {
LABEL = 2;
BOLT12_PAYMENT_HASH = 3;
CUSTOM_ID = 4;
PAYMENT_ID = 5;
}
message PaymentIdentifier {

View File

@@ -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#"

View File

@@ -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)?;
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)?;
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

View File

@@ -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?;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 "=============================================="
@@ -249,14 +294,16 @@ 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 " • 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
@@ -310,6 +364,10 @@ procs:
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
proc_list_width: 20

View File

@@ -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,7 +210,7 @@ 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
@@ -221,12 +222,61 @@ if [ $? -ne 0 ]; then
exit 1
fi
echo "Running happy_path_mint_wallet test with LND mint"
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 "happy_path_mint_wallet test with LDK mint failed, exiting"
exit 1
fi
echo "Running regtest test with LDK mint and CLN client"
cargo test -p cdk-integration-tests --test regtest
if [ $? -ne 0 ]; then
echo "regtest test LDK mint failed, exiting"
exit 1
fi
echo "All tests passed successfully"
exit 0

View File

@@ -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=$!

View File

@@ -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
@@ -199,6 +221,10 @@ procs:
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
proc_list_width: 20
@@ -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() {

View File

@@ -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"