Skip to content

Commit 3fe869f

Browse files
committed
Add ExternalNode trait with LND, CLN, and Eclair implementations
1 parent 4c6dfec commit 3fe869f

File tree

6 files changed

+1238
-1
lines changed

6 files changed

+1238
-1
lines changed

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,12 @@ corepc-node = { version = "0.10.0", default-features = false, features = ["27_2"
104104
clightningrpc = { version = "0.3.0-beta.8", default-features = false }
105105

106106
[target.'cfg(lnd_test)'.dev-dependencies]
107-
lnd_grpc_rust = { version = "2.10.0", default-features = false }
107+
lnd_grpc_rust = { version = "2.14.0", default-features = false }
108108
tokio = { version = "1.37", features = ["fs"] }
109109

110+
[target.'cfg(eclair_test)'.dev-dependencies]
111+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
112+
110113
[build-dependencies]
111114
uniffi = { version = "0.29.5", features = ["build"], optional = true }
112115

@@ -125,6 +128,7 @@ check-cfg = [
125128
"cfg(tokio_unstable)",
126129
"cfg(cln_test)",
127130
"cfg(lnd_test)",
131+
"cfg(eclair_test)",
128132
"cfg(cycle_tests)",
129133
]
130134

tests/common/cln.rs

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
use std::str::FromStr;
9+
use std::sync::Arc;
10+
11+
use async_trait::async_trait;
12+
use clightningrpc::lightningrpc::LightningRPC;
13+
use clightningrpc::lightningrpc::PayOptions;
14+
use ldk_node::bitcoin::secp256k1::PublicKey;
15+
use ldk_node::lightning::ln::msgs::SocketAddress;
16+
use serde_json::json;
17+
18+
use super::external_node::{ExternalChannel, ExternalNode, TestFailure};
19+
20+
pub(crate) struct TestClnNode {
21+
client: Arc<LightningRPC>,
22+
listen_addr: SocketAddress,
23+
}
24+
25+
impl TestClnNode {
26+
pub(crate) fn new(socket_path: &str, listen_addr: SocketAddress) -> Self {
27+
Self { client: Arc::new(LightningRPC::new(socket_path)), listen_addr }
28+
}
29+
30+
pub(crate) fn from_env() -> Self {
31+
let sock =
32+
std::env::var("CLN_SOCKET_PATH").unwrap_or_else(|_| "/tmp/lightning-rpc".to_string());
33+
let listen_addr: SocketAddress = std::env::var("CLN_P2P_ADDR")
34+
.unwrap_or_else(|_| "127.0.0.1:19846".to_string())
35+
.parse()
36+
.unwrap();
37+
Self::new(&sock, listen_addr)
38+
}
39+
40+
/// Run a synchronous CLN RPC call on a dedicated blocking thread.
41+
///
42+
/// The `clightningrpc` crate performs synchronous I/O over a Unix socket.
43+
/// Running these calls directly on the tokio runtime would block the worker
44+
/// thread, preventing LDK's background tasks from making progress and
45+
/// causing deadlocks (especially with `worker_threads = 1`).
46+
async fn rpc<F, T>(&self, f: F) -> T
47+
where
48+
F: FnOnce(&LightningRPC) -> T + Send + 'static,
49+
T: Send + 'static,
50+
{
51+
let client = Arc::clone(&self.client);
52+
tokio::task::spawn_blocking(move || f(&*client)).await.expect("CLN RPC task panicked")
53+
}
54+
55+
fn make_error(&self, detail: String) -> TestFailure {
56+
TestFailure::ExternalNodeError { node: "CLN".to_string(), detail }
57+
}
58+
59+
/// Repeatedly call `splice_update` until `commitments_secured` is true.
60+
/// Returns the final PSBT. Gives up after 10 attempts.
61+
async fn splice_update_loop(
62+
&self, channel_id: &str, mut psbt: String,
63+
) -> Result<String, TestFailure> {
64+
const MAX_ATTEMPTS: u32 = 10;
65+
for _ in 0..MAX_ATTEMPTS {
66+
let ch_id = channel_id.to_string();
67+
let psbt_arg = psbt.clone();
68+
let update_result: serde_json::Value = self
69+
.rpc(move |c| {
70+
c.call("splice_update", &json!({"channel_id": ch_id, "psbt": psbt_arg}))
71+
})
72+
.await
73+
.map_err(|e| self.make_error(format!("splice_update: {}", e)))?;
74+
psbt = update_result["psbt"]
75+
.as_str()
76+
.ok_or_else(|| self.make_error("splice_update did not return psbt".to_string()))?
77+
.to_string();
78+
if update_result["commitments_secured"].as_bool() == Some(true) {
79+
return Ok(psbt);
80+
}
81+
}
82+
Err(self.make_error(format!(
83+
"splice_update did not reach commitments_secured after {} attempts",
84+
MAX_ATTEMPTS
85+
)))
86+
}
87+
}
88+
89+
#[async_trait]
90+
impl ExternalNode for TestClnNode {
91+
fn name(&self) -> &str {
92+
"CLN"
93+
}
94+
95+
async fn get_node_id(&self) -> Result<PublicKey, TestFailure> {
96+
let info = self
97+
.rpc(|c| c.getinfo())
98+
.await
99+
.map_err(|e| self.make_error(format!("getinfo: {}", e)))?;
100+
PublicKey::from_str(&info.id).map_err(|e| self.make_error(format!("parse node id: {}", e)))
101+
}
102+
103+
async fn get_listening_address(&self) -> Result<SocketAddress, TestFailure> {
104+
Ok(self.listen_addr.clone())
105+
}
106+
107+
async fn get_block_height(&self) -> Result<u64, TestFailure> {
108+
let info = self
109+
.rpc(|c| c.getinfo())
110+
.await
111+
.map_err(|e| self.make_error(format!("getinfo: {}", e)))?;
112+
Ok(info.blockheight as u64)
113+
}
114+
115+
async fn connect_peer(
116+
&self, peer_id: PublicKey, addr: SocketAddress,
117+
) -> Result<(), TestFailure> {
118+
let uri = format!("{}@{}", peer_id, addr);
119+
let _: serde_json::Value = self
120+
.rpc(move |c| c.call("connect", &json!({"id": uri})))
121+
.await
122+
.map_err(|e| self.make_error(format!("connect: {}", e)))?;
123+
Ok(())
124+
}
125+
126+
async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> {
127+
let id = peer_id.to_string();
128+
let _: serde_json::Value = self
129+
.rpc(move |c| c.call("disconnect", &json!({"id": id, "force": true})))
130+
.await
131+
.map_err(|e| self.make_error(format!("disconnect: {}", e)))?;
132+
Ok(())
133+
}
134+
135+
async fn open_channel(
136+
&self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option<u64>,
137+
) -> Result<String, TestFailure> {
138+
// Use the generic `call` method to include `push_msat`, which the
139+
// typed `fundchannel` method does not support.
140+
let mut params = json!({
141+
"id": peer_id.to_string(),
142+
"amount": capacity_sat,
143+
});
144+
if let Some(push) = push_msat {
145+
params["push_msat"] = json!(push);
146+
}
147+
148+
let result: serde_json::Value = self
149+
.rpc(move |c| c.call("fundchannel", &params))
150+
.await
151+
.map_err(|e| self.make_error(format!("fundchannel: {}", e)))?;
152+
153+
let channel_id = result["channel_id"]
154+
.as_str()
155+
.ok_or_else(|| self.make_error("fundchannel did not return channel_id".to_string()))?;
156+
Ok(channel_id.to_string())
157+
}
158+
159+
async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
160+
let ch_id = channel_id.to_string();
161+
self.rpc(move |c| c.close(&ch_id, None, None))
162+
.await
163+
.map_err(|e| self.make_error(format!("close: {}", e)))?;
164+
Ok(())
165+
}
166+
167+
async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
168+
// CLN v23.08 removed the `force` parameter; use `unilateraltimeout: 1`
169+
// to trigger an immediate unilateral close.
170+
let ch_id = channel_id.to_string();
171+
let _: serde_json::Value = self
172+
.rpc(move |c| c.call("close", &json!({"id": ch_id, "unilateraltimeout": 1})))
173+
.await
174+
.map_err(|e| self.make_error(format!("force close: {}", e)))?;
175+
Ok(())
176+
}
177+
178+
async fn create_invoice(
179+
&self, amount_msat: u64, description: &str,
180+
) -> Result<String, TestFailure> {
181+
let desc = description.to_string();
182+
let label = format!(
183+
"{}-{}",
184+
desc,
185+
std::time::SystemTime::now()
186+
.duration_since(std::time::UNIX_EPOCH)
187+
.unwrap_or_default()
188+
.as_nanos()
189+
);
190+
let invoice = self
191+
.rpc(move |c| c.invoice(Some(amount_msat), &label, &desc, None, None, None))
192+
.await
193+
.map_err(|e| self.make_error(format!("invoice: {}", e)))?;
194+
Ok(invoice.bolt11)
195+
}
196+
197+
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
198+
let inv = invoice.to_string();
199+
let result = self
200+
.rpc(move |c| c.pay(&inv, PayOptions::default()))
201+
.await
202+
.map_err(|e| self.make_error(format!("pay: {}", e)))?;
203+
Ok(result.payment_preimage)
204+
}
205+
206+
async fn send_keysend(
207+
&self, peer_id: PublicKey, amount_msat: u64,
208+
) -> Result<String, TestFailure> {
209+
let dest = peer_id.to_string();
210+
let result: serde_json::Value = self
211+
.rpc(move |c| {
212+
c.call("keysend", &json!({"destination": dest, "amount_msat": amount_msat}))
213+
})
214+
.await
215+
.map_err(|e| self.make_error(format!("keysend: {}", e)))?;
216+
let preimage =
217+
result["payment_preimage"].as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
218+
self.make_error("keysend did not return payment_preimage".to_string())
219+
})?;
220+
Ok(preimage.to_string())
221+
}
222+
223+
async fn get_funding_address(&self) -> Result<String, TestFailure> {
224+
let addr = self
225+
.rpc(|c| c.newaddr(None))
226+
.await
227+
.map_err(|e| self.make_error(format!("newaddr: {}", e)))?;
228+
addr.bech32.ok_or_else(|| self.make_error("no bech32 address returned".to_string()))
229+
}
230+
231+
async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> {
232+
// Step 1: splice_init with positive relative_amount
233+
let ch_id = channel_id.to_string();
234+
let amount: i64 = amount_sat.try_into().map_err(|_| {
235+
self.make_error(format!("splice_in: amount_sat overflow: {}", amount_sat))
236+
})?;
237+
let init_result: serde_json::Value = self
238+
.rpc(move |c| {
239+
c.call("splice_init", &json!({"channel_id": ch_id, "relative_amount": amount}))
240+
})
241+
.await
242+
.map_err(|e| self.make_error(format!("splice_init: {}", e)))?;
243+
let mut psbt = init_result["psbt"]
244+
.as_str()
245+
.ok_or_else(|| self.make_error("splice_init did not return psbt".to_string()))?
246+
.to_string();
247+
248+
// Step 2: splice_update until commitments_secured
249+
psbt = self.splice_update_loop(channel_id, psbt).await?;
250+
251+
// Step 3: splice_signed
252+
let ch_id = channel_id.to_string();
253+
let _: serde_json::Value = self
254+
.rpc(move |c| c.call("splice_signed", &json!({"channel_id": ch_id, "psbt": psbt})))
255+
.await
256+
.map_err(|e| self.make_error(format!("splice_signed: {}", e)))?;
257+
Ok(())
258+
}
259+
260+
async fn splice_out(
261+
&self, channel_id: &str, amount_sat: u64, address: Option<&str>,
262+
) -> Result<(), TestFailure> {
263+
// CLN splice-out uses negative relative_amount.
264+
// Funds always go to CLN's own wallet; specifying a custom address
265+
// would require manual PSBT manipulation which is out of scope.
266+
if address.is_some() {
267+
return Err(self.make_error(
268+
"splice_out with custom address is not supported by CLN adapter".to_string(),
269+
));
270+
}
271+
let ch_id = channel_id.to_string();
272+
let positive: i64 = amount_sat.try_into().map_err(|_| {
273+
self.make_error(format!("splice_out: amount_sat overflow: {}", amount_sat))
274+
})?;
275+
let amount = -positive;
276+
let init_result: serde_json::Value = self
277+
.rpc(move |c| {
278+
c.call("splice_init", &json!({"channel_id": ch_id, "relative_amount": amount}))
279+
})
280+
.await
281+
.map_err(|e| self.make_error(format!("splice_init: {}", e)))?;
282+
let mut psbt = init_result["psbt"]
283+
.as_str()
284+
.ok_or_else(|| self.make_error("splice_init did not return psbt".to_string()))?
285+
.to_string();
286+
287+
psbt = self.splice_update_loop(channel_id, psbt).await?;
288+
289+
let ch_id = channel_id.to_string();
290+
let _: serde_json::Value = self
291+
.rpc(move |c| c.call("splice_signed", &json!({"channel_id": ch_id, "psbt": psbt})))
292+
.await
293+
.map_err(|e| self.make_error(format!("splice_signed: {}", e)))?;
294+
Ok(())
295+
}
296+
297+
async fn list_channels(&self) -> Result<Vec<ExternalChannel>, TestFailure> {
298+
let response: serde_json::Value = self
299+
.rpc(|c| c.call("listpeerchannels", &serde_json::Map::new()))
300+
.await
301+
.map_err(|e| self.make_error(format!("listpeerchannels: {}", e)))?;
302+
let mut channels = Vec::new();
303+
304+
for ch in response["channels"].as_array().unwrap_or(&vec![]) {
305+
let peer_id_str = ch["peer_id"]
306+
.as_str()
307+
.ok_or_else(|| self.make_error("list_channels: missing peer_id".to_string()))?;
308+
let peer_id = PublicKey::from_str(peer_id_str).map_err(|e| {
309+
self.make_error(format!("list_channels: invalid peer_id '{}': {}", peer_id_str, e))
310+
})?;
311+
let channel_id = ch["channel_id"]
312+
.as_str()
313+
.ok_or_else(|| self.make_error("list_channels: missing channel_id".to_string()))?
314+
.to_string();
315+
let total_msat = ch["total_msat"].as_u64().unwrap_or(0);
316+
let to_us_msat = ch["to_us_msat"].as_u64().unwrap_or(0);
317+
let funding_txid = ch["funding_txid"].as_str().map(|s| s.to_string());
318+
let state = ch["state"].as_str().unwrap_or("");
319+
channels.push(ExternalChannel {
320+
channel_id,
321+
peer_id,
322+
capacity_sat: total_msat / 1000,
323+
local_balance_msat: to_us_msat,
324+
remote_balance_msat: total_msat.saturating_sub(to_us_msat),
325+
funding_txid,
326+
is_active: state == "CHANNELD_NORMAL",
327+
});
328+
}
329+
Ok(channels)
330+
}
331+
}

0 commit comments

Comments
 (0)