From be126085afc1075b552c72d4d59ffd80ff2865f7 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Thu, 5 Mar 2026 14:21:10 -0300 Subject: [PATCH 1/3] refactor(tests): extract bitcoin-core helper module --- tests/test.rs | 123 +++---------------------------------- tests/util/bitcoin_core.rs | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 114 deletions(-) create mode 100644 tests/util/bitcoin_core.rs diff --git a/tests/test.rs b/tests/test.rs index bbb0262..88a378e 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,85 +1,14 @@ -use std::path::{Path, PathBuf}; +use bitcoin_capnp_types::mining_capnp; +use capnp_rpc::RpcSystem; +use tokio::task::LocalSet; -use bitcoin_capnp_types::{ - init_capnp::init, - mining_capnp::{self, block_template, mining}, - proxy_capnp::{thread, thread_map}, -}; -use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty::VatNetwork}; -use futures::io::BufReader; -use tokio::{ - net::{UnixStream, unix::OwnedReadHalf}, - task::LocalSet, -}; -use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; - -fn unix_socket_path() -> PathBuf { - let home_dir_string = std::env::var("HOME").unwrap(); - let home_dir = home_dir_string.parse::().unwrap(); - let bitcoin_dir = if cfg!(target_os = "macos") { - home_dir - .join("Library") - .join("Application Support") - .join("Bitcoin") - } else { - home_dir.join(".bitcoin") - }; - let regtest_dir = bitcoin_dir.join("regtest"); - regtest_dir.join("node.sock") -} - -async fn connect_unix_stream( - path: impl AsRef, -) -> VatNetwork>> { - let path = path.as_ref(); - let mut last_err = None; - for _ in 0..10 { - match UnixStream::connect(path).await { - Ok(stream) => { - let (reader, writer) = stream.into_split(); - let buf_reader = futures::io::BufReader::new(reader.compat()); - let buf_writer = futures::io::BufWriter::new(writer.compat_write()); - return VatNetwork::new(buf_reader, buf_writer, Side::Client, Default::default()); - } - Err(e) => { - last_err = Some(e); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - } - panic!( - "unix socket connection to {} failed after retries: {}. Is bitcoin running with -ipcbind=unix?", - path.display(), - last_err.unwrap() - ); -} +#[path = "util/bitcoin_core.rs"] +mod bitcoin_core_util; -/// Bootstrap an Init client, spawn the RPC system, and create a thread handle. -async fn bootstrap( - mut rpc_system: RpcSystem, -) -> (init::Client, thread::Client) { - let client: init::Client = rpc_system.bootstrap(Side::Server); - tokio::task::spawn_local(rpc_system); - let create_client_response = client - .construct_request() - .send() - .promise - .await - .expect("could not create initial request"); - let thread_map: thread_map::Client = create_client_response - .get() - .unwrap() - .get_thread_map() - .unwrap(); - let thread_reponse = thread_map - .make_thread_request() - .send() - .promise - .await - .unwrap(); - let thread: thread::Client = thread_reponse.get().unwrap().get_result().unwrap(); - (client, thread) -} +use bitcoin_core_util::{ + bootstrap, connect_unix_stream, destroy_template, make_block_template, make_mining, + unix_socket_path, +}; #[tokio::test] #[serial_test::parallel] @@ -135,40 +64,6 @@ async fn make_mining_old2_rejected() { .await; } -/// Obtain a Mining client from an Init client. -async fn make_mining(init: &init::Client, thread: &thread::Client) -> mining::Client { - let mut req = init.make_mining_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - resp.get().unwrap().get_result().unwrap() -} - -/// Create a new block template with default options and no cooldown. -/// -/// The node must have height > 16. At height <= 16 the BIP34 height push -/// is only one byte, which is shorter than the two-byte minimum scriptSig -/// required by consensus (see `CheckTransaction`), causing `createNewBlock` -/// to fail with `bad-cb-length`. Either generate blocks via bitcoin rpc -/// (`generatetodescriptor`) before running these tests, or (in a real miner) -/// pad the coinbase scriptSig with an extra push like `OP_0`. -async fn make_block_template( - mining: &mining::Client, - thread: &thread::Client, -) -> block_template::Client { - let mut req = mining.create_new_block_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - req.get().set_cooldown(false); - let resp = req.send().promise.await.unwrap(); - resp.get().unwrap().get_result().unwrap() -} - -/// Destroy a block template. -async fn destroy_template(template: &block_template::Client, thread: &thread::Client) { - let mut req = template.destroy_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - req.send().promise.await.unwrap(); -} - /// Check the four mining constants from the capnp schema. #[test] #[serial_test::parallel] diff --git a/tests/util/bitcoin_core.rs b/tests/util/bitcoin_core.rs new file mode 100644 index 0000000..67f4a6b --- /dev/null +++ b/tests/util/bitcoin_core.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; + +use bitcoin_capnp_types::{ + init_capnp::init, + mining_capnp::{block_template, mining}, + proxy_capnp::{thread, thread_map}, +}; +use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty::VatNetwork}; +use futures::io::BufReader; +use tokio::net::{UnixStream, unix::OwnedReadHalf}; +use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +pub fn unix_socket_path() -> PathBuf { + let home_dir_string = std::env::var("HOME").unwrap(); + let home_dir = home_dir_string.parse::().unwrap(); + let bitcoin_dir = if cfg!(target_os = "macos") { + home_dir + .join("Library") + .join("Application Support") + .join("Bitcoin") + } else { + home_dir.join(".bitcoin") + }; + let regtest_dir = bitcoin_dir.join("regtest"); + regtest_dir.join("node.sock") +} + +pub async fn connect_unix_stream( + path: impl AsRef, +) -> VatNetwork>> { + let path = path.as_ref(); + let mut last_err = None; + for _ in 0..10 { + match UnixStream::connect(path).await { + Ok(stream) => { + let (reader, writer) = stream.into_split(); + let buf_reader = futures::io::BufReader::new(reader.compat()); + let buf_writer = futures::io::BufWriter::new(writer.compat_write()); + return VatNetwork::new(buf_reader, buf_writer, Side::Client, Default::default()); + } + Err(e) => { + last_err = Some(e); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + panic!( + "unix socket connection to {} failed after retries: {}. Is bitcoin running with -ipcbind=unix?", + path.display(), + last_err.unwrap() + ); +} + +/// Bootstrap an Init client, spawn the RPC system, and create a thread handle. +pub async fn bootstrap( + mut rpc_system: RpcSystem, +) -> (init::Client, thread::Client) { + let client: init::Client = rpc_system.bootstrap(Side::Server); + tokio::task::spawn_local(rpc_system); + let create_client_response = client + .construct_request() + .send() + .promise + .await + .expect("could not create initial request"); + let thread_map: thread_map::Client = create_client_response + .get() + .unwrap() + .get_thread_map() + .unwrap(); + let thread_reponse = thread_map + .make_thread_request() + .send() + .promise + .await + .unwrap(); + let thread: thread::Client = thread_reponse.get().unwrap().get_result().unwrap(); + (client, thread) +} + +/// Obtain a Mining client from an Init client. +pub async fn make_mining(init: &init::Client, thread: &thread::Client) -> mining::Client { + let mut req = init.make_mining_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + resp.get().unwrap().get_result().unwrap() +} + +/// Create a new block template with default options and no cooldown. +/// +/// The node must have height > 16. At height <= 16 the BIP34 height push +/// is only one byte, which is shorter than the two-byte minimum scriptSig +/// required by consensus (see `CheckTransaction`), causing `createNewBlock` +/// to fail with `bad-cb-length`. Either generate blocks via bitcoin rpc +/// (`generatetodescriptor`) before running these tests, or (in a real miner) +/// pad the coinbase scriptSig with an extra push like `OP_0`. +pub async fn make_block_template( + mining: &mining::Client, + thread: &thread::Client, +) -> block_template::Client { + let mut req = mining.create_new_block_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.get().set_cooldown(false); + let resp = req.send().promise.await.unwrap(); + resp.get().unwrap().get_result().unwrap() +} + +/// Destroy a block template. +pub async fn destroy_template(template: &block_template::Client, thread: &thread::Client) { + let mut req = template.destroy_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.send().promise.await.unwrap(); +} From 20c2062be8ddc7f7a0d4e7308dca65b2bc72d7b2 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 11 Mar 2026 12:13:13 +0100 Subject: [PATCH 2/3] refactor(tests): add IPC test harness helpers --- tests/test.rs | 427 +++++++++++++++++-------------------- tests/util/bitcoin_core.rs | 33 ++- 2 files changed, 223 insertions(+), 237 deletions(-) diff --git a/tests/test.rs b/tests/test.rs index 88a378e..f03e631 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,46 +1,38 @@ use bitcoin_capnp_types::mining_capnp; -use capnp_rpc::RpcSystem; -use tokio::task::LocalSet; #[path = "util/bitcoin_core.rs"] mod bitcoin_core_util; use bitcoin_core_util::{ - bootstrap, connect_unix_stream, destroy_template, make_block_template, make_mining, - unix_socket_path, + destroy_template, make_block_template, with_init_client, with_mining_client, }; #[tokio::test] #[serial_test::parallel] async fn integration() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mut echo = client.make_echo_request(); - echo.get().get_context().unwrap().set_thread(thread.clone()); - let echo_client_request = echo.send().promise.await.unwrap(); - let echo_client = echo_client_request.get().unwrap().get_result().unwrap(); - let mut echo_conf = echo_client.echo_request(); - echo_conf - .get() - .get_context() - .unwrap() - .set_thread(thread.clone()); - echo_conf.get().set_echo("Hello world"); - let echo_response = echo_conf.send().promise.await.unwrap(); - let text = echo_response - .get() - .unwrap() - .get_result() - .unwrap() - .to_string() - .unwrap(); - assert_eq!("Hello world", text); - }) - .await; + with_init_client(|client, thread| async move { + let mut echo = client.make_echo_request(); + echo.get().get_context().unwrap().set_thread(thread.clone()); + let echo_client_request = echo.send().promise.await.unwrap(); + let echo_client = echo_client_request.get().unwrap().get_result().unwrap(); + let mut echo_conf = echo_client.echo_request(); + echo_conf + .get() + .get_context() + .unwrap() + .set_thread(thread.clone()); + echo_conf.get().set_echo("Hello world"); + let echo_response = echo_conf.send().promise.await.unwrap(); + let text = echo_response + .get() + .unwrap() + .get_result() + .unwrap() + .to_string() + .unwrap(); + assert_eq!("Hello world", text); + }) + .await; } /// Calling the deprecated makeMiningOld2 (@2) should return an error from the @@ -49,19 +41,14 @@ async fn integration() { #[tokio::test] #[serial_test::parallel] async fn make_mining_old2_rejected() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, _thread) = bootstrap(rpc_system).await; - let result = client.make_mining_old2_request().send().promise.await; - assert!( - result.is_err(), - "makeMiningOld2 should be rejected by the server" - ); - }) - .await; + with_init_client(|client, _thread| async move { + let result = client.make_mining_old2_request().send().promise.await; + assert!( + result.is_err(), + "makeMiningOld2 should be rejected by the server" + ); + }) + .await; } /// Check the four mining constants from the capnp schema. @@ -81,38 +68,31 @@ fn mining_constants() { #[tokio::test] #[serial_test::parallel] async fn mining_basic_queries() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mining = make_mining(&client, &thread).await; + with_mining_client(|_client, thread, mining| async move { + // isTestChain + let mut req = mining.is_test_chain_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + assert!(resp.get().unwrap().get_result(), "regtest is a test chain"); - // isTestChain - let mut req = mining.is_test_chain_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - assert!(resp.get().unwrap().get_result(), "regtest is a test chain"); + // isInitialBlockDownload + let mut req = mining.is_initial_block_download_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let _ibd: bool = resp.get().unwrap().get_result(); - // isInitialBlockDownload - let mut req = mining.is_initial_block_download_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let _ibd: bool = resp.get().unwrap().get_result(); - - // getTip - let mut req = mining.get_tip_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let results = resp.get().unwrap(); - assert!(results.get_has_result(), "node should have a tip"); - let tip = results.get_result().unwrap(); - let tip_hash = tip.get_hash().unwrap(); - assert_eq!(tip_hash.len(), 32, "block hash must be 32 bytes"); - assert!(tip.get_height() >= 0, "height must be non-negative"); - }) - .await; + // getTip + let mut req = mining.get_tip_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let results = resp.get().unwrap(); + assert!(results.get_has_result(), "node should have a tip"); + let tip = results.get_result().unwrap(); + let tip_hash = tip.get_hash().unwrap(); + assert_eq!(tip_hash.len(), 32, "block hash must be 32 bytes"); + assert!(tip.get_height() >= 0, "height must be non-negative"); + }) + .await; } /// waitTipChanged with a short timeout. @@ -120,34 +100,27 @@ async fn mining_basic_queries() { // Serialized because this assertion is sensitive to concurrent tip changes. #[serial_test::serial] async fn mining_wait_tip_changed() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mining = make_mining(&client, &thread).await; - - // Get the current tip first. - let mut req = mining.get_tip_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let results = resp.get().unwrap(); - let tip = results.get_result().unwrap(); - let tip_hash: Vec = tip.get_hash().unwrap().to_vec(); - let tip_height: i32 = tip.get_height(); + with_mining_client(|_client, thread, mining| async move { + // Get the current tip first. + let mut req = mining.get_tip_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let results = resp.get().unwrap(); + let tip = results.get_result().unwrap(); + let tip_hash: Vec = tip.get_hash().unwrap().to_vec(); + let tip_height: i32 = tip.get_height(); - // Wait with a short timeout; no new block should arrive. - let mut req = mining.wait_tip_changed_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - req.get().set_current_tip(&tip_hash); - req.get().set_timeout(500.0); // 500 ms - let resp = req.send().promise.await.unwrap(); - let wait_result = resp.get().unwrap().get_result().unwrap(); - assert_eq!(wait_result.get_hash().unwrap().len(), 32); - assert_eq!(wait_result.get_height(), tip_height); - }) - .await; + // Wait with a short timeout; no new block should arrive. + let mut req = mining.wait_tip_changed_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.get().set_current_tip(&tip_hash); + req.get().set_timeout(500.0); // 500 ms + let resp = req.send().promise.await.unwrap(); + let wait_result = resp.get().unwrap().get_result().unwrap(); + assert_eq!(wait_result.get_hash().unwrap().len(), 32); + assert_eq!(wait_result.get_height(), tip_height); + }) + .await; } /// createNewBlock + all BlockTemplate read methods: getBlockHeader, getBlock, @@ -155,68 +128,62 @@ async fn mining_wait_tip_changed() { #[tokio::test] #[serial_test::parallel] async fn mining_block_template_inspection() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mining = make_mining(&client, &thread).await; - let template = make_block_template(&mining, &thread).await; + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; - // getBlockHeader - let mut req = template.get_block_header_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let header = resp.get().unwrap().get_result().unwrap(); - assert_eq!(header.len(), 80, "block header must be 80 bytes"); + // getBlockHeader + let mut req = template.get_block_header_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let header = resp.get().unwrap().get_result().unwrap(); + assert_eq!(header.len(), 80, "block header must be 80 bytes"); - // getBlock - let mut req = template.get_block_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let block = resp.get().unwrap().get_result().unwrap(); - assert!(block.len() > 80, "serialized block must be > 80 bytes"); + // getBlock + let mut req = template.get_block_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let block = resp.get().unwrap().get_result().unwrap(); + assert!(block.len() > 80, "serialized block must be > 80 bytes"); - // getTxFees - let mut req = template.get_tx_fees_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let _fees = resp.get().unwrap().get_result().unwrap(); + // getTxFees + let mut req = template.get_tx_fees_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let _fees = resp.get().unwrap().get_result().unwrap(); - // getTxSigops - let mut req = template.get_tx_sigops_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let _sigops = resp.get().unwrap().get_result().unwrap(); + // getTxSigops + let mut req = template.get_tx_sigops_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let _sigops = resp.get().unwrap().get_result().unwrap(); - // getCoinbaseTx — inspect every CoinbaseTx field - let mut req = template.get_coinbase_tx_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let coinbase = resp.get().unwrap().get_result().unwrap(); - let _version: u32 = coinbase.get_version(); - let _sequence: u32 = coinbase.get_sequence(); - let script_sig_prefix = coinbase.get_script_sig_prefix().unwrap(); - assert!( - !script_sig_prefix.is_empty(), - "scriptSigPrefix must contain at least the block height" - ); - let _witness = coinbase.get_witness().unwrap(); - let reward: i64 = coinbase.get_block_reward_remaining(); - assert!(reward > 0 && reward <= mining_capnp::MAX_MONEY); - let _required_outputs = coinbase.get_required_outputs().unwrap(); - let _lock_time: u32 = coinbase.get_lock_time(); + // getCoinbaseTx — inspect every CoinbaseTx field + let mut req = template.get_coinbase_tx_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let coinbase = resp.get().unwrap().get_result().unwrap(); + let _version: u32 = coinbase.get_version(); + let _sequence: u32 = coinbase.get_sequence(); + let script_sig_prefix = coinbase.get_script_sig_prefix().unwrap(); + assert!( + !script_sig_prefix.is_empty(), + "scriptSigPrefix must contain at least the block height" + ); + let _witness = coinbase.get_witness().unwrap(); + let reward: i64 = coinbase.get_block_reward_remaining(); + assert!(reward > 0 && reward <= mining_capnp::MAX_MONEY); + let _required_outputs = coinbase.get_required_outputs().unwrap(); + let _lock_time: u32 = coinbase.get_lock_time(); - // getCoinbaseMerklePath - let mut req = template.get_coinbase_merkle_path_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - let resp = req.send().promise.await.unwrap(); - let _merkle_path = resp.get().unwrap().get_result().unwrap(); + // getCoinbaseMerklePath + let mut req = template.get_coinbase_merkle_path_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + let _merkle_path = resp.get().unwrap().get_result().unwrap(); - destroy_template(&template, &thread).await; - }) - .await; + destroy_template(&template, &thread).await; + }) + .await; } /// waitNext (short timeout), interruptWait, submitSolution (garbage), destroy. @@ -224,49 +191,43 @@ async fn mining_block_template_inspection() { // Serialized because submitSolution behavior depends on current chain tip. #[serial_test::serial] async fn mining_block_template_lifecycle() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mining = make_mining(&client, &thread).await; - let template = make_block_template(&mining, &thread).await; + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; - // waitNext — short timeout, no new transactions expected. - let mut req = template.wait_next_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - { - let mut opts = req.get().init_options(); - opts.set_timeout(100.0); // 100 ms - opts.set_fee_threshold(mining_capnp::MAX_MONEY); - } - let resp = req.send().promise.await.unwrap(); - let _has_next = resp.get().unwrap().has_result(); + // waitNext — short timeout, no new transactions expected. + let mut req = template.wait_next_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + { + let mut opts = req.get().init_options(); + opts.set_timeout(100.0); // 100 ms + opts.set_fee_threshold(mining_capnp::MAX_MONEY); + } + let resp = req.send().promise.await.unwrap(); + let _has_next = resp.get().unwrap().has_result(); - // interruptWait — should not crash. - template - .interrupt_wait_request() - .send() - .promise - .await - .unwrap(); + // interruptWait — should not crash. + template + .interrupt_wait_request() + .send() + .promise + .await + .unwrap(); - // submitSolution — garbage coinbase should be rejected. - // This mutates the template, so we do it right before destroy. - let mut req = template.submit_solution_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - req.get().set_version(1); - req.get().set_timestamp(0); - req.get().set_nonce(0); - req.get().set_coinbase(&[0u8; 64]); - let resp = req.send().promise.await.unwrap(); - let submitted = resp.get().unwrap().get_result(); - assert!(!submitted, "garbage solution must not be accepted"); + // submitSolution — garbage coinbase should be rejected. + // This mutates the template, so we do it right before destroy. + let mut req = template.submit_solution_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.get().set_version(1); + req.get().set_timestamp(0); + req.get().set_nonce(0); + req.get().set_coinbase(&[0u8; 64]); + let resp = req.send().promise.await.unwrap(); + let submitted = resp.get().unwrap().get_result(); + assert!(!submitted, "garbage solution must not be accepted"); - destroy_template(&template, &thread).await; - }) - .await; + destroy_template(&template, &thread).await; + }) + .await; } /// checkBlock with a template block payload, and interrupt. @@ -274,50 +235,44 @@ async fn mining_block_template_lifecycle() { // Serialized because interrupt() can affect other in-flight mining waits. #[serial_test::serial] async fn mining_check_block_and_interrupt() { - let path = unix_socket_path(); - let rpc_network = connect_unix_stream(path).await; - let rpc_system = RpcSystem::new(Box::new(rpc_network), None); - LocalSet::new() - .run_until(async move { - let (client, thread) = bootstrap(rpc_system).await; - let mining = make_mining(&client, &thread).await; - let template = make_block_template(&mining, &thread).await; + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; - let mut get_block_req = template.get_block_request(); - get_block_req - .get() - .get_context() - .unwrap() - .set_thread(thread.clone()); - let get_block_resp = get_block_req.send().promise.await.unwrap(); - let block = get_block_resp.get().unwrap().get_result().unwrap().to_vec(); + let mut get_block_req = template.get_block_request(); + get_block_req + .get() + .get_context() + .unwrap() + .set_thread(thread.clone()); + let get_block_resp = get_block_req.send().promise.await.unwrap(); + let block = get_block_resp.get().unwrap().get_result().unwrap().to_vec(); - // checkBlock should either error or return a response. - let mut req = mining.check_block_request(); - req.get().get_context().unwrap().set_thread(thread.clone()); - req.get().set_block(&block); - { - let mut opts = req.get().init_options(); - opts.set_check_merkle_root(true); - opts.set_check_pow(false); + // checkBlock should either error or return a response. + let mut req = mining.check_block_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.get().set_block(&block); + { + let mut opts = req.get().init_options(); + opts.set_check_merkle_root(true); + opts.set_check_pow(false); + } + let result = req.send().promise.await; + match result { + Ok(resp) => { + let results = resp.get().unwrap(); + let _valid: bool = results.get_result(); + let _reason = results.get_reason().unwrap(); + let _debug = results.get_debug().unwrap(); } - let result = req.send().promise.await; - match result { - Ok(resp) => { - let results = resp.get().unwrap(); - let _valid: bool = results.get_result(); - let _reason = results.get_reason().unwrap(); - let _debug = results.get_debug().unwrap(); - } - Err(_) => { - // Server may reject validation/deserialization. - } + Err(_) => { + // Server may reject validation/deserialization. } + } - destroy_template(&template, &thread).await; + destroy_template(&template, &thread).await; - // interrupt — should not crash. - mining.interrupt_request().send().promise.await.unwrap(); - }) - .await; + // interrupt — should not crash. + mining.interrupt_request().send().promise.await.unwrap(); + }) + .await; } diff --git a/tests/util/bitcoin_core.rs b/tests/util/bitcoin_core.rs index 67f4a6b..dd83634 100644 --- a/tests/util/bitcoin_core.rs +++ b/tests/util/bitcoin_core.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + future::Future, + path::{Path, PathBuf}, +}; use bitcoin_capnp_types::{ init_capnp::init, @@ -8,6 +11,7 @@ use bitcoin_capnp_types::{ use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty::VatNetwork}; use futures::io::BufReader; use tokio::net::{UnixStream, unix::OwnedReadHalf}; +use tokio::task::LocalSet; use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; pub fn unix_socket_path() -> PathBuf { @@ -25,6 +29,33 @@ pub fn unix_socket_path() -> PathBuf { regtest_dir.join("node.sock") } +pub async fn with_init_client(f: F) +where + F: FnOnce(init::Client, thread::Client) -> Fut, + Fut: Future, +{ + let rpc_network = connect_unix_stream(unix_socket_path()).await; + let rpc_system = RpcSystem::new(Box::new(rpc_network), None); + LocalSet::new() + .run_until(async move { + let (client, thread) = bootstrap(rpc_system).await; + f(client, thread).await; + }) + .await; +} + +pub async fn with_mining_client(f: F) +where + F: FnOnce(init::Client, thread::Client, mining::Client) -> Fut, + Fut: Future, +{ + with_init_client(|client, thread| async move { + let mining = make_mining(&client, &thread).await; + f(client, thread, mining).await; + }) + .await; +} + pub async fn connect_unix_stream( path: impl AsRef, ) -> VatNetwork>> { From f3f731939b9d493bd6d25b7f3d0071fd1210cca0 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 11 Mar 2026 13:02:29 +0100 Subject: [PATCH 3/3] ci,docs: prepare wallet-capable integration test environment --- .github/workflows/ci.yml | 6 +- Cargo.toml | 4 + README.md | 19 ++--- tests/test.rs | 25 +++++- tests/util/bitcoin_core.rs | 46 ++++++++++- tests/util/bitcoin_core_wallet.rs | 130 ++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 tests/util/bitcoin_core_wallet.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a38418..2985a39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,11 @@ jobs: - name: Build Bitcoin Core run: | cd bitcoin - cmake -B build -DENABLE_WALLET=OFF -DBUILD_TESTS=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + cmake -B build -DENABLE_WALLET=ON -DBUILD_TESTS=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build -j $(nproc) - name: Run Bitcoin Core Daemon run: cd bitcoin && ./build/bin/bitcoin node -chain=regtest -ipcbind=unix -server -debug=ipc -daemon - - name: Generate Blocks - run: cd bitcoin && ./build/bin/bitcoin rpc -regtest -rpcwait generatetodescriptor 101 "raw(51)" - name: Run Test Suite + env: + BITCOIN_BIN: ${{ github.workspace }}/bitcoin/build/bin/bitcoin run: cargo test diff --git a/Cargo.toml b/Cargo.toml index 1942c23..191c00d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,12 @@ capnp = "0.25.0" capnpc = "0.25.0" [dev-dependencies] +bitcoin-primitives = { git = "https://github.com/rust-bitcoin/rust-bitcoin", package = "bitcoin-primitives", tag = "bitcoin-0.33.0-beta" } capnp-rpc = "0.25.0" +encoding = { git = "https://github.com/rust-bitcoin/rust-bitcoin", package = "bitcoin-consensus-encoding", tag = "bitcoin-0.33.0-beta" } futures = "0.3.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" serial_test = "3" tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util", "time"] } tokio-util = { version = "0.7.16", features = ["compat"] } diff --git a/README.md b/README.md index b0ff965..af642a4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The integration tests connect to a running bitcoin node via IPC. ```sh cd /path/to/bitcoin -cmake -B build -DENABLE_WALLET=OFF -DBUILD_TESTS=OFF +cmake -B build -DENABLE_WALLET=ON -DBUILD_TESTS=OFF cmake --build build -j$(nproc) ``` @@ -50,23 +50,20 @@ cmake --build build -j$(nproc) ./build/bin/bitcoin node -chain=regtest -ipcbind=unix -server -debug=ipc -daemon ``` -### 3. Generate blocks +### 3. Run tests -The mining tests require chain height > 16. At height <= 16, `createNewBlock` -fails with `bad-cb-length` because the BIP34 height push is too short for the -coinbase scriptSig minimum. +If `bitcoin` is not in your `PATH`, set `BITCOIN_BIN` to the full path of +the Bitcoin Core binary. -```sh -./build/bin/bitcoin rpc -chain=regtest -rpcwait generatetodescriptor 101 "raw(51)" -``` - -### 4. Run tests +The test harness bootstraps regtest chain state and ensures the test wallet is +available before running integration tests. ```sh +BITCOIN_BIN=./build/bin/bitcoin \ cargo test ``` -### 5. Stop bitcoin +### 4. Stop bitcoin ```sh ./build/bin/bitcoin rpc -chain=regtest stop diff --git a/tests/test.rs b/tests/test.rs index f03e631..bf7e0d2 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,9 +2,14 @@ use bitcoin_capnp_types::mining_capnp; #[path = "util/bitcoin_core.rs"] mod bitcoin_core_util; +#[path = "util/bitcoin_core_wallet.rs"] +mod bitcoin_core_wallet_util; use bitcoin_core_util::{ - destroy_template, make_block_template, with_init_client, with_mining_client, + destroy_template, make_block_template, mempool_tx_count, with_init_client, with_mining_client, +}; +use bitcoin_core_wallet_util::{ + bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded, }; #[tokio::test] @@ -276,3 +281,21 @@ async fn mining_check_block_and_interrupt() { }) .await; } + +/// Minimal coverage for wallet/mempool helpers added for future mempool tests. +#[tokio::test] +#[serial_test::serial] +async fn wallet_helpers_create_mempool_transaction() { + let wallet = bitcoin_test_wallet(); + assert!(!wallet.is_empty(), "test wallet name must not be empty"); + + ensure_wallet_loaded_and_funded(&wallet); + let before = mempool_tx_count(); + let _tx = create_mempool_self_transfer(&wallet); + let after = mempool_tx_count(); + assert_eq!( + after, + before + 1, + "self-transfer should add one mempool transaction" + ); +} diff --git a/tests/util/bitcoin_core.rs b/tests/util/bitcoin_core.rs index dd83634..2171bf5 100644 --- a/tests/util/bitcoin_core.rs +++ b/tests/util/bitcoin_core.rs @@ -1,8 +1,12 @@ use std::{ future::Future, path::{Path, PathBuf}, + sync::Once, }; +use crate::bitcoin_core_wallet_util::{ + bitcoin_rpc_json, bitcoin_test_wallet, ensure_wallet_loaded, mine_blocks_to_new_address, +}; use bitcoin_capnp_types::{ init_capnp::init, mining_capnp::{block_template, mining}, @@ -10,10 +14,13 @@ use bitcoin_capnp_types::{ }; use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty::VatNetwork}; use futures::io::BufReader; +use serde::Deserialize; use tokio::net::{UnixStream, unix::OwnedReadHalf}; use tokio::task::LocalSet; use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +static CHAIN_SETUP: Once = Once::new(); + pub fn unix_socket_path() -> PathBuf { let home_dir_string = std::env::var("HOME").unwrap(); let home_dir = home_dir_string.parse::().unwrap(); @@ -29,6 +36,31 @@ pub fn unix_socket_path() -> PathBuf { regtest_dir.join("node.sock") } +pub fn mempool_tx_count() -> usize { + let mempool_info: MempoolInfo = bitcoin_rpc_json(None, &["getmempoolinfo"]) + .unwrap_or_else(|e| panic!("failed to query mempool info: {e}")); + mempool_info.size +} + +fn ensure_bootstrap_chain_ready() { + // `call_once` serializes bootstrap initialization across all tests in this + // process. Other callers block until this setup completes. + CHAIN_SETUP.call_once(|| { + let wallet = bitcoin_test_wallet(); + ensure_chain_height_at_least(101, &wallet); + }); +} + +fn ensure_chain_height_at_least(min_height: u32, wallet: &str) { + ensure_wallet_loaded(wallet); + let height: u32 = bitcoin_rpc_json(None, &["getblockcount"]) + .unwrap_or_else(|e| panic!("failed to query block height: {e}")); + if height < min_height { + mine_blocks_to_new_address(wallet, min_height - height) + .unwrap_or_else(|e| panic!("failed to reach height {min_height}: {e}")); + } +} + pub async fn with_init_client(f: F) where F: FnOnce(init::Client, thread::Client) -> Fut, @@ -86,6 +118,8 @@ pub async fn connect_unix_stream( pub async fn bootstrap( mut rpc_system: RpcSystem, ) -> (init::Client, thread::Client) { + ensure_bootstrap_chain_ready(); + let client: init::Client = rpc_system.bootstrap(Side::Server); tokio::task::spawn_local(rpc_system); let create_client_response = client @@ -122,9 +156,8 @@ pub async fn make_mining(init: &init::Client, thread: &thread::Client) -> mining /// The node must have height > 16. At height <= 16 the BIP34 height push /// is only one byte, which is shorter than the two-byte minimum scriptSig /// required by consensus (see `CheckTransaction`), causing `createNewBlock` -/// to fail with `bad-cb-length`. Either generate blocks via bitcoin rpc -/// (`generatetodescriptor`) before running these tests, or (in a real miner) -/// pad the coinbase scriptSig with an extra push like `OP_0`. +/// to fail with `bad-cb-length`. `bootstrap()` ensures chain height is at +/// least 101 before tests run, which satisfies this precondition. pub async fn make_block_template( mining: &mining::Client, thread: &thread::Client, @@ -142,3 +175,10 @@ pub async fn destroy_template(template: &block_template::Client, thread: &thread req.get().get_context().unwrap().set_thread(thread.clone()); req.send().promise.await.unwrap(); } + +#[derive(Deserialize)] +// Intentionally partial: tests currently only need the `size` field from +// `getmempoolinfo`. +struct MempoolInfo { + size: usize, +} diff --git a/tests/util/bitcoin_core_wallet.rs b/tests/util/bitcoin_core_wallet.rs new file mode 100644 index 0000000..9c37fdf --- /dev/null +++ b/tests/util/bitcoin_core_wallet.rs @@ -0,0 +1,130 @@ +use std::process::Command; + +use bitcoin_primitives::Transaction as BitcoinTransaction; +use bitcoin_primitives::hex; +use encoding::decode_from_slice; +use serde::de::DeserializeOwned; + +fn bitcoin_bin() -> String { + std::env::var("BITCOIN_BIN").unwrap_or_else(|_| "bitcoin".to_owned()) +} + +fn bitcoin_rpc(wallet: Option<&str>, args: &[&str]) -> Result { + let owned_args: Vec = args.iter().map(|arg| (*arg).to_owned()).collect(); + bitcoin_rpc_owned(wallet, &owned_args) +} + +pub fn bitcoin_rpc_json(wallet: Option<&str>, args: &[&str]) -> Result +where + T: DeserializeOwned, +{ + let output = bitcoin_rpc(wallet, args)?; + serde_json::from_str(&output).map_err(|e| format!("failed to parse rpc response as JSON: {e}")) +} + +pub fn bitcoin_test_wallet() -> String { + std::env::var("BITCOIN_TEST_WALLET").unwrap_or_else(|_| "ipc-test".to_owned()) +} + +fn bitcoin_rpc_owned(wallet: Option<&str>, args: &[String]) -> Result { + let mut command = Command::new(bitcoin_bin()); + command.arg("rpc").arg("-chain=regtest").arg("-rpcwait"); + if let Some(wallet) = wallet { + command.arg(format!("-rpcwallet={wallet}")); + } + command.args(args); + + let output = command + .output() + .map_err(|e| format!("failed to execute bitcoin rpc command: {e}"))?; + if output.status.success() { + Ok(String::from_utf8(output.stdout) + .unwrap_or_else(|_| String::new()) + .trim() + .to_owned()) + } else { + Err(format!( + "bitcoin rpc command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +pub fn ensure_wallet_loaded(wallet: &str) { + if bitcoin_rpc(Some(wallet), &["getwalletinfo"]).is_err() { + // First try loading an existing wallet from disk (common when regtest data + // directory is re-used), then fall back to creating it. + if bitcoin_rpc(None, &["loadwallet", wallet]).is_err() { + let _ = bitcoin_rpc(None, &["createwallet", wallet]); + } + + bitcoin_rpc(Some(wallet), &["getwalletinfo"]).unwrap_or_else(|e| { + panic!("wallet {wallet} is not available after load/create attempts: {e}") + }); + } +} + +pub fn ensure_wallet_loaded_and_funded(wallet: &str) { + ensure_wallet_loaded(wallet); + + // getbalance "*" 1 only counts confirmed spendable funds. + let balance: f64 = bitcoin_rpc_json(Some(wallet), &["getbalance", "*", "1"]) + .unwrap_or_else(|e| panic!("failed to query wallet balance for {wallet}: {e}")); + + if balance < 1.0 { + // Mining a single block can mature older coinbase outputs when balance is low. + mine_blocks_to_new_address(wallet, 1) + .unwrap_or_else(|e| panic!("failed to mine blocks to wallet {wallet}: {e}")); + } +} + +pub fn mine_blocks_to_new_address(wallet: &str, blocks: u32) -> Result<(), String> { + let blocks = blocks.to_string(); + let address = bitcoin_rpc(Some(wallet), &["getnewaddress"])?; + bitcoin_rpc( + Some(wallet), + &["generatetoaddress", blocks.as_str(), address.as_str()], + )?; + Ok(()) +} + +pub fn create_mempool_self_transfer(wallet: &str) -> BitcoinTransaction { + let send_self_transfer = || { + let address = bitcoin_rpc(Some(wallet), &["getnewaddress"])?; + let send_args = vec![ + "-named".to_owned(), + "sendtoaddress".to_owned(), + format!("address={address}"), + "amount=0.01".to_owned(), + "fee_rate=25".to_owned(), + ]; + bitcoin_rpc_owned(Some(wallet), &send_args) + }; + + let txid_hex = match send_self_transfer() { + Ok(txid) => txid, + Err(first_err) => { + // If the wallet exists but is unfunded or in an unexpected state, + // try to recover by ensuring funding and retry once. + ensure_wallet_loaded_and_funded(wallet); + send_self_transfer().unwrap_or_else(|second_err| { + panic!( + "failed to create self-transfer in {wallet}: initial send failed: {first_err}; retry after funding failed: {second_err}" + ) + }) + } + }; + let raw_tx_hex = bitcoin_rpc(None, &["getrawtransaction", txid_hex.as_str()]) + .unwrap_or_else(|e| panic!("failed to fetch raw transaction {txid_hex}: {e}")); + let raw_tx = hex::decode_to_vec(&raw_tx_hex) + .unwrap_or_else(|e| panic!("failed to decode raw transaction {txid_hex} from hex: {e}")); + let tx: BitcoinTransaction = decode_from_slice(&raw_tx) + .unwrap_or_else(|e| panic!("failed to deserialize raw transaction {txid_hex}: {e}")); + let txid = tx.compute_txid(); + let txid_display = format!("{txid:x}"); + assert_eq!( + txid_display, txid_hex, + "transaction id from raw tx should match RPC txid" + ); + tx +}