diff --git a/Cargo.lock b/Cargo.lock index b1c67002..82ad9a22 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-stream" version = "0.3.6" @@ -250,12 +256,18 @@ checksum = "1945a5048598e4189e239d3f809b19bdad4845c4b2ba400d304d2dcf26d2c462" dependencies = [ "bech32", "bitcoin-private", - "bitcoin_hashes", + "bitcoin_hashes 0.12.0", "hex_lit", "secp256k1", "serde", ] +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -272,6 +284,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -291,7 +313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.5.2", "constant_time_eq", ] @@ -971,6 +993,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec 0.7.6", +] + [[package]] name = "hex_lit" version = "0.1.1" @@ -1410,6 +1441,34 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "ldk-server-client" +version = "0.1.0" +source = "git+https://github.com/lightningdevkit/ldk-server?rev=570ed527535da0ec1dae431fac3ce46e5e6a468f#570ed527535da0ec1dae431fac3ce46e5e6a468f" +dependencies = [ + "bitcoin_hashes 0.14.1", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ldk-server-grpc", + "prost 0.11.9", + "reqwest 0.11.27", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", +] + +[[package]] +name = "ldk-server-grpc" +version = "0.1.0" +source = "git+https://github.com/lightningdevkit/ldk-server?rev=570ed527535da0ec1dae431fac3ce46e5e6a468f#570ed527535da0ec1dae431fac3ce46e5e6a468f" +dependencies = [ + "bytes", + "http 1.3.1", + "http-body 1.0.1", + "prost 0.11.9", + "prost-build 0.11.9", + "tokio", +] + [[package]] name = "libc" version = "0.2.171" @@ -1429,7 +1488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd92d4aa159374be430c7590e169b4a6c0fb79018f5bc4ea1bffde536384db3" dependencies = [ "bitcoin", - "hex-conservative", + "hex-conservative 0.1.2", ] [[package]] @@ -2192,6 +2251,47 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.15" @@ -2225,7 +2325,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower 0.5.2", @@ -2435,7 +2535,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.12.0", "secp256k1-sys", "serde", ] @@ -2592,6 +2692,7 @@ dependencies = [ "fedimint-tonic-lnd", "futures", "hex", + "ldk-server-client", "lightning", "log", "mockall", @@ -2600,7 +2701,7 @@ dependencies = [ "rand", "rand_chacha", "rand_distr", - "reqwest", + "reqwest 0.12.15", "serde", "serde_json", "serde_millis", @@ -2730,6 +2831,17 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -2738,7 +2850,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.9.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3397,6 +3519,12 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "which" version = "4.4.2" @@ -3687,6 +3815,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/README.md b/README.md index ea3358f2..0f36f7bf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ lightning network development. It may be useful to you if you are: * LND ✅ * CLN ✅ * Eclair ✅️ -* LDK-node 🏗️ +* LDK-server ✅ See our [tracking issue](https://github.com/bitcoin-dev-project/sim-ln/issues/26) for updates on implementation support (contributions welcome!). @@ -36,6 +36,7 @@ of the simulator uses keysend to execute payments, which must be enabled as foll * LND: `--accept-keysend` * CLN: enabled by default * Eclair: `-Declair.features.keysend=optional` (or `--features.keysend=optional` if you're using Polar) +* LDK-server: enabled by default via `spontaneous_send` NOTE: for CLN `keysend` to work with eclair, you need to add additional config to eclair: ``` @@ -98,6 +99,24 @@ The required access details will depend on the node implementation. "api_password": } ``` +* LDK-server: +``` +{ + "address": , + "api_key": , + "cert": , + "network": +} +``` +The `api_key` is the raw bytes of `~/.ldk-server//api_key` hex-encoded. +Unlike other backends, ldk-server does not require an `id` field — the node's +public key is fetched automatically on startup. + +Note: ldk-server channels are **unannounced by default**. Pass `--announce-channel` +to `open-channel` (and set `announcement_addresses` in the ldk-server config) to +make channels public and visible to other nodes for routing. Also ldk-server enforces a minimum final CLTV expiry delta +of **144 blocks** on inbound keysend payments. Ensure that sending nodes are configured with a sufficiently high final +CLTV delta. Payment activity can be simulated in two different ways: * [Random activity](#setup---random-activity): generate random activity on the `nodes` provided, @@ -135,6 +154,12 @@ to send and receive payments when running with random activity. "base_url": "127.0.0.1:8286", "api_username": "", "api_password": "eclairpw" + }, + { + "address": "localhost:3536", + "api_key": "ldk_server_hex_encoded_api_key", + "cert": "/path/tls.crt", + "network": "signet" } ] } diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index c5b06b7f..15415ad2 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -9,9 +9,9 @@ use simln_lib::sim_node::{ SimGraph, SimNode, SimulatedChannel, }; use simln_lib::{ - cln, cln::ClnNode, eclair, eclair::EclairNode, lnd, lnd::LndNode, serializers, - ActivityDefinition, Amount, Interval, LightningError, LightningNode, NodeId, NodeInfo, - Simulation, SimulationCfg, WriteResults, + cln, cln::ClnNode, eclair, eclair::EclairNode, ldk, ldk::LdkNode, lnd, lnd::LndNode, + serializers, ActivityDefinition, Amount, Interval, LightningError, LightningNode, NodeId, + NodeInfo, Simulation, SimulationCfg, WriteResults, }; use simln_lib::{ShortChannelID, SimulationError}; use std::collections::HashMap; @@ -160,6 +160,7 @@ pub enum NodeConnection { Lnd(lnd::LndConnection), Cln(cln::ClnConnection), Eclair(eclair::EclairConnection), + Ldk(ldk::LdkConnection), } /// Data structure that is used to parse information from the simulation file. It is used to @@ -396,6 +397,7 @@ async fn get_clients( NodeConnection::Lnd(c) => Arc::new(Mutex::new(LndNode::new(c).await?)), NodeConnection::Cln(c) => Arc::new(Mutex::new(ClnNode::new(c).await?)), NodeConnection::Eclair(c) => Arc::new(Mutex::new(EclairNode::new(c).await?)), + NodeConnection::Ldk(c) => Arc::new(Mutex::new(LdkNode::new(c).await?)), }; let node_info = node.lock().await.get_info().clone(); diff --git a/simln-lib/Cargo.toml b/simln-lib/Cargo.toml index 39d2f5ca..88a48518 100755 --- a/simln-lib/Cargo.toml +++ b/simln-lib/Cargo.toml @@ -33,6 +33,7 @@ rand_distr = "0.4.3" rand_chacha = "0.3.1" reqwest = { version = "0.12", features = ["json", "multipart"] } tokio-util = { version = "0.7.13", features = ["rt"] } +ldk-server-client = { git = "https://github.com/lightningdevkit/ldk-server", rev = "570ed527535da0ec1dae431fac3ce46e5e6a468f" } [dev-dependencies] ntest = "0.9.0" diff --git a/simln-lib/src/ldk.rs b/simln-lib/src/ldk.rs new file mode 100644 index 00000000..ce290e44 --- /dev/null +++ b/simln-lib/src/ldk.rs @@ -0,0 +1,272 @@ +use std::str::FromStr; + +use async_trait::async_trait; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Network; +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::error::{LdkServerError, LdkServerErrorCode}; +use ldk_server_client::ldk_server_grpc::api::{ + GetNodeInfoRequest, GetPaymentDetailsRequest, GraphGetNodeRequest, GraphListNodesRequest, + ListChannelsRequest, SpontaneousSendRequest, +}; +use ldk_server_client::ldk_server_grpc::types::PaymentStatus; +use lightning::ln::features::NodeFeatures; +use lightning::ln::PaymentHash; +use serde::{Deserialize, Serialize}; +use tokio::time::{self, Duration}; +use triggered::Listener; + +use std::collections::HashMap; + +use crate::{ + serializers, Graph, LightningError, LightningNode, NodeInfo, PaymentOutcome, PaymentResult, +}; + +pub struct LdkNode { + client: LdkServerClient, + info: NodeInfo, + network: Network, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct LdkConnection { + pub address: String, + pub api_key: String, + #[serde(deserialize_with = "serializers::deserialize_path")] + pub cert: String, + pub network: Network, +} + +impl LdkNode { + pub async fn new(connection: LdkConnection) -> Result { + let cert_pem = std::fs::read(&connection.cert).map_err(|err| { + LightningError::ConnectionError(format!("Cannot load TLS cert: {err}")) + })?; + + let base_url = connection + .address + .strip_prefix("https://") + .or_else(|| connection.address.strip_prefix("http://")) + .unwrap_or(&connection.address) + .trim_end_matches('/') + .to_string(); + + let client = LdkServerClient::new(base_url, connection.api_key.clone(), &cert_pem) + .map_err(LightningError::ConnectionError)?; + + let info = client + .get_node_info(GetNodeInfoRequest {}) + .await + .map_err(|err| LightningError::GetInfoError(err.to_string()))?; + + let pubkey = PublicKey::from_str(&info.node_id) + .map_err(|err| LightningError::GetInfoError(err.to_string()))?; + let alias = info.node_alias.unwrap_or_default(); + + // ldk-server doesn't expose feature bits, but it always supports keysend + // via `spontaneous_send`, so advertise it so sim-ln's keysend checks pass. + let mut features = NodeFeatures::empty(); + features.set_keysend_optional(); + + Ok(Self { + client, + info: NodeInfo { + pubkey, + features, + alias, + }, + network: connection.network, + }) + } +} + +#[async_trait] +impl LightningNode for LdkNode { + fn get_info(&self) -> &NodeInfo { + &self.info + } + + fn get_network(&self) -> Network { + self.network + } + + async fn send_payment( + &self, + dest: PublicKey, + amount_msat: u64, + ) -> Result { + let response = self + .client + .spontaneous_send(SpontaneousSendRequest { + amount_msat, + node_id: dest.to_string(), + route_parameters: None, + }) + .await + .map_err(ldk_server_error_to_send_error)?; + + string_to_payment_hash(&response.payment_id) + } + + async fn track_payment( + &self, + hash: &PaymentHash, + shutdown: Listener, + ) -> Result { + // ldk-node uses the payment hash as the payment_id for spontaneous + // payments, which is what `send_payment` dispatches. + let payment_id = hex::encode(hash.0); + + loop { + tokio::select! { + biased; + _ = shutdown.clone() => { + return Err(LightningError::TrackPaymentError( + "Shutdown before tracking results".to_string(), + )); + }, + _ = time::sleep(Duration::from_millis(500)) => { + let payment = self + .client + .get_payment_details(GetPaymentDetailsRequest { + payment_id: payment_id.clone(), + }) + .await + .map_err(|err| LightningError::TrackPaymentError(err.to_string()))? + .payment; + + let Some(payment) = payment else { continue }; + + let status = PaymentStatus::from_i32(payment.status).ok_or_else(|| { + LightningError::TrackPaymentError( + "Invalid payment status".to_string(), + ) + })?; + + let payment_outcome = match status { + PaymentStatus::Pending => continue, + PaymentStatus::Succeeded => PaymentOutcome::Success, + // ldk-server's Payment doesn't expose a failure reason, + // so we can't distinguish RouteNotFound/Expired/etc. + PaymentStatus::Failed => PaymentOutcome::UnexpectedError, + }; + + // ldk-server doesn't expose per-HTLC details; report as 1. + return Ok(PaymentResult { + htlc_count: 1, + payment_outcome, + }); + }, + } + } + } + + async fn get_node_info(&self, node_id: &PublicKey) -> Result { + if node_id == &self.info.pubkey { + return Ok(self.info.clone()); + } + + let response = self + .client + .graph_get_node(GraphGetNodeRequest { + node_id: node_id.to_string(), + }) + .await + .map_err(|err| LightningError::GetNodeInfoError(err.to_string()))?; + + let node = response + .node + .ok_or_else(|| LightningError::GetNodeInfoError("Node not found".to_string()))?; + + // ldk-server's graph RPC only surfaces the `node_announcement` fields + // (alias, rgb, addresses) and no feature bits, so fall back to empty. + let alias = node.announcement_info.map(|a| a.alias).unwrap_or_default(); + + Ok(NodeInfo { + pubkey: *node_id, + alias, + features: NodeFeatures::empty(), + }) + } + + async fn channel_capacities(&self) -> Result { + let response = self + .client + .list_channels(ListChannelsRequest {}) + .await + .map_err(|err| LightningError::ListChannelsError(err.to_string()))?; + + // ldk-server reports capacities in msat already; sum both sides to get + // total channel capacity (matching lnd/cln which report total capacity). + Ok(response + .channels + .iter() + .map(|channel| channel.outbound_capacity_msat + channel.inbound_capacity_msat) + .sum()) + } + + async fn get_graph(&self) -> Result { + let node_ids = self + .client + .graph_list_nodes(GraphListNodesRequest {}) + .await + .map_err(|err| LightningError::GetGraphError(err.to_string()))? + .node_ids; + + // ldk-server has no bulk "describe graph" RPC, so we pull each node's + // announcement (for its alias) one by one. This is sequential and will + // be slow on large public graphs — fine for regtest/signet simulations. + let mut nodes_by_pk: HashMap = HashMap::new(); + for node_id in node_ids { + let pubkey = PublicKey::from_str(&node_id) + .map_err(|err| LightningError::GetGraphError(err.to_string()))?; + + let alias = self + .client + .graph_get_node(GraphGetNodeRequest { + node_id: node_id.clone(), + }) + .await + .map_err(|err| LightningError::GetGraphError(err.to_string()))? + .node + .and_then(|n| n.announcement_info) + .map(|a| a.alias) + .unwrap_or_default(); + + nodes_by_pk.insert( + pubkey, + NodeInfo { + pubkey, + alias, + features: NodeFeatures::empty(), + }, + ); + } + + Ok(Graph { nodes_by_pk }) + } +} + +fn string_to_payment_hash(hash: &str) -> Result { + let bytes = hex::decode(hash).map_err(|_| LightningError::InvalidPaymentHash)?; + let slice: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| LightningError::InvalidPaymentHash)?; + Ok(PaymentHash(slice)) +} + +/// Map an ldk-server error returned from a send RPC to a sim-ln error. Errors +/// that are specific to the payment attempt (routing, balance, upstream +/// lightning failures) are returned as `SendPaymentError` so the simulation +/// keeps running; misconfiguration-style errors are surfaced as permanent. +fn ldk_server_error_to_send_error(err: LdkServerError) -> LightningError { + match err.error_code { + LdkServerErrorCode::LightningError + | LdkServerErrorCode::InternalError + | LdkServerErrorCode::InternalServerError => LightningError::SendPaymentError(err.message), + LdkServerErrorCode::AuthError | LdkServerErrorCode::InvalidRequestError => { + LightningError::PermanentError(err.message) + }, + } +} diff --git a/simln-lib/src/lib.rs b/simln-lib/src/lib.rs index a1b5fe0e..23988abb 100755 --- a/simln-lib/src/lib.rs +++ b/simln-lib/src/lib.rs @@ -36,6 +36,7 @@ pub mod clock; mod defined_activity; pub mod eclair; pub mod latency_interceptor; +pub mod ldk; pub mod lnd; mod random_activity; pub mod serializers;