diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 53693fac2..13bc8c36b 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 diff --git a/Cargo.lock b/Cargo.lock index 231e27675..00d706193 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 90d4b837c..f56587512 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 0f851e3eb..937a63bd1 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/src/bin/cli.rs b/lianad/src/bin/cli.rs index 66cf3d764..c99f010c4 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); diff --git a/lianad/tests/common/bitcoind.rs b/lianad/tests/common/bitcoind.rs new file mode 100644 index 000000000..d99ebebdb --- /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 000000000..a03ec24f9 --- /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 000000000..39c78f2f3 --- /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 000000000..a0316e04a --- /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 000000000..914782764 --- /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 000000000..e43975ff9 --- /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 000000000..566c2fb6f --- /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 000000000..ea01296a1 --- /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 000000000..ebc6d4491 --- /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]); +}