From 63699b87001d6f769473c0ef2fa48ee5aeb0f803 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 3 Apr 2026 00:13:40 +0900 Subject: [PATCH] feat(test): add interop test harness for CLN, LND, Eclair Bump test dependencies, reorganize Docker files, and add interop integration test infrastructure with shared scenario runner for CLN, LND, and Eclair. --- .github/workflows/cln-integration.yml | 39 +- .github/workflows/eclair-integration.yml | 56 +++ .github/workflows/kotlin.yml | 2 +- .github/workflows/lnd-integration.yml | 21 +- .github/workflows/python.yml | 2 +- Cargo.toml | 1 + tests/common/cln.rs | 330 ++++++++++++++ tests/common/eclair.rs | 340 +++++++++++++++ tests/common/external_node.rs | 148 +++++++ tests/common/lnd.rs | 406 ++++++++++++++++++ tests/common/mod.rs | 107 ++++- tests/common/scenarios/channel.rs | 103 +++++ tests/common/scenarios/connectivity.rs | 86 ++++ tests/common/scenarios/mod.rs | 251 +++++++++++ tests/common/scenarios/payment.rs | 62 +++ tests/docker/Dockerfile.eclair | 22 + tests/docker/docker-compose-cln.yml | 10 +- tests/docker/docker-compose-eclair.yml | 80 ++++ tests/docker/docker-compose-lnd.yml | 6 +- .../docker/docker-compose.yml | 0 tests/integration_tests_cln.rs | 144 ++----- tests/integration_tests_eclair.rs | 74 ++++ tests/integration_tests_lnd.rs | 240 ++--------- 23 files changed, 2182 insertions(+), 348 deletions(-) create mode 100644 .github/workflows/eclair-integration.yml create mode 100644 tests/common/cln.rs create mode 100644 tests/common/eclair.rs create mode 100644 tests/common/external_node.rs create mode 100644 tests/common/lnd.rs create mode 100644 tests/common/scenarios/channel.rs create mode 100644 tests/common/scenarios/connectivity.rs create mode 100644 tests/common/scenarios/mod.rs create mode 100644 tests/common/scenarios/payment.rs create mode 100644 tests/docker/Dockerfile.eclair create mode 100644 tests/docker/docker-compose-eclair.yml mode change 100755 => 100644 tests/docker/docker-compose-lnd.yml rename docker-compose.yml => tests/docker/docker-compose.yml (100%) create mode 100644 tests/integration_tests_eclair.rs diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index 5bdcb75bb..81eb82250 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -13,18 +13,41 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Install dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y socat + - name: Create temporary directory for CLN data + run: echo "CLN_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV - name: Start bitcoind, electrs, and lightningd run: docker compose -p ldk-node -f tests/docker/docker-compose-cln.yml up -d + env: + CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }} + + - name: Wait for CLN to be ready + run: | + for i in $(seq 1 30); do + if docker exec ldk-node-cln-1 lightning-cli --regtest getinfo 2>/dev/null | grep -q '"id"'; then + echo "CLN is ready" + break + fi + echo "Waiting for CLN... ($i/30)" + sleep 2 + done + docker exec ldk-node-cln-1 lightning-cli --regtest getinfo || { + echo "ERROR: CLN not responding" + docker compose -p ldk-node -f tests/docker/docker-compose-cln.yml logs cln + exit 1 + } - - name: Forward lightningd RPC socket + - name: Set permissions for CLN data directory run: | - docker exec ldk-node-cln-1 sh -c "socat -d -d TCP-LISTEN:9937,fork,reuseaddr UNIX-CONNECT:/root/.lightning/regtest/lightning-rpc&" - socat -d -d UNIX-LISTEN:/tmp/lightning-rpc,reuseaddr,fork TCP:127.0.0.1:9937& + sudo chown -R $(id -u):$(id -g) $CLN_DATA_DIR + sudo chmod 755 $CLN_DATA_DIR + sudo find $CLN_DATA_DIR -type d -exec chmod 755 {} + + sudo find $CLN_DATA_DIR -type f -exec chmod 644 {} + + env: + CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }} - name: Run CLN integration tests - run: RUSTFLAGS="--cfg cln_test" cargo test --test integration_tests_cln + run: CLN_SOCKET_PATH=$CLN_DATA_DIR/regtest/lightning-rpc + RUSTFLAGS="--cfg cln_test" cargo test --test integration_tests_cln -- --show-output --test-threads=1 + env: + CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }} diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml new file mode 100644 index 000000000..56d51b77e --- /dev/null +++ b/.github/workflows/eclair-integration.yml @@ -0,0 +1,56 @@ +name: CI Checks - Eclair Integration Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-eclair: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Start bitcoind and electrs + run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d bitcoin electrs + + - name: Wait for bitcoind to be healthy + run: | + for i in $(seq 1 30); do + if docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass getblockchaininfo > /dev/null 2>&1; then + echo "bitcoind is ready" + exit 0 + fi + echo "Waiting for bitcoind... ($i/30)" + sleep 2 + done + echo "ERROR: bitcoind not ready" + exit 1 + + - name: Create wallets on bitcoind + run: | + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet eclair + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=eclair getnewaddress + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test + + - name: Start Eclair + run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d eclair + + - name: Wait for Eclair to be ready + run: | + for i in $(seq 1 60); do + if curl -sf -u :eclairpassword -X POST http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then + echo "Eclair is ready" + exit 0 + fi + echo "Waiting for Eclair... ($i/60)" + sleep 5 + done + echo "Eclair failed to start" + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml logs eclair + exit 1 + + - name: Run Eclair integration tests + run: RUSTFLAGS="--cfg eclair_test" cargo test --test integration_tests_eclair -- --show-output --test-threads=1 diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index 627051c31..f4d55e3bc 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -47,7 +47,7 @@ jobs: run: ./scripts/uniffi_bindgen_generate_kotlin_android.sh - name: Start bitcoind and electrs - run: docker compose up -d + run: docker compose -p ldk-node -f tests/docker/docker-compose.yml up -d - name: Run ldk-node-jvm tests run: | diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml index 47ed7c311..caefbdb6b 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -33,7 +33,6 @@ jobs: fi - name: Create temporary directory for LND data - id: create-temp-dir run: echo "LND_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV - name: Start bitcoind, electrs, and LND @@ -41,16 +40,24 @@ jobs: env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} - - name: Set permissions for LND data directory - # In PR 4622 (https://github.com/lightningnetwork/lnd/pull/4622), - # LND sets file permissions to 0700, preventing test code from accessing them. - # This step ensures the test suite has the necessary permissions. - run: sudo chmod -R 755 $LND_DATA_DIR + - name: Wait for LND macaroon and set permissions + run: | + for i in $(seq 1 30); do + if docker exec ldk-node-lnd test -f /root/.lnd/data/chain/bitcoin/regtest/admin.macaroon 2>/dev/null; then + echo "LND macaroon found" + break + fi + echo "Waiting for LND macaroon... ($i/30)" + sleep 2 + done + sudo chmod 755 $LND_DATA_DIR + sudo find $LND_DATA_DIR -type d -exec chmod 755 {} + + sudo find $LND_DATA_DIR -type f -exec chmod 644 {} + env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} - name: Run LND integration tests run: LND_CERT_PATH=$LND_DATA_DIR/tls.cert LND_MACAROON_PATH=$LND_DATA_DIR/data/chain/bitcoin/regtest/admin.macaroon - RUSTFLAGS="--cfg lnd_test" cargo test --test integration_tests_lnd -- --exact --show-output + RUSTFLAGS="--cfg lnd_test" cargo test --test integration_tests_lnd -- --show-output --test-threads=1 env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4576bf550..e154faa7e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -24,7 +24,7 @@ jobs: run: ./scripts/uniffi_bindgen_generate_python.sh - name: Start bitcoind and electrs - run: docker compose up -d + run: docker compose -p ldk-node -f tests/docker/docker-compose.yml up -d - name: Run Python unit tests env: diff --git a/Cargo.toml b/Cargo.toml index d9afeb9a7..3f43cb7fa 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,7 @@ check-cfg = [ "cfg(tokio_unstable)", "cfg(cln_test)", "cfg(lnd_test)", + "cfg(eclair_test)", "cfg(cycle_tests)", ] diff --git a/tests/common/cln.rs b/tests/common/cln.rs new file mode 100644 index 000000000..3f5be9e54 --- /dev/null +++ b/tests/common/cln.rs @@ -0,0 +1,330 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use clightningrpc::lightningrpc::LightningRPC; +use clightningrpc::lightningrpc::PayOptions; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use serde_json::json; + +use super::external_node::{ExternalChannel, ExternalNode, TestFailure}; + +pub(crate) struct TestClnNode { + client: Arc, + listen_addr: SocketAddress, +} + +impl TestClnNode { + pub(crate) fn new(socket_path: &str, listen_addr: SocketAddress) -> Self { + Self { client: Arc::new(LightningRPC::new(socket_path)), listen_addr } + } + + pub(crate) fn from_env() -> Self { + let sock = + std::env::var("CLN_SOCKET_PATH").unwrap_or_else(|_| "/tmp/lightning-rpc".to_string()); + let listen_addr: SocketAddress = std::env::var("CLN_P2P_ADDR") + .unwrap_or_else(|_| "127.0.0.1:19846".to_string()) + .parse() + .unwrap(); + Self::new(&sock, listen_addr) + } + + /// Run a synchronous CLN RPC call on a dedicated blocking thread. + /// + /// `clightningrpc` is sync-only; calling it on the tokio runtime would block + /// the worker (we run with `worker_threads = 1`) and deadlock LDK's tasks. + async fn rpc(&self, f: F) -> T + where + F: FnOnce(&LightningRPC) -> T + Send + 'static, + T: Send + 'static, + { + let client = Arc::clone(&self.client); + tokio::task::spawn_blocking(move || f(&*client)).await.expect("CLN RPC task panicked") + } + + /// Repeatedly call `splice_update` until `commitments_secured` is true. + /// Returns the final PSBT. Gives up after 10 attempts. + async fn splice_update_loop( + &self, channel_id: &str, mut psbt: String, + ) -> Result { + const MAX_ATTEMPTS: u32 = 10; + for _ in 0..MAX_ATTEMPTS { + let ch_id = channel_id.to_string(); + let psbt_arg = psbt.clone(); + let update_result: serde_json::Value = self + .rpc(move |c| { + c.call("splice_update", &json!({"channel_id": ch_id, "psbt": psbt_arg})) + }) + .await + .map_err(|e| self.make_error(format!("splice_update: {}", e)))?; + psbt = update_result["psbt"] + .as_str() + .ok_or_else(|| self.make_error("splice_update did not return psbt"))? + .to_string(); + if update_result["commitments_secured"].as_bool() == Some(true) { + return Ok(psbt); + } + } + Err(self.make_error(format!( + "splice_update did not reach commitments_secured after {} attempts", + MAX_ATTEMPTS + ))) + } +} + +/// Parse a CLN msat value which may be either a plain integer or a string like "1000000msat". +/// Returns `None` if the value is missing or malformed; callers should propagate as an error. +fn parse_msat(v: &serde_json::Value) -> Option { + v.as_u64().or_else(|| v.as_str()?.strip_suffix("msat")?.parse().ok()) +} + +#[async_trait] +impl ExternalNode for TestClnNode { + fn name(&self) -> &str { + "CLN" + } + + async fn get_node_id(&self) -> Result { + let info = self + .rpc(|c| c.getinfo()) + .await + .map_err(|e| self.make_error(format!("getinfo: {}", e)))?; + PublicKey::from_str(&info.id).map_err(|e| self.make_error(format!("parse node id: {}", e))) + } + + async fn get_listening_address(&self) -> Result { + Ok(self.listen_addr.clone()) + } + + async fn connect_peer( + &self, peer_id: PublicKey, addr: SocketAddress, + ) -> Result<(), TestFailure> { + let uri = format!("{}@{}", peer_id, addr); + let _: serde_json::Value = self + .rpc(move |c| c.call("connect", &json!({"id": uri}))) + .await + .map_err(|e| self.make_error(format!("connect: {}", e)))?; + Ok(()) + } + + async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> { + let id = peer_id.to_string(); + let _: serde_json::Value = self + .rpc(move |c| c.call("disconnect", &json!({"id": id, "force": true}))) + .await + .map_err(|e| self.make_error(format!("disconnect: {}", e)))?; + Ok(()) + } + + async fn open_channel( + &self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option, + ) -> Result { + // Use the generic `call` method to include `push_msat`, which the + // typed `fundchannel` method does not support. + let mut params = json!({ + "id": peer_id.to_string(), + "amount": capacity_sat, + }); + if let Some(push) = push_msat { + params["push_msat"] = json!(push); + } + + let result: serde_json::Value = self + .rpc(move |c| c.call("fundchannel", ¶ms)) + .await + .map_err(|e| self.make_error(format!("fundchannel: {}", e)))?; + + Ok(result["channel_id"].as_str().unwrap_or("").to_string()) + } + + async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + let ch_id = channel_id.to_string(); + self.rpc(move |c| c.close(&ch_id, None, None)) + .await + .map_err(|e| self.make_error(format!("close: {}", e)))?; + Ok(()) + } + + async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + // `unilateraltimeout: 1` triggers an immediate unilateral close. + let ch_id = channel_id.to_string(); + let _: serde_json::Value = self + .rpc(move |c| c.call("close", &json!({"id": ch_id, "unilateraltimeout": 1}))) + .await + .map_err(|e| self.make_error(format!("force close: {}", e)))?; + Ok(()) + } + + async fn create_invoice( + &self, amount_msat: u64, description: &str, + ) -> Result { + let desc = description.to_string(); + let label = format!( + "{}-{}", + desc, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let invoice = self + .rpc(move |c| c.invoice(Some(amount_msat), &label, &desc, None, None, None)) + .await + .map_err(|e| self.make_error(format!("invoice: {}", e)))?; + Ok(invoice.bolt11) + } + + async fn pay_invoice(&self, invoice: &str) -> Result { + let inv = invoice.to_string(); + let result = self + .rpc(move |c| c.pay(&inv, PayOptions::default())) + .await + .map_err(|e| self.make_error(format!("pay: {}", e)))?; + Ok(result.payment_preimage) + } + + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result { + let dest = peer_id.to_string(); + let result: serde_json::Value = self + .rpc(move |c| { + c.call( + "keysend", + // maxdelay=288: CLN's default is too low for LDK's required final CLTV. + &json!({"destination": dest, "amount_msat": amount_msat, "maxdelay": 288}), + ) + }) + .await + .map_err(|e| self.make_error(format!("keysend: {}", e)))?; + let preimage = result["payment_preimage"] + .as_str() + .ok_or_else(|| self.make_error("keysend did not return payment_preimage"))?; + Ok(preimage.to_string()) + } + + async fn get_funding_address(&self) -> Result { + let addr = self + .rpc(|c| c.newaddr(None)) + .await + .map_err(|e| self.make_error(format!("newaddr: {}", e)))?; + addr.bech32.ok_or_else(|| self.make_error("no bech32 address returned")) + } + + async fn get_block_height(&self) -> Result { + let info = self + .rpc(|c| c.getinfo()) + .await + .map_err(|e| self.make_error(format!("getinfo: {}", e)))?; + Ok(info.blockheight as u64) + } + + async fn list_channels(&self) -> Result, TestFailure> { + let response: serde_json::Value = self + .rpc(|c| c.call("listpeerchannels", &serde_json::Map::new())) + .await + .map_err(|e| self.make_error(format!("listpeerchannels: {}", e)))?; + let mut channels = Vec::new(); + + for ch in response["channels"].as_array().into_iter().flatten() { + let peer_id_str = ch["peer_id"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing peer_id"))?; + let peer_id = PublicKey::from_str(peer_id_str).map_err(|e| { + self.make_error(format!("list_channels: invalid peer_id '{}': {}", peer_id_str, e)) + })?; + let channel_id = ch["channel_id"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing channel_id"))? + .to_string(); + let total_msat = parse_msat(&ch["total_msat"]) + .ok_or_else(|| self.make_error("list_channels: missing/invalid total_msat"))?; + let to_us_msat = parse_msat(&ch["to_us_msat"]) + .ok_or_else(|| self.make_error("list_channels: missing/invalid to_us_msat"))?; + let funding_txid = ch["funding_txid"].as_str().map(String::from); + let state = ch["state"].as_str().unwrap_or(""); + let pending_htlcs_count = ch["htlcs"].as_array().map(|a| a.len()).unwrap_or(0); + channels.push(ExternalChannel { + channel_id, + peer_id, + capacity_sat: total_msat / 1000, + local_balance_msat: to_us_msat, + remote_balance_msat: total_msat.saturating_sub(to_us_msat), + funding_txid, + is_active: state == "CHANNELD_NORMAL", + pending_htlcs_count, + }); + } + Ok(channels) + } + + async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> { + let ch_id = channel_id.to_string(); + let amount: i64 = amount_sat.try_into().map_err(|_| { + self.make_error(format!("splice_in: amount_sat overflow: {}", amount_sat)) + })?; + let init_result: serde_json::Value = self + .rpc(move |c| { + c.call("splice_init", &json!({"channel_id": ch_id, "relative_amount": amount})) + }) + .await + .map_err(|e| self.make_error(format!("splice_init: {}", e)))?; + let mut psbt = init_result["psbt"] + .as_str() + .ok_or_else(|| self.make_error("splice_init did not return psbt"))? + .to_string(); + + psbt = self.splice_update_loop(channel_id, psbt).await?; + + let ch_id = channel_id.to_string(); + let _: serde_json::Value = self + .rpc(move |c| c.call("splice_signed", &json!({"channel_id": ch_id, "psbt": psbt}))) + .await + .map_err(|e| self.make_error(format!("splice_signed: {}", e)))?; + Ok(()) + } + + async fn splice_out( + &self, channel_id: &str, amount_sat: u64, address: Option<&str>, + ) -> Result<(), TestFailure> { + // Funds always go to CLN's own wallet; specifying a custom address + // would require manual PSBT manipulation which is out of scope. + if address.is_some() { + return Err(self.make_error( + "splice_out with custom address is not supported by CLN adapter".to_string(), + )); + } + let ch_id = channel_id.to_string(); + let positive: i64 = amount_sat.try_into().map_err(|_| { + self.make_error(format!("splice_out: amount_sat overflow: {}", amount_sat)) + })?; + let amount = -positive; + let init_result: serde_json::Value = self + .rpc(move |c| { + c.call("splice_init", &json!({"channel_id": ch_id, "relative_amount": amount})) + }) + .await + .map_err(|e| self.make_error(format!("splice_init: {}", e)))?; + let mut psbt = init_result["psbt"] + .as_str() + .ok_or_else(|| self.make_error("splice_init did not return psbt"))? + .to_string(); + + psbt = self.splice_update_loop(channel_id, psbt).await?; + + let ch_id = channel_id.to_string(); + let _: serde_json::Value = self + .rpc(move |c| c.call("splice_signed", &json!({"channel_id": ch_id, "psbt": psbt}))) + .await + .map_err(|e| self.make_error(format!("splice_signed: {}", e)))?; + Ok(()) + } +} diff --git a/tests/common/eclair.rs b/tests/common/eclair.rs new file mode 100644 index 000000000..c1864261d --- /dev/null +++ b/tests/common/eclair.rs @@ -0,0 +1,340 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::str::FromStr; + +use async_trait::async_trait; +use base64::prelude::{Engine as _, BASE64_STANDARD}; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use serde_json::Value; + +use super::external_node::{ExternalChannel, ExternalNode, TestFailure}; + +/// Percent-encode a string for `application/x-www-form-urlencoded` form values. +fn form_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + }, + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +pub(crate) struct TestEclairNode { + base_url: String, + auth_header: String, + listen_addr: SocketAddress, +} + +impl TestEclairNode { + pub(crate) fn new(base_url: &str, password: &str, listen_addr: SocketAddress) -> Self { + let credentials = BASE64_STANDARD.encode(format!(":{}", password)); + Self { + base_url: base_url.to_string(), + auth_header: format!("Basic {}", credentials), + listen_addr, + } + } + + pub(crate) fn from_env() -> Self { + let base_url = + std::env::var("ECLAIR_API_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let password = + std::env::var("ECLAIR_API_PASSWORD").unwrap_or_else(|_| "eclairpassword".to_string()); + let listen_addr: SocketAddress = std::env::var("ECLAIR_P2P_ADDR") + .unwrap_or_else(|_| "127.0.0.1:9736".to_string()) + .parse() + .unwrap(); + Self::new(&base_url, &password, listen_addr) + } + + async fn post(&self, endpoint: &str, params: &[(&str, &str)]) -> Result { + let url = format!("{}{}", self.base_url, endpoint); + let body = params + .iter() + .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v))) + .collect::>() + .join("&"); + + let request = bitreq::post(&url) + .with_header("Authorization", &self.auth_header) + .with_header("Content-Type", "application/x-www-form-urlencoded") + .with_body(body) + .with_timeout(30); + + let response = request + .send_async() + .await + .map_err(|e| self.make_error(format!("request to {} failed: {}", endpoint, e)))?; + + if response.status_code < 200 || response.status_code >= 300 { + let body_str = response.as_str().unwrap_or("(non-utf8 body)"); + return Err(self.make_error(format!( + "{} returned {}: {}", + endpoint, response.status_code, body_str + ))); + } + + let body_str = response + .as_str() + .map_err(|e| self.make_error(format!("reading response from {}: {}", endpoint, e)))?; + + serde_json::from_str(body_str).map_err(|e| { + self.make_error(format!( + "parsing response from {}: {} (body: {})", + endpoint, e, body_str + )) + }) + } + + /// Poll /getsentinfo until the payment settles or fails. Surfaces Eclair-side + /// failure reasons rather than waiting for an opaque LDK event timeout. + async fn poll_payment_settlement( + &self, payment_id: &str, label: &str, + ) -> Result { + let timeout_secs = super::INTEROP_TIMEOUT_SECS; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); + loop { + if tokio::time::Instant::now() >= deadline { + return Err(self.make_error(format!( + "{} {} did not settle within {}s", + label, payment_id, timeout_secs + ))); + } + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + let info = self.post("/getsentinfo", &[("id", payment_id)]).await?; + if let Some(attempts) = info.as_array() { + if let Some(last) = attempts.last() { + let status = last["status"]["type"].as_str().unwrap_or(""); + if status == "sent" { + return Ok(payment_id.to_string()); + } else if status == "failed" { + let failure = last["status"]["failures"] + .as_array() + .and_then(|f| f.last()) + .and_then(|f| f["failureMessage"].as_str()) + .unwrap_or("unknown"); + return Err(self + .make_error(format!("{} {} failed: {}", label, payment_id, failure))); + } + } + } + } + } +} + +#[async_trait] +impl ExternalNode for TestEclairNode { + fn name(&self) -> &str { + "Eclair" + } + + async fn get_node_id(&self) -> Result { + let info = self.post("/getinfo", &[]).await?; + let node_id_str = info["nodeId"] + .as_str() + .ok_or_else(|| self.make_error("missing nodeId in getinfo response"))?; + PublicKey::from_str(node_id_str) + .map_err(|e| self.make_error(format!("parse nodeId: {}", e))) + } + + async fn get_listening_address(&self) -> Result { + Ok(self.listen_addr.clone()) + } + + async fn connect_peer( + &self, peer_id: PublicKey, addr: SocketAddress, + ) -> Result<(), TestFailure> { + let uri = format!("{}@{}", peer_id, addr); + self.post("/connect", &[("uri", &uri)]).await?; + Ok(()) + } + + async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> { + self.post("/disconnect", &[("nodeId", &peer_id.to_string())]).await?; + Ok(()) + } + + async fn open_channel( + &self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option, + ) -> Result { + let node_id = peer_id.to_string(); + let capacity = capacity_sat.to_string(); + let push_str = push_msat.map(|m| m.to_string()); + + let mut params = vec![("nodeId", node_id.as_str()), ("fundingSatoshis", capacity.as_str())]; + if let Some(ref push) = push_str { + params.push(("pushMsat", push.as_str())); + } + + let result = self.post("/open", ¶ms).await?; + let channel_id = result + .as_str() + .map(String::from) + .or_else(|| result["channelId"].as_str().map(String::from)) + .ok_or_else(|| { + self.make_error(format!("open did not return channel id: {}", result)) + })?; + Ok(channel_id) + } + + async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + self.post("/close", &[("channelId", channel_id)]).await?; + Ok(()) + } + + async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + self.post("/forceclose", &[("channelId", channel_id)]).await?; + Ok(()) + } + + async fn create_invoice( + &self, amount_msat: u64, description: &str, + ) -> Result { + let amount_str = amount_msat.to_string(); + let result = self + .post("/createinvoice", &[("amountMsat", &amount_str), ("description", description)]) + .await?; + let invoice = result["serialized"] + .as_str() + .ok_or_else(|| self.make_error("missing serialized in invoice response"))?; + Ok(invoice.to_string()) + } + + async fn pay_invoice(&self, invoice: &str) -> Result { + let result = self.post("/payinvoice", &[("invoice", invoice)]).await?; + let payment_id = result + .as_str() + .ok_or_else(|| self.make_error("payinvoice did not return payment id"))? + .to_string(); + self.poll_payment_settlement(&payment_id, "payment").await + } + + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result { + let amount_str = amount_msat.to_string(); + let node_id_str = peer_id.to_string(); + let result = self + .post("/sendtonode", &[("nodeId", &node_id_str), ("amountMsat", &amount_str)]) + .await?; + let payment_id = result + .as_str() + .ok_or_else(|| self.make_error("sendtonode did not return payment id"))? + .to_string(); + self.poll_payment_settlement(&payment_id, "keysend").await + } + + async fn get_funding_address(&self) -> Result { + let result = self.post("/getnewaddress", &[]).await?; + result + .as_str() + .map(String::from) + .ok_or_else(|| self.make_error("getnewaddress did not return string")) + } + + async fn get_block_height(&self) -> Result { + let info = self.post("/getinfo", &[]).await?; + info["blockHeight"] + .as_u64() + .ok_or_else(|| self.make_error("missing blockHeight in getinfo response")) + } + + async fn list_channels(&self) -> Result, TestFailure> { + let result = self.post("/channels", &[]).await?; + let channels_arr = + result.as_array().ok_or_else(|| self.make_error("/channels did not return array"))?; + + let mut channels = Vec::new(); + for ch in channels_arr { + let channel_id = ch["channelId"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing channelId"))? + .to_string(); + let node_id_str = ch["nodeId"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing nodeId"))?; + let peer_id = PublicKey::from_str(node_id_str).map_err(|e| { + self.make_error(format!("list_channels: invalid nodeId '{}': {}", node_id_str, e)) + })?; + let state_str = ch["state"].as_str().unwrap_or(""); + let commitments = &ch["data"]["commitments"]; + + // Closed/closing channels may lack active commitments -- skip them. + let active_commitment = match commitments["active"].as_array().and_then(|a| a.first()) { + Some(c) => c, + None => continue, + }; + + let capacity_sat = active_commitment["fundingAmount"] + .as_u64() + .ok_or_else(|| self.make_error("list_channels: missing fundingAmount"))?; + let funding_txid = active_commitment["fundingInput"] + .as_str() + .and_then(|s| s.split(':').next()) + .map(String::from); + let local_balance_msat = + active_commitment["localCommit"]["spec"]["toLocal"].as_u64().ok_or_else(|| { + self.make_error("list_channels: missing localCommit.spec.toLocal") + })?; + let remote_balance_msat = + active_commitment["localCommit"]["spec"]["toRemote"].as_u64().ok_or_else(|| { + self.make_error("list_channels: missing localCommit.spec.toRemote") + })?; + + let pending_htlcs_count = active_commitment["localCommit"]["spec"]["htlcs"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + channels.push(ExternalChannel { + channel_id, + peer_id, + capacity_sat, + local_balance_msat, + remote_balance_msat, + funding_txid, + is_active: state_str == "NORMAL", + pending_htlcs_count, + }); + } + Ok(channels) + } + + async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> { + let amount_str = amount_sat.to_string(); + self.post("/splicein", &[("channelId", channel_id), ("amountIn", &amount_str)]).await?; + Ok(()) + } + + async fn splice_out( + &self, channel_id: &str, amount_sat: u64, address: Option<&str>, + ) -> Result<(), TestFailure> { + // Eclair's /spliceout requires an address; if caller passes None, generate one + // from Eclair's own wallet so the trait contract is symmetric with CLN. + let owned_addr; + let addr = match address { + Some(a) => a, + None => { + owned_addr = self.get_funding_address().await?; + owned_addr.as_str() + }, + }; + let amount_str = amount_sat.to_string(); + self.post( + "/spliceout", + &[("channelId", channel_id), ("amountOut", &amount_str), ("address", addr)], + ) + .await?; + Ok(()) + } +} diff --git a/tests/common/external_node.rs b/tests/common/external_node.rs new file mode 100644 index 000000000..d4c93a650 --- /dev/null +++ b/tests/common/external_node.rs @@ -0,0 +1,148 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::fmt; + +use async_trait::async_trait; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; + +/// Represents a channel opened to or from an external Lightning node. +#[derive(Debug, Clone)] +pub(crate) struct ExternalChannel { + /// Implementation-specific channel identifier; treat as opaque. + pub channel_id: String, + pub peer_id: PublicKey, + pub capacity_sat: u64, + pub local_balance_msat: u64, + pub remote_balance_msat: u64, + pub funding_txid: Option, + pub is_active: bool, + /// In-flight HTLCs on the peer's view of the channel. + pub pending_htlcs_count: usize, +} + +/// Errors that can occur during interop test operations. +#[derive(Debug)] +pub(crate) enum TestFailure { + ExternalNodeError { node: String, detail: String }, + NotSupported { node: String, operation: String }, +} + +impl fmt::Display for TestFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TestFailure::ExternalNodeError { node, detail } => { + write!(f, "External node '{}' error: {}", node, detail) + }, + TestFailure::NotSupported { node, operation } => { + write!(f, "'{}' does not support '{}'", node, operation) + }, + } + } +} + +impl std::error::Error for TestFailure {} + +/// Abstraction over an external Lightning node used in interop tests. +#[async_trait] +pub(crate) trait ExternalNode: Send + Sync { + /// Human-readable name for this node (e.g. "eclair", "lnd", "cln"). + fn name(&self) -> &str; + + /// Returns the node's public key. + async fn get_node_id(&self) -> Result; + + /// Returns an address on which this node is listening. + async fn get_listening_address(&self) -> Result; + + /// Connect to a peer by public key and address. + async fn connect_peer( + &self, peer_id: PublicKey, addr: SocketAddress, + ) -> Result<(), TestFailure>; + + /// Disconnect from a peer by public key. + async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure>; + + /// Open a channel to a peer. + /// + /// Returns a channel id string that the implementation may use + /// to correlate with subsequent close/query calls. + async fn open_channel( + &self, peer_id: PublicKey, addr: SocketAddress, capacity_sat: u64, push_msat: Option, + ) -> Result; + + /// Cooperatively close a channel by its implementation-defined channel id. + async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure>; + + /// Force-close a channel by its implementation-defined channel id. + async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure>; + + /// Create a BOLT11 invoice for the given amount. + async fn create_invoice( + &self, amount_msat: u64, description: &str, + ) -> Result; + + /// Pay a BOLT11 invoice; returns an implementation-specific payment identifier on success. + async fn pay_invoice(&self, invoice: &str) -> Result; + + /// Send a keysend payment to a peer. + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result; + + /// Get an on-chain address that can be used to fund this node. + async fn get_funding_address(&self) -> Result; + + /// Returns the current blockchain height as seen by this node. + async fn get_block_height(&self) -> Result; + + /// List all channels known to this node. + async fn list_channels(&self) -> Result, TestFailure>; + + /// Construct a `TestFailure::ExternalNodeError` for this node. + fn make_error(&self, detail: impl Into) -> TestFailure { + TestFailure::ExternalNodeError { node: self.name().to_string(), detail: detail.into() } + } + + /// Wait until this node has synced to at least `min_height`. Polls for up to 60 seconds. + async fn wait_for_block_sync(&self, min_height: u64) -> Result<(), TestFailure> { + for _ in 0..60 { + if let Ok(h) = self.get_block_height().await { + if h >= min_height { + return Ok(()); + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + Err(self.make_error(format!("did not reach height {} after 60s", min_height))) + } + + /// Splice additional funds into an existing channel. + /// + /// Not all implementations support splicing. The default returns `NotSupported`. + async fn splice_in(&self, _channel_id: &str, _amount_sat: u64) -> Result<(), TestFailure> { + Err(TestFailure::NotSupported { + node: self.name().to_string(), + operation: "splice_in".to_string(), + }) + } + + /// Splice funds out of an existing channel. + /// + /// If `address` is provided, funds are sent to that on-chain address; + /// otherwise the implementation decides the destination (e.g. own wallet). + /// Not all implementations support splicing. The default returns `NotSupported`. + async fn splice_out( + &self, _channel_id: &str, _amount_sat: u64, _address: Option<&str>, + ) -> Result<(), TestFailure> { + Err(TestFailure::NotSupported { + node: self.name().to_string(), + operation: "splice_out".to_string(), + }) + } +} diff --git a/tests/common/lnd.rs b/tests/common/lnd.rs new file mode 100644 index 000000000..6ec64996f --- /dev/null +++ b/tests/common/lnd.rs @@ -0,0 +1,406 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::str::FromStr; + +use async_trait::async_trait; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::DisplayHex; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use lnd_grpc_rust::lnrpc::{ + payment::PaymentStatus, CloseChannelRequest as LndCloseChannelRequest, + ConnectPeerRequest as LndConnectPeerRequest, DisconnectPeerRequest as LndDisconnectPeerRequest, + GetInfoRequest as LndGetInfoRequest, Invoice as LndInvoice, + LightningAddress as LndLightningAddress, ListChannelsRequest as LndListChannelsRequest, + OpenChannelRequest as LndOpenChannelRequest, +}; +use lnd_grpc_rust::routerrpc::SendPaymentRequest; +use lnd_grpc_rust::{connect, LndClient}; +use tokio::fs; +use tokio::sync::Mutex; + +use super::external_node::{ExternalChannel, ExternalNode, TestFailure}; + +pub(crate) struct TestLndNode { + client: Mutex, + listen_addr: SocketAddress, +} + +impl TestLndNode { + pub(crate) async fn new( + cert_path: String, macaroon_path: String, endpoint: String, listen_addr: SocketAddress, + ) -> Self { + let cert_bytes = fs::read(&cert_path).await.expect("Failed to read TLS cert file"); + let mac_bytes = fs::read(&macaroon_path).await.expect("Failed to read macaroon file"); + let cert = cert_bytes.as_hex().to_string(); + let macaroon = mac_bytes.as_hex().to_string(); + let client = connect(cert, macaroon, endpoint).await.expect("Failed to connect to LND"); + Self { client: Mutex::new(client), listen_addr } + } + + pub(crate) async fn from_env() -> Self { + let cert_path = std::env::var("LND_CERT_PATH").expect("LND_CERT_PATH not set"); + let macaroon_path = std::env::var("LND_MACAROON_PATH").expect("LND_MACAROON_PATH not set"); + let endpoint = + std::env::var("LND_ENDPOINT").unwrap_or_else(|_| "127.0.0.1:8081".to_string()); + let listen_addr: SocketAddress = std::env::var("LND_P2P_ADDR") + .unwrap_or_else(|_| "127.0.0.1:9735".to_string()) + .parse() + .unwrap(); + Self::new(cert_path, macaroon_path, endpoint, listen_addr).await + } +} + +#[async_trait] +impl ExternalNode for TestLndNode { + fn name(&self) -> &str { + "LND" + } + + async fn get_node_id(&self) -> Result { + let mut client = self.client.lock().await; + let response = client + .lightning() + .get_info(LndGetInfoRequest {}) + .await + .map_err(|e| self.make_error(format!("get_info: {}", e)))? + .into_inner(); + PublicKey::from_str(&response.identity_pubkey) + .map_err(|e| self.make_error(format!("parse pubkey: {}", e))) + } + + async fn get_listening_address(&self) -> Result { + Ok(self.listen_addr.clone()) + } + + async fn connect_peer( + &self, peer_id: PublicKey, addr: SocketAddress, + ) -> Result<(), TestFailure> { + let mut client = self.client.lock().await; + let request = LndConnectPeerRequest { + addr: Some(LndLightningAddress { pubkey: peer_id.to_string(), host: addr.to_string() }), + ..Default::default() + }; + client + .lightning() + .connect_peer(request) + .await + .map_err(|e| self.make_error(format!("connect_peer: {}", e)))?; + Ok(()) + } + + async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> { + let mut client = self.client.lock().await; + let request = LndDisconnectPeerRequest { pub_key: peer_id.to_string() }; + client + .lightning() + .disconnect_peer(request) + .await + .map_err(|e| self.make_error(format!("disconnect_peer: {}", e)))?; + Ok(()) + } + + async fn open_channel( + &self, peer_id: PublicKey, _addr: SocketAddress, capacity_sat: u64, push_msat: Option, + ) -> Result { + let mut client = self.client.lock().await; + let local_funding_amount: i64 = capacity_sat + .try_into() + .map_err(|_| self.make_error(format!("capacity_sat overflow: {}", capacity_sat)))?; + // LND's OpenChannelRequest accepts push amount in sats only. + // Sub-satoshi precision is truncated (e.g. 1500 msat → 1 sat). + let push_sat: i64 = push_msat + .map(|m| (m / 1000).try_into()) + .transpose() + .map_err(|_| { + self.make_error(format!("push_msat overflow: {}", push_msat.unwrap_or(0))) + })? + .unwrap_or(0); + + let request = LndOpenChannelRequest { + node_pubkey: peer_id.serialize().to_vec(), + local_funding_amount, + push_sat, + ..Default::default() + }; + + let response = client + .lightning() + .open_channel_sync(request) + .await + .map_err(|e| self.make_error(format!("open_channel: {}", e)))? + .into_inner(); + + let txid_bytes = match response.funding_txid { + Some(lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidBytes(bytes)) => { + bytes + }, + Some(lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidStr(s)) => { + bitcoin::Txid::from_str(&s) + .map_err(|e| { + self.make_error(format!("open_channel: invalid txid string '{}': {}", s, e)) + })? + .as_byte_array() + .to_vec() + }, + None => return Err(self.make_error("No funding txid in response")), + }; + + // LND returns txid bytes in reversed order + let mut txid_arr: [u8; 32] = txid_bytes.try_into().map_err(|b: Vec| { + self.make_error(format!("open_channel: expected 32-byte txid, got {} bytes", b.len())) + })?; + txid_arr.reverse(); + let txid_hex = txid_arr.as_hex().to_string(); + Ok(format!("{}:{}", txid_hex, response.output_index)) + } + + async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + let mut client = self.client.lock().await; + let (txid_bytes, output_index) = parse_channel_point(channel_id)?; + let request = LndCloseChannelRequest { + channel_point: Some(lnd_grpc_rust::lnrpc::ChannelPoint { + funding_txid: Some( + lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidBytes(txid_bytes), + ), + output_index, + }), + sat_per_vbyte: 1, + ..Default::default() + }; + // CloseChannel is a server-streaming RPC that blocks until the close tx + // is confirmed. We spawn the stream in the background so the caller can + // mine blocks and wait for the ChannelClosed event separately. + let stream = client + .lightning() + .close_channel(request) + .await + .map_err(|e| self.make_error(format!("close_channel: {}", e)))? + .into_inner(); + tokio::spawn(async move { + let mut s = stream; + while let Some(msg) = s.message().await.transpose() { + if let Err(e) = msg { + eprintln!("close_channel stream error: {}", e); + break; + } + } + }); + Ok(()) + } + + async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + let mut client = self.client.lock().await; + let (txid_bytes, output_index) = parse_channel_point(channel_id)?; + let request = LndCloseChannelRequest { + channel_point: Some(lnd_grpc_rust::lnrpc::ChannelPoint { + funding_txid: Some( + lnd_grpc_rust::lnrpc::channel_point::FundingTxid::FundingTxidBytes(txid_bytes), + ), + output_index, + }), + force: true, + ..Default::default() + }; + let stream = client + .lightning() + .close_channel(request) + .await + .map_err(|e| self.make_error(format!("force_close_channel: {}", e)))? + .into_inner(); + tokio::spawn(async move { + let mut s = stream; + while let Some(msg) = s.message().await.transpose() { + if let Err(e) = msg { + eprintln!("force_close_channel stream error: {}", e); + break; + } + } + }); + Ok(()) + } + + async fn create_invoice( + &self, amount_msat: u64, description: &str, + ) -> Result { + let mut client = self.client.lock().await; + let value_msat: i64 = amount_msat + .try_into() + .map_err(|_| self.make_error(format!("amount_msat overflow: {}", amount_msat)))?; + let invoice = + LndInvoice { value_msat, memo: description.to_string(), ..Default::default() }; + let response = client + .lightning() + .add_invoice(invoice) + .await + .map_err(|e| self.make_error(format!("create_invoice: {}", e)))? + .into_inner(); + Ok(response.payment_request) + } + + async fn pay_invoice(&self, invoice: &str) -> Result { + let mut client = self.client.lock().await; + let request = SendPaymentRequest { + payment_request: invoice.to_string(), + timeout_seconds: 60, + no_inflight_updates: true, + ..Default::default() + }; + + let mut stream = client + .router() + .send_payment_v2(request) + .await + .map_err(|e| self.make_error(format!("pay_invoice: {}", e)))? + .into_inner(); + + while let Some(payment) = stream + .message() + .await + .map_err(|e| self.make_error(format!("pay_invoice stream: {}", e)))? + { + match PaymentStatus::try_from(payment.status) { + Ok(PaymentStatus::Succeeded) => { + return Ok(payment.payment_preimage); + }, + Ok(PaymentStatus::Failed) => { + return Err( + self.make_error(format!("payment failed: {:?}", payment.failure_reason)) + ); + }, + _ => continue, + } + } + + Err(self.make_error("payment stream ended without terminal status")) + } + + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result { + let mut client = self.client.lock().await; + + let mut preimage = [0u8; 32]; + rand::Rng::fill(&mut rand::rng(), &mut preimage); + let payment_hash = sha256::Hash::hash(&preimage).to_byte_array().to_vec(); + + // Keysend requires inserting the preimage as TLV record 5482373484. + let mut dest_custom_records = std::collections::HashMap::new(); + dest_custom_records.insert(5482373484, preimage.to_vec()); + let amt_msat: i64 = amount_msat + .try_into() + .map_err(|_| self.make_error(format!("amount_msat overflow: {}", amount_msat)))?; + + let request = SendPaymentRequest { + dest: peer_id.serialize().to_vec(), + amt_msat, + payment_hash, + dest_custom_records, + timeout_seconds: 60, + no_inflight_updates: true, + ..Default::default() + }; + + let mut stream = client + .router() + .send_payment_v2(request) + .await + .map_err(|e| self.make_error(format!("send_keysend: {}", e)))? + .into_inner(); + + while let Some(payment) = + stream.message().await.map_err(|e| self.make_error(format!("keysend stream: {}", e)))? + { + match PaymentStatus::try_from(payment.status) { + Ok(PaymentStatus::Succeeded) => { + return Ok(payment.payment_preimage); + }, + Ok(PaymentStatus::Failed) => { + return Err( + self.make_error(format!("keysend failed: {:?}", payment.failure_reason)) + ); + }, + _ => continue, + } + } + + Err(self.make_error("keysend stream ended without terminal status")) + } + + async fn get_funding_address(&self) -> Result { + let mut client = self.client.lock().await; + let response = client + .lightning() + .new_address(lnd_grpc_rust::lnrpc::NewAddressRequest { + r#type: 4, // TAPROOT_PUBKEY + ..Default::default() + }) + .await + .map_err(|e| self.make_error(format!("get_funding_address: {}", e)))? + .into_inner(); + Ok(response.address) + } + + async fn get_block_height(&self) -> Result { + let mut client = self.client.lock().await; + let response = client + .lightning() + .get_info(LndGetInfoRequest {}) + .await + .map_err(|e| self.make_error(format!("get_info: {}", e)))? + .into_inner(); + Ok(response.block_height as u64) + } + + async fn list_channels(&self) -> Result, TestFailure> { + let mut client = self.client.lock().await; + let response = client + .lightning() + .list_channels(LndListChannelsRequest { ..Default::default() }) + .await + .map_err(|e| self.make_error(format!("list_channels: {}", e)))? + .into_inner(); + + let channels = response + .channels + .into_iter() + .map(|ch| { + let peer_id = PublicKey::from_str(&ch.remote_pubkey).map_err(|e| { + self.make_error(format!( + "list_channels: invalid remote_pubkey '{}': {}", + ch.remote_pubkey, e + )) + })?; + // LND reports balances in satoshis; convert to msat (sub-sat precision lost). + Ok(ExternalChannel { + channel_id: ch.channel_point.clone(), + peer_id, + capacity_sat: ch.capacity as u64, + local_balance_msat: ch.local_balance as u64 * 1000, + remote_balance_msat: ch.remote_balance as u64 * 1000, + funding_txid: ch.channel_point.split(':').next().map(String::from), + is_active: ch.active, + pending_htlcs_count: ch.pending_htlcs.len(), + }) + }) + .collect::, _>>()?; + + Ok(channels) + } +} + +/// Parse a channel point string "txid:output_index" into (txid_bytes, output_index). +fn parse_channel_point(channel_point: &str) -> Result<(Vec, u32), TestFailure> { + let err = |msg: String| TestFailure::ExternalNodeError { node: "LND".to_string(), detail: msg }; + let (txid_str, idx_str) = channel_point + .split_once(':') + .ok_or_else(|| err(format!("invalid format: {}", channel_point)))?; + let txid = bitcoin::Txid::from_str(txid_str).map_err(|e| err(format!("bad txid: {}", e)))?; + let output_index: u32 = idx_str.parse().map_err(|e| err(format!("bad output index: {}", e)))?; + Ok((txid.as_byte_array().to_vec(), output_index)) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index be9e16189..3045599c5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,11 +5,19 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -#![cfg(any(test, cln_test, lnd_test, vss_test))] +#![cfg(any(test, cln_test, lnd_test, eclair_test, vss_test))] #![allow(dead_code)] +pub(crate) mod external_node; pub(crate) mod logging; +#[cfg(cln_test)] +pub(crate) mod cln; +#[cfg(eclair_test)] +pub(crate) mod eclair; +#[cfg(lnd_test)] +pub(crate) mod lnd; + use std::collections::{HashMap, HashSet}; use std::env; use std::future::Future; @@ -48,9 +56,24 @@ use rand::distr::Alphanumeric; use rand::{rng, Rng}; use serde_json::{json, Value}; +/// Shared timeout (in seconds) for waiting on LDK events and external node operations. +pub(crate) const INTEROP_TIMEOUT_SECS: u64 = 60; + macro_rules! expect_event { ($node:expr, $event_type:ident) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!( + "{} timed out waiting for {} event after 60s", + $node.node_id(), + std::stringify!($event_type) + ) + }); + match event { ref e @ Event::$event_type { .. } => { println!("{} got event {:?}", $node.node_id(), e); $node.event_handled().unwrap(); @@ -66,7 +89,15 @@ pub(crate) use expect_event; macro_rules! expect_channel_pending_event { ($node:expr, $counterparty_node_id:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for ChannelPending event after 60s", $node.node_id()) + }); + match event { ref e @ Event::ChannelPending { funding_txo, counterparty_node_id, .. } => { println!("{} got event {:?}", $node.node_id(), e); assert_eq!(counterparty_node_id, $counterparty_node_id); @@ -84,7 +115,15 @@ pub(crate) use expect_channel_pending_event; macro_rules! expect_channel_ready_event { ($node:expr, $counterparty_node_id:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for ChannelReady event after 60s", $node.node_id()) + }); + match event { ref e @ Event::ChannelReady { user_channel_id, counterparty_node_id, .. } => { println!("{} got event {:?}", $node.node_id(), e); assert_eq!(counterparty_node_id, Some($counterparty_node_id)); @@ -104,7 +143,15 @@ macro_rules! expect_channel_ready_events { ($node:expr, $counterparty_node_id_a:expr, $counterparty_node_id_b:expr) => {{ let mut ids = Vec::new(); for _ in 0..2 { - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for ChannelReady event after 60s", $node.node_id()) + }); + match event { ref e @ Event::ChannelReady { counterparty_node_id, .. } => { println!("{} got event {:?}", $node.node_id(), e); ids.push(counterparty_node_id); @@ -130,7 +177,15 @@ pub(crate) use expect_channel_ready_events; macro_rules! expect_splice_pending_event { ($node:expr, $counterparty_node_id:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for SplicePending event after 60s", $node.node_id()) + }); + match event { ref e @ Event::SplicePending { new_funding_txo, counterparty_node_id, .. } => { println!("{} got event {:?}", $node.node_id(), e); assert_eq!(counterparty_node_id, $counterparty_node_id); @@ -148,19 +203,27 @@ pub(crate) use expect_splice_pending_event; macro_rules! expect_payment_received_event { ($node:expr, $amount_msat:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for PaymentReceived event after 60s", $node.node_id()) + }); + match event { ref e @ Event::PaymentReceived { payment_id, amount_msat, .. } => { println!("{} got event {:?}", $node.node_id(), e); assert_eq!(amount_msat, $amount_msat); let payment = $node.payment(&payment_id.unwrap()).unwrap(); - if !matches!(payment.kind, PaymentKind::Onchain { .. }) { + if !matches!(payment.kind, ldk_node::payment::PaymentKind::Onchain { .. }) { assert_eq!(payment.fee_paid_msat, None); } $node.event_handled().unwrap(); payment_id }, ref e => { - panic!("{} got unexpected event!: {:?}", std::stringify!(node_b), e); + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); }, } }}; @@ -170,7 +233,18 @@ pub(crate) use expect_payment_received_event; macro_rules! expect_payment_claimable_event { ($node:expr, $payment_id:expr, $payment_hash:expr, $claimable_amount_msat:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!( + "{} timed out waiting for PaymentClaimable event after 60s", + std::stringify!($node) + ) + }); + match event { ref e @ Event::PaymentClaimable { payment_id, payment_hash, @@ -195,7 +269,15 @@ pub(crate) use expect_payment_claimable_event; macro_rules! expect_payment_successful_event { ($node:expr, $payment_id:expr, $fee_paid_msat:expr) => {{ - match $node.next_event_async().await { + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for PaymentSuccessful event after 60s", $node.node_id()) + }); + match event { ref e @ Event::PaymentSuccessful { payment_id, fee_paid_msat, .. } => { println!("{} got event {:?}", $node.node_id(), e); if let Some(fee_msat) = $fee_paid_msat { @@ -380,6 +462,9 @@ macro_rules! setup_builder { pub(crate) use setup_builder; +#[cfg(any(cln_test, lnd_test, eclair_test))] +pub(crate) mod scenarios; + pub(crate) fn setup_two_nodes( chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, anchors_trusted_no_reserve: bool, diff --git a/tests/common/scenarios/channel.rs b/tests/common/scenarios/channel.rs new file mode 100644 index 000000000..da968b469 --- /dev/null +++ b/tests/common/scenarios/channel.rs @@ -0,0 +1,103 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::time::Duration; + +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; + +use super::super::external_node::ExternalNode; +use super::super::generate_blocks_and_wait; +use super::Side; + +/// Open a channel from LDK to peer; returns (user_channel_id, external_channel_id). +pub(crate) async fn open_channel_to_external( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + funding_amount_sat: u64, push_msat: Option, +) -> (ldk_node::UserChannelId, String) { + let ext_node_id = peer.get_node_id().await.unwrap(); + let ext_addr = peer.get_listening_address().await.unwrap(); + + node.open_channel(ext_node_id, ext_addr, funding_amount_sat, push_msat, None).unwrap(); + + let funding_txo = expect_channel_pending_event!(node, ext_node_id); + super::super::wait_for_tx(electrs, funding_txo.txid).await; + generate_blocks_and_wait(bitcoind, electrs, 10).await; + super::sync_wallets_with_retry(node).await; + let user_channel_id = expect_channel_ready_event!(node, ext_node_id); + + let ext_channels = peer.list_channels().await.unwrap(); + let funding_txid_str = funding_txo.txid.to_string(); + let ext_channel_id = ext_channels + .iter() + .find(|ch| ch.funding_txid.as_deref() == Some(&funding_txid_str)) + // Fallback to active channel by peer_id; avoids picking up closing channels from prior scenarios. + .or_else(|| ext_channels.iter().find(|ch| ch.peer_id == node.node_id() && ch.is_active)) + .map(|ch| ch.channel_id.clone()) + .unwrap_or_else(|| panic!("Could not find channel on external node {}", peer.name())); + + (user_channel_id, ext_channel_id) +} + +/// Cooperative close from the chosen side. Mines 1 block and asserts ChannelClosed. +pub(crate) async fn cooperative_close( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + user_channel_id: &ldk_node::UserChannelId, ext_channel_id: &str, initiator: Side, +) { + tokio::time::sleep(Duration::from_secs(2)).await; + match initiator { + Side::Ldk => { + let ext_node_id = peer.get_node_id().await.unwrap(); + node.close_channel(user_channel_id, ext_node_id).unwrap(); + }, + Side::External => { + peer.close_channel(ext_channel_id).await.unwrap(); + }, + } + generate_blocks_and_wait(bitcoind, electrs, 1).await; + super::sync_wallets_with_retry(node).await; + expect_event!(node, ChannelClosed); +} + +/// Force close from the chosen side. Mines 6 blocks and asserts ChannelClosed. +/// +/// External-initiated path additionally polls the mempool because the peer's +/// commitment-broadcast can lag the force-close RPC return. +pub(crate) async fn force_close( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + user_channel_id: &ldk_node::UserChannelId, ext_channel_id: &str, initiator: Side, +) { + match initiator { + Side::Ldk => { + let ext_node_id = peer.get_node_id().await.unwrap(); + node.force_close_channel(user_channel_id, ext_node_id, None).unwrap(); + expect_event!(node, ChannelClosed); + generate_blocks_and_wait(bitcoind, electrs, 6).await; + super::sync_wallets_with_retry(node).await; + }, + Side::External => { + peer.force_close_channel(ext_channel_id).await.unwrap(); + // External peer's force-close RPC may return before commitment tx is broadcast. + let before = + bitcoind.call::>("getrawmempool", &[]).unwrap_or_default().len(); + for _ in 0..30 { + tokio::time::sleep(Duration::from_secs(1)).await; + let now = + bitcoind.call::>("getrawmempool", &[]).unwrap_or_default().len(); + if now > before { + break; + } + } + generate_blocks_and_wait(bitcoind, electrs, 6).await; + super::sync_wallets_with_retry(node).await; + tokio::time::sleep(Duration::from_secs(2)).await; + super::sync_wallets_with_retry(node).await; + expect_event!(node, ChannelClosed); + }, + } +} diff --git a/tests/common/scenarios/connectivity.rs b/tests/common/scenarios/connectivity.rs new file mode 100644 index 000000000..5eb0cae3a --- /dev/null +++ b/tests/common/scenarios/connectivity.rs @@ -0,0 +1,86 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::str::FromStr; +use std::time::Duration; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::{Event, Node}; +use lightning_invoice::Bolt11Invoice; + +use super::super::external_node::ExternalNode; +use super::Side; + +/// Disconnect a peer from the chosen side. Returns the dispatch result so callers +/// can tolerate races (mid-payment) by ignoring the error. +pub(crate) async fn disconnect_by_side( + node: &Node, peer: &(impl ExternalNode + ?Sized), side: &Side, +) -> Result<(), String> { + let ext_node_id = peer.get_node_id().await.unwrap(); + match side { + Side::Ldk => node.disconnect(ext_node_id).map_err(|e| format!("{:?}", e)), + Side::External => { + peer.disconnect_peer(node.node_id()).await.map_err(|e| format!("{:?}", e)) + }, + } +} + +/// Reconnect to a peer and wait until the connection is established. +pub(crate) async fn reconnect_and_wait( + node: &Node, peer_id: PublicKey, addr: SocketAddress, context: &str, +) { + node.connect(peer_id, addr, true).unwrap(); + let max_attempts = super::super::INTEROP_TIMEOUT_SECS; + for i in 0..max_attempts { + if node.list_peers().iter().any(|p| p.node_id == peer_id && p.is_connected) { + tokio::time::sleep(Duration::from_secs(2)).await; + return; + } + if i + 1 == max_attempts { + panic!("Peer did not reconnect within {}s ({})", max_attempts, context); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +/// Disconnect during payment, reconnect, verify payment resolves. +pub(crate) async fn disconnect_during_payment( + node: &Node, peer: &(impl ExternalNode + ?Sized), disconnect_side: &Side, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + let ext_addr = peer.get_listening_address().await.unwrap(); + + let invoice_str = peer.create_invoice(10_000_000, "disconnect-payment-test").await.unwrap(); + let parsed_invoice = Bolt11Invoice::from_str(&invoice_str).unwrap(); + + // If send() fails immediately, no event will arrive — skip event wait below. + let send_ok = node.bolt11_payment().send(&parsed_invoice, None).is_ok(); + + // Disconnect may race with payment delivery; tolerate failure. + let _ = disconnect_by_side(node, peer, disconnect_side).await; + + tokio::time::sleep(Duration::from_secs(2)).await; + reconnect_and_wait(node, ext_node_id, ext_addr, "disconnect during payment").await; + + if send_ok { + let event = tokio::time::timeout( + Duration::from_secs(super::super::INTEROP_TIMEOUT_SECS), + node.next_event_async(), + ) + .await + .expect("Timed out waiting for payment to resolve after reconnect"); + match event { + Event::PaymentSuccessful { .. } | Event::PaymentFailed { .. } => { + node.event_handled().unwrap(); + }, + other => { + panic!("Expected payment outcome event, got: {:?}", other); + }, + } + } +} diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs new file mode 100644 index 000000000..1999b7d0c --- /dev/null +++ b/tests/common/scenarios/mod.rs @@ -0,0 +1,251 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Shared interop test scenarios, generic over `ExternalNode`. +//! +//! - `channel` / `payment` / `connectivity` -- composable building blocks +//! - `interop_tests!` macro -- emits one `#[tokio::test]` per scenario + +pub(crate) mod channel; +pub(crate) mod connectivity; +pub(crate) mod payment; + +use std::future::Future; +use std::time::Duration; + +use bitcoin::Amount; +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; + +use super::external_node::ExternalNode; +use super::{generate_blocks_and_wait, premine_and_distribute_funds}; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Side { + Ldk, + External, +} + +/// Retry an async operation with 1s delay; used for ops that may fail due to gossip delay. +pub(crate) async fn retry_until_ok(max_attempts: u32, operation: &str, mut f: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, + E: std::fmt::Display, +{ + for attempt in 1..=max_attempts { + match f().await { + Ok(val) => return val, + Err(e) => { + if attempt == max_attempts { + panic!("{} failed after {} attempts: {}", operation, max_attempts, e); + } + tokio::time::sleep(Duration::from_secs(1)).await; + }, + } + } + unreachable!() +} + +/// Sync wallets, retrying on `WalletOperationTimeout`. +pub(crate) async fn sync_wallets_with_retry(node: &Node) { + for attempt in 0..3 { + match node.sync_wallets() { + Ok(()) => return, + Err(ldk_node::NodeError::WalletOperationTimeout) if attempt < 2 => { + tokio::time::sleep(Duration::from_secs(5)).await; + }, + Err(e) => panic!("sync_wallets failed: {:?}", e), + } + } +} + +/// Wait until the peer reports 0 pending HTLCs on the channel; required before close because +/// `PaymentSuccessful` fires one round-trip before the HTLC is removed from peer commitment. +pub(crate) async fn wait_for_htlcs_settled( + peer: &(impl ExternalNode + ?Sized), ext_channel_id: &str, +) { + for _ in 0..30 { + let channels = tokio::time::timeout(Duration::from_secs(5), peer.list_channels()) + .await + .ok() + .and_then(|r| r.ok()); + if let Some(channels) = channels { + if let Some(ch) = channels.iter().find(|c| c.channel_id == ext_channel_id) { + if ch.pending_htlcs_count == 0 { + return; + } + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + panic!("HTLCs did not settle on {} channel {} within 15s", peer.name(), ext_channel_id); +} + +/// Build a fresh LDK node configured for interop tests. Uses electrum at the +/// docker-compose default port and bumps sync timeouts for combo stress. +pub(crate) fn setup_ldk_node() -> Node { + let config = crate::common::random_config(true); + let mut builder = ldk_node::Builder::from_config(config.node_config); + let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; + sync_config.timeouts_config.lightning_wallet_sync_timeout_secs = 120; + builder.set_chain_source_electrum("tcp://127.0.0.1:50001".to_string(), Some(sync_config)); + let node = builder.build(config.node_entropy).unwrap(); + node.start().unwrap(); + node +} + +/// Fund both LDK node and external node, connect them. +pub(crate) async fn setup_interop_test( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let ldk_address = node.onchain_payment().new_address().unwrap(); + let premine_amount = Amount::from_sat(50_000_000); + premine_and_distribute_funds(bitcoind, electrs, vec![ldk_address], premine_amount).await; + + // Fund the peer via the ldk_node_test wallet loaded by premine_and_distribute_funds. + let ext_funding_addr_str = peer.get_funding_address().await.unwrap(); + let ext_amount = Amount::from_sat(50_000_000); + let amounts_json = serde_json::json!({&ext_funding_addr_str: ext_amount.to_btc()}); + let empty_account = serde_json::json!(""); + bitcoind + .call::( + "sendmany", + &[empty_account, amounts_json, serde_json::json!(0), serde_json::json!("")], + ) + .expect("failed to fund external node"); + generate_blocks_and_wait(bitcoind, electrs, 1).await; + + // Block until the peer indexes the funding tx, else channel opens time out. + let chain_height: u64 = bitcoind.get_blockchain_info().unwrap().blocks.try_into().unwrap(); + peer.wait_for_block_sync(chain_height).await.unwrap(); + + sync_wallets_with_retry(node).await; + + let ext_node_id = peer.get_node_id().await.unwrap(); + let ext_addr = peer.get_listening_address().await.unwrap(); + node.connect(ext_node_id, ext_addr, true).unwrap(); +} + +/// Drive a scenario end-to-end: fund LDK + peer, run the scenario, stop the node. +/// Each `#[tokio::test]` in the integration-test files calls this with the +/// per-impl `setup_clients` future and a scenario fn. +pub(crate) async fn run_interop_scenario( + setup_fut: impl Future, scenario: F, +) where + N: ExternalNode, + E: ElectrumApi, + F: AsyncFnOnce(&Node, &N, &BitcoindClient, &E), +{ + let (bitcoind, electrs, ext) = setup_fut.await; + let node = setup_ldk_node(); + setup_interop_test(&node, &ext, &bitcoind, &electrs).await; + scenario(&node, &ext, &bitcoind, &electrs).await; + node.stop().unwrap(); +} + +/// Open a channel, send a BOLT11 payment in each direction, then cooperatively close. +pub(crate) async fn basic_channel_cycle_scenario( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let (user_ch, ext_ch) = channel::open_channel_to_external( + node, + peer, + bitcoind, + electrs, + 1_000_000, + Some(500_000_000), + ) + .await; + + payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await; + payment::receive_bolt11_payment(node, peer, 10_000_000).await; + + channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await; +} + +/// Open a channel, send keysend in both directions, then cooperatively close. +pub(crate) async fn keysend_scenario( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let (user_ch, ext_ch) = channel::open_channel_to_external( + node, + peer, + bitcoind, + electrs, + 1_000_000, + Some(500_000_000), + ) + .await; + payment::send_keysend_to_peer(node, peer, 5_000_000).await; + payment::receive_keysend_payment(node, peer, 5_000_000).await; + channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await; +} + +/// Open a channel, send a payment, then force-close from the LDK side. +pub(crate) async fn force_close_after_payment_scenario( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let (user_ch, ext_ch) = channel::open_channel_to_external( + node, + peer, + bitcoind, + electrs, + 1_000_000, + Some(500_000_000), + ) + .await; + payment::send_bolt11_to_peer(node, peer, 5_000_000, "force-close").await; + wait_for_htlcs_settled(peer, &ext_ch).await; + channel::force_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await; +} + +/// Open a channel, dispatch a payment with a mid-flight disconnect+reconnect, +/// then cooperatively close. +pub(crate) async fn disconnect_during_payment_scenario( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let (user_ch, ext_ch) = channel::open_channel_to_external( + node, + peer, + bitcoind, + electrs, + 1_000_000, + Some(500_000_000), + ) + .await; + connectivity::disconnect_during_payment(node, peer, &Side::Ldk).await; + wait_for_htlcs_settled(peer, &ext_ch).await; + channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await; +} + +/// Open a channel, splice-in additional funds, send a post-splice payment, then close. +pub(crate) async fn splice_in_scenario( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + let (user_ch, ext_ch) = channel::open_channel_to_external( + node, + peer, + bitcoind, + electrs, + 1_000_000, + Some(500_000_000), + ) + .await; + let ext_node_id = peer.get_node_id().await.unwrap(); + node.splice_in(&user_ch, ext_node_id, 500_000).unwrap(); + expect_splice_pending_event!(node, ext_node_id); + generate_blocks_and_wait(bitcoind, electrs, 6).await; + sync_wallets_with_retry(node).await; + expect_channel_ready_event!(node, ext_node_id); + + payment::send_bolt11_to_peer(node, peer, 5_000_000, "post-splice").await; + + channel::cooperative_close(node, peer, bitcoind, electrs, &user_ch, &ext_ch, Side::Ldk).await; +} diff --git a/tests/common/scenarios/payment.rs b/tests/common/scenarios/payment.rs new file mode 100644 index 000000000..191f60abc --- /dev/null +++ b/tests/common/scenarios/payment.rs @@ -0,0 +1,62 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::str::FromStr; + +use ldk_node::{Event, Node}; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; + +use super::super::external_node::ExternalNode; +use super::retry_until_ok; + +/// LDK pays the peer via a fresh BOLT11 invoice; asserts `PaymentSuccessful`. +pub(crate) async fn send_bolt11_to_peer( + node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64, label: &str, +) { + let invoice_str = peer.create_invoice(amount_msat, label).await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); +} + +/// External node pays LDK via BOLT11 invoice. Retries to absorb gossip-propagation +/// delay (peer may not yet know a route to LDK right after channel confirmation). +pub(crate) async fn receive_bolt11_payment( + node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64, +) { + let invoice = node + .bolt11_payment() + .receive( + amount_msat, + &Bolt11InvoiceDescription::Direct( + Description::new("interop-receive-test".to_string()).unwrap(), + ), + 3600, + ) + .unwrap(); + let invoice_str = invoice.to_string(); + retry_until_ok(10, "receive_bolt11_payment", || peer.pay_invoice(&invoice_str)).await; + expect_payment_received_event!(node, amount_msat); +} + +/// LDK keysends to peer; asserts `PaymentSuccessful`. +pub(crate) async fn send_keysend_to_peer( + node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64, +) { + let peer_id = peer.get_node_id().await.unwrap(); + node.spontaneous_payment().send(amount_msat, peer_id, None).unwrap(); + expect_event!(node, PaymentSuccessful); +} + +/// External node sends keysend to LDK. Retries to absorb gossip-propagation delay. +pub(crate) async fn receive_keysend_payment( + node: &Node, peer: &(impl ExternalNode + ?Sized), amount_msat: u64, +) { + let node_id = node.node_id(); + retry_until_ok(10, "receive_keysend_payment", || peer.send_keysend(node_id, amount_msat)).await; + expect_payment_received_event!(node, amount_msat); +} diff --git a/tests/docker/Dockerfile.eclair b/tests/docker/Dockerfile.eclair new file mode 100644 index 000000000..dc2c79224 --- /dev/null +++ b/tests/docker/Dockerfile.eclair @@ -0,0 +1,22 @@ +# Repackage acinq/eclair:latest onto a glibc-based runtime. +# The official image uses Alpine (musl libc), which causes SIGSEGV in +# secp256k1-jni because the native library is compiled against glibc. +FROM acinq/eclair:latest AS source + +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash jq curl unzip && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=source /sbin/eclair-cli /sbin/eclair-cli +COPY --from=source /app/eclair-node /app/eclair-node + +ENV ECLAIR_DATADIR=/data +ENV JAVA_OPTS= + +RUN mkdir -p "$ECLAIR_DATADIR" +VOLUME [ "/data" ] + +ENTRYPOINT JAVA_OPTS="${JAVA_OPTS}" eclair-node/bin/eclair-node.sh "-Declair.datadir=${ECLAIR_DATADIR}" diff --git a/tests/docker/docker-compose-cln.yml b/tests/docker/docker-compose-cln.yml index ef0efa8d8..7b697eff3 100644 --- a/tests/docker/docker-compose-cln.yml +++ b/tests/docker/docker-compose-cln.yml @@ -11,11 +11,16 @@ services: "-rpcbind=0.0.0.0", "-rpcuser=user", "-rpcpassword=pass", - "-fallbackfee=0.00001" + "-fallbackfee=0.00001", + "-rest", + "-zmqpubrawblock=tcp://0.0.0.0:28332", + "-zmqpubrawtx=tcp://0.0.0.0:28333" ] ports: - "18443:18443" # Regtest RPC port - "18444:18444" # Regtest P2P port + - "28332:28332" # ZMQ block port + - "28333:28333" # ZMQ tx port networks: - bitcoin-electrs healthcheck: @@ -53,6 +58,8 @@ services: depends_on: bitcoin: condition: service_healthy + volumes: + - ${CLN_DATA_DIR:-/tmp/cln-data}:/root/.lightning command: [ "--bitcoin-rpcconnect=bitcoin", @@ -60,6 +67,7 @@ services: "--bitcoin-rpcuser=user", "--bitcoin-rpcpassword=pass", "--regtest", + "--experimental-splicing", ] ports: - "19846:19846" diff --git a/tests/docker/docker-compose-eclair.yml b/tests/docker/docker-compose-eclair.yml new file mode 100644 index 000000000..56a5629f1 --- /dev/null +++ b/tests/docker/docker-compose-eclair.yml @@ -0,0 +1,80 @@ +services: + # All services use host networking because Eclair subscribes to bitcoind + # ZMQ notifications (hashblock/rawtx). ZMQ PUB/SUB over Docker bridge + # networking is unreliable -- the subscriber may silently miss messages, + # causing Eclair to fall behind the chain tip. Host networking avoids + # this by keeping all inter-process communication on localhost. + bitcoin: + image: blockstream/bitcoind:30.2 + platform: linux/amd64 + network_mode: host + command: + [ + "bitcoind", + "-printtoconsole", + "-regtest=1", + "-rpcallowip=0.0.0.0/0", + "-rpcbind=0.0.0.0", + "-rpcuser=user", + "-rpcpassword=pass", + "-fallbackfee=0.00001", + "-rest", + "-txindex=1", + "-zmqpubhashblock=tcp://0.0.0.0:28332", + "-zmqpubrawtx=tcp://0.0.0.0:28333" + ] + healthcheck: + test: ["CMD", "bitcoin-cli", "-regtest", "-rpcuser=user", "-rpcpassword=pass", "getblockchaininfo"] + interval: 5s + timeout: 10s + retries: 5 + + electrs: + image: mempool/electrs:v3.2.0 + platform: linux/amd64 + network_mode: host + depends_on: + bitcoin: + condition: service_healthy + command: + [ + "-vvvv", + "--timestamp", + "--jsonrpc-import", + "--cookie=user:pass", + "--network=regtest", + "--daemon-rpc-addr=127.0.0.1:18443", + "--http-addr=0.0.0.0:3002", + "--electrum-rpc-addr=0.0.0.0:50001" + ] + + eclair: + build: + context: . + dockerfile: Dockerfile.eclair + image: ldk-node-eclair:local + platform: linux/amd64 + network_mode: host + depends_on: + bitcoin: + condition: service_healthy + environment: + JAVA_OPTS: >- + -Xmx512m + -Declair.allow-unsafe-startup=true + -Declair.chain=regtest + -Declair.server.port=9736 + -Declair.api.enabled=true + -Declair.api.binding-ip=0.0.0.0 + -Declair.api.port=8080 + -Declair.api.password=eclairpassword + -Declair.bitcoind.host=127.0.0.1 + -Declair.bitcoind.rpcport=18443 + -Declair.bitcoind.rpcuser=user + -Declair.bitcoind.rpcpassword=pass + -Declair.bitcoind.wallet=eclair + -Declair.bitcoind.zmqblock=tcp://127.0.0.1:28332 + -Declair.bitcoind.zmqtx=tcp://127.0.0.1:28333 + -Declair.features.keysend=optional + -Declair.on-chain-fees.confirmation-priority.funding=slow + -Declair.printToConsole diff --git a/tests/docker/docker-compose-lnd.yml b/tests/docker/docker-compose-lnd.yml old mode 100755 new mode 100644 index 304c4eb78..957030e78 --- a/tests/docker/docker-compose-lnd.yml +++ b/tests/docker/docker-compose-lnd.yml @@ -12,6 +12,7 @@ services: "-rpcuser=user", "-rpcpassword=pass", "-fallbackfee=0.00001", + "-rest", "-zmqpubrawblock=tcp://0.0.0.0:28332", "-zmqpubrawtx=tcp://0.0.0.0:28333" ] @@ -55,9 +56,10 @@ services: image: lightninglabs/lnd:v0.20.1-beta container_name: ldk-node-lnd depends_on: - - bitcoin + bitcoin: + condition: service_healthy volumes: - - ${LND_DATA_DIR}:/root/.lnd + - ${LND_DATA_DIR:-/tmp/lnd-data}:/root/.lnd ports: - "8081:8081" - "9735:9735" diff --git a/docker-compose.yml b/tests/docker/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to tests/docker/docker-compose.yml diff --git a/tests/integration_tests_cln.rs b/tests/integration_tests_cln.rs index 6eea7b067..1c90920ff 100644 --- a/tests/integration_tests_cln.rs +++ b/tests/integration_tests_cln.rs @@ -9,130 +9,48 @@ mod common; -use std::default::Default; -use std::str::FromStr; - -use clightningrpc::lightningrpc::LightningRPC; -use clightningrpc::responses::NetworkAddress; +use common::cln::TestClnNode; +use common::scenarios::{ + basic_channel_cycle_scenario, disconnect_during_payment_scenario, + force_close_after_payment_scenario, keysend_scenario, run_interop_scenario, splice_in_scenario, +}; use electrsd::corepc_client::client_sync::Auth; use electrsd::corepc_node::Client as BitcoindClient; use electrum_client::Client as ElectrumClient; -use ldk_node::bitcoin::secp256k1::PublicKey; -use ldk_node::bitcoin::Amount; -use ldk_node::lightning::ln::msgs::SocketAddress; -use ldk_node::{Builder, Event}; -use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; -use rand::distr::Alphanumeric; -use rand::{rng, Rng}; -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_cln() { - // Setup bitcoind / electrs clients - let bitcoind_client = BitcoindClient::new_with_auth( +async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestClnNode) { + let bitcoind = BitcoindClient::new_with_auth( "http://127.0.0.1:18443", Auth::UserPass("user".to_string(), "pass".to_string()), ) .unwrap(); - let electrs_client = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); - - // Give electrs a kick. - common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 1).await; - - // Setup LDK Node - let config = common::random_config(true); - let mut builder = Builder::from_config(config.node_config); - builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), None); - - let node = builder.build(config.node_entropy).unwrap(); - node.start().unwrap(); - - // Premine some funds and distribute - let address = node.onchain_payment().new_address().unwrap(); - let premine_amount = Amount::from_sat(5_000_000); - common::premine_and_distribute_funds( - &bitcoind_client, - &electrs_client, - vec![address], - premine_amount, - ) - .await; - - // Setup CLN - let sock = "/tmp/lightning-rpc"; - let cln_client = LightningRPC::new(&sock); - let cln_info = { - loop { - let info = cln_client.getinfo().unwrap(); - // Wait for CLN to sync block height before channel open. - // Prevents crash due to unset blockheight (see LDK Node issue #527). - if info.blockheight > 0 { - break info; - } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - }; - let cln_node_id = PublicKey::from_str(&cln_info.id).unwrap(); - let cln_address: SocketAddress = match cln_info.binding.first().unwrap() { - NetworkAddress::Ipv4 { address, port } => { - std::net::SocketAddrV4::new(*address, *port).into() - }, - NetworkAddress::Ipv6 { address, port } => { - std::net::SocketAddrV6::new(*address, *port, 0, 0).into() - }, - _ => { - panic!() - }, - }; - - node.sync_wallets().unwrap(); - - // Open the channel - let funding_amount_sat = 1_000_000; - - node.open_channel(cln_node_id, cln_address, funding_amount_sat, Some(500_000_000), None) - .unwrap(); + let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); + let cln = TestClnNode::from_env(); + (bitcoind, electrs, cln) +} - let funding_txo = common::expect_channel_pending_event!(node, cln_node_id); - common::wait_for_tx(&electrs_client, funding_txo.txid).await; - common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6).await; - node.sync_wallets().unwrap(); - let user_channel_id = common::expect_channel_ready_event!(node, cln_node_id); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_basic_channel_cycle() { + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; +} - // Send a payment to CLN - let mut rng = rng(); - let rand_label: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); - let cln_invoice = - cln_client.invoice(Some(10_000_000), &rand_label, &rand_label, None, None, None).unwrap(); - let parsed_invoice = Bolt11Invoice::from_str(&cln_invoice.bolt11).unwrap(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "CLN <=v25.12.x keysend final_cltv=22 < LDK min 42; fixed in master (ElementsProject/lightning#9034), awaiting v26.04 Docker image"] +async fn test_keysend() { + run_interop_scenario(setup_clients(), keysend_scenario).await; +} - node.bolt11_payment().send(&parsed_invoice, None).unwrap(); - common::expect_event!(node, PaymentSuccessful); - let cln_listed_invoices = - cln_client.listinvoices(Some(&rand_label), None, None, None).unwrap().invoices; - assert_eq!(cln_listed_invoices.len(), 1); - assert_eq!(cln_listed_invoices.first().unwrap().status, "paid"); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_force_close_after_payment() { + run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; +} - // Send a payment to LDK - let rand_label: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); - let invoice_description = - Bolt11InvoiceDescription::Direct(Description::new(rand_label).unwrap()); - let ldk_invoice = - node.bolt11_payment().receive(10_000_000, &invoice_description, 3600).unwrap(); - cln_client.pay(&ldk_invoice.to_string(), Default::default()).unwrap(); - common::expect_event!(node, PaymentReceived); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_disconnect_during_payment() { + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; +} - // Retry close until monitor updates settle (avoids flaky sleep). - for i in 0..10 { - match node.close_channel(&user_channel_id, cln_node_id) { - Ok(()) => break, - Err(e) => { - if i == 9 { - panic!("close_channel failed after 10 attempts: {:?}", e); - } - std::thread::sleep(std::time::Duration::from_secs(1)); - }, - } - } - common::expect_event!(node, ChannelClosed); - node.stop().unwrap(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_splice_in() { + run_interop_scenario(setup_clients(), splice_in_scenario).await; } diff --git a/tests/integration_tests_eclair.rs b/tests/integration_tests_eclair.rs new file mode 100644 index 000000000..42d617eec --- /dev/null +++ b/tests/integration_tests_eclair.rs @@ -0,0 +1,74 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +#![cfg(eclair_test)] + +mod common; + +use base64::prelude::{Engine as _, BASE64_STANDARD}; +use common::eclair::TestEclairNode; +use common::scenarios::{ + basic_channel_cycle_scenario, disconnect_during_payment_scenario, + force_close_after_payment_scenario, keysend_scenario, run_interop_scenario, splice_in_scenario, +}; +use electrsd::corepc_client::client_sync::Auth; +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::Client as ElectrumClient; + +/// Unlock all UTXOs in the given bitcoind wallet via JSON-RPC. +async fn unlock_utxos(wallet_url: &str, user: &str, pass: &str) { + let auth = BASE64_STANDARD.encode(format!("{}:{}", user, pass)); + let body = r#"{"jsonrpc":"1.0","method":"lockunspent","params":[true]}"#; + let _ = bitreq::post(wallet_url) + .with_header("Authorization", format!("Basic {}", auth)) + .with_header("Content-Type", "text/plain") + .with_body(body) + .with_timeout(5) + .send_async() + .await; +} + +async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestEclairNode) { + let bitcoind = BitcoindClient::new_with_auth( + "http://127.0.0.1:18443/wallet/ldk_node_test", + Auth::UserPass("user".to_string(), "pass".to_string()), + ) + .unwrap(); + let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); + + // Unlock any UTXOs left locked by previous force-close tests. + unlock_utxos("http://127.0.0.1:18443/wallet/eclair", "user", "pass").await; + + let eclair = TestEclairNode::from_env(); + (bitcoind, electrs, eclair) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_basic_channel_cycle() { + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_keysend() { + run_interop_scenario(setup_clients(), keysend_scenario).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_force_close_after_payment() { + run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_disconnect_during_payment() { + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "Eclair advertises splicing via custom bit 154 instead of BOLT bit 62/63; disjoint from LDK until Eclair migrates"] +async fn test_splice_in() { + run_interop_scenario(setup_clients(), splice_in_scenario).await; +} diff --git a/tests/integration_tests_lnd.rs b/tests/integration_tests_lnd.rs index 8f1d4c868..b3059ac48 100755 --- a/tests/integration_tests_lnd.rs +++ b/tests/integration_tests_lnd.rs @@ -1,224 +1,56 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + #![cfg(lnd_test)] mod common; -use std::default::Default; -use std::str::FromStr; - -use bitcoin::hex::DisplayHex; +use common::lnd::TestLndNode; +use common::scenarios::{ + basic_channel_cycle_scenario, disconnect_during_payment_scenario, + force_close_after_payment_scenario, keysend_scenario, run_interop_scenario, splice_in_scenario, +}; use electrsd::corepc_client::client_sync::Auth; use electrsd::corepc_node::Client as BitcoindClient; use electrum_client::Client as ElectrumClient; -use ldk_node::bitcoin::secp256k1::PublicKey; -use ldk_node::bitcoin::Amount; -use ldk_node::lightning::ln::msgs::SocketAddress; -use ldk_node::{Builder, Event}; -use lightning_invoice::{Bolt11InvoiceDescription, Description}; -use lnd_grpc_rust::lnrpc::invoice::InvoiceState::Settled as LndInvoiceStateSettled; -use lnd_grpc_rust::lnrpc::{ - GetInfoRequest as LndGetInfoRequest, GetInfoResponse as LndGetInfoResponse, - Invoice as LndInvoice, ListInvoiceRequest as LndListInvoiceRequest, - QueryRoutesRequest as LndQueryRoutesRequest, Route as LndRoute, SendRequest as LndSendRequest, -}; -use lnd_grpc_rust::{connect, LndClient}; -use tokio::fs; -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_lnd() { - // Setup bitcoind / electrs clients - let bitcoind_client = BitcoindClient::new_with_auth( +async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestLndNode) { + let bitcoind = BitcoindClient::new_with_auth( "http://127.0.0.1:18443", Auth::UserPass("user".to_string(), "pass".to_string()), ) .unwrap(); - let electrs_client = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); - - // Give electrs a kick. - common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 1).await; - - // Setup LDK Node - let config = common::random_config(true); - let mut builder = Builder::from_config(config.node_config); - builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), None); - - let node = builder.build(config.node_entropy).unwrap(); - node.start().unwrap(); - - // Premine some funds and distribute - let address = node.onchain_payment().new_address().unwrap(); - let premine_amount = Amount::from_sat(5_000_000); - common::premine_and_distribute_funds( - &bitcoind_client, - &electrs_client, - vec![address], - premine_amount, - ) - .await; - - // Setup LND - let endpoint = "127.0.0.1:8081"; - let cert_path = std::env::var("LND_CERT_PATH").expect("LND_CERT_PATH not set"); - let macaroon_path = std::env::var("LND_MACAROON_PATH").expect("LND_MACAROON_PATH not set"); - let mut lnd = TestLndClient::new(cert_path, macaroon_path, endpoint.to_string()).await; - - let lnd_node_info = lnd.get_node_info().await; - let lnd_node_id = PublicKey::from_str(&lnd_node_info.identity_pubkey).unwrap(); - let lnd_address: SocketAddress = "127.0.0.1:9735".parse().unwrap(); - - node.sync_wallets().unwrap(); - - // Open the channel - let funding_amount_sat = 1_000_000; - - node.open_channel(lnd_node_id, lnd_address, funding_amount_sat, Some(500_000_000), None) - .unwrap(); - - let funding_txo = common::expect_channel_pending_event!(node, lnd_node_id); - common::wait_for_tx(&electrs_client, funding_txo.txid).await; - common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6).await; - node.sync_wallets().unwrap(); - let user_channel_id = common::expect_channel_ready_event!(node, lnd_node_id); - - // Send a payment to LND - let lnd_invoice = lnd.create_invoice(100_000_000).await; - let parsed_invoice = lightning_invoice::Bolt11Invoice::from_str(&lnd_invoice).unwrap(); - - node.bolt11_payment().send(&parsed_invoice, None).unwrap(); - common::expect_event!(node, PaymentSuccessful); - let lnd_listed_invoices = lnd.list_invoices().await; - assert_eq!(lnd_listed_invoices.len(), 1); - assert_eq!(lnd_listed_invoices.first().unwrap().state, LndInvoiceStateSettled as i32); - - // Check route LND -> LDK - let amount_msat = 9_000_000; - let max_retries = 7; - for attempt in 1..=max_retries { - match lnd.query_routes(&node.node_id().to_string(), amount_msat).await { - Ok(routes) => { - if !routes.is_empty() { - break; - } - }, - Err(err) => { - if attempt == max_retries { - panic!("Failed to find route from LND to LDK: {}", err); - } - }, - }; - // wait for the payment process - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - - // Send a payment to LDK - let invoice_description = - Bolt11InvoiceDescription::Direct(Description::new("lndTest".to_string()).unwrap()); - let ldk_invoice = - node.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); - lnd.pay_invoice(&ldk_invoice.to_string()).await; - common::expect_event!(node, PaymentReceived); - - node.close_channel(&user_channel_id, lnd_node_id).unwrap(); - common::expect_event!(node, ChannelClosed); - node.stop().unwrap(); + let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); + let lnd = TestLndNode::from_env().await; + (bitcoind, electrs, lnd) } -struct TestLndClient { - client: LndClient, +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_basic_channel_cycle() { + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; } -impl TestLndClient { - async fn new(cert_path: String, macaroon_path: String, socket: String) -> Self { - // Read the contents of the file into a vector of bytes - let cert_bytes = fs::read(cert_path).await.expect("Failed to read tls cert file"); - let mac_bytes = fs::read(macaroon_path).await.expect("Failed to read macaroon file"); - - // Convert the bytes to a hex string - let cert = cert_bytes.as_hex().to_string(); - let macaroon = mac_bytes.as_hex().to_string(); - - let client = connect(cert, macaroon, socket).await.expect("Failed to connect to Lnd"); - - TestLndClient { client } - } - - async fn get_node_info(&mut self) -> LndGetInfoResponse { - let response = self - .client - .lightning() - .get_info(LndGetInfoRequest {}) - .await - .expect("Failed to fetch node info from LND") - .into_inner(); - - response - } - - async fn create_invoice(&mut self, amount_msat: u64) -> String { - let invoice = LndInvoice { value_msat: amount_msat as i64, ..Default::default() }; - - self.client - .lightning() - .add_invoice(invoice) - .await - .expect("Failed to create invoice on LND") - .into_inner() - .payment_request - } - - async fn list_invoices(&mut self) -> Vec { - self.client - .lightning() - .list_invoices(LndListInvoiceRequest { ..Default::default() }) - .await - .expect("Failed to list invoices from LND") - .into_inner() - .invoices - } - - async fn query_routes( - &mut self, pubkey: &str, amount_msat: u64, - ) -> Result, String> { - let request = LndQueryRoutesRequest { - pub_key: pubkey.to_string(), - amt_msat: amount_msat as i64, - ..Default::default() - }; - - let response = self - .client - .lightning() - .query_routes(request) - .await - .map_err(|err| format!("Failed to query routes from LND: {:?}", err))? - .into_inner(); - - if response.routes.is_empty() { - return Err(format!("No routes found for pubkey: {}", pubkey)); - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_keysend() { + run_interop_scenario(setup_clients(), keysend_scenario).await; +} - Ok(response.routes) - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_force_close_after_payment() { + run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; +} - async fn pay_invoice(&mut self, invoice_str: &str) { - let send_req = - LndSendRequest { payment_request: invoice_str.to_string(), ..Default::default() }; - let response = self - .client - .lightning() - .send_payment_sync(send_req) - .await - .expect("Failed to pay invoice on LND") - .into_inner(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_disconnect_during_payment() { + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; +} - if !response.payment_error.is_empty() || response.payment_preimage.is_empty() { - panic!( - "LND payment failed: {}", - if response.payment_error.is_empty() { - "No preimage returned" - } else { - &response.payment_error - } - ); - } - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "LND does not implement BOLT splicing"] +async fn test_splice_in() { + run_interop_scenario(setup_clients(), splice_in_scenario).await; }