Skip to content

Commit 7420893

Browse files
committed
refactor(eclair): replace reqwest/curl with bitreq and simplify test setup
Address review feedback on PR #839: - Replace reqwest dependency with bitreq for Eclair REST client - Replace curl shell calls with bitreq async HTTP requests - Remove per-test docker container recreation (reuse single Eclair instance, unlock UTXOs between tests instead) - Fix chmod -R 755 to u+rwX,go+rX in CLN/LND CI workflows - Add --fail flag to curl readiness check in Eclair CI
1 parent 9506ecf commit 7420893

File tree

6 files changed

+51
-134
lines changed

6 files changed

+51
-134
lines changed

.github/workflows/cln-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
- name: Set permissions for CLN data directory
6666
run: |
6767
sudo chown -R $(id -u):$(id -g) $CLN_DATA_DIR
68-
sudo chmod -R 755 $CLN_DATA_DIR
68+
sudo chmod -R u+rwX,go+rX $CLN_DATA_DIR
6969
env:
7070
CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }}
7171

.github/workflows/eclair-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
- name: Wait for Eclair to be ready
4242
run: |
4343
for i in $(seq 1 60); do
44-
if curl -s -u :eclairpassword -X POST http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then
44+
if curl -sf -u :eclairpassword -X POST http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then
4545
echo "Eclair is ready"
4646
exit 0
4747
fi

.github/workflows/lnd-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
}
5555
5656
- name: Set permissions for LND data directory
57-
run: sudo chmod -R 755 $LND_DATA_DIR
57+
run: sudo chmod -R u+rwX,go+rX $LND_DATA_DIR
5858
env:
5959
LND_DATA_DIR: ${{ env.LND_DATA_DIR }}
6060

Cargo.toml

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

111-
[target.'cfg(eclair_test)'.dev-dependencies]
112-
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
113-
114111
[build-dependencies]
115112
uniffi = { version = "0.29.5", features = ["build"], optional = true }
116113

tests/common/eclair.rs

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,25 @@
88
use std::str::FromStr;
99

1010
use async_trait::async_trait;
11+
use base64::prelude::{Engine as _, BASE64_STANDARD};
1112
use ldk_node::bitcoin::secp256k1::PublicKey;
1213
use ldk_node::lightning::ln::msgs::SocketAddress;
13-
use reqwest::Client;
1414
use serde_json::Value;
1515

1616
use super::external_node::{ExternalChannel, ExternalNode, TestFailure};
1717

1818
pub(crate) struct TestEclairNode {
19-
client: Client,
2019
base_url: String,
21-
password: String,
20+
auth_header: String,
2221
listen_addr: SocketAddress,
2322
}
2423

