diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml new file mode 100644 index 000000000..10a29c355 --- /dev/null +++ b/.github/workflows/lnd-integration.yml @@ -0,0 +1,33 @@ +name: CI Checks - LND Integration Tests + +on: [push, pull_request] + +jobs: + check-lnd: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - 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 + run: docker compose -f docker-compose-lnd.yml up -d + env: + LND_DATA_DIR: ${{ env.LND_DATA_DIR }} + + - name: Set permissions for LND data directory + # In PR 4622 (https://github.com/lightningnetwork/lnd/pull/4622), + # LND sets file permissions to 0700, preventing test code from accessing them. + # This step ensures the test suite has the necessary permissions. + run: sudo chmod -R 755 $LND_DATA_DIR + env: + LND_DATA_DIR: ${{ env.LND_DATA_DIR }} + + - name: Run LND integration tests + run: LND_CERT_PATH=$LND_DATA_DIR/tls.cert LND_MACAROON_PATH=$LND_DATA_DIR/data/chain/bitcoin/regtest/admin.macaroon + RUSTFLAGS="--cfg lnd_test" cargo test --test integration_tests_lnd -- --exact --show-output + env: + LND_DATA_DIR: ${{ env.LND_DATA_DIR }} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml old mode 100644 new mode 100755 index d89ad28e2..5481c76dd --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,10 @@ electrsd = { version = "0.29.0", features = ["legacy"] } [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 } +tokio = { version = "1.37", features = ["fs"] } + [build-dependencies] uniffi = { version = "0.27.3", features = ["build"], optional = true } @@ -123,4 +127,5 @@ check-cfg = [ "cfg(ldk_bench)", "cfg(tokio_unstable)", "cfg(cln_test)", + "cfg(lnd_test)", ] diff --git a/docker-compose-lnd.yml b/docker-compose-lnd.yml new file mode 100755 index 000000000..a11f4cac1 --- /dev/null +++ b/docker-compose-lnd.yml @@ -0,0 +1,87 @@ +services: + bitcoin: + image: blockstream/bitcoind:24.1 + platform: linux/amd64 + command: + [ + "bitcoind", + "-printtoconsole", + "-regtest=1", + "-rpcallowip=0.0.0.0/0", + "-rpcbind=0.0.0.0", + "-rpcuser=user", + "-rpcpassword=pass", + "-fallbackfee=0.00001", + "-zmqpubrawblock=tcp://0.0.0.0:28332", + "-zmqpubrawtx=tcp://0.0.0.0:28333" + ] + ports: + - "18443:18443" # Regtest RPC port + - "18444:18444" # Regtest P2P port + - "28332:28332" # ZMQ block port + - "28333:28333" # ZMQ tx port + networks: + - bitcoin-electrs + healthcheck: + test: ["CMD", "bitcoin-cli", "-regtest", "-rpcuser=user", "-rpcpassword=pass", "getblockchaininfo"] + interval: 5s + timeout: 10s + retries: 5 + + electrs: + image: blockstream/esplora:electrs-cd9f90c115751eb9d2bca9a4da89d10d048ae931 + platform: linux/amd64 + depends_on: + bitcoin: + condition: service_healthy + command: + [ + "/app/electrs_bitcoin/bin/electrs", + "-vvvv", + "--timestamp", + "--jsonrpc-import", + "--cookie=user:pass", + "--network=regtest", + "--daemon-rpc-addr=bitcoin:18443", + "--http-addr=0.0.0.0:3002", + "--electrum-rpc-addr=0.0.0.0:50001" + ] + ports: + - "3002:3002" + - "50001:50001" + networks: + - bitcoin-electrs + + lnd: + image: lightninglabs/lnd:v0.18.5-beta + container_name: ldk-node-lnd + depends_on: + - bitcoin + volumes: + - ${LND_DATA_DIR}:/root/.lnd + ports: + - "8081:8081" + - "9735:9735" + command: + - "--noseedbackup" + - "--trickledelay=5000" + - "--alias=ldk-node-lnd-test" + - "--externalip=lnd:9735" + - "--bitcoin.active" + - "--bitcoin.regtest" + - "--bitcoin.node=bitcoind" + - "--bitcoind.rpchost=bitcoin:18443" + - "--bitcoind.rpcuser=user" + - "--bitcoind.rpcpass=pass" + - "--bitcoind.zmqpubrawblock=tcp://bitcoin:28332" + - "--bitcoind.zmqpubrawtx=tcp://bitcoin:28333" + - "--accept-keysend" + - "--rpclisten=0.0.0.0:8081" + - "--tlsextradomain=lnd" + - "--tlsextraip=0.0.0.0" + networks: + - bitcoin-electrs + +networks: + bitcoin-electrs: + driver: bridge \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0949cadd3..f8bc62b80 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,7 +5,7 @@ // 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, vss_test))] +#![cfg(any(test, cln_test, lnd_test, vss_test))] #![allow(dead_code)] pub(crate) mod logging; diff --git a/tests/integration_tests_lnd.rs b/tests/integration_tests_lnd.rs new file mode 100755 index 000000000..feb981d8e --- /dev/null +++ b/tests/integration_tests_lnd.rs @@ -0,0 +1,227 @@ +#![cfg(lnd_test)] + +mod common; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Amount; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::{Builder, Event}; + +use lnd_grpc_rust::lnrpc::{ + invoice::InvoiceState::Settled as LndInvoiceStateSettled, 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 bitcoincore_rpc::Auth; +use bitcoincore_rpc::Client as BitcoindClient; + +use electrum_client::Client as ElectrumClient; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; + +use bitcoin::hex::DisplayHex; + +use std::default::Default; +use std::str::FromStr; +use tokio::fs; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_lnd() { + // Setup bitcoind / electrs clients + let bitcoind_client = BitcoindClient::new( + "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); + + // Setup LDK Node + let config = common::random_config(true); + let mut builder = Builder::from_config(config.node_config); + builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), None); + + let node = builder.build().unwrap(); + node.start().unwrap(); + + // Premine some funds and distribute + let address = node.onchain_payment().new_address().unwrap(); + let premine_amount = Amount::from_sat(5_000_000); + common::premine_and_distribute_funds( + &bitcoind_client, + &electrs_client, + vec![address], + premine_amount, + ); + + // Setup LND + let endpoint = "127.0.0.1:8081"; + let cert_path = std::env::var("LND_CERT_PATH").expect("LND_CERT_PATH not set"); + let macaroon_path = std::env::var("LND_MACAROON_PATH").expect("LND_MACAROON_PATH not set"); + let mut lnd = TestLndClient::new(cert_path, macaroon_path, endpoint.to_string()).await; + + let lnd_node_info = lnd.get_node_info().await; + let lnd_node_id = PublicKey::from_str(&lnd_node_info.identity_pubkey).unwrap(); + let lnd_address: SocketAddress = "127.0.0.1:9735".parse().unwrap(); + + node.sync_wallets().unwrap(); + + // Open the channel + let funding_amount_sat = 1_000_000; + + node.open_channel(lnd_node_id, lnd_address, funding_amount_sat, Some(500_000_000), None) + .unwrap(); + + let funding_txo = common::expect_channel_pending_event!(node, lnd_node_id); + common::wait_for_tx(&electrs_client, funding_txo.txid); + common::generate_blocks_and_wait(&bitcoind_client, &electrs_client, 6); + node.sync_wallets().unwrap(); + let user_channel_id = common::expect_channel_ready_event!(node, lnd_node_id); + + // Send a payment to LND + let lnd_invoice = lnd.create_invoice(100_000_000).await; + let parsed_invoice = lightning_invoice::Bolt11Invoice::from_str(&lnd_invoice).unwrap(); + + node.bolt11_payment().send(&parsed_invoice, None).unwrap(); + common::expect_event!(node, PaymentSuccessful); + let lnd_listed_invoices = lnd.list_invoices().await; + assert_eq!(lnd_listed_invoices.len(), 1); + assert_eq!(lnd_listed_invoices.first().unwrap().state, LndInvoiceStateSettled as i32); + + // Check route LND -> LDK + let amount_msat = 9_000_000; + let max_retries = 7; + for attempt in 1..=max_retries { + match lnd.query_routes(&node.node_id().to_string(), amount_msat).await { + Ok(routes) => { + if !routes.is_empty() { + break; + } + }, + Err(err) => { + if attempt == max_retries { + panic!("Failed to find route from LND to LDK: {}", err); + } + }, + }; + // wait for the payment process + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + + // Send a payment to LDK + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new("lndTest".to_string()).unwrap()); + let ldk_invoice = + node.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); + lnd.pay_invoice(&ldk_invoice.to_string()).await; + common::expect_event!(node, PaymentReceived); + + node.close_channel(&user_channel_id, lnd_node_id).unwrap(); + common::expect_event!(node, ChannelClosed); + node.stop().unwrap(); +} + +struct TestLndClient { + client: LndClient, +} + +impl TestLndClient { + async fn new(cert_path: String, macaroon_path: String, socket: String) -> Self { + // Read the contents of the file into a vector of bytes + let cert_bytes = fs::read(cert_path).await.expect("Failed to read tls cert file"); + let mac_bytes = fs::read(macaroon_path).await.expect("Failed to read macaroon file"); + + // 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 + } + ); + } + } +}