Skip to content

Commit 4cce03e

Browse files
committed
Implement Eclair ExternalNode via reqwest REST API
Add eclair_test cfg and reqwest dev-dependency
1 parent 3bddadd commit 4cce03e

3 files changed

Lines changed: 265 additions & 1 deletion

File tree

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: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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+
78+
#[async_trait]
79+
impl ExternalNode for TestEclairNode {
80+
fn name(&self) -> &str {
81+
"Eclair"
82+
}
83+
84+
async fn get_node_id(&self) -> Result<PublicKey, TestFailure> {
85+
let info = self.post("/getinfo", &[]).await?;
86+
let node_id_str = info["nodeId"]
87+
.as_str()
88+
.ok_or_else(|| self.make_error("missing nodeId in getinfo response".to_string()))?;
89+
PublicKey::from_str(node_id_str)
90+
.map_err(|e| self.make_error(format!("parse nodeId: {}", e)))
91+
}
92+
93+
async fn get_listening_address(&self) -> Result<SocketAddress, TestFailure> {
94+
Ok(self.listen_addr.clone())
95+
}
96+
97+
async fn get_block_height(&self) -> Result<u64, TestFailure> {
98+
let info = self.post("/getinfo", &[]).await?;
99+
info["blockHeight"]
100+
.as_u64()
101+
.ok_or_else(|| self.make_error("missing blockHeight in getinfo response".to_string()))
102+
}
103+
104+
async fn connect_peer(
105+
&self, peer_id: PublicKey, addr: SocketAddress,
106+
) -> Result<(), TestFailure> {
107+
let uri = format!("{}@{}", peer_id, addr);
108+
self.post("/connect", &[("uri", &uri)]).await?;
109+
Ok(())
110+
}
111+
112+
async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> {
113+
self.post("/disconnect", &[("nodeId", &peer_id.to_string())]).await?;
114+
Ok(())
115+
}
116+
117+
async fn open_channel(
118+
&self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option<u64>,
119+
) -> Result<String, TestFailure> {
120+
let node_id = peer_id.to_string();
121+
let capacity = capacity_sat.to_string();
122+
let push_str = push_msat.map(|m| m.to_string());
123+
124+
let mut params = vec![("nodeId", node_id.as_str()), ("fundingSatoshis", capacity.as_str())];
125+
if let Some(ref push) = push_str {
126+
params.push(("pushMsat", push.as_str()));
127+
}
128+
129+
let result = self.post("/open", &params).await?;
130+
let channel_id = result
131+
.as_str()
132+
.ok_or_else(|| self.make_error("open did not return channel id string".to_string()))?;
133+
Ok(channel_id.to_string())
134+
}
135+
136+
async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
137+
self.post("/close", &[("channelId", channel_id)]).await?;
138+
Ok(())
139+
}
140+
141+
async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> {
142+
self.post("/forceclose", &[("channelId", channel_id)]).await?;
143+
Ok(())
144+
}
145+
146+
async fn create_invoice(
147+
&self, amount_msat: u64, description: &str,
148+
) -> Result<String, TestFailure> {
149+
let amount_str = amount_msat.to_string();
150+
let result = self
151+
.post("/createinvoice", &[("amountMsat", &amount_str), ("description", description)])
152+
.await?;
153+
let invoice = result["serialized"]
154+
.as_str()
155+
.ok_or_else(|| self.make_error("missing serialized in invoice response".to_string()))?;
156+
Ok(invoice.to_string())
157+
}
158+
159+
async fn pay_invoice(&self, invoice: &str) -> Result<String, TestFailure> {
160+
let result = self.post("/payinvoice", &[("invoice", invoice)]).await?;
161+
// Eclair returns the payment id
162+
let payment_id = result
163+
.as_str()
164+
.filter(|s| !s.is_empty())
165+
.ok_or_else(|| self.make_error("payinvoice did not return payment id".to_string()))?;
166+
Ok(payment_id.to_string())
167+
}
168+
169+
async fn send_keysend(
170+
&self, peer_id: PublicKey, amount_msat: u64,
171+
) -> Result<String, TestFailure> {
172+
let amount_str = amount_msat.to_string();
173+
let node_id_str = peer_id.to_string();
174+
let result = self
175+
.post("/sendtonode", &[("nodeId", &node_id_str), ("amountMsat", &amount_str)])
176+
.await?;
177+
let payment_id = result
178+
.as_str()
179+
.filter(|s| !s.is_empty())
180+
.ok_or_else(|| self.make_error("sendtonode did not return payment id".to_string()))?;
181+
Ok(payment_id.to_string())
182+
}
183+
184+
async fn get_funding_address(&self) -> Result<String, TestFailure> {
185+
let result = self.post("/getnewaddress", &[]).await?;
186+
result
187+
.as_str()
188+
.map(|s| s.to_string())
189+
.ok_or_else(|| self.make_error("getnewaddress did not return string".to_string()))
190+
}
191+
192+
async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> {
193+
let amount_str = amount_sat.to_string();
194+
self.post("/splicein", &[("channelId", channel_id), ("amountIn", &amount_str)]).await?;
195+
Ok(())
196+
}
197+
198+
async fn splice_out(
199+
&self, channel_id: &str, amount_sat: u64, address: Option<&str>,
200+
) -> Result<(), TestFailure> {
201+
let addr = address
202+
.ok_or_else(|| self.make_error("Eclair splice_out requires an address".to_string()))?;
203+
let amount_str = amount_sat.to_string();
204+
self.post(
205+
"/spliceout",
206+
&[("channelId", channel_id), ("amountOut", &amount_str), ("address", addr)],
207+
)
208+
.await?;
209+
Ok(())
210+
}
211+
212+
async fn list_channels(&self) -> Result<Vec<ExternalChannel>, TestFailure> {
213+
let result = self.post("/channels", &[]).await?;
214+
let channels_arr = result
215+
.as_array()
216+
.ok_or_else(|| self.make_error("/channels did not return array".to_string()))?;
217+
218+
let mut channels = Vec::new();
219+
for ch in channels_arr {
220+
let channel_id = ch["channelId"].as_str().unwrap_or_default().to_string();
221+
let node_id_str = ch["nodeId"].as_str().unwrap_or_default();
222+
let peer_id = match PublicKey::from_str(node_id_str) {
223+
Ok(pk) => pk,
224+
Err(_) => continue,
225+
};
226+
let state_str = ch["state"].as_str().unwrap_or("");
227+
let capacity_sat = ch["data"]["commitments"]["active"]
228+
.as_array()
229+
.and_then(|a| a.first())
230+
.and_then(|c| c["fundingTx"]["amountSatoshis"].as_u64())
231+
.unwrap_or(0);
232+
let funding_txid = ch["data"]["commitments"]["active"]
233+
.as_array()
234+
.and_then(|a| a.first())
235+
.and_then(|c| c["fundingTx"]["txid"].as_str())
236+
.map(|s| s.to_string());
237+
let active_commitment =
238+
ch["data"]["commitments"]["active"].as_array().and_then(|a| a.first());
239+
let local_balance_msat = active_commitment
240+
.and_then(|c| c["localCommit"]["spec"]["toLocal"].as_u64())
241+
.unwrap_or(0);
242+
let remote_balance_msat = active_commitment
243+
.and_then(|c| c["localCommit"]["spec"]["toRemote"].as_u64())
244+
.unwrap_or(0);
245+
246+
channels.push(ExternalChannel {
247+
channel_id,
248+
peer_id,
249+
capacity_sat,
250+
local_balance_msat,
251+
remote_balance_msat,
252+
funding_txid,
253+
is_active: state_str == "NORMAL",
254+
});
255+
}
256+
Ok(channels)
257+
}
258+
}

tests/common/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66
// accordance with one or both of these licenses.
77

8-
#![cfg(any(test, cln_test, lnd_test, vss_test))]
8+
#![cfg(any(test, cln_test, lnd_test, vss_test, eclair_test))]
99
#![allow(dead_code)]
1010

1111
pub(crate) mod external_node;
1212
pub(crate) mod logging;
1313

1414
#[cfg(lnd_test)]
1515
pub(crate) mod lnd;
16+
#[cfg(eclair_test)]
17+
pub(crate) mod eclair;
1618

1719
use std::collections::{HashMap, HashSet};
1820
use std::env;

0 commit comments

Comments
 (0)