2524
impl TestEclairNode {
2625
pub(crate) fn new(base_url: &str, password: &str, listen_addr: SocketAddress) -> Self {
26+
let credentials = BASE64_STANDARD.encode(format!(":{}", password));
2727
Self {
28-
client: Client::new(),
2928
base_url: base_url.to_string(),
30-
password: password.to_string(),
29+
auth_header: format!("Basic {}", credentials),
3130
listen_addr,
3231
}
3332
}
@@ -46,27 +45,36 @@ impl TestEclairNode {
4645

4746
async fn post(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<Value, TestFailure> {
4847
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)))?;
48+
let body = params.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");
5749

58-
let status = response.status();
59-
let body = response
60-
.text()
50+
let request = bitreq::post(&url)
51+
.with_header("Authorization", &self.auth_header)
52+
.with_header("Content-Type", "application/x-www-form-urlencoded")
53+
.with_body(body)
54+
.with_timeout(30);
55+
56+
let response = request
57+
.send_async()
6158
.await
62-
.map_err(|e| self.make_error(format!("reading response from {}: {}", endpoint, e)))?;
59+
.map_err(|e| self.make_error(format!("request to {} failed: {}", endpoint, e)))?;
6360

64-
if !status.is_success() {
65-
return Err(self.make_error(format!("{} returned {}: {}", endpoint, status, body)));
61+
if response.status_code < 200 || response.status_code >= 300 {
62+
let body_str = response.as_str().unwrap_or("(non-utf8 body)");
63+
return Err(self.make_error(format!(
64+
"{} returned {}: {}",
65+
endpoint, response.status_code, body_str
66+
)));
6667
}
6768

68-
serde_json::from_str(&body).map_err(|e| {
69-
self.make_error(format!("parsing response from {}: {} (body: {})", endpoint, e, body))
69+
let body_str = response
70+
.as_str()
71+
.map_err(|e| self.make_error(format!("reading response from {}: {}", endpoint, e)))?;
72+
73+
serde_json::from_str(body_str).map_err(|e| {
74+
self.make_error(format!(
75+
"parsing response from {}: {} (body: {})",
76+
endpoint, e, body_str
77+
))
7078
})
7179
}
7280

@@ -297,13 +305,11 @@ impl ExternalNode for TestEclairNode {
297305
let commitments = &ch["data"]["commitments"];
298306

299307
// 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-
})?;
308+
// Closed/closing channels may lack active commitments — skip them.
309+
let active_commitment = match commitments["active"].as_array().and_then(|a| a.first()) {
310+
Some(c) => c,
311+
None => continue,
312+
};
307313

308314
let capacity_sat =
309315
active_commitment["fundingTx"]["amountSatoshis"].as_u64().unwrap_or(0);

tests/integration_tests_eclair.rs

Lines changed: 14 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod common;
1111

1212
use std::str::FromStr;
1313

14+
use base64::prelude::{Engine as _, BASE64_STANDARD};
1415
use common::eclair::TestEclairNode;
1516
use common::external_node::ExternalNode;
1617
use common::scenarios::channel::{
@@ -31,13 +32,17 @@ use electrsd::corepc_node::Client as BitcoindClient;
3132
use electrum_client::Client as ElectrumClient;
3233
use ldk_node::{Builder, Event};
3334

34-
/// Run a shell command via `spawn_blocking` to avoid blocking the tokio runtime.
35-
async fn run_cmd(program: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
36-
let program = program.to_string();
37-
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
38-
tokio::task::spawn_blocking(move || std::process::Command::new(&program).args(&args).output())
39-
.await
40-
.expect("spawn_blocking panicked")
35+
/// Unlock all UTXOs in the given bitcoind wallet via JSON-RPC.
36+
async fn unlock_utxos(wallet_url: &str, user: &str, pass: &str) {
37+
let auth = BASE64_STANDARD.encode(format!("{}:{}", user, pass));
38+
let body = r#"{"jsonrpc":"1.0","method":"lockunspent","params":[true]}"#;
39+
let _ = bitreq::post(wallet_url)
40+
.with_header("Authorization", format!("Basic {}", auth))
41+
.with_header("Content-Type", "text/plain")
42+
.with_body(body)
43+
.with_timeout(5)
44+
.send_async()
45+
.await;
4146
}
4247

4348
async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestEclairNode) {
@@ -53,99 +58,8 @@ async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestEclairNode) {
5358
.unwrap();
5459
let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap();
5560

56-
// Recreate the Eclair container between tests to give a fresh /data
57-
// directory, a new seed, and a clean initialization against the current
58-
// chain tip.
59-
let container_name =
60-
std::env::var("ECLAIR_CONTAINER_NAME").unwrap_or_else(|_| "ldk-node-eclair-1".to_string());
61-
run_cmd("docker", &["rm", "-f", &container_name]).await.ok();
62-
63-
// Unlock UTXOs and start Eclair, retrying if locked UTXOs remain.
64-
// Force-close transactions can lock new UTXOs between the unlock call
65-
// and Eclair startup, so we may need multiple attempts.
66-
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(90);
67-
let mut attempt = 0;
68-
loop {
69-
// Unlock any UTXOs left locked in the Eclair wallet.
70-
run_cmd(
71-
"curl",
72-
&[
73-
"-s",
74-
"--max-time",
75-
"5",
76-
"--user",
77-
"user:pass",
78-
"--data-binary",
79-
r#"{"jsonrpc":"1.0","method":"lockunspent","params":[true]}"#,
80-
"-H",
81-
"content-type: text/plain;",
82-
"http://127.0.0.1:18443/wallet/eclair",
83-
],
84-
)
85-
.await
86-
.ok();
87-
88-
if attempt > 0 {
89-
// On retry, recreate the container since Eclair exited.
90-
run_cmd("docker", &["rm", "-f", &container_name]).await.ok();
91-
}
92-
let output = run_cmd(
93-
"docker",
94-
&["compose", "-f", "docker-compose-eclair.yml", "up", "-d", "eclair"],
95-
)
96-
.await
97-
.expect("failed to spawn docker compose");
98-
assert!(
99-
output.status.success(),
100-
"docker compose up failed (exit {}): {}",
101-
output.status,
102-
String::from_utf8_lossy(&output.stderr),
103-
);
104-
105-
// Wait for Eclair to become ready.
106-
let mut ready = false;
107-
for _ in 0..30 {
108-
if std::time::Instant::now() >= deadline {
109-
let logs = run_cmd("docker", &["logs", "--tail", "50", &container_name]).await.ok();
110-
if let Some(l) = logs {
111-
eprintln!(
112-
"=== Eclair container logs ===\n{}{}",
113-
String::from_utf8_lossy(&l.stdout),
114-
String::from_utf8_lossy(&l.stderr)
115-
);
116-
}
117-
panic!("Eclair did not start within 90s (after {} attempts)", attempt + 1);
118-
}
119-
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
120-
ready = run_cmd(
121-
"curl",
122-
&[
123-
"-s",
124-
"--max-time",
125-
"2",
126-
"-u",
127-
":eclairpassword",
128-
"-X",
129-
"POST",
130-
"http://127.0.0.1:8080/getinfo",
131-
],
132-
)
133-
.await
134-
.map(|o| o.status.success() && !o.stdout.is_empty())
135-
.unwrap_or(false);
136-
if ready {
137-
break;
138-
}
139-
}
140-
141-
if ready {
142-
break;
143-
}
144-
145-
// Eclair likely failed due to locked UTXOs — retry.
146-
attempt += 1;
147-
eprintln!("Eclair failed to start (attempt {}), retrying after lockunspent...", attempt);
148-
}
61+
// Unlock any UTXOs left locked by previous force-close tests.
62+
unlock_utxos("http://127.0.0.1:18443/wallet/eclair", "user", "pass").await;
14963

15064
let eclair = TestEclairNode::from_env();
15165
(bitcoind, electrs, eclair)

0 commit comments

Comments
 (0)