From a1fdaf24d768085f31f69d4f04d4d1e950130abc Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 11:34:18 +0200 Subject: [PATCH 1/7] test: route existing helpers through util module This removes explicit path attributes for the pre-existing test util modules and lets tests/test.rs import them through tests/util/mod.rs instead. Review comment: https://github.com/2140-dev/bitcoin-capnp-types/pull/12#discussion_r3323286592 --- tests/test.rs | 9 +++------ tests/util/bitcoin_core.rs | 2 +- tests/util/mod.rs | 2 ++ 3 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 tests/util/mod.rs diff --git a/tests/test.rs b/tests/test.rs index bf7e0d2..3bae9f2 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,14 +1,11 @@ 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; +mod util; -use bitcoin_core_util::{ +use util::bitcoin_core::{ destroy_template, make_block_template, mempool_tx_count, with_init_client, with_mining_client, }; -use bitcoin_core_wallet_util::{ +use util::bitcoin_core_wallet::{ bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded, }; diff --git a/tests/util/bitcoin_core.rs b/tests/util/bitcoin_core.rs index 2171bf5..67bb579 100644 --- a/tests/util/bitcoin_core.rs +++ b/tests/util/bitcoin_core.rs @@ -4,7 +4,7 @@ use std::{ sync::Once, }; -use crate::bitcoin_core_wallet_util::{ +use crate::util::bitcoin_core_wallet::{ bitcoin_rpc_json, bitcoin_test_wallet, ensure_wallet_loaded, mine_blocks_to_new_address, }; use bitcoin_capnp_types::{ diff --git a/tests/util/mod.rs b/tests/util/mod.rs new file mode 100644 index 0000000..182ce04 --- /dev/null +++ b/tests/util/mod.rs @@ -0,0 +1,2 @@ +pub mod bitcoin_core; +pub mod bitcoin_core_wallet; From 568306e2e0da4262203420b66414fb857033edc1 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 09:35:41 +0200 Subject: [PATCH 2/7] test: check discarded results explicitly --- tests/test.rs | 58 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/tests/test.rs b/tests/test.rs index 3bae9f2..c36f0f9 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -81,7 +81,10 @@ async fn mining_basic_queries() { 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(); + let _ibd = resp + .get() + .expect("isInitialBlockDownload response should decode") + .get_result(); // getTip let mut req = mining.get_tip_request(); @@ -151,13 +154,21 @@ async fn mining_block_template_inspection() { 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(); + let _fees = resp + .get() + .expect("getTxFees response should decode") + .get_result() + .expect("getTxFees response should contain fees"); // 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(); + let _sigops = resp + .get() + .expect("getTxSigops response should decode") + .get_result() + .expect("getTxSigops response should contain sigops"); // getCoinbaseTx — inspect every CoinbaseTx field let mut req = template.get_coinbase_tx_request(); @@ -171,17 +182,25 @@ async fn mining_block_template_inspection() { !script_sig_prefix.is_empty(), "scriptSigPrefix must contain at least the block height" ); - let _witness = coinbase.get_witness().unwrap(); + let _witness = coinbase + .get_witness() + .expect("coinbase witness should decode"); 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 _required_outputs = coinbase + .get_required_outputs() + .expect("coinbase required outputs should decode"); 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(); + let _merkle_path = resp + .get() + .expect("getCoinbaseMerklePath response should decode") + .get_result() + .expect("getCoinbaseMerklePath response should contain a merkle path"); destroy_template(&template, &thread).await; }) @@ -205,7 +224,11 @@ async fn mining_block_template_lifecycle() { opts.set_fee_threshold(mining_capnp::MAX_MONEY); } let resp = req.send().promise.await.unwrap(); - let _has_next = resp.get().unwrap().has_result(); + let results = resp.get().expect("waitNext response should decode"); + assert!( + !results.has_result(), + "waitNext should time out without a new template" + ); // interruptWait — should not crash. template @@ -213,7 +236,7 @@ async fn mining_block_template_lifecycle() { .send() .promise .await - .unwrap(); + .expect("interruptWait should not fail"); // submitSolution — garbage coinbase should be rejected. // This mutates the template, so we do it right before destroy. @@ -261,10 +284,14 @@ async fn mining_check_block_and_interrupt() { 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 results = resp.get().expect("checkBlock response should decode"); + let _valid = results.get_result(); + let _reason = results + .get_reason() + .expect("checkBlock response should contain reason"); + let _debug = results + .get_debug() + .expect("checkBlock response should contain debug"); } Err(_) => { // Server may reject validation/deserialization. @@ -274,7 +301,12 @@ async fn mining_check_block_and_interrupt() { destroy_template(&template, &thread).await; // interrupt — should not crash. - mining.interrupt_request().send().promise.await.unwrap(); + mining + .interrupt_request() + .send() + .promise + .await + .expect("interrupt should not fail"); }) .await; } From 0a42cbaf6e122ee486ffb12faf39e25a9611bde6 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 09:07:05 +0200 Subject: [PATCH 3/7] test: add mining block helper --- Cargo.toml | 1 + tests/test.rs | 1 - tests/util/block.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++ tests/util/mod.rs | 1 + 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/util/block.rs diff --git a/Cargo.toml b/Cargo.toml index 1a61025..ab9297d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ capnp-rpc = "0.25.0" capnpc = "0.25.0" [dev-dependencies] +bitcoin = { git = "https://github.com/rust-bitcoin/rust-bitcoin", package = "bitcoin", tag = "bitcoin-0.33.0-beta" } bitcoin-primitives = { git = "https://github.com/rust-bitcoin/rust-bitcoin", package = "bitcoin-primitives", tag = "bitcoin-0.33.0-beta" } encoding = { git = "https://github.com/rust-bitcoin/rust-bitcoin", package = "bitcoin-consensus-encoding", tag = "bitcoin-0.33.0-beta" } futures = "0.3.0" diff --git a/tests/test.rs b/tests/test.rs index c36f0f9..bbf4317 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -8,7 +8,6 @@ use util::bitcoin_core::{ use util::bitcoin_core_wallet::{ bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded, }; - #[tokio::test] #[serial_test::parallel] async fn integration() { diff --git a/tests/util/block.rs b/tests/util/block.rs new file mode 100644 index 0000000..234bc96 --- /dev/null +++ b/tests/util/block.rs @@ -0,0 +1,110 @@ +use bitcoin::pow::Target; +use bitcoin::{Block as BitcoinBlock, BlockHeader, compute_merkle_root}; +use encoding::{decode_from_slice, encode_to_vec}; + +const BLOCK_HEADER_LEN: usize = 80; +const MERKLE_ROOT_OFFSET: usize = 36; +const NONCE_OFFSET: usize = 76; + +pub struct BlockSolution { + pub version: u32, + pub timestamp: u32, + pub nonce: u32, + pub coinbase: Vec, +} + +pub fn block_solution(block: &[u8]) -> BlockSolution { + let block: BitcoinBlock = + decode_from_slice(block).unwrap_or_else(|e| panic!("failed to decode block: {e}")); + let (header, transactions) = block.into_parts(); + let coinbase = transactions + .first() + .expect("block template must have a coinbase transaction"); + + BlockSolution { + version: header + .version + .to_consensus() + .try_into() + .expect("block version must fit in u32"), + timestamp: header.time.to_u32(), + nonce: header.nonce, + coinbase: encode_to_vec(coinbase), + } +} + +pub fn block_with_pow(block: &[u8], valid_pow: bool) -> Vec { + assert!( + block.len() >= BLOCK_HEADER_LEN, + "block must include an 80-byte header" + ); + + let mut block = block.to_vec(); + let mut header: BlockHeader = decode_from_slice(&block[..BLOCK_HEADER_LEN]) + .unwrap_or_else(|e| panic!("failed to decode block header: {e}")); + let decoded_block: BitcoinBlock = + decode_from_slice(&block).unwrap_or_else(|e| panic!("failed to decode block: {e}")); + let (_, transactions) = decoded_block.into_parts(); + // Keep the block self-consistent so submitBlock reaches the intended PoW + // branch instead of failing earlier on the Merkle root. + header.merkle_root = + compute_merkle_root(&transactions).expect("block template must have transactions"); + block[MERKLE_ROOT_OFFSET..MERKLE_ROOT_OFFSET + 32] + .copy_from_slice(header.merkle_root.as_byte_array()); + + let start_nonce = header.nonce; + let mut nonce = start_nonce; + loop { + nonce = nonce.wrapping_add(1); + header.nonce = nonce; + block[NONCE_OFFSET..NONCE_OFFSET + 4].copy_from_slice(&nonce.to_le_bytes()); + + if Target::from_compact(header.bits).is_met_by(header.block_hash()) == valid_pow { + return block; + } + + assert!( + nonce != start_nonce, + "failed to find a nonce with valid_pow={valid_pow}" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::Network; + use bitcoin::blockdata::constants::genesis_block; + + #[test] + fn grinds_valid_and_invalid_pow() { + let block = regtest_genesis_block(); + + let valid = block_with_pow(&block, true); + let header: BlockHeader = decode_from_slice(&valid[..BLOCK_HEADER_LEN]).unwrap(); + assert!(Target::from_compact(header.bits).is_met_by(header.block_hash())); + + let invalid = block_with_pow(&block, false); + let header: BlockHeader = decode_from_slice(&invalid[..BLOCK_HEADER_LEN]).unwrap(); + assert!(!Target::from_compact(header.bits).is_met_by(header.block_hash())); + } + + #[test] + fn extracts_submit_solution_fields() { + let block = block_with_pow(®test_genesis_block(), true); + let solution = block_solution(&block); + let decoded: BitcoinBlock = decode_from_slice(&block).unwrap(); + let (header, transactions) = decoded.into_parts(); + + assert_eq!(solution.version, header.version.to_consensus() as u32); + assert_eq!(solution.timestamp, header.time.to_u32()); + assert_eq!(solution.nonce, header.nonce); + assert_eq!(solution.coinbase, encode_to_vec(&transactions[0])); + } + + fn regtest_genesis_block() -> Vec { + let checked = genesis_block(Network::Regtest); + let block = BitcoinBlock::new_unchecked(*checked.header(), checked.transactions().to_vec()); + encode_to_vec(&block) + } +} diff --git a/tests/util/mod.rs b/tests/util/mod.rs index 182ce04..9e2b1ce 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -1,2 +1,3 @@ pub mod bitcoin_core; pub mod bitcoin_core_wallet; +pub mod block; From f167fc915b04ea1f8a7e042743c6badcbe529013 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 09:24:56 +0200 Subject: [PATCH 4/7] test: add block template helpers --- tests/test.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test.rs b/tests/test.rs index bbf4317..38b5104 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,4 +1,4 @@ -use bitcoin_capnp_types::mining_capnp; +use bitcoin_capnp_types::{mining_capnp, proxy_capnp::thread}; mod util; @@ -8,6 +8,17 @@ use util::bitcoin_core::{ use util::bitcoin_core_wallet::{ bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded, }; + +async fn get_template_block( + template: &mining_capnp::block_template::Client, + thread: &thread::Client, +) -> Vec { + let mut req = template.get_block_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + let resp = req.send().promise.await.unwrap(); + resp.get().unwrap().get_result().unwrap().to_vec() +} + #[tokio::test] #[serial_test::parallel] async fn integration() { @@ -262,14 +273,7 @@ async fn mining_check_block_and_interrupt() { 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 block = get_template_block(&template, &thread).await; // checkBlock should either error or return a response. let mut req = mining.check_block_request(); From b25fed132c12c7b5bf013d6cf63ebfaa6a4d23db Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 09:25:20 +0200 Subject: [PATCH 5/7] test: submit solved block template solution --- tests/test.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test.rs b/tests/test.rs index 38b5104..03f9531 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -8,6 +8,7 @@ use util::bitcoin_core::{ use util::bitcoin_core_wallet::{ bitcoin_test_wallet, create_mempool_self_transfer, ensure_wallet_loaded_and_funded, }; +use util::block::{block_solution, block_with_pow}; async fn get_template_block( template: &mining_capnp::block_template::Client, @@ -265,6 +266,37 @@ async fn mining_block_template_lifecycle() { .await; } +/// submitSolution with a solved template block should be accepted. +#[tokio::test] +#[serial_test::serial] +async fn mining_block_template_submit_solution_resolved() { + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; + + let block = get_template_block(&template, &thread).await; + let block = block_with_pow(&block, true); + let solution = block_solution(&block); + + let mut req = template.submit_solution_request(); + { + let mut params = req.get(); + params.set_version(solution.version); + params.set_timestamp(solution.timestamp); + params.set_nonce(solution.nonce); + params.set_coinbase(&solution.coinbase); + params.get_context().unwrap().set_thread(thread.clone()); + } + let resp = req.send().promise.await.unwrap(); + assert!( + resp.get().unwrap().get_result(), + "solved template solution must be accepted" + ); + + destroy_template(&template, &thread).await; + }) + .await; +} + /// checkBlock with a template block payload, and interrupt. #[tokio::test] // Serialized because interrupt() can affect other in-flight mining waits. From 676c74d646211c1d120a60d2b23031b46ef78a9a Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 29 May 2026 09:25:37 +0200 Subject: [PATCH 6/7] test: cover duplicate submit solution --- tests/test.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test.rs b/tests/test.rs index 03f9531..cc2051d 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -269,7 +269,7 @@ async fn mining_block_template_lifecycle() { /// submitSolution with a solved template block should be accepted. #[tokio::test] #[serial_test::serial] -async fn mining_block_template_submit_solution_resolved() { +async fn mining_block_template_submit_solution_resolved_and_duplicate() { with_mining_client(|_client, thread, mining| async move { let template = make_block_template(&mining, &thread).await; @@ -292,6 +292,23 @@ async fn mining_block_template_submit_solution_resolved() { "solved template solution must be accepted" ); + // A duplicate block currently returns true. bitcoin/bitcoin#34672 may + // change this to false, and this coverage should catch that change. + let mut req = template.submit_solution_request(); + { + let mut params = req.get(); + params.set_version(solution.version); + params.set_timestamp(solution.timestamp); + params.set_nonce(solution.nonce); + params.set_coinbase(&solution.coinbase); + params.get_context().unwrap().set_thread(thread.clone()); + } + let resp = req.send().promise.await.unwrap(); + assert!( + resp.get().unwrap().get_result(), + "duplicate template solution currently returns true" + ); + destroy_template(&template, &thread).await; }) .await; From 8e398bc7291519b24d11d48aa20b081a5be1862e Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Thu, 5 Mar 2026 15:36:23 -0300 Subject: [PATCH 7/7] capnp: add Mining.submitBlock() method --- capnp/mining.capnp | 1 + tests/test.rs | 127 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/capnp/mining.capnp b/capnp/mining.capnp index f790e12..2526323 100644 --- a/capnp/mining.capnp +++ b/capnp/mining.capnp @@ -23,6 +23,7 @@ interface Mining $Proxy.wrap("interfaces::Mining") { 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 () -> (); + submitBlock @7 (context :Proxy.Context, block: Data) -> (reason: Text, debug: Text, result: Bool); } interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") { diff --git a/tests/test.rs b/tests/test.rs index cc2051d..1a8b74f 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -10,6 +10,29 @@ use util::bitcoin_core_wallet::{ }; use util::block::{block_solution, block_with_pow}; +struct SubmitBlockOutcome { + accepted: bool, + reason: String, + debug: String, +} + +async fn submit_block( + mining: &mining_capnp::mining::Client, + thread: &thread::Client, + block: &[u8], +) -> SubmitBlockOutcome { + let mut req = mining.submit_block_request(); + req.get().get_context().unwrap().set_thread(thread.clone()); + req.get().set_block(block); + let resp = req.send().promise.await.unwrap(); + let results = resp.get().unwrap(); + SubmitBlockOutcome { + accepted: results.get_result(), + reason: results.get_reason().unwrap().to_string().unwrap(), + debug: results.get_debug().unwrap().to_string().unwrap(), + } +} + async fn get_template_block( template: &mining_capnp::block_template::Client, thread: &thread::Client, @@ -314,6 +337,110 @@ async fn mining_block_template_submit_solution_resolved_and_duplicate() { .await; } +/// submitBlock with insufficient PoW should be rejected. +#[tokio::test] +#[serial_test::serial] +async fn mining_submit_block_insufficient_pow() { + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; + + let block = get_template_block(&template, &thread).await; + let block = block_with_pow(&block, false); + + let outcome = submit_block(&mining, &thread, &block).await; + assert!( + !outcome.accepted, + "block with insufficient PoW must not be accepted" + ); + assert_eq!(outcome.reason, "high-hash"); + assert_eq!(outcome.debug, "proof of work failed"); + + destroy_template(&template, &thread).await; + }) + .await; +} + +/// submitBlock with invalid contents should be rejected even with sufficient PoW. +#[tokio::test] +#[serial_test::serial] +async fn mining_submit_block_invalid() { + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; + + let block = get_template_block(&template, &thread).await; + let mut block = block_with_pow(&block, true); + // Corrupt the serialized block after solving its header. This keeps + // the PoW valid while making the header's Merkle root stale. + *block + .last_mut() + .expect("serialized block must not be empty") ^= 1; + + let outcome = submit_block(&mining, &thread, &block).await; + assert!( + !outcome.accepted, + "invalid block with sufficient PoW must not be accepted" + ); + assert_eq!(outcome.reason, "bad-txnmrklroot"); + assert_eq!(outcome.debug, "hashMerkleRoot mismatch"); + + destroy_template(&template, &thread).await; + }) + .await; +} + +/// submitBlock with a solved template block should be accepted. +#[tokio::test] +#[serial_test::serial] +async fn mining_submit_block_resolved() { + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; + + let block = get_template_block(&template, &thread).await; + let block = block_with_pow(&block, true); + + let outcome = submit_block(&mining, &thread, &block).await; + assert!( + outcome.accepted, + "solved template block must be accepted: reason={}, debug={}", + outcome.reason, outcome.debug + ); + assert_eq!(outcome.reason, ""); + assert_eq!(outcome.debug, ""); + + destroy_template(&template, &thread).await; + }) + .await; +} + +/// submitBlock with a duplicate solved block should be rejected. +#[tokio::test] +#[serial_test::serial] +async fn mining_submit_block_duplicate() { + with_mining_client(|_client, thread, mining| async move { + let template = make_block_template(&mining, &thread).await; + + let block = get_template_block(&template, &thread).await; + let block = block_with_pow(&block, true); + + let outcome = submit_block(&mining, &thread, &block).await; + assert!( + outcome.accepted, + "first solved block submission must be accepted: reason={}, debug={}", + outcome.reason, outcome.debug + ); + assert_eq!(outcome.reason, ""); + assert_eq!(outcome.debug, ""); + + let outcome = submit_block(&mining, &thread, &block).await; + assert!(!outcome.accepted, "duplicate block must not be accepted"); + assert_eq!(outcome.reason, "duplicate"); + assert_eq!(outcome.debug, ""); + + destroy_template(&template, &thread).await; + }) + .await; +} + /// checkBlock with a template block payload, and interrupt. #[tokio::test] // Serialized because interrupt() can affect other in-flight mining waits.