diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ef049ad85..f5cf79033 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -23,7 +23,7 @@ jobs: uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - key: bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs uses: actions/cache@v4 @@ -34,7 +34,7 @@ jobs: if: "(steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" run: | source ./scripts/download_bitcoind_electrs.sh - mkdir bin + mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - name: Set bitcoind/electrs environment variables diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index 32e7b74c0..ebcfc76a7 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -13,18 +13,64 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install dependencies + - name: Create temporary directory for CLN data + run: echo "CLN_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV + + - name: Start bitcoind and electrs + run: docker compose -f docker-compose-cln.yml up -d bitcoin electrs + env: + CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }} + + - name: Wait for bitcoind to be healthy + run: | + for i in $(seq 1 30); do + if docker compose -f docker-compose-cln.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: Mine initial block for CLN run: | - sudo apt-get update -y - sudo apt-get install -y socat + docker compose -f docker-compose-cln.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet miner + ADDR=$(docker compose -f docker-compose-cln.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=miner getnewaddress) + docker compose -f docker-compose-cln.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass generatetoaddress 1 "$ADDR" + docker compose -f docker-compose-cln.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass unloadwallet miner + + - name: Start lightningd + run: docker compose -f docker-compose-cln.yml up -d cln + env: + CLN_DATA_DIR: ${{ env.CLN_DATA_DIR }} - - name: Start bitcoind, electrs, and lightningd - run: docker compose -f docker-compose-cln.yml up -d + - name: Wait for CLN to be ready + run: | + for i in $(seq 1 30); do + if docker compose -f docker-compose-cln.yml exec cln test -S /root/.lightning/regtest/lightning-rpc 2>/dev/null; then + echo "CLN RPC socket found" + break + fi + echo "Waiting for CLN RPC socket... ($i/30)" + sleep 2 + done + docker compose -f docker-compose-cln.yml exec cln test -S /root/.lightning/regtest/lightning-rpc || { + echo "ERROR: CLN RPC socket not found after 60 seconds" + docker compose -f 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 -R 755 $CLN_DATA_DIR + 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..55775462a --- /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 -f 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 -f 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 -f docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet eclair + docker compose -f docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=eclair getnewaddress + docker compose -f docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test + + - name: Start Eclair + run: docker compose -f docker-compose-eclair.yml up -d eclair + + - name: Wait for Eclair to be ready + run: | + for i in $(seq 1 60); do + if curl -s -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 -f 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/lnd-integration.yml b/.github/workflows/lnd-integration.yml index f913e92ad..ef3ffbb70 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -14,9 +14,6 @@ jobs: uses: actions/checkout@v4 - name: Check and install CMake if needed - # lnd_grpc_rust (via prost-build v0.10.4) requires CMake >= 3.5 but is incompatible with CMake >= 4.0. - # This step checks if CMake is missing, below 3.5, or 4.0 or higher, and installs CMake 3.31.6 if needed, - # ensuring compatibility with prost-build in ubuntu-latest. run: | if ! command -v cmake &> /dev/null || [ "$(cmake --version | head -n1 | cut -d' ' -f3)" \< "3.5" ] || @@ -33,7 +30,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 +37,47 @@ jobs: env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} + - name: Wait for LND to be ready + 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 + docker exec ldk-node-lnd test -f /root/.lnd/data/chain/bitcoin/regtest/admin.macaroon || { + echo "ERROR: LND macaroon not found after 60 seconds" + docker compose -f docker-compose-lnd.yml logs lnd + exit 1 + } + - 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 env: LND_DATA_DIR: ${{ env.LND_DATA_DIR }} + - name: Wait for LND gRPC to be ready + run: | + for i in $(seq 1 15); do + if docker exec ldk-node-lnd lncli \ + --rpcserver=localhost:8081 \ + --tlscertpath=/root/.lnd/tls.cert \ + --macaroonpath=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon \ + --network=regtest getinfo 2>/dev/null; then + echo "LND gRPC is ready" + exit 0 + fi + echo "Waiting for LND gRPC... ($i/15)" + sleep 2 + done + echo "ERROR: LND gRPC not ready after 30 seconds" + docker compose -f docker-compose-lnd.yml logs lnd + exit 1 + - 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/rust.yml b/.github/workflows/rust.yml index 1ccade444..9cd621dd1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -49,7 +49,7 @@ jobs: uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - key: bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs uses: actions/cache@v4 @@ -60,7 +60,7 @@ jobs: if: "matrix.platform != 'windows-latest' && (steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" run: | source ./scripts/download_bitcoind_electrs.sh - mkdir bin + mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - name: Set bitcoind/electrs environment variables diff --git a/Cargo.toml b/Cargo.toml index 07cabe33f..c749c5300 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,24 +88,28 @@ winapi = { version = "0.3", features = ["winbase"] } lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98393b3de3d8aec897e9ab783cb2418da504e204", features = ["std", "_test_utils"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" +paste = "1.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } ldk-node-062 = { package = "ldk-node", version = "=0.6.2" } [target.'cfg(not(no_download))'.dev-dependencies] -electrsd = { version = "0.36.1", default-features = false, features = ["legacy", "esplora_a33e97e1", "corepc-node_27_2"] } +electrsd = { version = "0.36.1", default-features = false, features = ["legacy", "esplora_a33e97e1", "corepc-node_29_0"] } [target.'cfg(no_download)'.dev-dependencies] electrsd = { version = "0.36.1", default-features = false, features = ["legacy"] } -corepc-node = { version = "0.10.0", default-features = false, features = ["27_2"] } +corepc-node = { version = "0.10.0", default-features = false, features = ["29_0"] } [target.'cfg(cln_test)'.dev-dependencies] clightningrpc = { version = "0.3.0-beta.8", default-features = false } [target.'cfg(lnd_test)'.dev-dependencies] -lnd_grpc_rust = { version = "2.10.0", default-features = false } +lnd_grpc_rust = { version = "2.14.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 +128,7 @@ check-cfg = [ "cfg(tokio_unstable)", "cfg(cln_test)", "cfg(lnd_test)", + "cfg(eclair_test)", "cfg(cycle_tests)", ] diff --git a/Dockerfile.eclair b/Dockerfile.eclair new file mode 100644 index 000000000..dc2c79224 --- /dev/null +++ b/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/docker-compose-cln.yml b/docker-compose-cln.yml index e1fb117e5..dfe090439 100644 --- a/docker-compose-cln.yml +++ b/docker-compose-cln.yml @@ -1,6 +1,6 @@ services: bitcoin: - image: blockstream/bitcoind:27.2 + image: blockstream/bitcoind:30.2 platform: linux/amd64 command: [ @@ -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 + - "18443:18443" + - "18444:18444" + - "28332:28332" + - "28333:28333" networks: - bitcoin-electrs healthcheck: @@ -48,11 +53,13 @@ services: - bitcoin-electrs cln: - image: blockstream/lightningd:v23.08 + image: elementsproject/lightningd:v24.08.2 platform: linux/amd64 depends_on: bitcoin: condition: service_healthy + volumes: + - ${CLN_DATA_DIR:-/tmp/cln-data}:/root/.lightning command: [ "--bitcoin-rpcconnect=bitcoin", @@ -60,7 +67,9 @@ services: "--bitcoin-rpcuser=user", "--bitcoin-rpcpassword=pass", "--regtest", - "--experimental-anchors", + "--experimental-splicing", + "--allow-deprecated-apis=true", + "--rpc-file-mode=0666", ] ports: - "19846:19846" diff --git a/docker-compose-eclair.yml b/docker-compose-eclair.yml new file mode 100644 index 000000000..c4604dfcc --- /dev/null +++ b/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/docker-compose-lnd.yml b/docker-compose-lnd.yml old mode 100755 new mode 100644 index 8b44aba2d..30ec3cd0f --- a/docker-compose-lnd.yml +++ b/docker-compose-lnd.yml @@ -1,6 +1,6 @@ services: bitcoin: - image: blockstream/bitcoind:27.2 + image: blockstream/bitcoind:29.1 platform: linux/amd64 command: [ @@ -12,14 +12,15 @@ services: "-rpcuser=user", "-rpcpassword=pass", "-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 + - "18443:18443" + - "18444:18444" + - "28332:28332" + - "28333:28333" networks: - bitcoin-electrs healthcheck: @@ -55,9 +56,10 @@ services: image: lightninglabs/lnd:v0.18.5-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/docker-compose.yml index e71fd70fb..224891474 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -version: '3' - services: bitcoin: - image: blockstream/bitcoind:27.2 + image: blockstream/bitcoind:30.2 platform: linux/amd64 command: [ @@ -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,12 @@ 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 diff --git a/scripts/download_bitcoind_electrs.sh b/scripts/download_bitcoind_electrs.sh index 47a95332e..f94e280e3 100755 --- a/scripts/download_bitcoind_electrs.sh +++ b/scripts/download_bitcoind_electrs.sh @@ -10,17 +10,17 @@ HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" ELECTRS_DL_ENDPOINT="https://github.com/RCasatta/electrsd/releases/download/electrs_releases" ELECTRS_VERSION="esplora_a33e97e1a1fc63fa9c20a116bb92579bbf43b254" BITCOIND_DL_ENDPOINT="https://bitcoincore.org/bin/" -BITCOIND_VERSION="27.2" +BITCOIND_VERSION="29.0" if [[ "$HOST_PLATFORM" == *linux* ]]; then ELECTRS_DL_FILE_NAME=electrs_linux_"$ELECTRS_VERSION".zip ELECTRS_DL_HASH="865e26a96e8df77df01d96f2f569dcf9622fc87a8d99a9b8fe30861a4db9ddf1" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-linux-gnu.tar.gz - BITCOIND_DL_HASH="acc223af46c178064c132b235392476f66d486453ddbd6bca6f1f8411547da78" + BITCOIND_DL_HASH="a681e4f6ce524c338a105f214613605bac6c33d58c31dc5135bbc02bc458bb6c" elif [[ "$HOST_PLATFORM" == *darwin* ]]; then ELECTRS_DL_FILE_NAME=electrs_macos_"$ELECTRS_VERSION".zip ELECTRS_DL_HASH="2d5ff149e8a2482d3658e9b386830dfc40c8fbd7c175ca7cbac58240a9505bcd" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-apple-darwin.tar.gz - BITCOIND_DL_HASH="6ebc56ca1397615d5a6df2b5cf6727b768e3dcac320c2d5c2f321dcaabc7efa2" + BITCOIND_DL_HASH="5bb824fc86a15318d6a83a1b821ff4cd4b3d3d0e1ec3d162b805ccf7cae6fca8" else printf "\n\n" echo "Unsupported platform: $HOST_PLATFORM Exiting.." diff --git a/tests/common/cln.rs b/tests/common/cln.rs new file mode 100644 index 000000000..404abc6fe --- /dev/null +++ b/tests/common/cln.rs @@ -0,0 +1,331 @@ +// 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. + /// + /// The `clightningrpc` crate performs synchronous I/O over a Unix socket. + /// Running these calls directly on the tokio runtime would block the worker + /// thread, preventing LDK's background tasks from making progress and + /// causing deadlocks (especially with `worker_threads = 1`). + 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") + } + + fn make_error(&self, detail: String) -> TestFailure { + TestFailure::ExternalNodeError { node: "CLN".to_string(), detail } + } + + /// 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()))? + .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 + ))) + } +} + +#[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 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 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)))?; + + let channel_id = result["channel_id"] + .as_str() + .ok_or_else(|| self.make_error("fundchannel did not return channel_id".to_string()))?; + Ok(channel_id.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> { + // CLN v23.08 removed the `force` parameter; use `unilateraltimeout: 1` + // to trigger 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", &json!({"destination": dest, "amount_msat": amount_msat})) + }) + .await + .map_err(|e| self.make_error(format!("keysend: {}", e)))?; + let preimage = + result["payment_preimage"].as_str().filter(|s| !s.is_empty()).ok_or_else(|| { + self.make_error("keysend did not return payment_preimage".to_string()) + })?; + 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".to_string())) + } + + async fn splice_in(&self, channel_id: &str, amount_sat: u64) -> Result<(), TestFailure> { + // Step 1: splice_init with positive relative_amount + 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()))? + .to_string(); + + // Step 2: splice_update until commitments_secured + psbt = self.splice_update_loop(channel_id, psbt).await?; + + // Step 3: splice_signed + 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> { + // CLN splice-out uses negative relative_amount. + // 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()))? + .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 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().unwrap_or(&vec![]) { + let peer_id_str = ch["peer_id"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing peer_id".to_string()))?; + 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()))? + .to_string(); + let total_msat = ch["total_msat"].as_u64().unwrap_or(0); + let to_us_msat = ch["to_us_msat"].as_u64().unwrap_or(0); + let funding_txid = ch["funding_txid"].as_str().map(|s| s.to_string()); + let state = ch["state"].as_str().unwrap_or(""); + 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", + }); + } + Ok(channels) + } +} diff --git a/tests/common/eclair.rs b/tests/common/eclair.rs new file mode 100644 index 000000000..6a6dbb584 --- /dev/null +++ b/tests/common/eclair.rs @@ -0,0 +1,329 @@ +// 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 ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use reqwest::Client; +use serde_json::Value; + +use super::external_node::{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 } + } + + /// Poll /getsentinfo until the payment settles or fails. + /// Eclair's pay/keysend APIs are fire-and-forget, so without polling + /// the caller would hang on the LDK event timeout when a payment fails. + 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".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 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".to_string())) + } + + async fn wait_for_block_sync(&self, min_height: u64) -> Result<(), TestFailure> { + for i in 0..60 { + match self.get_block_height().await { + Ok(h) if h >= min_height => return Ok(()), + Ok(h) => { + if i % 10 == 0 { + println!( + "Waiting for {} to reach height {} (currently at {})...", + self.name(), + min_height, + h + ); + } + }, + Err(e) => { + if i % 10 == 0 { + eprintln!("wait_for_block_sync: get_block_height error: {}", e); + } + }, + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + Err(self.make_error(format!("did not reach height {} after 60s", min_height))) + } + + 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(|s| s.to_string()) + .or_else(|| result["channelId"].as_str().map(|s| s.to_string())) + .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".to_string()))?; + 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() + .filter(|s| !s.is_empty()) + .ok_or_else(|| self.make_error("payinvoice did not return payment id".to_string()))? + .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() + .filter(|s| !s.is_empty()) + .ok_or_else(|| self.make_error("sendtonode did not return payment id".to_string()))? + .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(|s| s.to_string()) + .ok_or_else(|| self.make_error("getnewaddress did not return string".to_string())) + } + + 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> { + let addr = address + .ok_or_else(|| self.make_error("Eclair splice_out requires an address".to_string()))?; + let amount_str = amount_sat.to_string(); + self.post( + "/spliceout", + &[("channelId", channel_id), ("amountOut", &amount_str), ("address", addr)], + ) + .await?; + Ok(()) + } + + 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() + .ok_or_else(|| self.make_error("list_channels: missing channelId".to_string()))? + .to_string(); + let node_id_str = ch["nodeId"] + .as_str() + .ok_or_else(|| self.make_error("list_channels: missing nodeId".to_string()))?; + 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"]; + + // Eclair 0.10+ uses commitments.active[] array (splice support). + let active_commitment = + commitments["active"].as_array().and_then(|a| a.first()).ok_or_else(|| { + self.make_error(format!( + "list_channels: missing commitments.active[] for channel {}", + channel_id + )) + })?; + + let capacity_sat = + active_commitment["fundingTx"]["amountSatoshis"].as_u64().unwrap_or(0); + let funding_txid = + active_commitment["fundingTx"]["txid"].as_str().map(|s| s.to_string()); + let local_balance_msat = + active_commitment["localCommit"]["spec"]["toLocal"].as_u64().unwrap_or(0); + let remote_balance_msat = + active_commitment["localCommit"]["spec"]["toRemote"].as_u64().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", + }); + } + Ok(channels) + } +} diff --git a/tests/common/external_node.rs b/tests/common/external_node.rs new file mode 100644 index 000000000..e591d2960 --- /dev/null +++ b/tests/common/external_node.rs @@ -0,0 +1,141 @@ +// 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 { + /// Implementation-specific channel identifier. + /// LND uses `txid:vout` (channel point), CLN uses a hex channel ID, + /// and Eclair uses its own hex format. + 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, +} + +/// Errors that can occur during interop test operations. +#[derive(Debug)] +pub(crate) enum TestFailure { + Timeout { operation: String, duration: Duration }, + 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::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 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 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; + + /// 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>; + + /// Wait until this node has synced to at least `min_height`. + /// + /// The default is a no-op — most implementations (LND, CLN) sync blocks + /// fast enough that explicit waiting is unnecessary. Override this for + /// implementations like Eclair that may lag behind the chain tip. + async fn wait_for_block_sync(&self, _min_height: u64) -> Result<(), TestFailure> { + Ok(()) + } + + /// Splice additional funds into an existing channel. + /// + /// Not all implementations support splicing. The default returns an error. + async fn splice_in(&self, _channel_id: &str, _amount_sat: u64) -> Result<(), TestFailure> { + Err(TestFailure::ExternalNodeError { + node: self.name().to_string(), + detail: "splice_in not supported".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 an error. + async fn splice_out( + &self, _channel_id: &str, _amount_sat: u64, _address: Option<&str>, + ) -> Result<(), TestFailure> { + Err(TestFailure::ExternalNodeError { + node: self.name().to_string(), + detail: "splice_out not supported".to_string(), + }) + } +} diff --git a/tests/common/lnd.rs b/tests/common/lnd.rs new file mode 100644 index 000000000..e1ce3d3db --- /dev/null +++ b/tests/common/lnd.rs @@ -0,0 +1,424 @@ +// 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 std::convert::TryInto; +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 + } + + 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 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 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)))?; + 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(); + + // 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)) => { + // LND normally returns FundingTxidBytes; this branch exists for + // forward-compatibility but has never been observed in practice. + 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".to_string())), + }; + + // 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".to_string())) + } + + async fn send_keysend( + &self, peer_id: PublicKey, amount_msat: u64, + ) -> Result { + let mut client = self.client.lock().await; + + // Generate a random preimage and compute its SHA256 hash for the payment. + 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".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() + .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(|s| s.to_string()), + is_active: ch.active, + }) + }) + .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 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 7854a77f2..7c444f60b 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; @@ -47,9 +55,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(); @@ -65,7 +88,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); @@ -83,7 +114,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)); @@ -103,7 +142,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); @@ -129,7 +176,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); @@ -147,19 +202,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); }, } }}; @@ -169,7 +232,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, @@ -194,7 +268,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 { @@ -382,6 +464,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, @@ -1691,3 +1776,72 @@ impl TestSyncStoreInner { self.do_list(primary_namespace, secondary_namespace) } } + +/// Generates 16 individual `#[tokio::test]` functions covering every combination +/// of (Phase, disconnect Side, CloseType, close Side): +/// 2 phases × 2 disconnect sides × 2 close types × 2 close initiators = 16. +/// +/// PayType is fixed to Bolt11 — keysend vs bolt11 doesn't affect channel close +/// behavior and is already covered by dedicated named tests (`test_*_receive_keysend`, +/// `test_*_receive_payments`). Do NOT add a Keysend axis here: CLN and Eclair +/// have known keysend interop issues (see `#[ignore]` tests in their entry points). +/// +/// Usage (inside each `integration_tests_*.rs`): +/// ```ignore +/// interop_combo_tests!(test_lnd, setup_clients, setup_ldk_node); +/// ``` +#[macro_export] +macro_rules! interop_combo_tests { + ($prefix:ident, $setup_clients:ident, $setup_ldk_node:ident) => { + $crate::interop_combo_tests!( + @phase $prefix, $setup_clients, $setup_ldk_node, + [payment, Phase::Payment], [idle, Phase::Idle] + ); + }; + + (@phase $prefix:ident, $sc:ident, $sn:ident, $([$pn:ident, $pv:expr]),+) => { + $( + $crate::interop_combo_tests!( + @disc $prefix, $sc, $sn, $pn, $pv, + [ldk, Side::Ldk], [ext, Side::External] + ); + )+ + }; + + (@disc $prefix:ident, $sc:ident, $sn:ident, $pn:ident, $pv:expr, $([$dn:ident, $dv:expr]),+) => { + $( + $crate::interop_combo_tests!( + @close $prefix, $sc, $sn, $pn, $pv, $dn, $dv, + [coop, CloseType::Cooperative], [force, CloseType::Force] + ); + )+ + }; + + (@close $prefix:ident, $sc:ident, $sn:ident, $pn:ident, $pv:expr, $dn:ident, $dv:expr, + $([$cn:ident, $cv:expr]),+) => { + $( + $crate::interop_combo_tests!( + @ci $prefix, $sc, $sn, $pn, $pv, $dn, $dv, $cn, $cv, + [ldk, Side::Ldk], [ext, Side::External] + ); + )+ + }; + + (@ci $prefix:ident, $sc:ident, $sn:ident, $pn:ident, $pv:expr, $dn:ident, $dv:expr, + $cn:ident, $cv:expr, $([$cin:ident, $civ:expr]),+) => { + $( + paste::paste! { + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn [<$prefix _combo_ $pn _ $dn _ $cn _ $cin>]() { + let (bitcoind, electrs, peer) = $sc().await; + let node = $sn(); + run_interop_combo_test( + &node, &peer, &bitcoind, &electrs, + $pv, $dv, $cv, $civ, PayType::Bolt11, + ).await; + node.stop().unwrap(); + } + } + )+ + }; +} diff --git a/tests/common/scenarios/channel.rs b/tests/common/scenarios/channel.rs new file mode 100644 index 000000000..db378c208 --- /dev/null +++ b/tests/common/scenarios/channel.rs @@ -0,0 +1,303 @@ +// 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 electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; +use lightning::events::ClosureReason; +use lightning_invoice::Bolt11Invoice; + +use super::super::external_node::ExternalNode; +use super::super::generate_blocks_and_wait; +use super::payment::receive_bolt11_payment; +use super::{setup_interop_test, CloseType, Side}; + +/// Wait for a ChannelClosed event and assert the closure reason matches expectations. +async fn expect_channel_closed(node: &Node, expected_local_initiated: bool) { + let event = tokio::time::timeout( + Duration::from_secs(super::super::INTEROP_TIMEOUT_SECS), + node.next_event_async(), + ) + .await + .unwrap_or_else(|_| panic!("{} timed out waiting for ChannelClosed event", node.node_id())); + match event { + Event::ChannelClosed { ref reason, .. } => { + println!("{} got ChannelClosed: reason={:?}", node.node_id(), reason); + if let Some(ref r) = reason { + match r { + ClosureReason::HolderForceClosed { .. } => { + assert!( + expected_local_initiated, + "Got HolderForceClosed but expected remote-initiated close" + ); + }, + ClosureReason::LocallyInitiatedCooperativeClosure => { + assert!( + expected_local_initiated, + "Got LocallyInitiatedCooperativeClosure but expected remote-initiated" + ); + }, + ClosureReason::CounterpartyInitiatedCooperativeClosure => { + assert!( + !expected_local_initiated, + "Got CounterpartyInitiatedCooperativeClosure but expected local-initiated" + ); + }, + ClosureReason::CommitmentTxConfirmed => { + assert!( + !expected_local_initiated, + "Got CommitmentTxConfirmed but expected local-initiated close" + ); + }, + _ => { + // Other reasons (e.g. LegacyCooperativeClosure) are acceptable + }, + } + } + node.event_handled().unwrap(); + }, + ref other => { + panic!("{} expected ChannelClosed, got: {:?}", node.node_id(), other); + }, + } +} + +/// 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::super::wait_for_tx(electrs, funding_txo.txid).await; + // Mine 10 blocks: Eclair requires minimum_depth=8 for 1M sat channels (amount-based formula), + // so 6 blocks is not enough. 10 gives a comfortable margin for any external node. + generate_blocks_and_wait(bitcoind, electrs, 10).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) +} + +/// Open a channel from external node to LDK, wait for it to be confirmed. +/// Returns (user_channel_id, external_channel_id). +pub(crate) async fn open_channel_from_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 ldk_addr = node.listening_addresses().unwrap().first().unwrap().clone(); + + peer.open_channel(node.node_id(), ldk_addr, funding_amount_sat, push_msat).await.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; + node.sync_wallets().unwrap(); + let user_channel_id = expect_channel_ready_event!(node, ext_node_id); + + // Look up the final channel ID from the external node (the temporary ID from + // /open changes after funding and may include extra characters in some versions). + let funding_txid_str = funding_txo.txid.to_string(); + let ext_channels = peer.list_channels().await.unwrap(); + 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) +} + +/// Cooperative close initiated by LDK. +pub(crate) async fn cooperative_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.close_channel(user_channel_id, ext_node_id).unwrap(); + generate_blocks_and_wait(bitcoind, electrs, 1).await; + node.sync_wallets().unwrap(); + expect_channel_closed(node, true).await; +} + +/// Cooperative close initiated by external node. +pub(crate) async fn cooperative_close_by_external( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + ext_channel_id: &str, +) { + peer.close_channel(ext_channel_id).await.unwrap(); + generate_blocks_and_wait(bitcoind, electrs, 1).await; + node.sync_wallets().unwrap(); + expect_channel_closed(node, false).await; +} + +/// Force close by LDK. +pub(crate) async fn 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_channel_closed(node, true).await; + generate_blocks_and_wait(bitcoind, electrs, 6).await; + node.sync_wallets().unwrap(); +} + +/// Force close by external node. +pub(crate) async fn 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_channel_closed(node, false).await; +} + +/// Helper to close a channel using the given type and initiator. +pub(crate) async fn close_channel( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + user_channel_id: &ldk_node::UserChannelId, ext_channel_id: &str, close_type: &CloseType, + close_initiator: &Side, +) { + match (close_type, close_initiator) { + (CloseType::Cooperative, Side::Ldk) => { + cooperative_close_by_ldk(node, peer, bitcoind, electrs, user_channel_id).await; + }, + (CloseType::Cooperative, Side::External) => { + cooperative_close_by_external(node, peer, bitcoind, electrs, ext_channel_id).await; + }, + (CloseType::Force, Side::Ldk) => { + force_close_by_ldk(node, peer, bitcoind, electrs, user_channel_id).await; + }, + (CloseType::Force, Side::External) => { + force_close_by_external(node, peer, bitcoind, electrs, ext_channel_id).await; + }, + } +} + +/// External node opens channel to LDK, payments flow both ways, then close. +pub(crate) async fn run_inbound_channel_test( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + close_type: CloseType, close_initiator: Side, +) { + setup_interop_test(node, peer, bitcoind, electrs).await; + + // External node opens channel to LDK with push so both sides have balance + let (user_channel_id, ext_channel_id) = + open_channel_from_external(node, peer, bitcoind, electrs, 1_000_000, Some(500_000_000)) + .await; + + // External → LDK payment + receive_bolt11_payment(node, peer, 5_000_000).await; + + // LDK → External payment + let invoice_str = peer.create_invoice(5_000_000, "inbound-ldk-to-ext").await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); + + // Close + close_channel( + node, + peer, + bitcoind, + electrs, + &user_channel_id, + &ext_channel_id, + &close_type, + &close_initiator, + ) + .await; +} + +/// Open a channel, mine many blocks to advance the chain state, then verify +/// cooperative close still succeeds after time has passed. +/// +/// Note: on regtest with empty blocks, bitcoind's fee estimator may not +/// actually change. This test's primary value is as a smoke test for channel +/// operations after significant chain activity. +pub(crate) async fn cooperative_close_after_fee_change( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + setup_interop_test(node, peer, bitcoind, electrs).await; + + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(node, peer, bitcoind, electrs, 1_000_000, Some(500_000_000)).await; + + // Make a payment to confirm channel is working + let invoice_str = peer.create_invoice(5_000_000, "pre-fee-change").await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); + + // Mine many blocks to change fee environment. + // In regtest, bitcoind's fee estimation changes as blocks are mined + // with varying transaction densities. + generate_blocks_and_wait(bitcoind, electrs, 50).await; + node.sync_wallets().unwrap(); + + // Allow fee rate updates to propagate between peers + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify payment still works after fee rate change + let invoice_str = peer.create_invoice(5_000_000, "post-fee-change").await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); + + // Cooperative close + let ext_node_id = peer.get_node_id().await.unwrap(); + node.close_channel(&user_channel_id, ext_node_id).unwrap(); + expect_channel_closed(node, true).await; +} + +/// Open a channel, mine many blocks, then force close. +/// Verifies that the commitment transaction is accepted on-chain after +/// significant chain activity. +pub(crate) async fn force_close_after_fee_change( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, +) { + setup_interop_test(node, peer, bitcoind, electrs).await; + + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(node, peer, bitcoind, electrs, 1_000_000, Some(500_000_000)).await; + + // Mine many blocks to shift fee environment + generate_blocks_and_wait(bitcoind, electrs, 50).await; + node.sync_wallets().unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; + + // Force close — commitment tx must have a fee that gets accepted into mempool + let ext_node_id = peer.get_node_id().await.unwrap(); + node.force_close_channel(&user_channel_id, ext_node_id, None).unwrap(); + expect_channel_closed(node, true).await; + + // Mine blocks to confirm the commitment tx + generate_blocks_and_wait(bitcoind, electrs, 6).await; + node.sync_wallets().unwrap(); +} diff --git a/tests/common/scenarios/combo.rs b/tests/common/scenarios/combo.rs new file mode 100644 index 000000000..f2e6eb32a --- /dev/null +++ b/tests/common/scenarios/combo.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. + +//! Combo test orchestrator: runs disconnect → payment → close across all +//! parameter combinations. Used by the `interop_combo_tests!` macro. + +use std::str::FromStr; +use std::time::Duration; + +use electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; +use lightning_invoice::Bolt11Invoice; + +use super::super::external_node::ExternalNode; +use super::channel::{close_channel, open_channel_to_external}; +use super::disconnect::{disconnect_during_payment, disconnect_reconnect_idle}; +use super::{find_ext_channel, setup_interop_test, CloseType, PayType, Phase, Side}; + +/// Run a combined interop scenario: setup → open channel → disconnect/reconnect +/// → payment → close. Each parameter axis is varied by `interop_combo_tests!` +/// to cover all 16 combinations (2 phases × 2 disconnect sides × 2 close types +/// × 2 close initiators). +pub(crate) async fn run_interop_combo_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 => { + disconnect_reconnect_idle(node, peer, bitcoind, electrs, &disconnect_initiator).await; + }, + Phase::Payment => { + disconnect_during_payment(node, peer, bitcoind, electrs, &disconnect_initiator).await; + }, + } + + // Wait for channel to be reestablished after disconnect/reconnect. + // Note: LND may report active=false for a while after reconnect, even though + // the channel is functional (payments succeed). We wait for peer connectivity + // instead, since disconnect_reconnect_idle already verified the channel + // works by completing a payment. + let ext_node_id = peer.get_node_id().await.unwrap(); + for i in 0..30 { + let connected = + node.list_peers().iter().any(|p| p.node_id == ext_node_id && p.is_connected); + if connected { + break; + } + if i == 29 { + panic!("Peer did not reconnect within 30s"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + tokio::time::sleep(Duration::from_secs(2)).await; + + // Phase 2: Make a payment and verify balance changes + let payment_amount_msat = 5_000_000; + let before = find_ext_channel(peer, &ext_channel_id).await; + match payment_type { + PayType::Bolt11 => { + let invoice_str = + peer.create_invoice(payment_amount_msat, "combo-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(payment_amount_msat, ext_node_id, None).unwrap(); + expect_event!(node, PaymentSuccessful); + }, + } + // Wait for external node to reflect the balance update, then verify. + // LND in particular may not update `local_balance` immediately. + let mut balance_increase = 0; + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(500)).await; + let after = find_ext_channel(peer, &ext_channel_id).await; + balance_increase = after.local_balance_msat.saturating_sub(before.local_balance_msat); + if balance_increase > 0 { + break; + } + } + let tolerance_msat = 10_000; // 10 sat tolerance for fees + rounding + assert!( + balance_increase + tolerance_msat >= payment_amount_msat, + "External node balance did not increase enough after payment: \ + before={}, increase={}, expected>={}", + before.local_balance_msat, + balance_increase, + payment_amount_msat, + ); + + // Phase 3: Close channel + close_channel( + node, + peer, + bitcoind, + electrs, + &user_channel_id, + &ext_channel_id, + &close_type, + &close_initiator, + ) + .await; +} diff --git a/tests/common/scenarios/disconnect.rs b/tests/common/scenarios/disconnect.rs new file mode 100644 index 000000000..07706acfc --- /dev/null +++ b/tests/common/scenarios/disconnect.rs @@ -0,0 +1,109 @@ +// 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 electrsd::corepc_node::Client as BitcoindClient; +use electrum_client::ElectrumApi; +use ldk_node::{Event, Node}; +use lightning_invoice::Bolt11Invoice; + +use super::super::external_node::ExternalNode; +use super::Side; + +/// Disconnect during idle, reconnect, verify channel still works. +pub(crate) async fn 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 and wait for channel reestablishment + node.connect(ext_node_id, ext_addr, true).unwrap(); + for i in 0..30 { + let connected = + node.list_peers().iter().any(|p| p.node_id == ext_node_id && p.is_connected); + if connected { + break; + } + if i == 29 { + panic!("Peer did not reconnect within 30s after idle disconnect"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + // Allow channel reestablishment to complete + tokio::time::sleep(Duration::from_secs(2)).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 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). + // If send() fails immediately, no event will arrive — skip to reconnect verification. + let send_ok = node.bolt11_payment().send(&parsed_invoice, None).is_ok(); + + // 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; + + // If the payment was initiated, wait for it to resolve. + 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 { + ldk_node::Event::PaymentSuccessful { .. } | ldk_node::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..19b35bfdd --- /dev/null +++ b/tests/common/scenarios/mod.rs @@ -0,0 +1,138 @@ +// 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. +//! +//! ## Naming convention +//! +//! - **Building blocks** (e.g. `receive_bolt11_payment`, `force_close_by_ldk`): +//! Composable primitives that assume the caller has already called +//! `setup_interop_test` and opened a channel. No `test_` prefix. +//! +//! - **Full scenarios** (e.g. `run_inbound_channel_test`, +//! `cooperative_close_after_fee_change`): End-to-end flows that call +//! `setup_interop_test` internally. Used directly as test bodies. +//! +//! - **Combo orchestrator** (`combo::run_interop_combo_test`): Composes +//! building blocks across disconnect → payment → close phases. Driven by +//! the `interop_combo_tests!` macro. + +pub(crate) mod channel; +pub(crate) mod combo; +pub(crate) mod disconnect; +pub(crate) mod payment; +pub(crate) mod splice; + +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::Node; + +use super::external_node::{ExternalChannel, ExternalNode}; +use super::{generate_blocks_and_wait, premine_and_distribute_funds}; + +#[derive(Debug, Clone)] +pub(crate) enum Phase { + Payment, + 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, +} + +/// Find a specific channel on the external node by its channel ID. +pub(crate) async fn find_ext_channel( + peer: &(impl ExternalNode + ?Sized), ext_channel_id: &str, +) -> ExternalChannel { + let channels = peer.list_channels().await.unwrap(); + channels + .into_iter() + .find(|ch| ch.channel_id == ext_channel_id) + .unwrap_or_else(|| panic!("Channel {} not found on {}", ext_channel_id, peer.name())) +} + +/// 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; + + // Wait for external node to sync to the current chain tip before proceeding. + // Without this, the external node may not have indexed the funding tx yet, + // causing channel opens to time out. + let chain_height: u64 = bitcoind.get_blockchain_info().unwrap().blocks.try_into().unwrap(); + peer.wait_for_block_sync(chain_height).await.unwrap(); + + 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(); +} + +/// Retry an async operation up to `max_attempts` times with a 1-second delay between attempts. +/// Used for operations that may fail due to gossip propagation 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!() +} diff --git a/tests/common/scenarios/payment.rs b/tests/common/scenarios/payment.rs new file mode 100644 index 000000000..71c7e435b --- /dev/null +++ b/tests/common/scenarios/payment.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::str::FromStr; +use std::time::Duration; + +use ldk_node::{Event, Node}; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; + +use super::super::external_node::ExternalNode; +use super::{find_ext_channel, retry_until_ok}; + +/// External node pays LDK via BOLT11 invoice. +/// +/// Retries the payment to handle gossip propagation delay — the external node +/// 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); +} + +/// External node sends keysend to LDK. +/// +/// Retries to handle 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); +} + +/// Bidirectional payments: LDK → external, then external → LDK. +pub(crate) async fn bidirectional_payments( + node: &Node, peer: &(impl ExternalNode + ?Sized), ext_channel_id: &str, amount_msat: u64, +) { + let before = find_ext_channel(peer, ext_channel_id).await; + + // LDK → external + let invoice_str = peer.create_invoice(amount_msat, "bidir-forward").await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + expect_event!(node, PaymentSuccessful); + + // external → LDK + let ldk_invoice = node + .bolt11_payment() + .receive( + amount_msat, + &Bolt11InvoiceDescription::Direct( + Description::new("bidir-reverse".to_string()).unwrap(), + ), + 3600, + ) + .unwrap(); + let invoice_str = ldk_invoice.to_string(); + retry_until_ok(10, "bidirectional_reverse_payment", || peer.pay_invoice(&invoice_str)).await; + expect_event!(node, PaymentReceived); + + // Net balance on external node should be roughly unchanged (± fees + rounding). + // Poll because some implementations (LND) are slow to reflect balance updates. + let mut diff: i64 = 0; + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(500)).await; + let after = find_ext_channel(peer, ext_channel_id).await; + diff = (after.local_balance_msat as i64) - (before.local_balance_msat as i64); + if diff.abs() <= 10_000 { + break; + } + } + let tolerance_msat: i64 = 10_000; // 10 sat tolerance for fees + rounding + assert!( + diff.abs() <= tolerance_msat, + "Bidirectional payments should roughly cancel out, but balance diff was {} msat", + diff, + ); +} + +/// Pay an expired invoice — should fail gracefully. +/// +/// Note: invoice expiry is checked by the *payer*, so exact behavior is +/// implementation-dependent. Most implementations reject expired invoices +/// before attempting to route, but the timing window is inherently racy. +pub(crate) async fn pay_expired_invoice(node: &Node, peer: &(impl ExternalNode + ?Sized)) { + // Create invoice with short expiry + let invoice = node + .bolt11_payment() + .receive( + 1_000_000, + &Bolt11InvoiceDescription::Direct(Description::new("expire-test".to_string()).unwrap()), + 3, + ) + .unwrap(); + + // Wait for it to expire (generous margin for slow CI) + tokio::time::sleep(Duration::from_secs(5)).await; + + // External node attempts to pay — should fail + let result = peer.pay_invoice(&invoice.to_string()).await; + assert!(result.is_err(), "Paying an expired invoice should fail"); +} + +/// Send multiple payments in rapid succession to test concurrent HTLC handling. +pub(crate) async fn concurrent_payments( + node: &Node, peer: &(impl ExternalNode + ?Sized), num_payments: usize, amount_msat_each: u64, +) { + for i in 0..num_payments { + let invoice_str = + peer.create_invoice(amount_msat_each, &format!("concurrent-{}", i)).await.unwrap(); + let parsed = Bolt11Invoice::from_str(&invoice_str).unwrap(); + node.bolt11_payment().send(&parsed, None).unwrap(); + } + + // Collect all payment outcomes + for _ in 0..num_payments { + let event = tokio::time::timeout( + Duration::from_secs(super::super::INTEROP_TIMEOUT_SECS), + node.next_event_async(), + ) + .await + .expect("Timed out waiting for concurrent payment to resolve"); + match event { + Event::PaymentSuccessful { .. } => { + node.event_handled().unwrap(); + }, + other => { + panic!("Expected PaymentSuccessful, got: {:?}", other); + }, + } + } +} diff --git a/tests/common/scenarios/splice.rs b/tests/common/scenarios/splice.rs new file mode 100644 index 000000000..e009ff65e --- /dev/null +++ b/tests/common/scenarios/splice.rs @@ -0,0 +1,158 @@ +// 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 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::{find_ext_channel, retry_until_ok}; + +/// Test splice-in from external node, then splice-out. +/// +/// Soft-fails if the external node does not support splicing. +pub(crate) async fn splice_from_external( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + ext_channel_id: &str, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + + // Capture capacity before splice for proper assertion + let capacity_before_in = find_ext_channel(peer, ext_channel_id).await.capacity_sat; + + // Splice in: external node adds 500k sats to the channel + let splice_in_amount_sat = 500_000; + match peer.splice_in(ext_channel_id, splice_in_amount_sat).await { + Ok(_) => { + println!( + "[splice] {} splice-in of {} sats initiated", + peer.name(), + splice_in_amount_sat + ); + + // Wait for LDK to see the splice pending event + let splice_txo = expect_splice_pending_event!(node, ext_node_id); + super::super::wait_for_tx(electrs, splice_txo.txid).await; + generate_blocks_and_wait(bitcoind, electrs, 6).await; + let expected_height = bitcoind.get_blockchain_info().unwrap().blocks as u64; + peer.wait_for_block_sync(expected_height).await.unwrap(); + node.sync_wallets().unwrap(); + + // After confirmation, channel should be ready with new capacity + expect_channel_ready_event!(node, ext_node_id); + + let ch = find_ext_channel(peer, ext_channel_id).await; + // Allow small tolerance for on-chain fees deducted from the splice tx + let tolerance_sat = 10_000; + assert!( + ch.capacity_sat + tolerance_sat >= capacity_before_in + splice_in_amount_sat, + "[splice] capacity after splice-in ({}) should be >= before ({}) + splice amount ({}) (minus fees)", + ch.capacity_sat, + capacity_before_in, + splice_in_amount_sat, + ); + println!( + "[splice] splice-in confirmed successfully (capacity: {} -> {} sat)", + capacity_before_in, ch.capacity_sat + ); + }, + Err(e) => { + println!("[splice] {} does not support splice-in (skipping): {}", peer.name(), e); + return; + }, + } + + // Splice out: external node removes 200k sats from channel to LDK on-chain address + let splice_out_amount_sat = 200_000; + let capacity_before_out = find_ext_channel(peer, ext_channel_id).await.capacity_sat; + let ldk_address = node.onchain_payment().new_address().unwrap(); + match peer + .splice_out(ext_channel_id, splice_out_amount_sat, Some(&ldk_address.to_string())) + .await + { + Ok(_) => { + println!( + "[splice] {} splice-out of {} sats initiated", + peer.name(), + splice_out_amount_sat + ); + + let splice_txo = expect_splice_pending_event!(node, ext_node_id); + super::super::wait_for_tx(electrs, splice_txo.txid).await; + generate_blocks_and_wait(bitcoind, electrs, 6).await; + node.sync_wallets().unwrap(); + + expect_channel_ready_event!(node, ext_node_id); + + let ch = find_ext_channel(peer, ext_channel_id).await; + assert!( + ch.capacity_sat < capacity_before_out, + "[splice] capacity after splice-out ({}) should be less than before ({})", + ch.capacity_sat, + capacity_before_out, + ); + println!( + "[splice] splice-out confirmed successfully (capacity: {} -> {} sat)", + capacity_before_out, ch.capacity_sat + ); + }, + Err(e) => { + println!("[splice] {} does not support splice-out (skipping): {}", peer.name(), e); + }, + } +} + +/// Splice in additional funds from external, then verify the external node +/// can pay LDK using the newly spliced-in capacity. +/// +/// The splice adds funds to the external side of the channel. We verify +/// by having the external node pay LDK an amount that exercises the +/// new capacity. +pub(crate) async fn splice_then_payment( + node: &Node, peer: &(impl ExternalNode + ?Sized), bitcoind: &BitcoindClient, electrs: &E, + ext_channel_id: &str, +) { + let ext_node_id = peer.get_node_id().await.unwrap(); + let splice_amount_sat = 500_000; + + // Check how much the external node can send before the splice + let before = find_ext_channel(peer, ext_channel_id).await; + let ext_balance_before = before.local_balance_msat; + + match peer.splice_in(ext_channel_id, splice_amount_sat).await { + Ok(_) => { + let splice_txo = expect_splice_pending_event!(node, ext_node_id); + super::super::wait_for_tx(electrs, splice_txo.txid).await; + generate_blocks_and_wait(bitcoind, electrs, 6).await; + let expected_height = bitcoind.get_blockchain_info().unwrap().blocks as u64; + peer.wait_for_block_sync(expected_height).await.unwrap(); + node.sync_wallets().unwrap(); + expect_channel_ready_event!(node, ext_node_id); + + // Pay an amount that exceeds the pre-splice external balance, + // proving the splice-in capacity is actually usable. + let payment_msat = ext_balance_before + splice_amount_sat as u64 * 1000 / 2; + let ldk_invoice = node + .bolt11_payment() + .receive( + payment_msat, + &lightning_invoice::Bolt11InvoiceDescription::Direct( + lightning_invoice::Description::new("splice-then-pay".to_string()).unwrap(), + ), + 3600, + ) + .unwrap(); + let invoice_str = ldk_invoice.to_string(); + retry_until_ok(10, "splice_then_payment", || peer.pay_invoice(&invoice_str)).await; + expect_event!(node, PaymentReceived); + }, + Err(e) => { + println!("[splice] {} does not support splice-in (skipping): {}", peer.name(), e); + }, + } +} diff --git a/tests/integration_tests_cln.rs b/tests/integration_tests_cln.rs index 0245f1fdf..781c07a24 100644 --- a/tests/integration_tests_cln.rs +++ b/tests/integration_tests_cln.rs @@ -9,119 +9,249 @@ mod common; -use std::default::Default; use std::str::FromStr; -use clightningrpc::lightningrpc::LightningRPC; -use clightningrpc::responses::NetworkAddress; +use common::cln::TestClnNode; +use common::external_node::ExternalNode; +use common::scenarios::channel::{ + cooperative_close_after_fee_change, cooperative_close_by_ldk, force_close_after_fee_change, + force_close_by_external, force_close_by_ldk, open_channel_to_external, + run_inbound_channel_test, +}; +use common::scenarios::combo::run_interop_combo_test; +use common::scenarios::disconnect::disconnect_reconnect_idle; +use common::scenarios::payment::{ + bidirectional_payments, concurrent_payments, pay_expired_invoice, receive_bolt11_payment, + receive_keysend_payment, +}; +use common::scenarios::splice::{splice_from_external, splice_then_payment}; +use common::scenarios::{setup_interop_test, CloseType, PayType, Phase, Side}; 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(); + let electrs = 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 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); - + builder.set_chain_source_electrum("tcp://127.0.0.1:50001".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_cln_basic_channel_cycle() { + let (bitcoind, electrs, cln) = setup_clients().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 + receive_bolt11_payment(&node, &cln, 10_000_000).await; + + cooperative_close_by_ldk(&node, &cln, &bitcoind, &electrs, &user_channel_id).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_disconnect_reconnect() { + let (bitcoind, electrs, cln) = setup_clients().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; + + disconnect_reconnect_idle(&node, &cln, &bitcoind, &electrs, &Side::Ldk).await; + 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().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; + + 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().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; + + force_close_by_external(&node, &cln, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "CLN splicing requires --experimental-splicing flag and CLN v25+"] +async fn test_cln_splice() { + let (bitcoind, electrs, cln) = setup_clients().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; + + splice_from_external(&node, &cln, &bitcoind, &electrs, &ext_ch).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "CLN splicing requires --experimental-splicing flag and CLN v25+"] +async fn test_cln_splice_then_payment() { + let (bitcoind, electrs, cln) = setup_clients().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; + + splice_then_payment(&node, &cln, &bitcoind, &electrs, &ext_ch).await; + + node.stop().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, +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_inbound_channel() { + let (bitcoind, electrs, cln) = setup_clients().await; + let node = setup_ldk_node(); + run_inbound_channel_test( + &node, + &cln, + &bitcoind, + &electrs, + CloseType::Cooperative, + Side::External, ) .await; + node.stop().unwrap(); +} - // 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 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); - - // 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(); - - 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); - - node.close_channel(&user_channel_id, cln_node_id).unwrap(); - common::expect_event!(node, ChannelClosed); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_receive_payments() { + let (bitcoind, electrs, cln) = setup_clients().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; + + receive_bolt11_payment(&node, &cln, 5_000_000).await; + + node.stop().unwrap(); +} + +/// CLN v24.08+ includes a `payment_secret` in outbound keysend HTLCs. +/// LDK treats any inbound HTLC with `payment_secret` as a BOLT11 payment and +/// verifies it against a stored invoice — which fails for spontaneous payments. +/// Upstream LDK fix needed: skip payment_secret verification when a valid +/// `keysend_preimage` TLV is present. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "CLN v24.08+ sends payment_secret in keysend — LDK rejects (upstream fix needed)"] +async fn test_cln_receive_keysend() { + let (bitcoind, electrs, cln) = setup_clients().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; + + receive_keysend_payment(&node, &cln, 5_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_bidirectional_payments() { + let (bitcoind, electrs, cln) = setup_clients().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; + + bidirectional_payments(&node, &cln, &ext_ch, 5_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_pay_expired_invoice() { + let (bitcoind, electrs, cln) = setup_clients().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; + + pay_expired_invoice(&node, &cln).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_concurrent_payments() { + let (bitcoind, electrs, cln) = setup_clients().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; + + concurrent_payments(&node, &cln, 5, 1_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_cooperative_close_after_fee_change() { + let (bitcoind, electrs, cln) = setup_clients().await; + let node = setup_ldk_node(); + cooperative_close_after_fee_change(&node, &cln, &bitcoind, &electrs).await; node.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cln_force_close_after_fee_change() { + let (bitcoind, electrs, cln) = setup_clients().await; + let node = setup_ldk_node(); + force_close_after_fee_change(&node, &cln, &bitcoind, &electrs).await; + node.stop().unwrap(); +} + +interop_combo_tests!(test_cln, setup_clients, setup_ldk_node); diff --git a/tests/integration_tests_eclair.rs b/tests/integration_tests_eclair.rs new file mode 100644 index 000000000..c26579f34 --- /dev/null +++ b/tests/integration_tests_eclair.rs @@ -0,0 +1,366 @@ +// 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 common::eclair::TestEclairNode; +use common::external_node::ExternalNode; +use common::scenarios::channel::{ + cooperative_close_after_fee_change, cooperative_close_by_ldk, force_close_after_fee_change, + force_close_by_external, force_close_by_ldk, open_channel_to_external, + run_inbound_channel_test, +}; +use common::scenarios::combo::run_interop_combo_test; +use common::scenarios::disconnect::disconnect_reconnect_idle; +use common::scenarios::payment::{ + bidirectional_payments, concurrent_payments, pay_expired_invoice, receive_bolt11_payment, + receive_keysend_payment, +}; +use common::scenarios::splice::{splice_from_external, splice_then_payment}; +use common::scenarios::{setup_interop_test, CloseType, PayType, Phase, Side}; +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}; + +/// Run a shell command via `spawn_blocking` to avoid blocking the tokio runtime. +async fn run_cmd(program: &str, args: &[&str]) -> std::io::Result { + let program = program.to_string(); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + tokio::task::spawn_blocking(move || std::process::Command::new(&program).args(&args).output()) + .await + .expect("spawn_blocking panicked") +} + +async 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(); + + // Recreate the Eclair container between tests to give a fresh /data + // directory, a new seed, and a clean initialization against the current + // chain tip. + let container_name = + std::env::var("ECLAIR_CONTAINER_NAME").unwrap_or_else(|_| "ldk-node-eclair-1".to_string()); + run_cmd("docker", &["rm", "-f", &container_name]).await.ok(); + + // Unlock UTXOs and start Eclair, retrying if locked UTXOs remain. + // Force-close transactions can lock new UTXOs between the unlock call + // and Eclair startup, so we may need multiple attempts. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(90); + let mut attempt = 0; + loop { + // Unlock any UTXOs left locked in the Eclair wallet. + run_cmd( + "curl", + &[ + "-s", + "--max-time", + "5", + "--user", + "user:pass", + "--data-binary", + r#"{"jsonrpc":"1.0","method":"lockunspent","params":[true]}"#, + "-H", + "content-type: text/plain;", + "http://127.0.0.1:18443/wallet/eclair", + ], + ) + .await + .ok(); + + if attempt > 0 { + // On retry, recreate the container since Eclair exited. + run_cmd("docker", &["rm", "-f", &container_name]).await.ok(); + } + let output = run_cmd( + "docker", + &["compose", "-f", "docker-compose-eclair.yml", "up", "-d", "eclair"], + ) + .await + .expect("failed to spawn docker compose"); + assert!( + output.status.success(), + "docker compose up failed (exit {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + // Wait for Eclair to become ready. + let mut ready = false; + for _ in 0..30 { + if std::time::Instant::now() >= deadline { + let logs = run_cmd("docker", &["logs", "--tail", "50", &container_name]).await.ok(); + if let Some(l) = logs { + eprintln!( + "=== Eclair container logs ===\n{}{}", + String::from_utf8_lossy(&l.stdout), + String::from_utf8_lossy(&l.stderr) + ); + } + panic!("Eclair did not start within 90s (after {} attempts)", attempt + 1); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + ready = run_cmd( + "curl", + &[ + "-s", + "--max-time", + "2", + "-u", + ":eclairpassword", + "-X", + "POST", + "http://127.0.0.1:8080/getinfo", + ], + ) + .await + .map(|o| o.status.success() && !o.stdout.is_empty()) + .unwrap_or(false); + if ready { + break; + } + } + + if ready { + break; + } + + // Eclair likely failed due to locked UTXOs — retry. + attempt += 1; + eprintln!("Eclair failed to start (attempt {}), retrying after lockunspent...", attempt); + } + + 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_electrum("tcp://127.0.0.1:50001".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().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 + receive_bolt11_payment(&node, &eclair, 10_000_000).await; + + cooperative_close_by_ldk(&node, &eclair, &bitcoind, &electrs, &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().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; + + disconnect_reconnect_idle(&node, &eclair, &bitcoind, &electrs, &Side::Ldk).await; + 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().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; + + 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().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; + + force_close_by_external(&node, &eclair, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} + +/// Eclair 0.8.0 rejects LDK keysend with `InvalidOnionPayload(8,0)` — LDK includes +/// `payment_data` (TLV type 8) in keysend onions, which Eclair considers invalid for +/// spontaneous payments. Eclair 0.14.0+ may handle this differently. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "Eclair 0.8.0 rejects LDK keysend with InvalidOnionPayload(8,0)"] +async fn test_eclair_send_keysend() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + let ext_node_id = eclair.get_node_id().await.unwrap(); + node.spontaneous_payment().send(5_000_000, ext_node_id, None).unwrap(); + common::expect_event!(node, PaymentSuccessful); + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "Eclair 0.8.0 does not support splicing (introduced in v0.10.0+)"] +async fn test_eclair_splice() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + splice_from_external(&node, &eclair, &bitcoind, &electrs, &ext_ch).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "Eclair 0.8.0 does not support splicing (introduced in v0.10.0+)"] +async fn test_eclair_splice_then_payment() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + splice_then_payment(&node, &eclair, &bitcoind, &electrs, &ext_ch).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_inbound_channel() { + let (bitcoind, electrs, eclair) = setup_clients().await; + let node = setup_ldk_node(); + run_inbound_channel_test( + &node, + &eclair, + &bitcoind, + &electrs, + CloseType::Cooperative, + Side::External, + ) + .await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_receive_payments() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + receive_bolt11_payment(&node, &eclair, 5_000_000).await; + receive_keysend_payment(&node, &eclair, 5_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_bidirectional_payments() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + bidirectional_payments(&node, &eclair, &ext_ch, 5_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_pay_expired_invoice() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + pay_expired_invoice(&node, &eclair).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_concurrent_payments() { + let (bitcoind, electrs, eclair) = setup_clients().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; + + concurrent_payments(&node, &eclair, 5, 1_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_cooperative_close_after_fee_change() { + let (bitcoind, electrs, eclair) = setup_clients().await; + let node = setup_ldk_node(); + cooperative_close_after_fee_change(&node, &eclair, &bitcoind, &electrs).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_eclair_force_close_after_fee_change() { + let (bitcoind, electrs, eclair) = setup_clients().await; + let node = setup_ldk_node(); + force_close_after_fee_change(&node, &eclair, &bitcoind, &electrs).await; + node.stop().unwrap(); +} + +interop_combo_tests!(test_eclair, setup_clients, setup_ldk_node); diff --git a/tests/integration_tests_lnd.rs b/tests/integration_tests_lnd.rs index 8f1d4c868..116eb045c 100755 --- a/tests/integration_tests_lnd.rs +++ b/tests/integration_tests_lnd.rs @@ -1,224 +1,206 @@ +// 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::external_node::ExternalNode; +use common::lnd::TestLndNode; +use common::scenarios::channel::{ + cooperative_close_after_fee_change, cooperative_close_by_ldk, force_close_after_fee_change, + force_close_by_external, force_close_by_ldk, open_channel_to_external, + run_inbound_channel_test, +}; +use common::scenarios::combo::run_interop_combo_test; +use common::scenarios::disconnect::disconnect_reconnect_idle; +use common::scenarios::payment::{ + bidirectional_payments, concurrent_payments, pay_expired_invoice, receive_bolt11_payment, + receive_keysend_payment, +}; +use common::scenarios::{setup_interop_test, CloseType, PayType, Phase, Side}; 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; + 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); - + builder.set_chain_source_electrum("tcp://127.0.0.1:50001".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, +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_basic_channel_cycle() { + let (bitcoind, electrs, lnd) = setup_clients().await; + let node = setup_ldk_node(); + setup_interop_test(&node, &lnd, &bitcoind, &electrs).await; + + let (user_channel_id, _ext_channel_id) = + open_channel_to_external(&node, &lnd, &bitcoind, &electrs, 1_000_000, Some(500_000_000)) + .await; + + // LDK -> LND payment + let invoice = lnd.create_invoice(10_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); + + // LND -> LDK payment + receive_bolt11_payment(&node, &lnd, 10_000_000).await; + + cooperative_close_by_ldk(&node, &lnd, &bitcoind, &electrs, &user_channel_id).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_disconnect_reconnect() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; + + disconnect_reconnect_idle(&node, &lnd, &bitcoind, &electrs, &Side::Ldk).await; + disconnect_reconnect_idle(&node, &lnd, &bitcoind, &electrs, &Side::External).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_force_close_by_ldk() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; + + force_close_by_ldk(&node, &lnd, &bitcoind, &electrs, &user_ch).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_force_close_by_external() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; + + force_close_by_external(&node, &lnd, &bitcoind, &electrs, &ext_ch).await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_inbound_channel() { + let (bitcoind, electrs, lnd) = setup_clients().await; + let node = setup_ldk_node(); + run_inbound_channel_test( + &node, + &lnd, + &bitcoind, + &electrs, + CloseType::Cooperative, + Side::External, ) .await; + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_receive_payments() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; + + receive_bolt11_payment(&node, &lnd, 5_000_000).await; + receive_keysend_payment(&node, &lnd, 5_000_000).await; + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_bidirectional_payments() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; - // 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; + bidirectional_payments(&node, &lnd, &ext_ch, 5_000_000).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.stop().unwrap(); +} - node.sync_wallets().unwrap(); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_pay_expired_invoice() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; - // Open the channel - let funding_amount_sat = 1_000_000; + pay_expired_invoice(&node, &lnd).await; - node.open_channel(lnd_node_id, lnd_address, funding_amount_sat, Some(500_000_000), None) - .unwrap(); + node.stop().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); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_concurrent_payments() { + let (bitcoind, electrs, lnd) = setup_clients().await; + 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; - // 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(); + concurrent_payments(&node, &lnd, 5, 1_000_000).await; - 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(); } -struct TestLndClient { - client: LndClient, +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_cooperative_close_after_fee_change() { + let (bitcoind, electrs, lnd) = setup_clients().await; + let node = setup_ldk_node(); + cooperative_close_after_fee_change(&node, &lnd, &bitcoind, &electrs).await; + node.stop().unwrap(); } -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)); - } - - Ok(response.routes) - } - - 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 - } - ); - } - } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd_force_close_after_fee_change() { + let (bitcoind, electrs, lnd) = setup_clients().await; + let node = setup_ldk_node(); + force_close_after_fee_change(&node, &lnd, &bitcoind, &electrs).await; + node.stop().unwrap(); } + +interop_combo_tests!(test_lnd, setup_clients, setup_ldk_node);