From f35c984921f31831868ec253f3362b19c584716b Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 16:36:39 -0300 Subject: [PATCH 1/8] tests: detect macOS default socket path On macOS, Bitcoin Core's default data directory is ~/Library/Application Support/Bitcoin/ rather than ~/.bitcoin/. Use cfg!(target_os) to select the correct default socket path for integration tests on each platform. --- tests/test.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test.rs b/tests/test.rs index 4b99b7c..2315be8 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -15,7 +15,14 @@ use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatE 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 = home_dir.join(".bitcoin"); + 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") } From 6c3074dbc764ec96580899a8dfe07d4a646e2254 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 16:50:20 -0300 Subject: [PATCH 2/8] ci: update to Bitcoin Core master, generate blocks - Checkout master branch instead of 30.x (needed for 31.x schemas) - Add -DBUILD_TESTS=OFF to skip building Bitcoin Core tests - Add -server flag to bitcoin-node for RPC access - Generate 101 blocks before running tests (mining tests need height >= 17 to avoid bad-cb-length from BIP34 height push) --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f05f69..9a44406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,13 @@ jobs: run: | sudo apt-get update sudo apt-get install build-essential cmake pkgconf python3 libevent-dev libboost-dev capnproto libcapnp-dev - - name: Checkout Bitcoin Core v30.0 - run: git clone --depth 1 --branch 30.x https://github.com/bitcoin/bitcoin.git - - name: Build and Install Bitcoin Core - run: cd bitcoin && cmake -B build -DENABLE_IPC=ON -DENABLE_WALLET=OFF && cmake --build build -j 16 + - name: Checkout Bitcoin Core + run: git clone --depth 1 --branch master https://github.com/bitcoin/bitcoin.git + - name: Build Bitcoin Core + run: cd bitcoin && cmake -B build -DENABLE_WALLET=OFF -DBUILD_TESTS=OFF && cmake --build build -j 16 - name: Run Bitcoin Core Daemon - run: cd bitcoin && ./build/bin/bitcoin-node -chain=regtest -ipcbind=unix -debug=ipc -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 run: cargo test From 2942c68905c6ed62dd56b87e9e53e14806c7595f Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 17:30:56 -0300 Subject: [PATCH 3/8] doc: add build and test instructions to README --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 476b929..684419a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,52 @@ This project auto-generates the client code to interact with Bitcoin Core in Rus To compile this crate your project must use a Rust compiler of **1.85** or higher. +## Building + +```sh +cargo build +``` + +## Running integration tests + +The integration tests connect to a running bitcoin node via IPC. + +### 1. Build Bitcoin Core + +```sh +cd /path/to/bitcoin +cmake -B build -DENABLE_WALLET=OFF -DBUILD_TESTS=OFF +cmake --build build -j$(nproc) +``` + +### 2. Start bitcoin + +```sh +./build/bin/bitcoin node -chain=regtest -ipcbind=unix -server -debug=ipc -daemon +``` + +### 3. Generate blocks + +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. + +```sh +./build/bin/bitcoin rpc -regtest -rpcwait generatetodescriptor 101 "raw(51)" +``` + +### 4. Run tests + +```sh +cargo test +``` + +### 5. Stop bitcoin + +```sh +./build/bin/bitcoin rpc -regtest stop +``` + ## License Creative Commons 1.0 Universal From 6b6d315139239a40ba4e7665c187d612acfd0c19 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 16:38:05 -0300 Subject: [PATCH 4/8] tests: extract bootstrap() helper Factor bootstrapping logic (RPC system spawn, Init construct, thread creation) into a reusable async helper. This simplifies writing new integration tests that connect to a running Bitcoin Core IPC socket. Also drops the now-unnecessary `mut` from `let rpc_system` in the integration test. --- tests/test.rs | 50 +++++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/test.rs b/tests/test.rs index 2315be8..f9b88c8 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -39,33 +39,41 @@ async fn connect_unix_stream( VatNetwork::new(buf_reader, buf_writer, Side::Client, Default::default()) } +/// 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) +} + #[tokio::test] async fn integration() { let path = unix_socket_path(); let rpc_network = connect_unix_stream(path).await; - let mut rpc_system = RpcSystem::new(Box::new(rpc_network), None); + let rpc_system = RpcSystem::new(Box::new(rpc_network), None); LocalSet::new() .run_until(async move { - 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(); + 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(); From 83d487b2bc6135897a4255b363585e53659b44dd Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 16:50:03 -0300 Subject: [PATCH 5/8] Update capnp schemas to match Bitcoin Core master (pre-31.x) Sync capnp proto files with Bitcoin Core master (e09b81638ba1). bitcoin/bitcoin#33819 (mining: getCoinbase() returns struct instead of raw tx) mining.capnp: - BlockTemplate.getCoinbaseTx now returns CoinbaseTx struct (was Data) - Added CoinbaseTx struct with fields: tx, txFee, totalFee, blockReservedWeight, coinbaseOutputMaxAdditionalSigops, defaultSignetChallenge, defaultWitnessCommitment - Added constants: maxMoney, maxDouble, defaultBlockReservedWeight, defaultCoinbaseOutputMaxAdditionalSigops - Added default values to all struct fields (BlockTemplateOptions, Amount, CoinbaseTx) bitcoin/bitcoin#34568 (mining: Break compatibility with existing IPC mining clients) init.capnp: - makeMining renumbered from @2 to @3 - Added deprecated makeMiningOld2 @2 placeholder (Cap'n Proto requires sequential ordinals, so this cannot be removed) mining.capnp: - Mining: removed getCoinbaseCommitment and getWitnessCommitmentIndex - Mining: added interrupt @6 - BlockTemplate: methods renumbered (@5 through @9) bitcoin/bitcoin#34184 (mining: add cooldown to createNewBlock() immediately after IBD) mining.capnp: - Mining.createNewBlock: added context @3, cooldown @4 params - Mining.checkBlock: added context @2 param - Mining.createNewBlock: timeout now has default value (60000000) Also adds make_mining_old2_rejected integration test verifying that the server returns an error for the deprecated method. --- capnp/init.capnp | 3 ++- capnp/mining.capnp | 48 ++++++++++++++++++++++++++++++---------------- tests/test.rs | 20 +++++++++++++++++++ 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/capnp/init.capnp b/capnp/init.capnp index 65f4f72..7a8dbd4 100644 --- a/capnp/init.capnp +++ b/capnp/init.capnp @@ -14,5 +14,6 @@ using Mining = import "mining.capnp"; interface Init $Proxy.wrap("interfaces::Init") { construct @0 (threadMap: Proxy.ThreadMap) -> (threadMap :Proxy.ThreadMap); makeEcho @1 (context :Proxy.Context) -> (result :Echo.Echo); - makeMining @2 (context :Proxy.Context) -> (result :Mining.Mining); + makeMiningOld2 @2 () -> (); + makeMining @3 (context :Proxy.Context) -> (result :Mining.Mining); } diff --git a/capnp/mining.capnp b/capnp/mining.capnp index 2c76e6e..f790e12 100644 --- a/capnp/mining.capnp +++ b/capnp/mining.capnp @@ -10,13 +10,19 @@ $Cxx.namespace("ipc::capnp::messages"); using Common = import "common.capnp"; using Proxy = import "proxy.capnp"; +const maxMoney :Int64 = 2100000000000000; +const maxDouble :Float64 = 1.7976931348623157e308; +const defaultBlockReservedWeight :UInt32 = 8000; +const defaultCoinbaseOutputMaxAdditionalSigops :UInt32 = 400; + interface Mining $Proxy.wrap("interfaces::Mining") { isTestChain @0 (context :Proxy.Context) -> (result: Bool); isInitialBlockDownload @1 (context :Proxy.Context) -> (result: Bool); getTip @2 (context :Proxy.Context) -> (result: Common.BlockRef, hasResult: Bool); - waitTipChanged @3 (context :Proxy.Context, currentTip: Data, timeout: Float64) -> (result: Common.BlockRef); - createNewBlock @4 (options: BlockCreateOptions) -> (result: BlockTemplate); - checkBlock @5 (block: Data, options: BlockCheckOptions) -> (reason: Text, debug: Text, result: Bool); + waitTipChanged @3 (context :Proxy.Context, currentTip: Data, timeout: Float64 = .maxDouble) -> (result: Common.BlockRef); + createNewBlock @4 (context :Proxy.Context, options: BlockCreateOptions, cooldown: Bool = true) -> (result: BlockTemplate); + checkBlock @5 (context :Proxy.Context, block: Data, options: BlockCheckOptions) -> (reason: Text, debug: Text, result: Bool); + interrupt @6 () -> (); } interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") { @@ -25,27 +31,35 @@ interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") { getBlock @2 (context: Proxy.Context) -> (result: Data); getTxFees @3 (context: Proxy.Context) -> (result: List(Int64)); getTxSigops @4 (context: Proxy.Context) -> (result: List(Int64)); - getCoinbaseTx @5 (context: Proxy.Context) -> (result: Data); - getCoinbaseCommitment @6 (context: Proxy.Context) -> (result: Data); - getWitnessCommitmentIndex @7 (context: Proxy.Context) -> (result: Int32); - getCoinbaseMerklePath @8 (context: Proxy.Context) -> (result: List(Data)); - submitSolution @9 (context: Proxy.Context, version: UInt32, timestamp: UInt32, nonce: UInt32, coinbase :Data) -> (result: Bool); - waitNext @10 (context: Proxy.Context, options: BlockWaitOptions) -> (result: BlockTemplate); - interruptWait @11() -> (); + getCoinbaseTx @5 (context: Proxy.Context) -> (result: CoinbaseTx); + getCoinbaseMerklePath @6 (context: Proxy.Context) -> (result: List(Data)); + submitSolution @7 (context: Proxy.Context, version: UInt32, timestamp: UInt32, nonce: UInt32, coinbase :Data) -> (result: Bool); + waitNext @8 (context: Proxy.Context, options: BlockWaitOptions) -> (result: BlockTemplate); + interruptWait @9() -> (); } struct BlockCreateOptions $Proxy.wrap("node::BlockCreateOptions") { - useMempool @0 :Bool $Proxy.name("use_mempool"); - blockReservedWeight @1 :UInt64 $Proxy.name("block_reserved_weight"); - coinbaseOutputMaxAdditionalSigops @2 :UInt64 $Proxy.name("coinbase_output_max_additional_sigops"); + useMempool @0 :Bool = true $Proxy.name("use_mempool"); + blockReservedWeight @1 :UInt64 = .defaultBlockReservedWeight $Proxy.name("block_reserved_weight"); + coinbaseOutputMaxAdditionalSigops @2 :UInt64 = .defaultCoinbaseOutputMaxAdditionalSigops $Proxy.name("coinbase_output_max_additional_sigops"); } struct BlockWaitOptions $Proxy.wrap("node::BlockWaitOptions") { - timeout @0 : Float64 $Proxy.name("timeout"); - feeThreshold @1 : Int64 $Proxy.name("fee_threshold"); + timeout @0 : Float64 = .maxDouble $Proxy.name("timeout"); + feeThreshold @1 : Int64 = .maxMoney $Proxy.name("fee_threshold"); } struct BlockCheckOptions $Proxy.wrap("node::BlockCheckOptions") { - checkMerkleRoot @0 :Bool $Proxy.name("check_merkle_root"); - checkPow @1 :Bool $Proxy.name("check_pow"); + checkMerkleRoot @0 :Bool = true $Proxy.name("check_merkle_root"); + checkPow @1 :Bool = true $Proxy.name("check_pow"); +} + +struct CoinbaseTx $Proxy.wrap("node::CoinbaseTx") { + version @0 :UInt32 $Proxy.name("version"); + sequence @1 :UInt32 $Proxy.name("sequence"); + scriptSigPrefix @2 :Data $Proxy.name("script_sig_prefix"); + witness @3 :Data $Proxy.name("witness"); + blockRewardRemaining @4 :Int64 $Proxy.name("block_reward_remaining"); + requiredOutputs @5 :List(Data) $Proxy.name("required_outputs"); + lockTime @6 :UInt32 $Proxy.name("lock_time"); } diff --git a/tests/test.rs b/tests/test.rs index f9b88c8..2188bc2 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -97,3 +97,23 @@ async fn integration() { }) .await; } + +/// Calling the deprecated makeMiningOld2 (@2) should return an error from the +/// server. Cap'n Proto requires sequential ordinals so this placeholder cannot +/// be removed, but the server intentionally rejects it. +#[tokio::test] +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; +} From 640fa352a7f9291267aab5b55f70af2359460ecc Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 17:20:51 -0300 Subject: [PATCH 6/8] tests: retry IPC connection for parallel test execution The IPC socket's listen backlog can reject connections when all tests connect concurrently. In Bitcoin Core src/ipc/capnp/protocol.cpp: ::listen(listen_fd, /*backlog=*/5) Add retry logic with short backoff to connect_unix_stream() so tests run reliably without --test-threads=1. --- Cargo.toml | 2 +- tests/test.rs | 28 +++++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2a9ee42..5512645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,5 @@ capnpc = "0.25.0" [dev-dependencies] capnp-rpc = "0.25.0" futures = "0.3.0" -tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util", "time"] } tokio-util = { version = "0.7.16", features = ["compat"] } diff --git a/tests/test.rs b/tests/test.rs index 2188bc2..9d80631 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -30,13 +30,27 @@ fn unix_socket_path() -> PathBuf { async fn connect_unix_stream( path: impl AsRef, ) -> VatNetwork>> { - let unix_stream = UnixStream::connect(path) - .await - .expect("unix socket connection failed. is Bitcoin Core running in Regtest?"); - let (reader, writer) = unix_stream.into_split(); - let buf_reader = futures::io::BufReader::new(reader.compat()); - let buf_writer = futures::io::BufWriter::new(writer.compat_write()); - VatNetwork::new(buf_reader, buf_writer, Side::Client, Default::default()) + 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. From d0e958d2af20f2de136be827fe9d1d9e4682e3ec Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 4 Mar 2026 16:50:09 -0300 Subject: [PATCH 7/8] tests: exercise all Mining and BlockTemplate methods Add tests that call every method in the Mining and BlockTemplate interfaces and inspect all returned properties. This ensures the capnp schema is correctly encoded and methods don't crash. Split into focused test functions: mining_constants: maxMoney, maxDouble, defaultBlockReservedWeight, defaultCoinbaseOutputMaxAdditionalSigops mining_basic_queries: isTestChain, isInitialBlockDownload, getTip mining_wait_tip_changed: waitTipChanged with short timeout mining_block_template_inspection: createNewBlock + getBlockHeader, getBlock, getTxFees, getTxSigops, getCoinbaseTx (all CoinbaseTx fields), getCoinbaseMerklePath mining_block_template_lifecycle: waitNext, interruptWait, submitSolution (garbage), destroy mining_check_block_and_interrupt: checkBlock (garbage), interrupt The node must have height >= 17 before running (createNewBlock fails with bad-cb-length when the BIP34 height push is < 2 bytes). --- tests/test.rs | 284 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) diff --git a/tests/test.rs b/tests/test.rs index 9d80631..ba19247 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; 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}; @@ -131,3 +132,286 @@ 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] +fn mining_constants() { + assert_eq!(mining_capnp::MAX_MONEY, 2_100_000_000_000_000i64); + const { assert!(mining_capnp::MAX_DOUBLE > 1e300) }; + assert_eq!(mining_capnp::DEFAULT_BLOCK_RESERVED_WEIGHT, 8_000u32); + assert_eq!( + mining_capnp::DEFAULT_COINBASE_OUTPUT_MAX_ADDITIONAL_SIGOPS, + 400u32 + ); +} + +/// isTestChain, isInitialBlockDownload, getTip. +#[tokio::test] +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; + + // 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(); + + // 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. +#[tokio::test] +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(); + + // 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, +/// getTxFees, getTxSigops, getCoinbaseTx, getCoinbaseMerklePath. +#[tokio::test] +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; + + // 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"); + + // 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(); + + // 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(); + + destroy_template(&template, &thread).await; + }) + .await; +} + +/// waitNext (short timeout), interruptWait, submitSolution (garbage), destroy. +#[tokio::test] +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; + + // 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(); + + // 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; +} + +/// checkBlock with a template block payload, and interrupt. +#[tokio::test] +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; + + 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); + } + 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. + } + } + + destroy_template(&template, &thread).await; + + // interrupt — should not crash. + mining.interrupt_request().send().promise.await.unwrap(); + }) + .await; +} From 65c044387d9c242da193e0fbd7efc4e5f80b450f Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Thu, 5 Mar 2026 11:03:39 -0300 Subject: [PATCH 8/8] tests: use selective serial and explicit parallel annotations Run only shared-state mining tests under serial execution and mark other integration tests as parallel. Keep serialization for tests that can observe global tip changes or trigger interrupt behavior, while allowing unrelated tests to run concurrently. --- Cargo.toml | 1 + tests/test.rs | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 5512645..1942c23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,6 @@ capnpc = "0.25.0" [dev-dependencies] capnp-rpc = "0.25.0" futures = "0.3.0" +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/tests/test.rs b/tests/test.rs index ba19247..bbb0262 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -82,6 +82,7 @@ async fn bootstrap( } #[tokio::test] +#[serial_test::parallel] async fn integration() { let path = unix_socket_path(); let rpc_network = connect_unix_stream(path).await; @@ -117,6 +118,7 @@ async fn integration() { /// server. Cap'n Proto requires sequential ordinals so this placeholder cannot /// be removed, but the server intentionally rejects it. #[tokio::test] +#[serial_test::parallel] async fn make_mining_old2_rejected() { let path = unix_socket_path(); let rpc_network = connect_unix_stream(path).await; @@ -169,6 +171,7 @@ async fn destroy_template(template: &block_template::Client, thread: &thread::Cl /// Check the four mining constants from the capnp schema. #[test] +#[serial_test::parallel] fn mining_constants() { assert_eq!(mining_capnp::MAX_MONEY, 2_100_000_000_000_000i64); const { assert!(mining_capnp::MAX_DOUBLE > 1e300) }; @@ -181,6 +184,7 @@ fn mining_constants() { /// isTestChain, isInitialBlockDownload, getTip. #[tokio::test] +#[serial_test::parallel] async fn mining_basic_queries() { let path = unix_socket_path(); let rpc_network = connect_unix_stream(path).await; @@ -218,6 +222,8 @@ async fn mining_basic_queries() { /// waitTipChanged with a short timeout. #[tokio::test] +// 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; @@ -252,6 +258,7 @@ async fn mining_wait_tip_changed() { /// createNewBlock + all BlockTemplate read methods: getBlockHeader, getBlock, /// getTxFees, getTxSigops, getCoinbaseTx, getCoinbaseMerklePath. #[tokio::test] +#[serial_test::parallel] async fn mining_block_template_inspection() { let path = unix_socket_path(); let rpc_network = connect_unix_stream(path).await; @@ -319,6 +326,8 @@ async fn mining_block_template_inspection() { /// waitNext (short timeout), interruptWait, submitSolution (garbage), destroy. #[tokio::test] +// 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; @@ -367,6 +376,8 @@ async fn mining_block_template_lifecycle() { /// checkBlock with a template block payload, and interrupt. #[tokio::test] +// 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;