Skip to content

Commit be9b865

Browse files
committed
Implement Eclair ExternalNode via reqwest REST API
- Implement ExternalNode trait with 60s request timeout and settlement polling for pay_invoice/send_keysend - Override wait_for_block_sync() to poll /getinfo until chain tip is reached
1 parent c5987c7 commit be9b865

File tree

3 files changed

+335
-0
lines changed

3 files changed

+335
-0
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ clightningrpc = { version = "0.3.0-beta.8", default-features = false }
106106
lnd_grpc_rust = { version = "2.14.0", default-features = false }
107107
tokio = { version = "1.37", features = ["fs"] }
108108

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

@@ -124,6 +127,7 @@ check-cfg = [
124127
"cfg(tokio_unstable)",
125128
"cfg(cln_test)",
126129
"cfg(lnd_test)",
130+
"cfg(eclair_test)",
127131
"cfg(cycle_tests)",
128132
]
129133

tests/common/eclair.rs

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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+
10+
use async_trait::async_trait;
11+
use ldk_node::bitcoin::secp256k1::PublicKey;
12+
use ldk_node::lightning::ln::msgs::SocketAddress;
13+
use reqwest::Client;
14+
use serde_json::Value;
15+
16+
use super::external_node::{ExternalChannel, ExternalNode, TestFailure};
17+
18+
pub(crate) struct TestEclairNode {
19+
client: Client,
20+
base_url: String,
21+
password: String,
22+
listen_addr: SocketAddress,
23+
}
24+
25+
impl TestEclairNode {
26+
pub(crate) fn new(base_url: &str, password: &str, listen_addr: SocketAddress) -> Self {
27+
Self {
28+
client: Client::new(),
29+
base_url: base_url.to_string(),
30+
password: password.to_string(),
31+
listen_addr,
32+
}
33+
}
34+
35+
pub(crate) fn from_env() -> Self {
36+
let base_url =
37+
std::env::var("ECLAIR_API_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
38+
let password =
39+
std::env::var("ECLAIR_API_PASSWORD").unwrap_or_else(|_| "eclairpassword".to_string());
40+
let listen_addr: SocketAddress = std::env::var("ECLAIR_P2P_ADDR")
41+
.unwrap_or_else(|_| "127.0.0.1:9736".to_string())
42+
.parse()
43+
.unwrap();
44+
Self::new(&base_url, &password, listen_addr)
45+
}
46+
47+
async fn post(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<Value, TestFailure> {
48+
let url = format!("{}{}", self.base_url, endpoint);
49+
let response = self
50+
.client
51+
.post(&url)
52+
.basic_auth("", Some(&self.password))
53+
.form(params)
54+
.send()
55+
.await
56+
.map_err(|e| self.make_error(format!("request to {} failed: {}", endpoint, e)))?;
57+
58+
let status = response.status();
59+
let body = response
60+
.text()
61+
.await
62+
.map_err(|e| self.make_error(format!("reading response from {}: {}", endpoint, e)))?;
63+
64+
if !status.is_success() {
65+
return Err(self.make_error(format!("{} returned {}: {}", endpoint, status, body)));
66+
}
67+
68+
serde_json::from_str(&body).map_err(|e| {
69+
self.make_error(format!("parsing response from {}: {} (body: {})", endpoint, e, body))
70+
})
71+
}
72+
73+
fn make_error(&self, detail: String) -> TestFailure {
74+
TestFailure::ExternalNodeError { node: "Eclair".to_string(), detail }
75+
}
76+
77+
/// Poll /getsentinfo until the payment settles or fails.
78+
/// Eclair's pay/keysend APIs are fire-and-forget, so without polling
79+
/// the caller would hang on the LDK event timeout when a payment fails.
80+
async fn poll_payment_settlement(
81+
&self, payment_id: &str, label: &str,
82+
) -> Result<String, TestFailure> {
83+
let timeout_secs = super::INTEROP_TIMEOUT_SECS;
84+
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
85+
loop {
86+
if tokio::time::Instant::now() >= deadline {
87+
return Err(self.make_error(format!(
88+
"{} {} did not settle within {}s",
89+
label, payment_id, timeout_secs
90+
)));
91+
}
92+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
93+
let info = self.post("/getsentinfo", &[("id", payment_id)]).await?;
94+
if let Some(attempts) = info.as_array() {
95+
if let Some(last) = attempts.last() {
96+
let status = last["status"]["type"].as_str().unwrap_or("");
97+
if status == "sent" {
98+
return Ok(payment_id.to_string());
99+
} else if status == "failed" {
100+
let failure = last["status"]["failures"]
101+
.as_array()
102+
.and_then(|f| f.last())
103+
.and_then(|f| f["failureMessage"].as_str())
104+
.unwrap_or("unknown");
105+
return Err(self
106+
.make_error(format!("{} {} failed: {}", label, payment_id, failure)));
107+
}
108+
}
109+
}
110+
}
111+
}
112+
}
113+
114+
#[async_trait]
115+
impl ExternalNode for TestEclairNode {
116+
fn name(&self) -> &str {
117+
"Eclair"
118+
}
119+
120+
async fn get_node_id(&self) -> Result<PublicKey, TestFailure> {
121+
let info = self.post("/getinfo", &[]).await?;
122+
let node_id_str = info["nodeId"]
123+
.as_str()
124+
.ok_or_else(|| self.make_error("missing nodeId in getinfo response".to_string()))?;
125+
PublicKey::from_str(node_id_str)
126+
.map_err(|e| self.make_error(format!("parse nodeId: {}", e)))
127+
}
128+
129+
async fn get_listening_address(&self) -> Result<SocketAddress, TestFailure> {
130+
Ok(self.listen_addr.clone())
131+
}
132+
133+
async fn get_block_height(&self) -> Result<u64, TestFailure> {
134+
let info = self.post("/getinfo", &[]).await?;
135+
info["blockHeight"]
136+
.as_u64()
137+
.ok_or_else(|| self.make_error("missing blockHeight in getinfo response".to_string()))
138+
}
139+
140+
async fn wait_for_block_sync(&self, min_height: u64) -> Result<(), TestFailure> {
141+
for i in 0..60 {
142+
match self.get_block_height().await {
143+
Ok(h) if h >= min_height => return Ok(()),
144+
Ok(h) => {
145+
if i % 10 == 0 {
146+
println!(
147+
"Waiting for {} to reach height {} (currently at {})...",
148+
self.name(),
149+
min_height,
150+
h
151+
);
152+
}
153+
},
154+
Err(e) => {
155+
if i % 10 == 0 {
156+
eprintln!("wait_for_block_sync: get_block_height error: {}", e);
157+
}
158+
},
159+
}
160+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
161+
}
162+
Err(self.make_error(format!("did not reach height {} after 60s", min_height)))
163+
}
164+
165+
async fn connect_peer(
166+
&self, peer_id: PublicKey, addr: SocketAddress,
167+
) -> Result<(), TestFailure> {
168+
let uri = format!("{}@{}", peer_id, addr);
169+
self.post("/connect", &[("uri", &uri)]).await?;
170+
Ok(())
171+
}
172+
173+
async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> {
174+
self.post("/disconnect", &[("nodeId", &peer_id.to_string())]).await?;
175+
Ok(())
176+
}
177+
178+
async fn open_channel(
179+
&self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option<u64>,
180+
) -> Result<String, TestFailure> {
181+
let node_id = peer_id.to_string();
182+
let capacity = capacity_sat.to_string();
183+
let push_str = push_msat.map(|m| m.to_string());
184+
185+
let mut params = vec![("nodeId", node_id.as_str()), ("fundingSatoshis", capacity.as_str())];
186+
if let Some(ref push) = push_str {
187+
params.push(("pushMsat", push.as_str()));
188+
}
189+
190+
let result = self.post("/open", &params).await?;
191+
let channel_id = result
192+
.as_str()
193+
.map(|s| s.to_string())
194+
.or_else(|| result["channelId"].as_str().map(|s| s.to_string()))
195+
.ok_or_else(|| {
196+
self.make_error(format!("open did not return channel id: {}", result))
197+
})?;
198+
Ok(channel_id)
199+
}
200+
201+
async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
202+
self.post("/close", &[("channelId", channel_id)]).await?;
203+
Ok(())
204+
}
205+
206+
async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
207+
self.post("/forceclose", &[("channelId", channel_id)]).await?;
208+
Ok(())
209+
}
210+
211+
async fn create_invoice(
212+
&self, amount_msat: u64, description: &str,
213+
) -> Result<String, TestFailure> {
214+
let amount_str = amount_msat.to_string();
215+
let result = self
216+
.post("/createinvoice", &[("amountMsat", &amount_str), ("description", description)])
217+
.await?;
218+
let invoice = result["serialized"]
219+
.as_str()
220+
.ok_or_else(|| self.make_error("missing serialized in invoice response".to_string()))?;
221+
Ok(invoice.to_string())
222+
}
223+
224+
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
225+
let result = self.post("/payinvoice", &[("invoice", invoice)]).await?;
226+
let payment_id = result
227+
.as_str()
228+
.filter(|s| !s.is_empty())
229+
.ok_or_else(|| self.make_error("payinvoice did not return payment id".to_string()))?
230+
.to_string();
231+
self.poll_payment_settlement(&payment_id, "payment").await
232+
}
233+
234+
async fn send_keysend(
235+
&self, peer_id: PublicKey, amount_msat: u64,
236+
) -> Result<String, TestFailure> {
237+
let amount_str = amount_msat.to_string();
238+
let node_id_str = peer_id.to_string();
239+
let result = self
240+
.post("/sendtonode", &[("nodeId", &node_id_str), ("amountMsat", &amount_str)])
241+
.await?;
242+
let payment_id = result
243+
.as_str()
244+
.filter(|s| !s.is_empty())
245+
.ok_or_else(|| self.make_error("sendtonode did not return payment id".to_string()))?
246+
.to_string();
247+
self.poll_payment_settlement(&payment_id, "keysend").await
248+
}
249+
250+
async fn get_funding_address(&self) -> Result<String, TestFailure> {
251+
let result = self.post("/getnewaddress", &[]).await?;
252+
result
253+
.as_str()
254+
.map(|s| s.to_string())
255+
.ok_or_else(|| self.make_error("getnewaddress did not return string".to_string()))
256+
}
257+
258+
async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> {
259+
let amount_str = amount_sat.to_string();
260+
self.post("/splicein", &[("channelId", channel_id), ("amountIn", &amount_str)]).await?;
261+
Ok(())
262+
}
263+
264+
async fn splice_out(
265+
&self, channel_id: &str, amount_sat: u64, address: Option<&str>,
266+
) -> Result<(), TestFailure> {
267+
let addr = address
268+
.ok_or_else(|| self.make_error("Eclair splice_out requires an address".to_string()))?;
269+
let amount_str = amount_sat.to_string();
270+
self.post(
271+
"/spliceout",
272+
&[("channelId", channel_id), ("amountOut", &amount_str), ("address", addr)],
273+
)
274+
.await?;
275+
Ok(())
276+
}
277+
278+
async fn list_channels(&self) -> Result<Vec<ExternalChannel>, TestFailure> {
279+
let result = self.post("/channels", &[]).await?;
280+
let channels_arr = result
281+
.as_array()
282+
.ok_or_else(|| self.make_error("/channels did not return array".to_string()))?;
283+
284+
let mut channels = Vec::new();
285+
for ch in channels_arr {
286+
let channel_id = ch["channelId"]
287+
.as_str()
288+
.ok_or_else(|| self.make_error("list_channels: missing channelId".to_string()))?
289+
.to_string();
290+
let node_id_str = ch["nodeId"]
291+
.as_str()
292+
.ok_or_else(|| self.make_error("list_channels: missing nodeId".to_string()))?;
293+
let peer_id = PublicKey::from_str(node_id_str).map_err(|e| {
294+
self.make_error(format!("list_channels: invalid nodeId '{}': {}", node_id_str, e))
295+
})?;
296+
let state_str = ch["state"].as_str().unwrap_or("");
297+
let commitments = &ch["data"]["commitments"];
298+
299+
// Eclair 0.10+ uses commitments.active[] array (splice support).
300+
let active_commitment =
301+
commitments["active"].as_array().and_then(|a| a.first()).ok_or_else(|| {
302+
self.make_error(format!(
303+
"list_channels: missing commitments.active[] for channel {}",
304+
channel_id
305+
))
306+
})?;
307+
308+
let capacity_sat =
309+
active_commitment["fundingTx"]["amountSatoshis"].as_u64().unwrap_or(0);
310+
let funding_txid =
311+
active_commitment["fundingTx"]["txid"].as_str().map(|s| s.to_string());
312+
let local_balance_msat =
313+
active_commitment["localCommit"]["spec"]["toLocal"].as_u64().unwrap_or(0);
314+
let remote_balance_msat =
315+
active_commitment["localCommit"]["spec"]["toRemote"].as_u64().unwrap_or(0);
316+
317+
channels.push(ExternalChannel {
318+
channel_id,
319+
peer_id,
320+
capacity_sat,
321+
local_balance_msat,
322+
remote_balance_msat,
323+
funding_txid,
324+
is_active: state_str == "NORMAL",
325+
});
326+
}
327+
Ok(channels)
328+
}
329+
}

tests/common/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
pub(crate) mod external_node;
1212
pub(crate) mod logging;
1313

14+
#[cfg(eclair_test)]
15+
pub(crate) mod eclair;
1416
#[cfg(lnd_test)]
1517
pub(crate) mod lnd;
1618

0 commit comments

Comments
 (0)