diff --git a/Cargo.lock b/Cargo.lock index e972f7eb8..c5f9766f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1167,11 +1167,13 @@ dependencies = [ "bdk_electrum", "bdk_wallet", "bitcoin 0.32.8", + "bitcoin-harness", "derive_builder", "electrum-pool", "futures", "moka", "proptest", + "rand 0.8.5", "reqwest 0.12.25", "rust_decimal", "serde", @@ -1179,9 +1181,12 @@ dependencies = [ "swap-env", "swap-proptest", "swap-serde", + "testcontainers", "thiserror 1.0.69", "tokio", "tracing", + "tracing-subscriber", + "url", ] [[package]] diff --git a/bitcoin-wallet/Cargo.toml b/bitcoin-wallet/Cargo.toml index dd4f62af4..f2562e287 100644 --- a/bitcoin-wallet/Cargo.toml +++ b/bitcoin-wallet/Cargo.toml @@ -28,3 +28,10 @@ swap-serde = { path = "../swap-serde" } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +bitcoin-harness = { git = "https://github.com/eigenwallet/bitcoin-harness-rs", branch = "master" } +rand = { workspace = true } +testcontainers = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } diff --git a/bitcoin-wallet/tests/harness/bitcoind.rs b/bitcoin-wallet/tests/harness/bitcoind.rs new file mode 100644 index 000000000..34c4f3df4 --- /dev/null +++ b/bitcoin-wallet/tests/harness/bitcoind.rs @@ -0,0 +1,90 @@ +use std::collections::BTreeMap; + +use testcontainers::{core::WaitFor, Image, ImageArgs}; + +pub const RPC_USER: &str = "admin"; +pub const RPC_PASSWORD: &str = "123"; +pub const RPC_PORT: u16 = 18443; +pub const PORT: u16 = 18886; +pub const DATADIR: &str = "/home/bdk"; + +#[derive(Debug)] +pub struct Bitcoind { + entrypoint: Option, + volumes: BTreeMap, +} + +impl Image for Bitcoind { + type Args = BitcoindArgs; + + fn name(&self) -> String { + "coblox/bitcoin-core".into() + } + + fn tag(&self) -> String { + "0.21.0".into() + } + + fn ready_conditions(&self) -> Vec { + vec![WaitFor::message_on_stdout("init message: Done loading")] + } + + fn volumes(&self) -> Box + '_> { + Box::new(self.volumes.iter()) + } + + fn entrypoint(&self) -> Option { + self.entrypoint.to_owned() + } +} + +impl Default for Bitcoind { + fn default() -> Self { + Bitcoind { + entrypoint: Some("/usr/bin/bitcoind".into()), + volumes: BTreeMap::default(), + } + } +} + +impl Bitcoind { + pub fn with_volume(mut self, volume: String) -> Self { + self.volumes.insert(volume, DATADIR.to_string()); + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct BitcoindArgs; + +impl IntoIterator for BitcoindArgs { + type Item = String; + type IntoIter = ::std::vec::IntoIter; + + fn into_iter(self) -> ::IntoIter { + let args = vec![ + "-server".to_string(), + "-regtest".to_string(), + "-listen=1".to_string(), + "-prune=0".to_string(), + "-rpcallowip=0.0.0.0/0".to_string(), + "-rpcbind=0.0.0.0".to_string(), + format!("-rpcuser={}", RPC_USER), + format!("-rpcpassword={}", RPC_PASSWORD), + "-printtoconsole".to_string(), + "-fallbackfee=0.0002".to_string(), + format!("-datadir={}", DATADIR), + format!("-rpcport={}", RPC_PORT), + format!("-port={}", PORT), + "-rest".to_string(), + ]; + + args.into_iter() + } +} + +impl ImageArgs for BitcoindArgs { + fn into_iterator(self) -> Box> { + Box::new(self.into_iter()) + } +} diff --git a/bitcoin-wallet/tests/harness/electrs.rs b/bitcoin-wallet/tests/harness/electrs.rs new file mode 100644 index 000000000..f791e77c5 --- /dev/null +++ b/bitcoin-wallet/tests/harness/electrs.rs @@ -0,0 +1,136 @@ +use std::collections::BTreeMap; + +use bitcoin::Network; +use testcontainers::{core::WaitFor, Image, ImageArgs}; + +use super::bitcoind; + +pub const HTTP_PORT: u16 = 60401; +pub const RPC_PORT: u16 = 3002; + +#[derive(Debug)] +pub struct Electrs { + tag: String, + args: ElectrsArgs, + entrypoint: Option, + wait_for_message: String, + volumes: BTreeMap, +} + +impl Image for Electrs { + type Args = ElectrsArgs; + + fn name(&self) -> String { + "vulpemventures/electrs".into() + } + + fn tag(&self) -> String { + self.tag.clone() + } + + fn ready_conditions(&self) -> Vec { + vec![WaitFor::message_on_stderr(self.wait_for_message.clone())] + } + + fn volumes(&self) -> Box + '_> { + Box::new(self.volumes.iter()) + } + + fn entrypoint(&self) -> Option { + self.entrypoint.to_owned() + } +} + +impl Default for Electrs { + fn default() -> Self { + Electrs { + tag: "v0.16.0.3".into(), + args: ElectrsArgs::default(), + entrypoint: Some("/build/electrs".into()), + wait_for_message: "Running accept thread".to_string(), + volumes: BTreeMap::default(), + } + } +} + +impl Electrs { + pub fn with_tag(self, tag_str: &str) -> Self { + Electrs { + tag: tag_str.to_string(), + ..self + } + } + + pub fn with_volume(mut self, volume: String) -> Self { + self.volumes.insert(volume, bitcoind::DATADIR.to_string()); + self + } + + pub fn with_daemon_rpc_addr(mut self, name: String) -> Self { + self.args.daemon_rpc_addr = name; + self + } + + pub fn self_and_args(self) -> (Self, ElectrsArgs) { + let args = self.args.clone(); + (self, args) + } +} + +#[derive(Debug, Clone)] +pub struct ElectrsArgs { + pub network: Network, + pub daemon_dir: String, + pub daemon_rpc_addr: String, + pub cookie: String, + pub http_addr: String, + pub electrum_rpc_addr: String, + pub cors: String, +} + +impl Default for ElectrsArgs { + fn default() -> Self { + ElectrsArgs { + network: Network::Regtest, + daemon_dir: bitcoind::DATADIR.to_string(), + daemon_rpc_addr: format!("0.0.0.0:{}", bitcoind::RPC_PORT), + cookie: format!("{}:{}", bitcoind::RPC_USER, bitcoind::RPC_PASSWORD), + http_addr: format!("0.0.0.0:{}", HTTP_PORT), + electrum_rpc_addr: format!("0.0.0.0:{}", RPC_PORT), + cors: "*".to_string(), + } + } +} + +impl IntoIterator for ElectrsArgs { + type Item = String; + type IntoIter = ::std::vec::IntoIter; + + fn into_iter(self) -> ::IntoIter { + let mut args = Vec::new(); + + match self.network { + Network::Testnet => args.push("--network=testnet".to_string()), + Network::Regtest => args.push("--network=regtest".to_string()), + Network::Bitcoin => {} + Network::Signet => panic!("signet not supported"), + otherwise => panic!("unsupported network: {:?}", otherwise), + } + + args.push("-vvvvv".to_string()); + args.push(format!("--daemon-dir={}", self.daemon_dir.as_str())); + args.push(format!("--daemon-rpc-addr={}", self.daemon_rpc_addr)); + args.push(format!("--cookie={}", self.cookie)); + args.push(format!("--http-addr={}", self.http_addr)); + args.push(format!("--electrum-rpc-addr={}", self.electrum_rpc_addr)); + args.push(format!("--cors={}", self.cors)); + + args.into_iter() + } +} + +impl ImageArgs for ElectrsArgs { + fn into_iterator(self) -> Box> { + Box::new(self.into_iter()) + } +} diff --git a/bitcoin-wallet/tests/harness/mod.rs b/bitcoin-wallet/tests/harness/mod.rs new file mode 100644 index 000000000..dda409c3a --- /dev/null +++ b/bitcoin-wallet/tests/harness/mod.rs @@ -0,0 +1,273 @@ +pub mod bitcoind; +pub mod electrs; + +use anyhow::{Context, Result}; +use bitcoin_harness::{BitcoindRpcApi, Client as BitcoindClient}; +use testcontainers::clients::Cli; +use testcontainers::{Container, RunnableImage}; +use url::Url; + +pub const BITCOIN_TEST_WALLET_NAME: &str = "bitcoin-wallet-it"; + +#[allow(dead_code)] +pub struct TestEnv<'a> { + pub electrum_url: String, + pub bitcoind_url: Url, + pub bitcoind: BitcoindClient, + pub electrs_port: u16, + _bitcoind_container: Container<'a, bitcoind::Bitcoind>, + _electrs_container: Container<'a, electrs::Electrs>, +} + +pub async fn setup<'a>(cli: &'a Cli) -> Result> { + ensure_docker_available()?; + + let prefix = random_prefix(); + let network = format!("{}-btc", prefix); + let bitcoind_name = format!("{}-bitcoind", prefix); + + let (bitcoind_container, bitcoind_url) = init_bitcoind_container(cli, prefix.clone(), bitcoind_name.clone(), network.clone()) + .await + .context("init bitcoind container")?; + + let electrs_container = init_electrs_container(cli, prefix, bitcoind_name, network, bitcoind::RPC_PORT) + .await + .context("init electrs container")?; + + let electrs_port = electrs_container.get_host_port_ipv4(electrs::RPC_PORT); + // Use a plain TCP electrum URL; we explicitly wait for electrs readiness below. + let electrum_url = format!("tcp://127.0.0.1:{}", electrs_port); + + let bitcoind = BitcoindClient::new(bitcoind_url.clone()); + + // Ensure bitcoind has a wallet with mature coins we can spend from. + init_bitcoind_wallet(&bitcoind).await?; + + // Electrs can print its "ready" line before it's actually able to serve requests. + // Wait until it answers at least one basic RPC call. + wait_for_electrs(&electrum_url).await?; + + Ok(TestEnv { + electrum_url, + bitcoind_url, + bitcoind, + electrs_port, + _bitcoind_container: bitcoind_container, + _electrs_container: electrs_container, + }) +} + +async fn wait_for_electrs(url: &str) -> Result<()> { + use std::io::{Read, Write}; + use std::net::TcpStream; + use std::time::{Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(30); + let (host, port) = parse_tcp_electrum_host_port(url)?; + + loop { + let host = host.clone(); + let res = tokio::task::spawn_blocking(move || { + let addr = (host.as_str(), port); + let mut stream = TcpStream::connect(addr) + .with_context(|| format!("failed to connect to electrs at {host}:{port}"))?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .context("failed to set read timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .context("failed to set write timeout")?; + + // Minimal Electrum JSON-RPC request. + // If electrs isn't ready, it may close immediately (EOF) or not respond. + let req = b"{\"id\":0,\"method\":\"server.version\",\"params\":[\"bitcoin-wallet-it\",\"1.4\"]}\n"; + stream.write_all(req).context("failed to write electrum request")?; + stream.flush().ok(); + + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).context("failed to read electrum response")?; + if n == 0 { + anyhow::bail!("EOF") + } + + let s = String::from_utf8_lossy(&buf[..n]); + if !s.contains("\"result\"") { + anyhow::bail!("unexpected electrum response: {s}") + } + + Ok::<(), anyhow::Error>(()) + }) + .await + .context("failed to join electrs readiness task")?; + + match res { + Ok(()) => return Ok(()), + Err(e) => { + if Instant::now() >= deadline { + anyhow::bail!("electrs did not become ready in time: {e}") + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } +} + +fn parse_tcp_electrum_host_port(url: &str) -> Result<(String, u16)> { + // Expected forms in this repo: + // - tcp://@127.0.0.1:50001 + // - tcp://@localhost:50001 + // - tcp://user:pass@host:port + let rest = url + .strip_prefix("tcp://") + .ok_or_else(|| anyhow::anyhow!("unsupported electrum url scheme: {url}"))?; + + let host_port = rest + .rsplit('@') + .next() + .unwrap_or(rest) + .trim(); + + let mut parts = host_port.split(':'); + let host = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing host in electrum url: {url}"))? + .to_string(); + let port_str = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing port in electrum url: {url}"))?; + let port: u16 = port_str + .parse() + .with_context(|| format!("invalid port in electrum url: {url}"))?; + + Ok((host, port)) +} + +fn ensure_docker_available() -> Result<()> { + let output = std::process::Command::new("docker") + .arg("info") + .output() + .context("failed to execute `docker info` (is Docker installed?)")?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "Docker daemon is not reachable. Start Docker Desktop (or the Docker daemon) and re-run the tests.\n\n`docker info` error:\n{stderr}" + ) +} + +pub async fn fund_and_mine( + bitcoind: &BitcoindClient, + recipient: bitcoin::Address, + amount: bitcoin::Amount, +) -> Result<()> { + bitcoind + .send_to_address(BITCOIN_TEST_WALLET_NAME, recipient, amount) + .await + .context("send_to_address")?; + + let miner_addr = bitcoind + .with_wallet(BITCOIN_TEST_WALLET_NAME)? + .getnewaddress(None, None) + .await + .context("getnewaddress")?; + + let miner_addr = miner_addr.require_network(bitcoind.network().await?)?; + + bitcoind + .generatetoaddress(1, miner_addr) + .await + .context("generatetoaddress")?; + + // We don't get the txid directly from send_to_address in this harness; for our + // wallet tests it's enough that a tx is confirmed and shows up after sync. + // Callers can query the wallet for the txid if needed. + Ok(()) +} + +async fn init_bitcoind_wallet(bitcoind: &BitcoindClient) -> Result<()> { + // Idempotent-ish: if wallet exists, createwallet will error. We treat that as OK. + let _ = bitcoind + .createwallet(BITCOIN_TEST_WALLET_NAME, None, None, None, None) + .await; + + let reward_address = bitcoind + .with_wallet(BITCOIN_TEST_WALLET_NAME)? + .getnewaddress(None, None) + .await + .context("getnewaddress")?; + + let reward_address = reward_address.require_network(bitcoind.network().await?)?; + + // Mine enough blocks so coinbase is spendable. + bitcoind + .generatetoaddress(101, reward_address) + .await + .context("initial mining")?; + + Ok(()) +} + +async fn init_bitcoind_container<'a>( + cli: &'a Cli, + volume: String, + name: String, + network: String, +) -> Result<(Container<'a, bitcoind::Bitcoind>, Url)> { + let image = bitcoind::Bitcoind::default().with_volume(volume); + let image = RunnableImage::from(image) + .with_container_name(name) + .with_network(network); + + let docker = cli.run(image); + let host_rpc_port = docker.get_host_port_ipv4(bitcoind::RPC_PORT); + + let bitcoind_url = { + let input = format!( + "http://{}:{}@127.0.0.1:{}", + bitcoind::RPC_USER, + bitcoind::RPC_PASSWORD, + host_rpc_port + ); + Url::parse(&input).expect("valid bitcoind rpc url") + }; + + Ok((docker, bitcoind_url)) +} + +async fn init_electrs_container<'a>( + cli: &'a Cli, + volume: String, + bitcoind_container_name: String, + network: String, + bitcoind_rpc_port_in_network: u16, +) -> Result> { + let bitcoind_rpc_addr = format!("{}:{}", bitcoind_container_name, bitcoind_rpc_port_in_network); + let image = electrs::Electrs::default() + .with_volume(volume) + .with_daemon_rpc_addr(bitcoind_rpc_addr) + .with_tag("latest"); + + let image = RunnableImage::from(image.self_and_args()) + .with_network(network.clone()) + .with_container_name(format!("{}_electrs", network)); + + Ok(cli.run(image)) +} + +fn random_prefix() -> String { + use rand::distributions::Alphanumeric; + use rand::{thread_rng, Rng}; + use std::iter; + + const LEN: usize = 8; + + let mut rng = thread_rng(); + iter::repeat(()) + .map(|()| rng.sample(Alphanumeric)) + .map(char::from) + .take(LEN) + .collect() +} diff --git a/bitcoin-wallet/tests/integration.rs b/bitcoin-wallet/tests/integration.rs new file mode 100644 index 000000000..bd6846726 --- /dev/null +++ b/bitcoin-wallet/tests/integration.rs @@ -0,0 +1,175 @@ +mod harness; + +use anyhow::Result; +use bitcoin_harness::BitcoindRpcApi; +use bitcoin_wallet::{PersisterConfig, WalletBuilder}; +use std::time::Duration; +use testcontainers::clients::Cli; + +async fn sync_until_balance( + wallet: &bitcoin_wallet::Wallet, + expected_at_least: bitcoin::Amount, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + wallet.sync().await?; + if wallet.balance().await? >= expected_at_least { + return Ok(()); + } + + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for wallet balance to reach {} sats", + expected_at_least.to_sat() + ); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[derive(Clone, Debug)] +struct TestSeed([u8; 64]); + +impl Default for TestSeed { + fn default() -> Self { + // Deterministic seed for reproducible integration tests. + Self([42u8; 64]) + } +} + +impl bitcoin_wallet::BitcoinWalletSeed for TestSeed { + fn derive_extended_private_key( + &self, + network: bitcoin::Network, + ) -> anyhow::Result { + #[allow(deprecated)] + { + Ok(bitcoin::bip32::ExtendedPrivKey::new_master(network, &self.0)?) + } + } + + fn derive_extended_private_key_legacy( + &self, + network: bdk::bitcoin::Network, + ) -> anyhow::Result { + Ok(bdk::bitcoin::util::bip32::ExtendedPrivKey::new_master( + network, + &self.0, + )?) + } +} + +fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_env_filter("info,bitcoin_wallet=debug,electrum_pool=debug,testcontainers=info") + .with_test_writer() + .try_init(); +} + +#[tokio::test] +async fn wallet_syncs_and_receives_funds() -> Result<()> { + init_tracing(); + + let cli = Cli::default(); + let env = harness::setup(&cli).await?; + + let wallet = WalletBuilder::::default() + .seed(TestSeed::default()) + .network(bitcoin::Network::Regtest) + .electrum_rpc_urls(vec![env.electrum_url.clone()]) + .persister(PersisterConfig::InMemorySqlite) + .finality_confirmations(1u32) + .target_block(1u32) + .sync_interval(Duration::from_millis(0)) + .use_mempool_space_fee_estimation(false) + .build() + .await?; + + wallet.sync().await?; + + let receive_addr = wallet.new_address().await?; + + // Fund wallet via bitcoind+electrs + let amount = bitcoin::Amount::from_sat(50_000); + harness::fund_and_mine(&env.bitcoind, receive_addr, amount).await?; + + sync_until_balance(&wallet, amount).await?; + + Ok(()) +} + +#[tokio::test] +async fn wallet_sends_broadcasts_and_confirms() -> Result<()> { + init_tracing(); + + let cli = Cli::default(); + let env = harness::setup(&cli).await?; + + let wallet = WalletBuilder::::default() + .seed(TestSeed::default()) + .network(bitcoin::Network::Regtest) + .electrum_rpc_urls(vec![env.electrum_url.clone()]) + .persister(PersisterConfig::InMemorySqlite) + .finality_confirmations(1u32) + .target_block(1u32) + .sync_interval(Duration::from_millis(0)) + .use_mempool_space_fee_estimation(false) + .build() + .await?; + + wallet.sync().await?; + + let receive_addr = wallet.new_address().await?; + let funding = bitcoin::Amount::from_sat(100_000); + harness::fund_and_mine(&env.bitcoind, receive_addr, funding).await?; + + sync_until_balance(&wallet, funding).await?; + + // Build spend + let recipient = env + .bitcoind + .with_wallet(harness::BITCOIN_TEST_WALLET_NAME)? + .getnewaddress(None, None) + .await? + .require_network(env.bitcoind.network().await?)?; + + let send_amount = bitcoin::Amount::from_sat(25_000); + let fee = bitcoin::Amount::from_sat(2_000); + + let psbt = wallet + .send_to_address(recipient, send_amount, fee, None) + .await?; + + let tx = wallet.sign_and_finalize(psbt).await?; + + let (txid, sub) = wallet.broadcast(tx, "it-send").await?; + + // Confirm it + let miner_addr = env + .bitcoind + .with_wallet(harness::BITCOIN_TEST_WALLET_NAME)? + .getnewaddress(None, None) + .await? + .require_network(env.bitcoind.network().await?)?; + env.bitcoind.generatetoaddress(1, miner_addr).await?; + + // Sync until electrum indexes the tx and it becomes final. + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + wallet.sync().await?; + if wallet.get_raw_transaction(txid).await?.is_some() { + break; + } + + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("timed out waiting for raw transaction {txid}"); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } + + sub.wait_until_final().await?; + + Ok(()) +}