From 364fd642351ea9ffbd4853e01797fe33f4f85a85 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 9 Dec 2025 23:26:41 +0000 Subject: [PATCH 1/3] compile liana-cli also on windows There can sometimes be errors about a missing main function when compiling this binary crate on Windows. This adds a simple placeholder for Windows that prints a message saying the CLI is not supported. --- lianad/src/bin/cli.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lianad/src/bin/cli.rs b/lianad/src/bin/cli.rs index 66cf3d7646..c99f010c4d 100644 --- a/lianad/src/bin/cli.rs +++ b/lianad/src/bin/cli.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_os = "windows"))] - use lianad::config::Config; use std::{ @@ -11,8 +9,15 @@ use std::{ use serde_json::Value as Json; +#[cfg(not(target_os = "windows"))] use std::os::unix::net::UnixStream; +#[cfg(target_os = "windows")] +fn main() { + eprintln!("liana-cli: Windows support not implemented"); + process::exit(1); +} + // Exits with error fn show_usage() { eprintln!("Usage:"); @@ -109,6 +114,7 @@ fn trimmed(mut vec: Vec, bytes_read: usize) -> Vec { vec } +#[cfg(not(target_os = "windows"))] fn main() { let args = env::args().collect(); let (conf_file, raw, method, params) = parse_args(args); From 94d170eb364c619281c2a1d6a8aec294dbf413c7 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Wed, 24 Sep 2025 17:08:58 +0100 Subject: [PATCH 2/3] test: add rust integration test framework This will allow to use lianad's Rust APIs in integration tests and to complement the Python functional tests, which only test lianad's RPC commands. As an initial example, the test_spend_change Python test has been added as a Rust test. --- Cargo.lock | 210 +++++++++++++++++++++++++----- Cargo.toml | 4 + lianad/Cargo.toml | 8 ++ lianad/tests/common/bitcoind.rs | 76 +++++++++++ lianad/tests/common/descriptor.rs | 115 ++++++++++++++++ lianad/tests/common/electrs.rs | 20 +++ lianad/tests/common/env.rs | 15 +++ lianad/tests/common/lianad.rs | 90 +++++++++++++ lianad/tests/common/mod.rs | 9 ++ lianad/tests/common/node.rs | 26 ++++ lianad/tests/common/utils.rs | 37 ++++++ lianad/tests/test_spend.rs | 117 +++++++++++++++++ 12 files changed, 698 insertions(+), 29 deletions(-) create mode 100644 lianad/tests/common/bitcoind.rs create mode 100644 lianad/tests/common/descriptor.rs create mode 100644 lianad/tests/common/electrs.rs create mode 100644 lianad/tests/common/env.rs create mode 100644 lianad/tests/common/lianad.rs create mode 100644 lianad/tests/common/mod.rs create mode 100644 lianad/tests/common/node.rs create mode 100644 lianad/tests/common/utils.rs create mode 100644 lianad/tests/test_spend.rs diff --git a/Cargo.lock b/Cargo.lock index 231e276750..00d7061930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,7 +263,7 @@ dependencies = [ "async-trait", "cfg-if", "pin-project", - "rustix", + "rustix 0.38.44", "thiserror 1.0.69", "tokio", "windows-sys 0.52.0", @@ -318,7 +318,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 0.38.44", "slab", "tracing", "windows-sys 0.59.0", @@ -361,7 +361,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 0.38.44", "tracing", ] @@ -388,7 +388,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 0.38.44", "signal-hook-registry", "slab", "windows-sys 0.59.0", @@ -511,7 +511,7 @@ version = "0.15.0" source = "git+https://github.com/wizardsardine/bdk?branch=release%2F1.0.0-alpha.13#8936e828861f0f32500c4156b6534238e5154b0f" dependencies = [ "bdk_chain", - "electrum-client", + "electrum-client 0.23.1", ] [[package]] @@ -821,7 +821,7 @@ dependencies = [ "bitflags 2.8.0", "log", "polling", - "rustix", + "rustix 0.38.44", "slab", "thiserror 1.0.69", ] @@ -833,7 +833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ "calloop", - "rustix", + "rustix 0.38.44", "wayland-backend", "wayland-client", ] @@ -1142,6 +1142,50 @@ dependencies = [ "libc", ] +[[package]] +name = "corepc-client" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755b8b9219b23d166a5897b5e2d8266cbdd0de5861d351b96f6db26bcf415f3" +dependencies = [ + "bitcoin", + "corepc-types", + "jsonrpc 0.18.0", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "corepc-node" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "768391062ec3812e223bb3031c5b2fcdd6e0e60b816157f21df82fd3e6617dc0" +dependencies = [ + "anyhow", + "bitcoin_hashes 0.14.0", + "corepc-client", + "flate2", + "log", + "minreq", + "serde_json", + "tar", + "tempfile", + "which 3.1.1", + "zip", +] + +[[package]] +name = "corepc-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22db78b0223b66f82f92b14345f06307078f76d94b18280431ea9bc6cd9cbb6" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + [[package]] name = "cosmic-text" version = "0.12.1" @@ -1423,7 +1467,7 @@ dependencies = [ "bytemuck", "drm-ffi", "drm-fourcc", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -1433,7 +1477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" dependencies = [ "drm-sys", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -1472,6 +1516,22 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "electrsd" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8926868af723c2819807809e54585992aaea0e26a6f5089ac8c2598eaec8d01" +dependencies = [ + "bitcoin_hashes 0.14.0", + "corepc-client", + "corepc-node", + "electrum-client 0.24.1", + "log", + "minreq", + "nix 0.25.1", + "zip", +] + [[package]] name = "electrum-client" version = "0.23.1" @@ -1489,6 +1549,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "electrum-client" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" +dependencies = [ + "bitcoin", + "log", + "serde", + "serde_json", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -2912,6 +2984,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq", + "serde", + "serde_json", +] + [[package]] name = "k256" version = "0.13.4" @@ -3086,7 +3170,7 @@ dependencies = [ "iced", "iced_aw", "iced_runtime", - "jsonrpc", + "jsonrpc 0.17.0", "liana", "liana-ui", "lianad", @@ -3124,26 +3208,29 @@ dependencies = [ name = "lianad" version = "13.0.0" dependencies = [ + "anyhow", "backtrace", "bdk_electrum", "bip329", "dirs", + "electrsd", "fern", - "jsonrpc", + "jsonrpc 0.17.0", "liana", "log", "miniscript", "rusqlite", "serde", "serde_json", + "tempfile", "toml 0.5.11", ] [[package]] name = "libc" -version = "0.2.169" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libfuzzer-sys" @@ -3235,6 +3322,12 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.7.4" @@ -3348,6 +3441,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3406,8 +3508,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0c420feb01b9fb5061f8c8f452534361dd783756dcf38ec45191ce55e7a161" dependencies = [ "log", + "once_cell", + "rustls 0.21.12", + "rustls-webpki 0.101.7", "serde", "serde_json", + "webpki-roots", ] [[package]] @@ -3506,6 +3612,20 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.26.4" @@ -3527,7 +3647,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -4204,7 +4324,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", - "rustix", + "rustix 0.38.44", "tracing", "windows-sys 0.59.0", ] @@ -4339,7 +4459,7 @@ dependencies = [ "regex", "syn 1.0.109", "tempfile", - "which", + "which 4.4.2", ] [[package]] @@ -4786,6 +4906,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.8.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.21.12" @@ -5207,7 +5340,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix", + "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", "wayland-client", @@ -5292,7 +5425,7 @@ dependencies = [ "objc2-quartz-core", "raw-window-handle", "redox_syscall 0.5.8", - "rustix", + "rustix 0.38.44", "tiny-xlib", "wasm-bindgen", "wayland-backend", @@ -5459,19 +5592,19 @@ checksum = "c65998313f8e17d0d553d28f91a0df93e4dbbbf770279c7bc21ca0f09ea1a1f6" dependencies = [ "filetime", "libc", + "xattr", ] [[package]] name = "tempfile" -version = "3.16.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "cfg-if", "fastrand", "getrandom 0.3.1", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -5865,7 +5998,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -6203,7 +6336,7 @@ checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 0.38.44", "scoped-tls", "smallvec", "wayland-sys", @@ -6216,7 +6349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" dependencies = [ "bitflags 2.8.0", - "rustix", + "rustix 0.38.44", "wayland-backend", "wayland-scanner", ] @@ -6238,7 +6371,7 @@ version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a93029cbb6650748881a00e4922b076092a6a08c11e7fbdb923f064b23968c5d" dependencies = [ - "rustix", + "rustix 0.38.44", "wayland-client", "xcursor", ] @@ -6443,6 +6576,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + [[package]] name = "which" version = "4.4.2" @@ -6452,7 +6594,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -6771,7 +6913,7 @@ dependencies = [ "pin-project", "raw-window-handle", "redox_syscall 0.4.1", - "rustix", + "rustix 0.38.44", "sctk-adwaita", "smithay-client-toolkit", "smol_str", @@ -6863,7 +7005,7 @@ dependencies = [ "libc", "libloading 0.8.6", "once_cell", - "rustix", + "rustix 0.38.44", "x11rb-protocol", ] @@ -6884,6 +7026,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.3", +] + [[package]] name = "xcursor" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index 90d4b837cc..f565875127 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,3 +70,7 @@ flate2 = { version = "1.0", default-features = false } winresource = "0.1.24" unicode-segmentation = "1.0" bitcoin = "0.32" + +# dev dependencies +anyhow = "1" +tempfile = "3.23" diff --git a/lianad/Cargo.toml b/lianad/Cargo.toml index 0f851e3eb4..937a63bd1c 100644 --- a/lianad/Cargo.toml +++ b/lianad/Cargo.toml @@ -52,3 +52,11 @@ jsonrpc = { workspace = true, features = ["minreq_http"], default-features = fal # import/export labels bip329 = { workspace = true, default-features = false } + +[dev-dependencies] +anyhow = { workspace = true } +tempfile = { workspace = true } + +# Platform-specific test dependencies (excluded on Windows) +[target.'cfg(not(windows))'.dev-dependencies] +electrsd = { version = "0.36", default-features = false, features = ["electrs_0_10_6", "corepc-node_29_0"] } diff --git a/lianad/tests/common/bitcoind.rs b/lianad/tests/common/bitcoind.rs new file mode 100644 index 0000000000..d99ebebdbe --- /dev/null +++ b/lianad/tests/common/bitcoind.rs @@ -0,0 +1,76 @@ +use std::thread::sleep; +use std::time::Duration; + +use electrsd::corepc_client::client_sync::v29 as rpc; +use electrsd::corepc_node::{Conf, Node, P2P}; +use miniscript::bitcoin::{self, address::NetworkUnchecked, Address}; + +use crate::common::utils::wait_for; + +pub fn start_bitcoind() -> anyhow::Result { + let mut conf = Conf::default(); + + conf.args.push("-printtoconsole"); + conf.args.push("-server"); + conf.args.push("-debug"); + conf.args.push("-debugexclude=libevent"); + conf.args.push("-debugexclude=tor"); + conf.args.push("-txindex=1"); // enable txindex for electrs + conf.args.push("-peertimeout=172800"); // = 2 days (2 * 24 * 60 * 60) + conf.args.push("-rpcthreads=32"); + + conf.p2p = P2P::Yes; // electrs requires p2p port open + + // Check for custom bitcoind binary path + let exe = match std::env::var("BITCOIND_PATH") { + Ok(path) if !path.is_empty() => { + println!("Using custom bitcoind binary"); + path + } + _ => { + println!("Using downloaded bitcoind binary"); + electrsd::corepc_node::downloaded_exe_path()? + } + }; + println!("bitcoind binary: {}", exe); + let bitcoind = Node::with_conf(&exe, &conf)?; + Ok(bitcoind) +} + +pub fn new_address_unchecked(client: &rpc::Client) -> Address { + client.new_address().unwrap().into_unchecked() +} + +/// Create bitcoind process and set up wallet. +pub fn setup_bitcoind() -> anyhow::Result { + let bitcoind = start_bitcoind().expect("start_bitcoind"); + + bitcoind + .client + .generate_to_address(101, &bitcoind.client.new_address().unwrap()) + .unwrap(); + + while bitcoind.client.get_balance().unwrap().0 < 50.0 { + sleep(Duration::from_millis(100)); + } + + Ok(bitcoind) +} + +pub fn generate_blocks(client: &rpc::Client, num_blocks: usize, wait_for_txs: &[bitcoin::Txid]) { + assert!(wait_for(|| { + let mempool = client.get_raw_mempool().unwrap(); + for txid in wait_for_txs { + if !mempool.0.contains(&txid.to_string()) { + return false; + } + } + true + })); + let old_block_count = client.get_block_count().unwrap(); + let addr = client.new_address().unwrap(); + client.generate_to_address(num_blocks, &addr).unwrap(); + assert!(wait_for( + || client.get_block_count().unwrap().0 == old_block_count.0 + num_blocks as u64 + )); +} diff --git a/lianad/tests/common/descriptor.rs b/lianad/tests/common/descriptor.rs new file mode 100644 index 0000000000..a03ec24f91 --- /dev/null +++ b/lianad/tests/common/descriptor.rs @@ -0,0 +1,115 @@ +use std::{collections::BTreeMap, str::FromStr}; + +use liana::{ + bip39::Mnemonic, + descriptors::{LianaDescriptor, LianaPolicy, PathInfo}, + signer::HotSigner, +}; +use miniscript::{ + bitcoin::{bip32, secp256k1, Network, Psbt}, + descriptor::{DerivPaths, DescriptorMultiXKey, Wildcard}, + DescriptorPublicKey, +}; + +// Create a hot signer. The index and timelock (if any) are used to vary the entropy +// so that different signers are created for different indices and timelocks. +fn create_hot_signer(index: u8, timelock: Option) -> HotSigner { + let timelock_bytes = timelock.unwrap_or_default().to_le_bytes(); + + let mut entropy = [0u8; 32]; + entropy[0] = timelock_bytes[0]; + entropy[1] = timelock_bytes[1]; + entropy[2] = index; + + let mnemonic = Mnemonic::from_entropy(&entropy).expect("valid entropy for mnemonic"); + + HotSigner::from_str(Network::Regtest, mnemonic.to_string().as_str()) + .expect("valid mnemonic for signer") +} + +fn hot_signer_multi_xkey(signer: &HotSigner) -> DescriptorPublicKey { + let secp = secp256k1::Secp256k1::signing_only(); + let fg = signer.fingerprint(&secp); + let xkey = signer.xpub_at(&bip32::DerivationPath::master(), &secp); + DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((fg, bip32::DerivationPath::master())), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/0").unwrap(), + bip32::DerivationPath::from_str("m/1").unwrap(), + ]) + .expect("valid deriv paths"), + wildcard: Wildcard::Unhardened, + }) +} + +/// Kinds of test descriptors that can be created. +pub enum DescriptorKind { + /// A primary path and one recovery path. + /// + /// Each spending path consists of one signer only. + SingleSig, +} + +pub struct TestDescriptor { + pub descriptor: LianaDescriptor, + pub primary_signers: Vec, + pub recovery_signers: BTreeMap>, +} + +impl TestDescriptor { + pub fn new(kind: DescriptorKind, use_taproot: bool) -> Self { + match kind { + DescriptorKind::SingleSig => { + const TIMELOCK: u16 = 10; + // One signer per path. + let prim_signer = create_hot_signer(0, None); + let recov_signer = create_hot_signer(0, Some(TIMELOCK)); + + let prim_xpub = hot_signer_multi_xkey(&prim_signer); + let recov_xpub = hot_signer_multi_xkey(&recov_signer); + let recov_paths = BTreeMap::from([(TIMELOCK, PathInfo::Single(recov_xpub))]); + + let policy = if use_taproot { + LianaPolicy::new(PathInfo::Single(prim_xpub), recov_paths) + .expect("taproot policy") + } else { + LianaPolicy::new_legacy(PathInfo::Single(prim_xpub), recov_paths) + .expect("legacy policy") + }; + let descriptor = LianaDescriptor::new(policy); + Self { + descriptor, + primary_signers: vec![prim_signer], + recovery_signers: BTreeMap::from([(TIMELOCK, vec![recov_signer])]), + } + } + } + } + + /// Sign the given PSBT using the signer at the given index in the path with the given + /// timelock, if any, or otherwise the primary path. + pub fn sign_psbt( + &self, + psbt: Psbt, + secp: &secp256k1::Secp256k1, + signer_index: usize, + timelock: Option, + ) -> Result { + let signers = if let Some(tl) = timelock { + self.recovery_signers + .get(&tl) + .expect("signers for given timelock") + } else { + &self.primary_signers + }; + signers[signer_index].sign_psbt(psbt, secp).map_err(|e| { + anyhow::anyhow!( + "signing psbt with signer at timelock {:?} and index {} failed: {}", + timelock, + signer_index, + e + ) + }) + } +} diff --git a/lianad/tests/common/electrs.rs b/lianad/tests/common/electrs.rs new file mode 100644 index 0000000000..39c78f2f39 --- /dev/null +++ b/lianad/tests/common/electrs.rs @@ -0,0 +1,20 @@ +use electrsd::corepc_node::Node; +use electrsd::{downloaded_exe_path, ElectrsD}; + +pub fn start_electrs(bitcoind: &Node) -> anyhow::Result { + let mut conf = electrsd::Conf::default(); + conf.view_stderr = true; + let exe_path = match std::env::var("ELECTRS_PATH") { + Ok(path) if !path.is_empty() => { + println!("Using custom electrs binary"); + path + } + _ => { + println!("Using downloaded electrs binary"); + downloaded_exe_path().ok_or_else(|| anyhow::anyhow!("electrs binary not available"))? + } + }; + println!("electrs binary: {}", exe_path); + let electrs = ElectrsD::with_conf(exe_path, bitcoind, &conf)?; + Ok(electrs) +} diff --git a/lianad/tests/common/env.rs b/lianad/tests/common/env.rs new file mode 100644 index 0000000000..a0316e04aa --- /dev/null +++ b/lianad/tests/common/env.rs @@ -0,0 +1,15 @@ +use crate::common::node::NodeKind; + +/// Returns the node to use for tests. +pub fn node_kind() -> NodeKind { + // we use same env var as from Python tests + match std::env::var("BITCOIN_BACKEND_TYPE") { + Ok(v) if v.eq_ignore_ascii_case("electrs") => NodeKind::Electrs, + _ => NodeKind::Bitcoind, + } +} + +/// Whether test runs should use Taproot descriptors. +pub fn use_taproot() -> bool { + matches!(std::env::var("USE_TAPROOT"), Ok(v) if v == "1" || v.eq_ignore_ascii_case("true")) +} diff --git a/lianad/tests/common/lianad.rs b/lianad/tests/common/lianad.rs new file mode 100644 index 0000000000..9147827647 --- /dev/null +++ b/lianad/tests/common/lianad.rs @@ -0,0 +1,90 @@ +use electrsd; +use electrsd::corepc_node::Node as BitcoinD; +use liana::descriptors::LianaDescriptor; +use lianad::config::{ + BitcoinBackend, BitcoinConfig, BitcoindConfig, BitcoindRpcAuth, Config as LianadConfig, + ElectrumConfig, +}; +use lianad::datadir::DataDirectory; +use lianad::miniscript::bitcoin::Network; +use lianad::{DaemonControl, DaemonHandle}; + +use crate::common::descriptor::{DescriptorKind, TestDescriptor}; +use crate::common::node::{Node, NodeKind}; + +pub struct LianaD<'a> { + pub handle: DaemonHandle, + pub desc: TestDescriptor, + // keep node and temp dir alive while lianad is running + _node: Node<'a>, + _data_directory: tempfile::TempDir, +} + +impl<'a> LianaD<'a> { + pub fn new( + bitcoind: &'a BitcoinD, + node_kind: NodeKind, + use_taproot: bool, + descriptor_kind: DescriptorKind, + ) -> Self { + let node = Node::new(node_kind, bitcoind).expect("start node for lianad"); + let desc = TestDescriptor::new(descriptor_kind, use_taproot); + let data_directory = tempfile::TempDir::new().unwrap(); + + let cfg = lianad_config( + Network::Regtest, + desc.descriptor.clone(), + &node, + &data_directory, + ); + let handle = DaemonHandle::start_default(cfg, false).expect("start daemon"); + Self { + handle, + desc, + _node: node, + _data_directory: data_directory, + } + } + + pub fn new_single_sig(bitcoind: &'a BitcoinD, node_kind: NodeKind, use_taproot: bool) -> Self { + Self::new(bitcoind, node_kind, use_taproot, DescriptorKind::SingleSig) + } + + pub fn control(&self) -> &DaemonControl { + match &self.handle { + DaemonHandle::Controller { control, .. } => control, + _ => panic!("expected Controller handle in integration tests"), + } + } +} + +fn lianad_config( + network: Network, + liana_desc: LianaDescriptor, + node: &Node<'_>, + data_directory: &tempfile::TempDir, +) -> LianadConfig { + let bitcoin_config = BitcoinConfig { + network, + poll_interval_secs: std::time::Duration::from_secs(1), + }; + + let backend_config = match node { + Node::Bitcoind(d) => BitcoinBackend::Bitcoind(BitcoindConfig { + rpc_auth: BitcoindRpcAuth::CookieFile(d.params.cookie_file.clone()), + addr: std::net::SocketAddr::V4(d.params.rpc_socket), + }), + Node::Electrs(e) => BitcoinBackend::Electrum(ElectrumConfig { + addr: e.electrum_url.clone(), + validate_domain: true, + }), + }; + + LianadConfig::new( + bitcoin_config, + Some(backend_config), + log::LevelFilter::Debug, + liana_desc, + DataDirectory::new(data_directory.path().to_path_buf()), + ) +} diff --git a/lianad/tests/common/mod.rs b/lianad/tests/common/mod.rs new file mode 100644 index 0000000000..e43975ff93 --- /dev/null +++ b/lianad/tests/common/mod.rs @@ -0,0 +1,9 @@ +#![cfg(not(target_os = "windows"))] + +pub mod bitcoind; +pub mod descriptor; +pub mod electrs; +pub mod env; +pub mod lianad; +pub mod node; +pub mod utils; diff --git a/lianad/tests/common/node.rs b/lianad/tests/common/node.rs new file mode 100644 index 0000000000..566c2fb6f0 --- /dev/null +++ b/lianad/tests/common/node.rs @@ -0,0 +1,26 @@ +use electrsd::corepc_node::Node as BitcoinD; +use electrsd::{self, ElectrsD}; + +use crate::common::electrs::start_electrs; + +/// Node kind used by lianad process. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum NodeKind { + Bitcoind, + Electrs, // we may also want to test against other electrum servers in the future +} + +/// Node instance used by lianad process. +pub enum Node<'a> { + Bitcoind(&'a BitcoinD), + Electrs(Box), +} + +impl<'a> Node<'a> { + pub fn new(kind: NodeKind, bitcoind: &'a BitcoinD) -> anyhow::Result { + match kind { + NodeKind::Bitcoind => Ok(Node::Bitcoind(bitcoind)), + NodeKind::Electrs => start_electrs(bitcoind).map(|e| Node::Electrs(Box::new(e))), + } + } +} diff --git a/lianad/tests/common/utils.rs b/lianad/tests/common/utils.rs new file mode 100644 index 0000000000..ea01296a11 --- /dev/null +++ b/lianad/tests/common/utils.rs @@ -0,0 +1,37 @@ +use std::thread::sleep; +use std::time::{Duration, Instant}; + +const TIMEOUT: Duration = Duration::from_secs(30); + +pub fn wait_for_while_condition_holds(success: S, condition: C) -> bool +where + S: Fn() -> bool, + C: Fn() -> bool, +{ + let start = Instant::now(); + let mut interval = Duration::from_millis(250); + + loop { + if start.elapsed() > TIMEOUT { + return false; + } + + if !condition() { + return false; + } + + if success() { + return true; + } + + sleep(interval); + interval = (interval * 2).min(Duration::from_secs(5)); + } +} + +pub fn wait_for(success: S) -> bool +where + S: Fn() -> bool, +{ + wait_for_while_condition_holds(success, || true) +} diff --git a/lianad/tests/test_spend.rs b/lianad/tests/test_spend.rs new file mode 100644 index 0000000000..ebc6d44912 --- /dev/null +++ b/lianad/tests/test_spend.rs @@ -0,0 +1,117 @@ +#![cfg(not(target_os = "windows"))] + +mod common; + +use lianad::commands::CreateSpendResult; +use miniscript::bitcoin::address::NetworkUnchecked; +use miniscript::bitcoin::{secp256k1, Address, Amount}; +use std::collections::HashMap; + +use crate::common::bitcoind::{generate_blocks, new_address_unchecked, setup_bitcoind}; +use crate::common::env::{node_kind, use_taproot}; +use crate::common::lianad::LianaD; +use crate::common::utils::wait_for; + +#[ignore] +#[test] +fn test_spend_change() { + // We can spend a coin that was received on a change address. + + let bitcoind = setup_bitcoind().unwrap(); + let lianad = LianaD::new_single_sig(&bitcoind, node_kind(), use_taproot()); + let control = lianad.control(); + + assert!(wait_for( + || control.get_info().block_height as u64 == bitcoind.client.get_block_count().unwrap().0 + )); + + // Receive a coin on a fresh receive address. + let addr = control.get_new_address().address; + let txid = bitcoind + .client + .send_to_address(&addr, Amount::from_sat(1_000_000)) + .unwrap() + .txid() + .unwrap(); + + generate_blocks(&bitcoind.client, 1, &[txid]); + + assert!(wait_for(|| control.list_coins(&[], &[]).coins.len() == 1)); + + // Create a transaction that will spend this coin to: + // 1. one of our receive addresses + // 2. an external address + // 3. one of our change addresses + let outpoints: Vec<_> = control + .list_coins(&[], &[]) + .coins + .iter() + .map(|c| c.outpoint) + .collect(); + assert_eq!(outpoints.len(), 1); + + let destinations = HashMap::, u64>::from([ + (new_address_unchecked(&bitcoind.client), 100_000), + (control.get_new_address().address.into_unchecked(), 100_000), + ]); + + let res = control + .create_spend(&destinations, &outpoints, 2, None) + .unwrap(); + let CreateSpendResult::Success { + psbt: spend_psbt, + warnings, + } = res + else { + panic!("expected successful spend creation, got {:?}", res); + }; + + // The transaction must contain a change output. + assert_eq!(spend_psbt.outputs.len(), 3); + assert_eq!(spend_psbt.unsigned_tx.output.len(), 3); + assert!(warnings.is_empty(), "unexpected warnings: {:?}", warnings); + + // Sign and broadcast this first spend transaction. + let secp = secp256k1::Secp256k1::new(); + let signed_psbt = lianad.desc.sign_psbt(spend_psbt, &secp, 0, None).unwrap(); + control.update_spend(signed_psbt.clone()).unwrap(); + let spend_txid = signed_psbt.unsigned_tx.compute_txid(); + control.broadcast_spend(&spend_txid).unwrap(); + generate_blocks(&bitcoind.client, 1, &[spend_txid]); + assert!(wait_for(|| control.list_coins(&[], &[]).coins.len() == 3)); + + // Now create a new transaction that spends the change output as well as + // the output sent to the receive address. + let outpoints: Vec<_> = control + .list_coins(&[], &[]) + .coins + .iter() + .filter_map(|c| c.spend_info.as_ref().is_none().then_some(c.outpoint)) + .collect(); + assert_eq!(outpoints.len(), 2); + let destinations = HashMap::, u64>::from([( + new_address_unchecked(&bitcoind.client), + 100_000, + )]); + + let res = control + .create_spend(&destinations, &outpoints, 2, None) + .unwrap(); + let CreateSpendResult::Success { + psbt: spend_psbt, + warnings, + } = res + else { + panic!("expected successful spend creation, got {:?}", res); + }; + assert_eq!(spend_psbt.outputs.len(), 2); + assert_eq!(spend_psbt.unsigned_tx.output.len(), 2); + assert!(warnings.is_empty(), "unexpected warnings: {:?}", warnings); + + // We can sign and broadcast it. + let signed_psbt = lianad.desc.sign_psbt(spend_psbt, &secp, 0, None).unwrap(); + control.update_spend(signed_psbt.clone()).unwrap(); + let spend_txid = signed_psbt.unsigned_tx.compute_txid(); + control.broadcast_spend(&spend_txid).unwrap(); + generate_blocks(&bitcoind.client, 1, &[spend_txid]); +} From 47c99ac97fc2bb4030eeff109fb6c791dc38fd09 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 9 Dec 2025 21:03:12 +0000 Subject: [PATCH 3/3] qa: add lianad rust integration tests --- .github/workflows/integration.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 53693fac28..13bc8c36be 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -107,13 +107,13 @@ jobs: curl -O https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz echo "49df6e444515d457ea0b885d66f521f2a26ca92ccf73d5296082e633544253bf bitcoin-24.0.1-x86_64-linux-gnu.tar.gz" | sha256sum -c tar -xzf bitcoin-24.0.1-x86_64-linux-gnu.tar.gz - export BITCOIND_PATH=bitcoin-24.0.1/bin/bitcoind + export BITCOIND_PATH=$PWD/bitcoin-24.0.1/bin/bitcoind export IS_NOT_BITCOIND_24=0 else curl -O https://bitcoincore.org/bin/bitcoin-core-29.0/bitcoin-29.0-x86_64-linux-gnu.tar.gz echo "a681e4f6ce524c338a105f214613605bac6c33d58c31dc5135bbc02bc458bb6c bitcoin-29.0-x86_64-linux-gnu.tar.gz" | sha256sum -c tar -xzf bitcoin-29.0-x86_64-linux-gnu.tar.gz - export BITCOIND_PATH=bitcoin-29.0/bin/bitcoind + export BITCOIND_PATH=$PWD/bitcoin-29.0/bin/bitcoind fi echo "BITCOIN_BACKEND_TYPE = $BITCOIN_BACKEND_TYPE" @@ -131,8 +131,11 @@ jobs: tar -xzf liana-0.3.1-x86_64-linux-gnu.tar.gz export OLD_LIANAD_PATH="$PWD/liana-0.3.1-x86_64-linux-gnu/lianad" + # Run the functional tests (Rust) + cargo test --package lianad --verbose --color always -- --ignored --nocapture + # Clean Cargo index before cache save rm -rf ~/.cargo/registry/index - # Run the functional tests + # Run the functional tests (Python) LIANAD_PATH=$PWD/target/release/lianad pytest tests/ -vvv -n 8