diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index 32e7b74c02..d7cf61b28a 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -19,7 +19,7 @@ jobs: sudo apt-get install -y socat - name: Start bitcoind, electrs, and lightningd - run: docker compose -f docker-compose-cln.yml up -d + run: docker compose --profile cln up -d - name: Forward lightningd RPC socket run: | diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml new file mode 100644 index 0000000000..dd702f181a --- /dev/null +++ b/.github/workflows/eclair-integration.yml @@ -0,0 +1,50 @@ +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 up -d + + - name: Wait for bitcoind to be healthy + run: | + for i in $(seq 1 30); do + if docker exec ldk-node-bitcoin-1 bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass getblockchaininfo > /dev/null 2>&1; then + echo "bitcoind is ready" + break + fi + echo "Waiting for bitcoind... ($i/30)" + sleep 2 + done + + - name: Create Eclair wallet on bitcoind + run: | + docker exec ldk-node-bitcoin-1 bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet eclair || true + + - name: Start Eclair + run: docker compose --profile eclair up -d + + - name: Wait for Eclair to be ready + run: | + for i in $(seq 1 60); do + if curl -s -u :eclairpassword http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then + echo "Eclair is ready" + break + fi + echo "Waiting for Eclair... ($i/60)" + sleep 5 + done + curl -s -u :eclairpassword http://127.0.0.1:8080/getinfo || { echo "Eclair failed to start"; docker logs ldk-node-eclair-1; exit 1; } + + - name: Run Eclair integration tests + run: RUSTFLAGS="--cfg eclair_test" cargo test --test integration_tests_eclair -- --test-threads=1 diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml index f913e92ad5..13471b00af 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -37,7 +37,20 @@ jobs: run: echo "LND_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV - name: Start bitcoind, electrs, and LND - run: docker compose -f docker-compose-lnd.yml up -d + run: docker compose --profile lnd up -d + env: + LND_DATA_DIR: ${{ env.LND_DATA_DIR }} + + - name: Wait for LND to create macaroon + run: | + for i in $(seq 1 30); do + if [ -f "$LND_DATA_DIR/data/chain/bitcoin/regtest/admin.macaroon" ]; then + echo "LND macaroon found" + break + fi + echo "Waiting for LND macaroon... ($i/30)" + sleep 2 + done env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} @@ -51,6 +64,6 @@ jobs: - 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/Cargo.toml b/Cargo.toml index 18947b72fd..7dfd862eb1 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,9 @@ clightningrpc = { version = "0.3.0-beta.8", default-features = false } lnd_grpc_rust = { version = "2.10.0", default-features = false } tokio = { version = "1.37", features = ["fs"] } +[target.'cfg(eclair_test)'.dev-dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + [build-dependencies] uniffi = { version = "0.29.5", features = ["build"], optional = true } @@ -124,6 +127,7 @@ check-cfg = [ "cfg(tokio_unstable)", "cfg(cln_test)", "cfg(lnd_test)", + "cfg(eclair_test)", "cfg(cycle_tests)", ] diff --git a/docker-compose-cln.yml b/docker-compose-cln.yml deleted file mode 100644 index e1fb117e57..0000000000 --- a/docker-compose-cln.yml +++ /dev/null @@ -1,73 +0,0 @@ -services: - bitcoin: - image: blockstream/bitcoind:27.2 - platform: linux/amd64 - command: - [ - "bitcoind", - "-printtoconsole", - "-regtest=1", - "-rpcallowip=0.0.0.0/0", - "-rpcbind=0.0.0.0", - "-rpcuser=user", - "-rpcpassword=pass", - "-fallbackfee=0.00001" - ] - ports: - - "18443:18443" # Regtest RPC port - - "18444:18444" # Regtest P2P port - networks: - - bitcoin-electrs - 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 - depends_on: - bitcoin: - condition: service_healthy - command: - [ - "-vvvv", - "--timestamp", - "--jsonrpc-import", - "--cookie=user:pass", - "--network=regtest", - "--daemon-rpc-addr=bitcoin:18443", - "--http-addr=0.0.0.0:3002", - "--electrum-rpc-addr=0.0.0.0:50001" - ] - ports: - - "3002:3002" - - "50001:50001" - networks: - - bitcoin-electrs - - cln: - image: blockstream/lightningd:v23.08 - platform: linux/amd64 - depends_on: - bitcoin: - condition: service_healthy - command: - [ - "--bitcoin-rpcconnect=bitcoin", - "--bitcoin-rpcport=18443", - "--bitcoin-rpcuser=user", - "--bitcoin-rpcpassword=pass", - "--regtest", - "--experimental-anchors", - ] - ports: - - "19846:19846" - - "9937:9937" - networks: - - bitcoin-electrs - -networks: - bitcoin-electrs: - driver: bridge diff --git a/docker-compose-lnd.yml b/docker-compose-lnd.yml deleted file mode 100755 index 8b44aba2d2..0000000000 --- a/docker-compose-lnd.yml +++ /dev/null @@ -1,86 +0,0 @@ -services: - bitcoin: - image: blockstream/bitcoind:27.2 - platform: linux/amd64 - command: - [ - "bitcoind", - "-printtoconsole", - "-regtest=1", - "-rpcallowip=0.0.0.0/0", - "-rpcbind=0.0.0.0", - "-rpcuser=user", - "-rpcpassword=pass", - "-fallbackfee=0.00001", - "-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: - 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 - depends_on: - bitcoin: - condition: service_healthy - command: - [ - "-vvvv", - "--timestamp", - "--jsonrpc-import", - "--cookie=user:pass", - "--network=regtest", - "--daemon-rpc-addr=bitcoin:18443", - "--http-addr=0.0.0.0:3002", - "--electrum-rpc-addr=0.0.0.0:50001" - ] - ports: - - "3002:3002" - - "50001:50001" - networks: - - bitcoin-electrs - - lnd: - image: lightninglabs/lnd:v0.18.5-beta - container_name: ldk-node-lnd - depends_on: - - bitcoin - volumes: - - ${LND_DATA_DIR}:/root/.lnd - ports: - - "8081:8081" - - "9735:9735" - command: - - "--noseedbackup" - - "--trickledelay=5000" - - "--alias=ldk-node-lnd-test" - - "--externalip=lnd:9735" - - "--bitcoin.active" - - "--bitcoin.regtest" - - "--bitcoin.node=bitcoind" - - "--bitcoind.rpchost=bitcoin:18443" - - "--bitcoind.rpcuser=user" - - "--bitcoind.rpcpass=pass" - - "--bitcoind.zmqpubrawblock=tcp://bitcoin:28332" - - "--bitcoind.zmqpubrawtx=tcp://bitcoin:28333" - - "--accept-keysend" - - "--rpclisten=0.0.0.0:8081" - - "--tlsextradomain=lnd" - - "--tlsextraip=0.0.0.0" - networks: - - bitcoin-electrs - -networks: - bitcoin-electrs: - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index e71fd70fba..80c390a5a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3' - services: bitcoin: image: blockstream/bitcoind:27.2 @@ -14,11 +12,15 @@ services: "-rpcuser=user", "-rpcpassword=pass", "-fallbackfee=0.00001", - "-rest" + "-rest", + "-zmqpubrawblock=tcp://0.0.0.0:28332", + "-zmqpubrawtx=tcp://0.0.0.0:28333" ] ports: - "18443:18443" # Regtest REST and RPC port - "18444:18444" # Regtest P2P port + - "28332:28332" # ZMQ block port + - "28333:28333" # ZMQ tx port networks: - bitcoin-electrs healthcheck: @@ -41,10 +43,81 @@ services: "--cookie=user:pass", "--network=regtest", "--daemon-rpc-addr=bitcoin:18443", - "--http-addr=0.0.0.0:3002" + "--http-addr=0.0.0.0:3002", + "--electrum-rpc-addr=0.0.0.0:50001" ] ports: - "3002:3002" + - "50001:50001" + networks: + - bitcoin-electrs + + cln: + image: blockstream/lightningd:v23.08 + platform: linux/amd64 + profiles: ["cln"] + depends_on: + bitcoin: + condition: service_healthy + command: + [ + "--bitcoin-rpcconnect=bitcoin", + "--bitcoin-rpcport=18443", + "--bitcoin-rpcuser=user", + "--bitcoin-rpcpassword=pass", + "--regtest", + "--experimental-anchors", + ] + ports: + - "19846:19846" + - "9937:9937" + networks: + - bitcoin-electrs + + lnd: + image: lightninglabs/lnd:v0.18.5-beta + container_name: ldk-node-lnd + profiles: ["lnd"] + depends_on: + bitcoin: + condition: service_healthy + volumes: + - ${LND_DATA_DIR:-/tmp/lnd-data}:/root/.lnd + ports: + - "8081:8081" + - "9735:9735" + command: + - "--noseedbackup" + - "--trickledelay=5000" + - "--alias=ldk-node-lnd-test" + - "--externalip=lnd:9735" + - "--bitcoin.active" + - "--bitcoin.regtest" + - "--bitcoin.node=bitcoind" + - "--bitcoind.rpchost=bitcoin:18443" + - "--bitcoind.rpcuser=user" + - "--bitcoind.rpcpass=pass" + - "--bitcoind.zmqpubrawblock=tcp://bitcoin:28332" + - "--bitcoind.zmqpubrawtx=tcp://bitcoin:28333" + - "--accept-keysend" + - "--rpclisten=0.0.0.0:8081" + - "--tlsextradomain=lnd" + - "--tlsextraip=0.0.0.0" + networks: + - bitcoin-electrs + + eclair: + image: acinq/eclair:release-0.8.0 + platform: linux/amd64 + profiles: ["eclair"] + depends_on: + bitcoin: + condition: service_healthy + ports: + - "8080:8080" + - "9736:9736" + environment: + - JAVA_OPTS=-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=bitcoin -Declair.bitcoind.rpcuser=user -Declair.bitcoind.rpcpassword=pass -Declair.bitcoind.rpcport=18443 -Declair.bitcoind.wallet=eclair -Declair.bitcoind.zmqblock=tcp://bitcoin:28332 -Declair.bitcoind.zmqtx=tcp://bitcoin:28333 networks: - bitcoin-electrs diff --git a/tests/common/cln.rs b/tests/common/cln.rs new file mode 100644 index 0000000000..ff2f3fd7db --- /dev/null +++ b/tests/common/cln.rs @@ -0,0 +1,217 @@ +// 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 async_trait::async_trait; +use clightningrpc::lightningrpc::LightningRPC; +use clightningrpc::lightningrpc::PayOptions; +use clightningrpc::requests::AmountOrAll; +use clightningrpc::responses::NetworkAddress; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use serde_json::json; + +use super::external_node::{ChannelState, ExternalChannel, ExternalNode, TestFailure}; + +pub(crate) struct TestClnNode { + client: LightningRPC, +} + +impl TestClnNode { + pub(crate) fn new(socket_path: &str) -> Self { + Self { client: LightningRPC::new(socket_path) } + } + + pub(crate) fn from_env() -> Self { + let sock = + std::env::var("CLN_SOCKET_PATH").unwrap_or_else(|_| "/tmp/lightning-rpc".to_string()); + Self::new(&sock) + } + + /// Wait for CLN to sync to chain tip (blockheight > 0). + pub(crate) async fn wait_for_sync(&self) { + loop { + let info = self.client.getinfo().unwrap(); + if info.blockheight > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + + fn make_error(&self, detail: String) -> TestFailure { + TestFailure::ExternalNodeError { node: "CLN".to_string(), detail } + } +} + +#[async_trait] +impl ExternalNode for TestClnNode { + fn name(&self) -> &str { + "CLN" + } + + async fn get_node_id(&self) -> Result { + let info = self.client.getinfo().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 { + let info = self.client.getinfo().map_err(|e| self.make_error(format!("getinfo: {}", e)))?; + let binding = info + .binding + .first() + .ok_or_else(|| self.make_error("no binding address".to_string()))?; + match binding { + NetworkAddress::Ipv4 { address, port } => { + Ok(std::net::SocketAddrV4::new(*address, *port).into()) + }, + NetworkAddress::Ipv6 { address, port } => { + Ok(std::net::SocketAddrV6::new(*address, *port, 0, 0).into()) + }, + _ => Err(self.make_error("unsupported CLN address type".to_string())), + } + } + + async fn connect_peer( + &self, peer_id: PublicKey, addr: SocketAddress, + ) -> Result<(), TestFailure> { + let uri = format!("{}@{}", peer_id, addr); + let _: serde_json::Value = self + .client + .call("connect", &json!({"id": uri})) + .map_err(|e| self.make_error(format!("connect: {}", e)))?; + Ok(()) + } + + async fn disconnect_peer(&self, peer_id: PublicKey) -> Result<(), TestFailure> { + let _: serde_json::Value = self + .client + .call("disconnect", &json!({"id": peer_id.to_string()})) + .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 { + let result = self + .client + .fundchannel(&peer_id.to_string(), AmountOrAll::Amount(capacity_sat), None) + .map_err(|e| self.make_error(format!("fundchannel: {}", e)))?; + Ok(result.txid) + } + + async fn close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + self.client + .close(channel_id, None, None) + .map_err(|e| self.make_error(format!("close: {}", e)))?; + Ok(()) + } + + async fn force_close_channel(&self, channel_id: &str) -> Result<(), TestFailure> { + // CLN: close with force=true forces a unilateral close + self.client + .close(channel_id, Some(true), None) + .map_err(|e| self.make_error(format!("force close: {}", e)))?; + Ok(()) + } + + async fn create_invoice( + &self, amount_msat: u64, description: &str, + ) -> Result { + let invoice = self + .client + .invoice(Some(amount_msat), description, description, None, None, None) + .map_err(|e| self.make_error(format!("invoice: {}", e)))?; + Ok(invoice.bolt11) + } + + async fn pay_invoice(&self, invoice: &str) -> Result { + let result = self + .client + .pay(invoice, PayOptions::default()) + .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 result: serde_json::Value = self + .client + .call("keysend", &json!({"destination": peer_id.to_string(), "msatoshi": amount_msat})) + .map_err(|e| self.make_error(format!("keysend: {}", e)))?; + Ok(result["payment_preimage"].as_str().unwrap_or("").to_string()) + } + + async fn get_funding_address(&self) -> Result { + let addr = + self.client.newaddr(None).map_err(|e| self.make_error(format!("newaddr: {}", e)))?; + addr.bech32.ok_or_else(|| self.make_error("no bech32 address returned".to_string())) + } + + async fn list_channels(&self) -> Result, TestFailure> { + let peers = self + .client + .listpeers(None, None) + .map_err(|e| self.make_error(format!("listpeers: {}", e)))? + .peers; + let mut channels = Vec::new(); + for peer in peers { + let peer_id = match PublicKey::from_str(&peer.id) { + Ok(pk) => pk, + Err(_) => continue, + }; + for ch in peer.channels { + channels.push(ExternalChannel { + channel_id: ch.short_channel_id.unwrap_or_default(), + peer_id, + capacity_sat: ch.total_msat.0 / 1000, + funding_txid: Some(ch.funding_txid), + is_active: ch.state == "CHANNELD_NORMAL", + }); + } + } + Ok(channels) + } + + async fn wait_for_channel_state( + &self, channel_id: &str, target_state: ChannelState, timeout: Duration, + ) -> Result<(), TestFailure> { + let cln_state = match target_state { + ChannelState::Active => "CHANNELD_NORMAL", + ChannelState::PendingOpen => "CHANNELD_AWAITING_LOCKIN", + ChannelState::PendingClose => "CHANNELD_SHUTTING_DOWN", + ChannelState::Closed => "ONCHAIN", + }; + let channel_id_owned = channel_id.to_string(); + + tokio::time::timeout( + timeout, + super::async_exponential_backoff_poll(|| async { + let peers = self.client.listpeers(None, None).ok()?.peers; + for peer in &peers { + for ch in &peer.channels { + if ch.short_channel_id.as_deref() == Some(&channel_id_owned) + && ch.state == cln_state + { + return Some(()); + } + } + } + None + }), + ) + .await + .map_err(|_| TestFailure::Timeout { + operation: format!("wait for channel {} to reach {:?}", channel_id, target_state), + duration: timeout, + }) + } +} diff --git a/tests/common/eclair.rs b/tests/common/eclair.rs new file mode 100644 index 0000000000..63bc59a597 --- /dev/null +++ b/tests/common/eclair.rs @@ -0,0 +1,252 @@ +// 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 async_trait::async_trait; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use reqwest::Client; +use serde_json::Value; + +use super::external_node::{ChannelState, ExternalChannel, ExternalNode, TestFailure}; + +pub(crate) struct TestEclairNode { + client: Client, + base_url: String, + password: String, + listen_addr: SocketAddress, +} + +impl TestEclairNode { + pub(crate) fn new(base_url: &str, password: &str, listen_addr: SocketAddress) -> Self { + Self { + client: Client::new(), + base_url: base_url.to_string(), + password: password.to_string(), + 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 response = self + .client + .post(&url) + .basic_auth("", Some(&self.password)) + .form(params) + .send() + .await + .map_err(|e| self.make_error(format!("request to {} failed: {}", endpoint, e)))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| self.make_error(format!("reading response from {}: {}", endpoint, e)))?; + + if !status.is_success() { + return Err(self.make_error(format!("{} returned {}: {}", endpoint, status, body))); + } + + serde_json::from_str(&body).map_err(|e| { + self.make_error(format!("parsing response from {}: {} (body: {})", endpoint, e, body)) + }) + } + + fn make_error(&self, detail: String) -> TestFailure { + TestFailure::ExternalNodeError { node: "Eclair".to_string(), detail } + } +} + +#[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".to_string()))?; + 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 mut params = + vec![("nodeId", peer_id.to_string()), ("fundingSatoshis", capacity_sat.to_string())]; + if let Some(push) = push_msat { + params.push(("pushMsat", push.to_string())); + } + let params_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let result = self.post("/open", ¶ms_refs).await?; + let channel_id = result + .as_str() + .ok_or_else(|| self.make_error("open did not return channel id string".to_string()))?; + Ok(channel_id.to_string()) + } + + 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".to_string()))?; + Ok(invoice.to_string()) + } + + async fn pay_invoice(&self, invoice: &str) -> Result { + let result = self.post("/payinvoice", &[("invoice", invoice)]).await?; + // Eclair returns the payment id + let payment_id = result.as_str().unwrap_or("").to_string(); + Ok(payment_id) + } + + 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().unwrap_or("").to_string(); + Ok(payment_id) + } + + async fn get_funding_address(&self) -> Result { + let result = self.post("/getnewaddress", &[]).await?; + result + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| self.make_error("getnewaddress did not return string".to_string())) + } + + 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".to_string()))?; + + let mut channels = Vec::new(); + for ch in channels_arr { + let channel_id = ch["channelId"].as_str().unwrap_or_default().to_string(); + let node_id_str = ch["nodeId"].as_str().unwrap_or_default(); + let peer_id = match PublicKey::from_str(node_id_str) { + Ok(pk) => pk, + Err(_) => continue, + }; + let state_str = ch["state"].as_str().unwrap_or(""); + let capacity_sat = ch["data"]["commitments"]["active"] + .as_array() + .and_then(|a| a.first()) + .and_then(|c| c["fundingTx"]["amountSatoshis"].as_u64()) + .unwrap_or(0); + let funding_txid = ch["data"]["commitments"]["active"] + .as_array() + .and_then(|a| a.first()) + .and_then(|c| c["fundingTx"]["txid"].as_str()) + .map(|s| s.to_string()); + + channels.push(ExternalChannel { + channel_id, + peer_id, + capacity_sat, + funding_txid, + is_active: state_str == "NORMAL", + }); + } + Ok(channels) + } + + async fn wait_for_channel_state( + &self, channel_id: &str, target_state: ChannelState, timeout: Duration, + ) -> Result<(), TestFailure> { + let eclair_state = match target_state { + ChannelState::Active => "NORMAL", + ChannelState::PendingOpen => "WAIT_FOR_FUNDING_CONFIRMED", + ChannelState::PendingClose => "CLOSING", + ChannelState::Closed => "CLOSED", + }; + let channel_id_owned = channel_id.to_string(); + + tokio::time::timeout( + timeout, + super::async_exponential_backoff_poll(|| async { + let result = self.post("/channels", &[]).await.ok()?; + let channels_arr = result.as_array()?; + if eclair_state == "CLOSED" { + channels_arr + .iter() + .all(|ch| ch["channelId"].as_str() != Some(&channel_id_owned)) + .then_some(()) + } else { + channels_arr + .iter() + .find(|ch| { + ch["channelId"].as_str() == Some(&channel_id_owned) + && ch["state"].as_str() == Some(eclair_state) + }) + .map(|_| ()) + } + }), + ) + .await + .map_err(|_| TestFailure::Timeout { + operation: format!("wait for channel {} to reach {:?}", channel_id, target_state), + duration: timeout, + }) + } +} diff --git a/tests/common/external_node.rs b/tests/common/external_node.rs new file mode 100644 index 0000000000..a8816db1fd --- /dev/null +++ b/tests/common/external_node.rs @@ -0,0 +1,118 @@ +// 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 std::time::Duration; + +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 { + pub channel_id: String, + pub peer_id: PublicKey, + pub capacity_sat: u64, + pub funding_txid: Option, + pub is_active: bool, +} + +/// The lifecycle state of a channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ChannelState { + PendingOpen, + Active, + PendingClose, + Closed, +} + +/// Errors that can occur during interop test operations. +#[derive(Debug)] +pub(crate) enum TestFailure { + Timeout { operation: String, duration: Duration }, + UnexpectedState { expected: String, actual: String }, + ExternalNodeError { node: String, detail: String }, +} + +impl fmt::Display for TestFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TestFailure::Timeout { operation, duration } => { + write!(f, "Timeout waiting for '{}' after {:?}", operation, duration) + }, + TestFailure::UnexpectedState { expected, actual } => { + write!(f, "Unexpected state: expected '{}', got '{}'", expected, actual) + }, + TestFailure::ExternalNodeError { node, detail } => { + write!(f, "External node '{}' error: {}", node, detail) + }, + } + } +} + +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 `UserChannelId`-style 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 a payment identifier on success + /// (preimage for LND/CLN, payment UUID for Eclair). + 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; + + /// List all channels known to this node. + async fn list_channels(&self) -> Result, TestFailure>; + + /// Block until a specific channel reaches `target_state` or the timeout elapses. + async fn wait_for_channel_state( + &self, channel_id: &str, target_state: ChannelState, timeout: Duration, + ) -> Result<(), TestFailure>; +} diff --git a/tests/common/lnd.rs b/tests/common/lnd.rs new file mode 100644 index 0000000000..deae78d4a8 --- /dev/null +++ b/tests/common/lnd.rs @@ -0,0 +1,328 @@ +// 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 async_trait::async_trait; +use bitcoin::hashes::Hash; +use bitcoin::hex::DisplayHex; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use lnd_grpc_rust::lnrpc::{ + CloseChannelRequest as LndCloseChannelRequest, ConnectPeerRequest as LndConnectPeerRequest, + DisconnectPeerRequest as LndDisconnectPeerRequest, GetInfoRequest as LndGetInfoRequest, + Invoice as LndInvoice, LightningAddress as LndLightningAddress, + ListChannelsRequest as LndListChannelsRequest, OpenChannelRequest as LndOpenChannelRequest, + SendRequest as LndSendRequest, +}; +use lnd_grpc_rust::{connect, LndClient}; +use tokio::fs; +use tokio::sync::Mutex; + +use super::external_node::{ChannelState, 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 = "127.0.0.1:9735".parse().unwrap(); + Self::new(cert_path, macaroon_path, endpoint, listen_addr).await + } + + fn make_error(&self, detail: String) -> TestFailure { + TestFailure::ExternalNodeError { node: "LND".to_string(), detail } + } +} + +#[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 request = LndOpenChannelRequest { + node_pubkey: peer_id.serialize().to_vec(), + local_funding_amount: capacity_sat as i64, + push_sat: push_msat.map(|m| (m / 1000) as i64).unwrap_or(0), + ..Default::default() + }; + let response = client + .lightning() + .open_channel_sync(request) + .await + .map_err(|e| self.make_error(format!("open_channel: {}", e)))? + .into_inner(); + // Construct channel point string from response + 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)) => { + s.into_bytes() + }, + None => return Err(self.make_error("No funding txid in response".to_string())), + }; + // LND returns txid bytes in reversed order + let mut txid_arr = [0u8; 32]; + txid_arr.copy_from_slice(&txid_bytes); + 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, + }), + ..Default::default() + }; + client + .lightning() + .close_channel(request) + .await + .map_err(|e| self.make_error(format!("close_channel: {}", e)))?; + 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() + }; + client + .lightning() + .close_channel(request) + .await + .map_err(|e| self.make_error(format!("force_close_channel: {}", e)))?; + Ok(()) + } + + async fn create_invoice( + &self, amount_msat: u64, _description: &str, + ) -> Result { + let mut client = self.client.lock().await; + let invoice = LndInvoice { value_msat: amount_msat as i64, ..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 send_req = + LndSendRequest { payment_request: invoice.to_string(), ..Default::default() }; + let response = client + .lightning() + .send_payment_sync(send_req) + .await + .map_err(|e| self.make_error(format!("pay_invoice: {}", e)))? + .into_inner(); + if !response.payment_error.is_empty() { + return Err(self.make_error(format!("payment failed: {}", response.payment_error))); + } + if response.payment_preimage.is_empty() { + return Err(self.make_error("No preimage returned".to_string())); + } + Ok(response.payment_preimage.as_hex().to_string()) + } + + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result { + let mut client = self.client.lock().await; + let send_req = LndSendRequest { + dest: peer_id.serialize().to_vec(), + amt_msat: amount_msat as i64, + ..Default::default() + }; + let response = client + .lightning() + .send_payment_sync(send_req) + .await + .map_err(|e| self.make_error(format!("send_keysend: {}", e)))? + .into_inner(); + if !response.payment_error.is_empty() { + return Err(self.make_error(format!("keysend failed: {}", response.payment_error))); + } + Ok(response.payment_preimage.as_hex().to_string()) + } + + 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 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() + .filter_map(|ch| { + let peer_id = match PublicKey::from_str(&ch.remote_pubkey) { + Ok(pk) => pk, + Err(_) => return None, + }; + Some(ExternalChannel { + channel_id: ch.channel_point.clone(), + peer_id, + capacity_sat: ch.capacity as u64, + funding_txid: ch.channel_point.split(':').next().map(|s| s.to_string()), + is_active: ch.active, + }) + }) + .collect(); + Ok(channels) + } + + async fn wait_for_channel_state( + &self, channel_id: &str, target_state: ChannelState, timeout: Duration, + ) -> Result<(), TestFailure> { + let channel_id_owned = channel_id.to_string(); + tokio::time::timeout( + timeout, + super::async_exponential_backoff_poll(|| async { + let channels = self.list_channels().await.ok()?; + let found = match target_state { + ChannelState::Active => { + channels.iter().any(|ch| ch.channel_id == channel_id_owned && ch.is_active) + }, + ChannelState::Closed => { + channels.iter().all(|ch| ch.channel_id != channel_id_owned) + }, + ChannelState::PendingOpen | ChannelState::PendingClose => { + channels.iter().any(|ch| ch.channel_id == channel_id_owned && !ch.is_active) + }, + }; + found.then_some(()) + }), + ) + .await + .map_err(|_| TestFailure::Timeout { + operation: format!("wait for channel {} to reach {:?}", channel_id, target_state), + duration: timeout, + }) + } +} + +/// 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 parts: Vec<&str> = channel_point.split(':').collect(); + if parts.len() != 2 { + return Err(TestFailure::ExternalNodeError { + node: "LND".to_string(), + detail: format!("Invalid channel point format: {}", channel_point), + }); + } + let txid = bitcoin::Txid::from_str(parts[0]).map_err(|e| TestFailure::ExternalNodeError { + node: "LND".to_string(), + detail: format!("Invalid txid in channel point: {}", e), + })?; + let output_index: u32 = parts[1].parse().map_err(|e| TestFailure::ExternalNodeError { + node: "LND".to_string(), + detail: format!("Invalid output index in channel point: {}", e), + })?; + Ok((txid.as_byte_array().to_vec(), output_index)) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7854a77f23..94df9724da 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, vss_test, eclair_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; @@ -382,6 +390,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, @@ -606,6 +617,27 @@ where } } +pub(crate) async fn async_exponential_backoff_poll(mut poll: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut delay = Duration::from_millis(64); + let mut tries = 0; + loop { + match poll().await { + Some(data) => break data, + None if delay.as_millis() < 512 => { + delay = delay.mul_f32(2.0); + }, + None => {}, + } + assert!(tries < 20, "Reached max tries."); + tries += 1; + tokio::time::sleep(delay).await; + } +} + pub(crate) async fn premine_and_distribute_funds( bitcoind: &BitcoindClient, electrs: &E, addrs: Vec
, amount: Amount, ) { diff --git a/tests/common/scenarios.rs b/tests/common/scenarios.rs new file mode 100644 index 0000000000..b7d929e7c9 --- /dev/null +++ b/tests/common/scenarios.rs @@ -0,0 +1,351 @@ +// 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`. +//! +//! Each scenario function takes an LDK `Node`, an external node implementation, +//! and the regtest infrastructure clients. Test entry-point files +//! (`integration_tests_{cln,lnd,eclair}.rs`) call these functions with their +//! concrete `ExternalNode` implementation. + +use std::str::FromStr; +use std::time::Duration; + +use bitcoin::Amount; +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; +use lightning_invoice::Bolt11Invoice; + +use super::external_node::ExternalNode; +use super::{generate_blocks_and_wait, premine_and_distribute_funds}; + +// --------------------------------------------------------------------------- +// Proptest parameter types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(crate) enum Phase { + ChannelOpen, + Payment, + Close, + Idle, +} + +#[derive(Debug, Clone)] +pub(crate) enum Side { + Ldk, + External, +} + +#[derive(Debug, Clone)] +pub(crate) enum CloseType { + Cooperative, + Force, +} + +#[derive(Debug, Clone)] +pub(crate) enum PayType { + Bolt11, + Keysend, +} + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +/// 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, +) { + // Fund LDK node + let ldk_address = node.onchain_payment().new_address().unwrap(); + let premine_amount = Amount::from_sat(5_000_000); + premine_and_distribute_funds(bitcoind, electrs, vec![ldk_address], premine_amount).await; + + // Fund external node using the already-loaded wallet + let ext_funding_addr_str = peer.get_funding_address().await.unwrap(); + let ext_amount = Amount::from_sat(5_000_000); + let amounts_json = serde_json::json!({&ext_funding_addr_str: ext_amount.to_btc()}); + let empty_account = serde_json::json!(""); + // Use the ldk_node_test wallet that premine_and_distribute_funds already loaded + 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; + + node.sync_wallets().unwrap(); + + // Connect LDK to external node + 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(); +} + +/// Open a channel from LDK to external node, wait for it to be confirmed. +/// 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::wait_for_tx(electrs, funding_txo.txid).await; + generate_blocks_and_wait(bitcoind, electrs, 6).await; + node.sync_wallets().unwrap(); + let user_channel_id = expect_channel_ready_event!(node, ext_node_id); + + // Find the external node's channel ID for this channel + 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)) + .or_else(|| ext_channels.iter().find(|ch| ch.peer_id == node.node_id())) + .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) +} + +// --------------------------------------------------------------------------- +// Disconnect/Reconnect scenarios +// --------------------------------------------------------------------------- + +/// Disconnect during idle, reconnect, verify channel still works. +pub(crate) async fn test_disconnect_reconnect_idle( + node: &Node, peer: &(impl ExternalNode + ?Sized), _bitcoind: &BitcoindClient, _electrs: &E, + disconnect_side: &Side, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + let ext_addr = peer.get_listening_address().await.unwrap(); + + match disconnect_side { + Side::Ldk => { + node.disconnect(ext_node_id).unwrap(); + }, + Side::External => { + peer.disconnect_peer(node.node_id()).await.unwrap(); + }, + } + + tokio::time::sleep(Duration::from_secs(1)).await; + + // Reconnect + node.connect(ext_node_id, ext_addr, true).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + + // Verify channel still works with a payment + let invoice_str = peer.create_invoice(10_000_000, "disconnect-idle-test").await.unwrap(); + let parsed_invoice = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed_invoice, None).unwrap(); + expect_event!(node, PaymentSuccessful); +} + +/// Disconnect during payment, reconnect, verify payment resolves. +pub(crate) async fn test_disconnect_during_payment( + node: &Node, peer: &(impl ExternalNode + ?Sized), _bitcoind: &BitcoindClient, _electrs: &E, + 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(); + + // Send payment (may or may not complete before disconnect) + let _ = node.bolt11_payment().send(&parsed_invoice, None); + + // Disconnect immediately + match disconnect_side { + Side::Ldk => { + let _ = node.disconnect(ext_node_id); + }, + Side::External => { + let _ = peer.disconnect_peer(node.node_id()).await; + }, + } + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Reconnect + node.connect(ext_node_id, ext_addr, true).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + + // Payment should eventually resolve + let event = node.next_event_async().await; + match event { + ldk_node::Event::PaymentSuccessful { .. } | ldk_node::Event::PaymentFailed { .. } => { + node.event_handled().unwrap(); + }, + other => { + panic!("Expected payment outcome event, got: {:?}", other); + }, + } +} + +// --------------------------------------------------------------------------- +// Channel Closing scenarios +// --------------------------------------------------------------------------- + +/// Cooperative close initiated by LDK. +pub(crate) async fn test_cooperative_close_by_ldk( + node: &Node, peer: &(impl ExternalNode + ?Sized), user_channel_id: &ldk_node::UserChannelId, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + node.close_channel(user_channel_id, ext_node_id).unwrap(); + expect_event!(node, ChannelClosed); +} + +/// Cooperative close initiated by external node. +pub(crate) async fn test_cooperative_close_by_external( + node: &Node, peer: &(impl ExternalNode + ?Sized), ext_channel_id: &str, +) { + peer.close_channel(ext_channel_id).await.unwrap(); + expect_event!(node, ChannelClosed); +} + +/// Force close by LDK. +pub(crate) async fn test_force_close_by_ldk( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + user_channel_id: &ldk_node::UserChannelId, +) { + 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; + node.sync_wallets().unwrap(); +} + +/// Force close by external node. +pub(crate) async fn test_force_close_by_external( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + ext_channel_id: &str, +) { + peer.force_close_channel(ext_channel_id).await.unwrap(); + generate_blocks_and_wait(bitcoind, electrs, 6).await; + node.sync_wallets().unwrap(); + expect_event!(node, ChannelClosed); +} + +/// Force close with pending HTLCs. +pub(crate) async fn test_force_close_with_pending_htlcs( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + user_channel_id: &ldk_node::UserChannelId, ext_channel_id: &str, close_side: &Side, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + + // Send a payment that will be in-flight + let invoice_str = peer.create_invoice(10_000_000, "pending-htlc-test").await.unwrap(); + let parsed_invoice = Bolt11Invoice::from_str(&invoice_str).unwrap(); + let _ = node.bolt11_payment().send(&parsed_invoice, None); + + // Force close immediately while payment is in-flight + match close_side { + Side::Ldk => { + node.force_close_channel(user_channel_id, ext_node_id, None).unwrap(); + }, + Side::External => { + peer.force_close_channel(ext_channel_id).await.unwrap(); + }, + } + + // Mine enough blocks for HTLC timeout and sweep + generate_blocks_and_wait(bitcoind, electrs, 150).await; + node.sync_wallets().unwrap(); + + // Drain events — expect ChannelClosed and possibly PaymentFailed + loop { + match tokio::time::timeout(Duration::from_secs(30), node.next_event_async()).await { + Ok(event) => match event { + ldk_node::Event::ChannelClosed { .. } + | ldk_node::Event::PaymentFailed { .. } + | ldk_node::Event::PaymentSuccessful { .. } => { + node.event_handled().unwrap(); + }, + _ => { + node.event_handled().unwrap(); + break; + }, + }, + Err(_) => break, // No more events within timeout + } + } +} + +// --------------------------------------------------------------------------- +// Combined proptest scenario +// --------------------------------------------------------------------------- + +/// Run a combined interop scenario with randomized parameters. +pub(crate) async fn run_interop_property_test( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + disconnect_phase: Phase, disconnect_initiator: Side, close_type: CloseType, + close_initiator: Side, payment_type: PayType, +) { + // Setup: fund + connect + setup_interop_test(node, peer, bitcoind, electrs).await; + + // Open channel + let (user_channel_id, ext_channel_id) = + open_channel_to_external(node, peer, bitcoind, electrs, 1_000_000, Some(500_000_000)).await; + + // Phase 1: Disconnect/Reconnect at the specified phase + match disconnect_phase { + Phase::Idle => { + test_disconnect_reconnect_idle(node, peer, bitcoind, electrs, &disconnect_initiator) + .await; + }, + Phase::Payment => { + test_disconnect_during_payment(node, peer, bitcoind, electrs, &disconnect_initiator) + .await; + }, + Phase::ChannelOpen | Phase::Close => { + // Simplified: do idle disconnect for these phases + test_disconnect_reconnect_idle(node, peer, bitcoind, electrs, &disconnect_initiator) + .await; + }, + } + + // Phase 2: Make a payment + let ext_node_id = peer.get_node_id().await.unwrap(); + match payment_type { + PayType::Bolt11 => { + let invoice_str = peer.create_invoice(5_000_000, "proptest-bolt11").await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); + }, + PayType::Keysend => { + node.spontaneous_payment().send(5_000_000, ext_node_id, None).unwrap(); + expect_event!(node, PaymentSuccessful); + }, + } + + // Phase 3: Close channel + match (&close_type, &close_initiator) { + (CloseType::Cooperative, Side::Ldk) => { + test_cooperative_close_by_ldk(node, peer, &user_channel_id).await; + }, + (CloseType::Cooperative, Side::External) => { + test_cooperative_close_by_external(node, peer, &ext_channel_id).await; + }, + (CloseType::Force, Side::Ldk) => { + test_force_close_by_ldk(node, peer, bitcoind, electrs, &user_channel_id).await; + }, + (CloseType::Force, Side::External) => { + test_force_close_by_external(node, peer, bitcoind, electrs, &ext_channel_id).await; + }, + } +} diff --git a/tests/integration_tests_cln.rs b/tests/integration_tests_cln.rs index 0245f1fdf6..a33416e6f3 100644 --- a/tests/integration_tests_cln.rs +++ b/tests/integration_tests_cln.rs @@ -9,119 +9,157 @@ mod common; -use std::default::Default; use std::str::FromStr; -use clightningrpc::lightningrpc::LightningRPC; -use clightningrpc::responses::NetworkAddress; 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}; +use proptest::prelude::*; +use proptest::proptest; -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_cln() { - // Setup bitcoind / electrs clients - let bitcoind_client = BitcoindClient::new_with_auth( +use common::cln::TestClnNode; +use common::external_node::ExternalNode; +use common::scenarios::*; + +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; + let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); + let cln = TestClnNode::from_env(); + (bitcoind, electrs, cln) +} - // Setup LDK Node +fn setup_ldk_node() -> ldk_node::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(); + node +} - // 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) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_basic_channel_cycle() { + let (bitcoind, electrs, cln) = setup_clients(); + cln.wait_for_sync().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &cln, &bitcoind, &electrs).await; + + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(&node, &cln, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + // LDK -> CLN payment + let invoice = cln.create_invoice(10_000_000, "cln-test-send").await.unwrap(); + let parsed = lightning_invoice::Bolt11Invoice::from_str(&invoice).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + common::expect_event!(node, PaymentSuccessful); + + // CLN -> LDK payment + let ldk_invoice = node + .bolt11_payment() + .receive( + 10_000_000, + &lightning_invoice::Bolt11InvoiceDescription::Direct( + lightning_invoice::Description::new("cln-test-recv".to_string()).unwrap(), + ), + 3600, + ) .unwrap(); + cln.pay_invoice(&ldk_invoice.to_string()).await.unwrap(); + common::expect_event!(node, PaymentReceived); - 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); + test_cooperative_close_by_ldk(&node, &cln, &user_channel_id).await; + node.stop().unwrap(); +} - // 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)] +async fn test_cln_disconnect_reconnect() { + let (bitcoind, electrs, cln) = setup_clients(); + cln.wait_for_sync().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).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"); - - // 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); + let node = setup_ldk_node(); + setup_interop_test(&node, &cln, &bitcoind, &electrs).await; + let (_user_ch, _ext_ch) = + open_channel_to_external(&node, &cln, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; - node.close_channel(&user_channel_id, cln_node_id).unwrap(); - common::expect_event!(node, ChannelClosed); + test_disconnect_reconnect_idle(&node, &cln, &bitcoind, &electrs, &Side::Ldk).await; + test_disconnect_reconnect_idle(&node, &cln, &bitcoind, &electrs, &Side::External).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_force_close_by_ldk() { + let (bitcoind, electrs, cln) = setup_clients(); + cln.wait_for_sync().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &cln, &bitcoind, &electrs).await; + let (user_ch, _ext_ch) = + open_channel_to_external(&node, &cln, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + test_force_close_by_ldk(&node, &cln, &bitcoind, &electrs, &user_ch).await; node.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_force_close_by_external() { + let (bitcoind, electrs, cln) = setup_clients(); + cln.wait_for_sync().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &cln, &bitcoind, &electrs).await; + let (_user_ch, ext_ch) = + open_channel_to_external(&node, &cln, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + test_force_close_by_external(&node, &cln, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} + +proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(8))] + #[test] + fn test_cln_interop_proptest( + disconnect_phase in prop_oneof![ + Just(Phase::ChannelOpen), + Just(Phase::Payment), + Just(Phase::Close), + Just(Phase::Idle), + ], + disconnect_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + close_type in prop_oneof![Just(CloseType::Cooperative), Just(CloseType::Force)], + close_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + payment_type in prop_oneof![Just(PayType::Bolt11), Just(PayType::Keysend)], + ) { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (bitcoind, electrs, cln) = setup_clients(); + cln.wait_for_sync().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + run_interop_property_test( + &node, &cln, &bitcoind, &electrs, + disconnect_phase, disconnect_initiator, close_type, + close_initiator, payment_type, + ).await; + node.stop().unwrap(); + }); + } +} diff --git a/tests/integration_tests_eclair.rs b/tests/integration_tests_eclair.rs new file mode 100644 index 0000000000..c8a9830952 --- /dev/null +++ b/tests/integration_tests_eclair.rs @@ -0,0 +1,166 @@ +// 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 std::str::FromStr; + +use electrsd::corepc_client::client_sync::Auth; +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::Client as ElectrumClient; +use ldk_node::{Builder, Event}; +use proptest::prelude::*; +use proptest::proptest; + +use common::eclair::TestEclairNode; +use common::external_node::ExternalNode; +use common::scenarios::*; + +fn setup_clients() -> (BitcoindClient, ElectrumClient, TestEclairNode) { + // Use wallet-specific RPC URL to avoid multi-wallet conflicts. + // Eclair loads its own "eclair" wallet on bitcoind, and our tests + // create "ldk_node_test". With two wallets loaded, plain RPC calls + // fail with "Wallet file not specified". Using the wallet URL + // ensures our calls go to the right wallet. + 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(); + + let eclair = TestEclairNode::from_env(); + (bitcoind, electrs, eclair) +} + +fn setup_ldk_node() -> ldk_node::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(); + node +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_basic_channel_cycle() { + let (bitcoind, electrs, eclair) = setup_clients(); + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &eclair, &bitcoind, &electrs).await; + + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(&node, &eclair, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + // LDK -> Eclair payment + let invoice = eclair.create_invoice(10_000_000, "eclair-test-send").await.unwrap(); + let parsed = lightning_invoice::Bolt11Invoice::from_str(&invoice).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + common::expect_event!(node, PaymentSuccessful); + + // Eclair -> LDK payment + let ldk_invoice = node + .bolt11_payment() + .receive( + 10_000_000, + &lightning_invoice::Bolt11InvoiceDescription::Direct( + lightning_invoice::Description::new("eclair-test-recv".to_string()).unwrap(), + ), + 3600, + ) + .unwrap(); + eclair.pay_invoice(&ldk_invoice.to_string()).await.unwrap(); + common::expect_event!(node, PaymentReceived); + + test_cooperative_close_by_ldk(&node, &eclair, &user_channel_id).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_disconnect_reconnect() { + let (bitcoind, electrs, eclair) = setup_clients(); + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &eclair, &bitcoind, &electrs).await; + let (_user_ch, _ext_ch) = + open_channel_to_external(&node, &eclair, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + test_disconnect_reconnect_idle(&node, &eclair, &bitcoind, &electrs, &Side::Ldk).await; + test_disconnect_reconnect_idle(&node, &eclair, &bitcoind, &electrs, &Side::External).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_force_close_by_ldk() { + let (bitcoind, electrs, eclair) = setup_clients(); + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &eclair, &bitcoind, &electrs).await; + let (user_ch, _ext_ch) = + open_channel_to_external(&node, &eclair, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + test_force_close_by_ldk(&node, &eclair, &bitcoind, &electrs, &user_ch).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_force_close_by_external() { + let (bitcoind, electrs, eclair) = setup_clients(); + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + setup_interop_test(&node, &eclair, &bitcoind, &electrs).await; + let (_user_ch, ext_ch) = + open_channel_to_external(&node, &eclair, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + test_force_close_by_external(&node, &eclair, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} + +proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(8))] + #[test] + fn test_eclair_interop_proptest( + disconnect_phase in prop_oneof![ + Just(Phase::ChannelOpen), + Just(Phase::Payment), + Just(Phase::Close), + Just(Phase::Idle), + ], + disconnect_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + close_type in prop_oneof![Just(CloseType::Cooperative), Just(CloseType::Force)], + close_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + payment_type in prop_oneof![Just(PayType::Bolt11), Just(PayType::Keysend)], + ) { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (bitcoind, electrs, eclair) = setup_clients(); + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + run_interop_property_test( + &node, &eclair, &bitcoind, &electrs, + disconnect_phase, disconnect_initiator, close_type, + close_initiator, payment_type, + ).await; + node.stop().unwrap(); + }); + } +} diff --git a/tests/integration_tests_lnd.rs b/tests/integration_tests_lnd.rs index 8f1d4c8687..ac95eca02c 100755 --- a/tests/integration_tests_lnd.rs +++ b/tests/integration_tests_lnd.rs @@ -1,224 +1,161 @@ +// 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 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; +use proptest::prelude::*; +use proptest::proptest; -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_lnd() { - // Setup bitcoind / electrs clients - let bitcoind_client = BitcoindClient::new_with_auth( +use common::external_node::ExternalNode; +use common::lnd::TestLndNode; +use common::scenarios::*; + +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; + let electrs = ElectrumClient::new("tcp://127.0.0.1:50001").unwrap(); + let lnd = TestLndNode::from_env().await; + (bitcoind, electrs, lnd) +} - // Setup LDK Node +fn setup_ldk_node() -> ldk_node::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(); + node +} - // 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(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_basic_channel_cycle() { + let (bitcoind, electrs, lnd) = setup_clients().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; - 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); + let node = setup_ldk_node(); + setup_interop_test(&node, &lnd, &bitcoind, &electrs).await; - // 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(); + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(&node, &lnd, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; - node.bolt11_payment().send(&parsed_invoice, None).unwrap(); + // LDK -> LND payment + let invoice = lnd.create_invoice(100_000_000, "lnd-test-send").await.unwrap(); + let parsed = lightning_invoice::Bolt11Invoice::from_str(&invoice).unwrap(); + node.bolt11_payment().send(&parsed, 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; + // LND -> LDK payment (wait for routing sync) + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let ldk_invoice = node + .bolt11_payment() + .receive( + 9_000_000, + &lightning_invoice::Bolt11InvoiceDescription::Direct( + lightning_invoice::Description::new("lnd-test-recv".to_string()).unwrap(), + ), + 3600, + ) + .unwrap(); + lnd.pay_invoice(&ldk_invoice.to_string()).await.unwrap(); common::expect_event!(node, PaymentReceived); - node.close_channel(&user_channel_id, lnd_node_id).unwrap(); - common::expect_event!(node, ChannelClosed); + test_cooperative_close_by_ldk(&node, &lnd, &user_channel_id).await; node.stop().unwrap(); } -struct TestLndClient { - client: LndClient, -} - -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"); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_disconnect_reconnect() { + let (bitcoind, electrs, lnd) = setup_clients().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; - // Convert the bytes to a hex string - let cert = cert_bytes.as_hex().to_string(); - let macaroon = mac_bytes.as_hex().to_string(); + let node = setup_ldk_node(); + setup_interop_test(&node, &lnd, &bitcoind, &electrs).await; + let (_user_ch, _ext_ch) = + open_channel_to_external(&node, &lnd, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; - let client = connect(cert, macaroon, socket).await.expect("Failed to connect to Lnd"); + test_disconnect_reconnect_idle(&node, &lnd, &bitcoind, &electrs, &Side::Ldk).await; + test_disconnect_reconnect_idle(&node, &lnd, &bitcoind, &electrs, &Side::External).await; - TestLndClient { client } - } + node.stop().unwrap(); +} - 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(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_force_close_by_ldk() { + let (bitcoind, electrs, lnd) = setup_clients().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; - response - } + let node = setup_ldk_node(); + setup_interop_test(&node, &lnd, &bitcoind, &electrs).await; + let (user_ch, _ext_ch) = + open_channel_to_external(&node, &lnd, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; - async fn create_invoice(&mut self, amount_msat: u64) -> String { - let invoice = LndInvoice { value_msat: amount_msat as i64, ..Default::default() }; + test_force_close_by_ldk(&node, &lnd, &bitcoind, &electrs, &user_ch).await; + node.stop().unwrap(); +} - self.client - .lightning() - .add_invoice(invoice) - .await - .expect("Failed to create invoice on LND") - .into_inner() - .payment_request - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_force_close_by_external() { + let (bitcoind, electrs, lnd) = setup_clients().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; - 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 - } + let node = setup_ldk_node(); + setup_interop_test(&node, &lnd, &bitcoind, &electrs).await; + let (_user_ch, ext_ch) = + open_channel_to_external(&node, &lnd, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; - 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)); - } - - Ok(response.routes) - } + test_force_close_by_external(&node, &lnd, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} - 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(); - - 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 - } - ); - } +proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(8))] + #[test] + fn test_lnd_interop_proptest( + disconnect_phase in prop_oneof![ + Just(Phase::ChannelOpen), + Just(Phase::Payment), + Just(Phase::Close), + Just(Phase::Idle), + ], + disconnect_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + close_type in prop_oneof![Just(CloseType::Cooperative), Just(CloseType::Force)], + close_initiator in prop_oneof![Just(Side::Ldk), Just(Side::External)], + payment_type in prop_oneof![Just(PayType::Bolt11), Just(PayType::Keysend)], + ) { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (bitcoind, electrs, lnd) = setup_clients().await; + common::generate_blocks_and_wait(&bitcoind, &electrs, 1).await; + + let node = setup_ldk_node(); + run_interop_property_test( + &node, &lnd, &bitcoind, &electrs, + disconnect_phase, disconnect_initiator, close_type, + close_initiator, payment_type, + ).await; + node.stop().unwrap(); + }); } }