Skip to content

Commit 236cdc6

Browse files
committed
Add Eclair integration test harness
Add `integration_tests_eclair.rs` with a `TestEclairClient` wrapping Eclair's HTTP REST API via `bitreq`, and a `test_eclair()` function exercising channel open/close, bidirectional BOLT11 payments, and soft-fail splice in/out tests. Also add `eclair_test` to the `check-cfg` list in `Cargo.toml` and to the cfg gate in `tests/common/mod.rs`. Generated with the help of AI (Claude Code). Co-Authored-By: HAL 9000
1 parent 80fb49b commit 236cdc6

File tree

3 files changed

+261
-1
lines changed

3 files changed

+261
-1
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ check-cfg = [
121121
"cfg(ldk_bench)",
122122
"cfg(tokio_unstable)",
123123
"cfg(cln_test)",
124+
"cfg(eclair_test)",
124125
"cfg(lnd_test)",
125126
"cfg(cycle_tests)",
126127
]

tests/common/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
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, eclair_test, lnd_test, vss_test))]
99
#![allow(dead_code)]
1010

1111
pub(crate) mod logging;

tests/integration_tests_eclair.rs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
#![cfg(eclair_test)]
9+
10+
mod common;
11+
12+
use std::str::FromStr;
13+
14+
use base64::prelude::BASE64_STANDARD;
15+
use base64::Engine;
16+
use electrsd::corepc_client::client_sync::Auth;
17+
use electrsd::corepc_node::Client as BitcoindClient;
18+
use electrum_client::Client as ElectrumClient;
19+
use ldk_node::bitcoin::secp256k1::PublicKey;
20+
use ldk_node::bitcoin::Amount;
21+
use ldk_node::lightning::ln::msgs::SocketAddress;
22+
use ldk_node::{Builder, Event};
23+
use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description};
24+
25+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
26+
async fn test_eclair() {
27+
// Setup bitcoind / electrs clients
28+
let bitcoind_client = BitcoindClient::new_with_auth(
29+
"http://127.0.0.1:18443",
30+
Auth::UserPass("user".to_string(), "pass".to_string()),
31+
)
32+
.unwrap();
33+
let electrs_client = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap();
34+
35+
// Give electrs a kick.
36+
common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 1).await;
37+
38+
// Setup LDK Node
39+
let config = common::random_config(true);
40+
let mut builder = Builder::from_config(config.node_config);
41+
builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), None);
42+
43+
let node = builder.build(config.node_entropy).unwrap();
44+
node.start().unwrap();
45+
46+
// Premine some funds and distribute
47+
let address = node.onchain_payment().new_address().unwrap();
48+
let premine_amount = Amount::from_sat(5_000_000);
49+
common::premine_and_distribute_funds(
50+
&bitcoind_client,
51+
&electrs_client,
52+
vec![address],
53+
premine_amount,
54+
)
55+
.await;
56+
57+
// Setup Eclair
58+
let eclair = TestEclairClient::new("http://127.0.0.1:8080", "eclairpw");
59+
60+
// Wait for Eclair to be synced
61+
let eclair_info = {
62+
loop {
63+
match eclair.get_info().await {
64+
Ok(info) => {
65+
let block_height =
66+
info["blockHeight"].as_u64().expect("blockHeight should be a number");
67+
if block_height > 0 {
68+
break info;
69+
}
70+
},
71+
Err(e) => {
72+
println!("Waiting for Eclair to be ready: {}", e);
73+
},
74+
}
75+
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
76+
}
77+
};
78+
79+
let eclair_node_id =
80+
PublicKey::from_str(eclair_info["nodeId"].as_str().unwrap()).expect("valid nodeId");
81+
let eclair_address: SocketAddress = "127.0.0.1:9736".parse().unwrap();
82+
83+
node.sync_wallets().unwrap();
84+
85+
// Open the channel
86+
let funding_amount_sat = 1_000_000;
87+
88+
node.open_channel(eclair_node_id, eclair_address, funding_amount_sat, Some(500_000_000), None)
89+
.unwrap();
90+
91+
let funding_txo = common::expect_channel_pending_event!(node, eclair_node_id);
92+
common::wait_for_tx(&electrs_client, funding_txo.txid).await;
93+
common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6).await;
94+
node.sync_wallets().unwrap();
95+
let user_channel_id = common::expect_channel_ready_event!(node, eclair_node_id);
96+
97+
// Send a payment to Eclair (LDK -> Eclair)
98+
let eclair_invoice_str = eclair
99+
.create_invoice(100_000_000, "test-ldk-to-eclair")
100+
.await
101+
.expect("Failed to create Eclair invoice");
102+
let parsed_invoice = Bolt11Invoice::from_str(&eclair_invoice_str).unwrap();
103+
104+
node.bolt11_payment().send(&parsed_invoice, None).unwrap();
105+
common::expect_event!(node, PaymentSuccessful);
106+
107+
// Verify Eclair received the payment
108+
let received_info = eclair
109+
.get_received_info(&eclair_invoice_str)
110+
.await
111+
.expect("Failed to get received info from Eclair");
112+
let status = received_info["status"]["type"].as_str().unwrap_or("unknown");
113+
assert_eq!(status, "received", "Eclair payment should be in received state");
114+
115+
// Send a payment to LDK (Eclair -> LDK)
116+
let amount_msat = 9_000_000;
117+
let invoice_description =
118+
Bolt11InvoiceDescription::Direct(Description::new("eclairTest".to_string()).unwrap());
119+
let ldk_invoice =
120+
node.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap();
121+
eclair.pay_invoice(&ldk_invoice.to_string()).await.expect("Eclair failed to pay invoice");
122+
common::expect_event!(node, PaymentReceived);
123+
124+
// Splice in (soft-fail: splice interop between LDK and Eclair may not yet be compatible)
125+
let eclair_channels = eclair.list_channels().await.expect("Failed to list Eclair channels");
126+
if let Some(channel) = eclair_channels.as_array().and_then(|arr| arr.first()) {
127+
let channel_id = channel["channelId"].as_str().unwrap_or("");
128+
if !channel_id.is_empty() {
129+
match eclair.splice_in(channel_id, 500_000).await {
130+
Ok(_) => {
131+
println!("Splice in succeeded, mining blocks to confirm...");
132+
common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6).await;
133+
node.sync_wallets().unwrap();
134+
},
135+
Err(e) => {
136+
println!(
137+
"Splice in not yet supported in LDK<->Eclair interop, skipping: {}",
138+
e
139+
);
140+
},
141+
}
142+
143+
// Splice out (soft-fail)
144+
let addr = node.onchain_payment().new_address().unwrap();
145+
match eclair.splice_out(channel_id, 200_000, &addr.to_string()).await {
146+
Ok(_) => {
147+
println!("Splice out succeeded, mining blocks to confirm...");
148+
common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6).await;
149+
node.sync_wallets().unwrap();
150+
},
151+
Err(e) => {
152+
println!(
153+
"Splice out not yet supported in LDK<->Eclair interop, skipping: {}",
154+
e
155+
);
156+
},
157+
}
158+
}
159+
}
160+
161+
// Close the channel
162+
node.close_channel(&user_channel_id, eclair_node_id).unwrap();
163+
common::expect_event!(node, ChannelClosed);
164+
node.stop().unwrap();
165+
}
166+
167+
struct TestEclairClient {
168+
base_url: String,
169+
auth_header: String,
170+
}
171+
172+
impl TestEclairClient {
173+
fn new(base_url: &str, password: &str) -> Self {
174+
let credentials = format!(":{}", password);
175+
let auth_header = format!("Basic {}", BASE64_STANDARD.encode(credentials.as_bytes()));
176+
TestEclairClient { base_url: base_url.to_string(), auth_header }
177+
}
178+
179+
async fn eclair_post(
180+
&self, endpoint: &str, params: &[(&str, &str)],
181+
) -> Result<serde_json::Value, String> {
182+
let url = format!("{}/{}", self.base_url, endpoint);
183+
let body = params.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");
184+
185+
let request = bitreq::post(&url)
186+
.with_header("Authorization", &self.auth_header)
187+
.with_header("Content-Type", "application/x-www-form-urlencoded")
188+
.with_body(body.as_bytes())
189+
.with_timeout(30);
190+
191+
let response = request
192+
.send_async()
193+
.await
194+
.map_err(|e| format!("HTTP request to {} failed: {}", endpoint, e))?;
195+
196+
if response.status_code != 200 {
197+
let body_str = response.as_str().unwrap_or("(non-utf8 body)");
198+
return Err(format!(
199+
"Eclair {} returned HTTP {}: {}",
200+
endpoint, response.status_code, body_str
201+
));
202+
}
203+
204+
let body_str = response
205+
.as_str()
206+
.map_err(|e| format!("Failed to read response body from {}: {}", endpoint, e))?;
207+
208+
serde_json::from_str(body_str)
209+
.map_err(|e| format!("Failed to parse JSON from {}: {}", endpoint, e))
210+
}
211+
212+
async fn get_info(&self) -> Result<serde_json::Value, String> {
213+
self.eclair_post("getinfo", &[]).await
214+
}
215+
216+
async fn create_invoice(&self, amount_msat: u64, description: &str) -> Result<String, String> {
217+
let amount_str = amount_msat.to_string();
218+
let result = self
219+
.eclair_post(
220+
"createinvoice",
221+
&[("amountMsat", &amount_str), ("description", description)],
222+
)
223+
.await?;
224+
result["serialized"]
225+
.as_str()
226+
.map(|s| s.to_string())
227+
.ok_or_else(|| "Missing 'serialized' field in createinvoice response".to_string())
228+
}
229+
230+
async fn pay_invoice(&self, invoice: &str) -> Result<serde_json::Value, String> {
231+
self.eclair_post("payinvoice", &[("invoice", invoice), ("blocking", "true")]).await
232+
}
233+
234+
async fn get_received_info(&self, invoice: &str) -> Result<serde_json::Value, String> {
235+
self.eclair_post("getreceivedinfo", &[("invoice", invoice)]).await
236+
}
237+
238+
async fn list_channels(&self) -> Result<serde_json::Value, String> {
239+
self.eclair_post("channels", &[]).await
240+
}
241+
242+
async fn splice_in(
243+
&self, channel_id: &str, amount_sat: u64,
244+
) -> Result<serde_json::Value, String> {
245+
let amount_str = amount_sat.to_string();
246+
self.eclair_post("splicein", &[("channelId", channel_id), ("amountIn", &amount_str)]).await
247+
}
248+
249+
async fn splice_out(
250+
&self, channel_id: &str, amount_sat: u64, address: &str,
251+
) -> Result<serde_json::Value, String> {
252+
let amount_str = amount_sat.to_string();
253+
self.eclair_post(
254+
"spliceout",
255+
&[("channelId", channel_id), ("amountOut", &amount_str), ("address", address)],
256+
)
257+
.await
258+
}
259+
}

0 commit comments

Comments
 (0)