From d77416c491a6786566d536cd17f26a37ad250ab2 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Wed, 11 Feb 2026 16:23:09 -0500 Subject: [PATCH 1/3] error: Unimplement `Error::source` In error types that wrap an underlying error, the underlying error should either be returned by the outer error's source, or rendered by the outer error's Display implementation, but not both. ref: --- src/error.rs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/error.rs b/src/error.rs index 5574ca4..3e8f3ba 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,22 +67,7 @@ impl fmt::Display for Error { } } -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::DecodeHex(e) => Some(e), - Error::JsonRpc(e) => Some(e), - Error::HexToArray(e) => Some(e), - Error::Json(e) => Some(e), - Error::Io(e) => Some(e), - Error::TryFromInt(e) => Some(e), - Error::GetBlockVerboseOne(e) => Some(e), - Error::GetBlockHeaderVerbose(e) => Some(e), - Error::GetBlockFilter(e) => Some(e), - _ => None, - } - } -} +impl std::error::Error for Error {} // Conversions from other error types impl From for Error { From ee52af4a11a630f2c6b72c16dda2a933dcdabe40 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 30 Jan 2026 12:46:09 -0500 Subject: [PATCH 2/3] test: Improve unit, integration tests - test,deps: Add `anyhow` - test: Add `testenv` module --- Cargo.lock | 1 + Cargo.toml | 1 + src/client.rs | 11 ++ tests/test_rpc_client.rs | 326 ++++++++++++++------------------------- tests/testenv.rs | 62 ++++++++ 5 files changed, 193 insertions(+), 208 deletions(-) create mode 100644 tests/testenv.rs diff --git a/Cargo.lock b/Cargo.lock index 4d060cc..f25a144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" name = "bdk-bitcoind-client" version = "0.1.0" dependencies = [ + "anyhow", "corepc-node", "corepc-types 0.11.0", "jsonrpc 0.19.0", diff --git a/Cargo.toml b/Cargo.toml index f792ef4..e06032e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ default = ["30_0"] 28_0 = [] [dev-dependencies] +anyhow = "1" corepc-node = { version = "0.10.1", features = ["download", "29_0"] } diff --git a/src/client.rs b/src/client.rs index b9f58dc..3f7201b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -284,6 +284,7 @@ mod test_auth { } #[test] + #[ignore = "modifies the local filesystem"] fn test_auth_cookie_file_get_user_pass() { let temp_dir = std::env::temp_dir(); let cookie_path = temp_dir.join("test_auth_cookie"); @@ -299,4 +300,14 @@ mod test_auth { std::fs::remove_file(cookie_path).ok(); } + + #[test] + fn test_auth_invalid_cookie_file() { + let dummy_url = "http://127.0.0.1:18443"; + let cookie_path = PathBuf::from("/nonexistent/path/to/cookie"); + + let result = Client::with_auth(dummy_url, Auth::CookieFile(cookie_path)); + + assert!(matches!(result, Err(Error::InvalidCookieFile))); + } } diff --git a/tests/test_rpc_client.rs b/tests/test_rpc_client.rs index 159bfcf..676b408 100644 --- a/tests/test_rpc_client.rs +++ b/tests/test_rpc_client.rs @@ -1,83 +1,20 @@ -//! Integration tests for the Bitcoin RPC client. +//! Integration tests for the `bdk_bitcoind_client` [`Client`]. //! -//! These tests require a running Bitcoin Core node in regtest mode. -//! -//! Setup: -//! ```bash -//! bitcoind -regtest -rpcuser=bitcoin -rpcpassword=bitcoin -rpcport=18443 -//! ``` +//! These tests require a running Bitcoin Core node in regtest mode. To setup refer to [`corepc_node`]. use bdk_bitcoind_client::{Auth, Client, Error}; -use corepc_node::{exe_path, Conf, Node}; -use corepc_types::bitcoin::{BlockHash, Txid}; -use jsonrpc::serde_json::json; -use std::{path::PathBuf, str::FromStr}; - -/// Helper to initialize the bitcoind executable path -fn init() -> String { - exe_path().expect("bitcoind executable not found. Set BITCOIND_EXE or enable download feature.") -} - -/// Helper to set up a clean bitcoind node and return the client. -fn setup() -> (Client, Node) { - let exe = init(); - - let mut conf = Conf::default(); - - conf.args.push("-blockfilterindex=1"); - conf.args.push("-txindex=1"); - - let node = Node::with_conf(exe, &conf).expect("Failed to start node"); - - let rpc_url = node.rpc_url(); - let cookie = node - .params - .get_cookie_values() - .expect("Failed to read cookie") - .expect("Cookie file empty"); - - let auth = Auth::UserPass(cookie.user, cookie.password); - - let client = Client::with_auth(&rpc_url, auth).expect("failed to create client"); - - (client, node) -} - -/// Helper to mine blocks -fn mine_blocks(client: &Client, n: u64) -> Result, Error> { - let address: String = client.call("getnewaddress", &[])?; - client.call("generatetoaddress", &[json!(n), json!(address)]) -} - -#[test] -fn test_client_with_user_pass() { - let (client, mut node) = setup(); +use corepc_types::bitcoin::{Amount, BlockHash, Txid}; +use std::str::FromStr; - let block_hash = client - .get_best_block_hash() - .expect("failed to call getbestblockhash"); +mod testenv; - assert_eq!( - block_hash.to_string().len(), - 64, - "block hash should be 64 characters" - ); - assert!( - block_hash - .to_string() - .chars() - .all(|c| c.is_ascii_hexdigit()), - "hash should only contain hex digits" - ); - - node.stop().expect("failed to stop node"); -} +use testenv::TestEnv; #[test] fn test_invalid_credentials() { - let (_, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let client = Client::with_auth( - &node.rpc_url(), + &env.node.rpc_url(), Auth::UserPass("wrong".to_string(), "credentials".to_string()), ) .expect("client creation should succeed"); @@ -85,38 +22,17 @@ fn test_invalid_credentials() { let result: Result = client.get_best_block_hash(); assert!(result.is_err()); - - node.stop().expect("failed to stop node"); -} - -#[test] -fn test_invalid_cookie_file() { - let dummy_url = "http://127.0.0.1:18443"; - let cookie_path = PathBuf::from("/nonexistent/path/to/cookie"); - - let result = Client::with_auth(dummy_url, Auth::CookieFile(cookie_path)); - - assert!( - result.is_err(), - "Client should fail when cookie file is missing" - ); - - match result { - Err(Error::InvalidCookieFile) => (), - Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => (), - Err(e) => panic!("Expected InvalidCookieFile or NotFound Io error, got: {e:?}"), - _ => panic!("Expected an error but got Ok"), - } } #[test] fn test_client_with_custom_transport() { use jsonrpc::http::bitreq_http::Builder; - let (_, node) = setup(); + let env = TestEnv::setup().unwrap(); - let rpc_url = node.rpc_url(); - let cookie = node + let rpc_url = env.node.rpc_url(); + let cookie = env + .node .params .get_cookie_values() .expect("Failed to read cookie") @@ -131,105 +47,81 @@ fn test_client_with_custom_transport() { let client = Client::with_transport(transport); - let result = client + let _result = client .get_best_block_hash() .expect("failed to call getbestblockhash"); - - assert_eq!( - result.to_string().len(), - 64, - "block hash should be 64 characters" - ); } #[test] fn test_get_block_count() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let block_count = client.get_block_count().expect("failed to get block count"); + let block_count = env + .client + .get_block_count() + .expect("failed to get block count"); assert_eq!(block_count, 0); - - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let genesis_hash = client + let _genesis_hash = env + .client .get_block_hash(0) .expect("failed to get genesis block hash"); - - assert_eq!(genesis_hash.to_string().len(), 64); - - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash_for_current_height() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let block_count = client.get_block_count().expect("failed to get block count"); - let block_hash = client + let _block_hash = client .get_block_hash(block_count) .expect("failed to get block hash"); - - assert_eq!(block_hash.to_string().len(), 64); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash_invalid_height() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let result = client.get_block_hash(999999999); + let result = env.client.get_block_hash(999_999_999); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_best_block_hash() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let best_block_hash = client .get_best_block_hash() .expect("failed to get best block hash"); - assert_eq!(best_block_hash.to_string().len(), 64); - let block_count = client.get_block_count().expect("failed to get block count"); let block_hash = client .get_block_hash(block_count) .expect("failed to get block hash"); assert_eq!(best_block_hash, block_hash); - node.stop().expect("failed to stop node"); -} - -#[test] -fn test_get_best_block_hash_changes_after_mining() { - let (client, mut node) = setup(); - - let hash_before = client - .get_best_block_hash() - .expect("failed to get best block hash"); - - mine_blocks(&client, 1).expect("failed to mine block"); - - let hash_after = client - .get_best_block_hash() - .expect("failed to get best block hash"); - - assert_ne!(hash_before, hash_after); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) @@ -241,40 +133,59 @@ fn test_get_block() { assert_eq!(block.block_hash(), genesis_hash); assert!(!block.txdata.is_empty()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_after_mining() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let hashes = mine_blocks(&client, 1).expect("failed to mine block"); - let block_hash = BlockHash::from_str(&hashes[0]).expect("invalid hash"); + let hashes = env.mine_blocks(1, None).expect("failed to mine block"); + let block_hash = hashes[0]; - let block = client.get_block(&block_hash).expect("failed to get block"); + let block = env + .client + .get_block(&block_hash) + .expect("failed to get block"); assert_eq!(block.block_hash(), block_hash); assert!(!block.txdata.is_empty()); - node.stop().expect("failed to stop node"); +} + +#[test] +fn test_get_block_verbose() { + let env = TestEnv::setup().unwrap(); + + let hashes = env.mine_blocks(1, None).expect("failed to mine block"); + let block_hash = hashes[0]; + + let get_block_verbose_one = env + .client + .get_block_verbose(&block_hash) + .expect("failed to get block verbose 1"); + + assert_eq!(get_block_verbose_one.hash, block_hash); + assert_eq!(get_block_verbose_one.confirmations, 1); } #[test] fn test_get_block_invalid_hash() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let invalid_hash = BlockHash::from_str("0000000000000000000000000000000000000000000000000000000000000000") .unwrap(); - let result = client.get_block(&invalid_hash); + let result = env.client.get_block(&invalid_hash); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_header() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) @@ -285,114 +196,113 @@ fn test_get_block_header() { .expect("failed to get block header"); assert_eq!(header.block_hash(), genesis_hash); - node.stop().expect("failed to stop node"); } #[test] -fn test_get_block_header_has_valid_fields() { - let (client, mut node) = setup(); +fn test_get_block_header_verbose() { + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) .expect("failed to get genesis hash"); let header = client - .get_block_header(&genesis_hash) - .expect("failed to get block header"); + .get_block_header_verbose(&genesis_hash) + .expect("failed to get block header verbose"); - assert!(header.time > 0); - assert!(header.nonce >= 1); - node.stop().expect("failed to stop node"); + assert_eq!(header.hash, genesis_hash); } #[test] fn test_get_raw_mempool_empty() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - mine_blocks(&client, 1).expect("failed to mine block"); + let _hashes = env.mine_blocks(1, None).expect("failed to mine block"); std::thread::sleep(std::time::Duration::from_millis(100)); - let mempool = client.get_raw_mempool().expect("failed to get mempool"); + let mempool = env.client.get_raw_mempool().expect("failed to get mempool"); assert!(mempool.is_empty()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_raw_mempool_with_transaction() { - let (client, mut node) = setup(); - - mine_blocks(&client, 101).expect("failed to mine blocks"); - - let address: String = client - .call("getnewaddress", &[]) - .expect("failed to get address"); - let txid: String = client - .call("sendtoaddress", &[json!(address), json!(0.001)]) - .expect("failed to send transaction"); - - let mempool = client.get_raw_mempool().expect("failed to get mempool"); - - let txid_parsed = Txid::from_str(&txid).unwrap(); - assert!(mempool.contains(&txid_parsed)); - node.stop().expect("failed to stop node"); + let env = TestEnv::setup().unwrap(); + + let _hashes = env.mine_blocks(101, None).expect("failed to mine block"); + + let address = env.node.client.new_address().unwrap(); + let txid = env + .node + .client + .send_to_address(&address, Amount::from_btc(0.001).unwrap()) + .expect("failed to send to address") + .into_model() + .unwrap() + .txid; + + let mempool = env.client.get_raw_mempool().expect("failed to get mempool"); + assert!(mempool.contains(&txid)); } #[test] fn test_get_raw_transaction() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - mine_blocks(&client, 1).expect("failed to mine block"); + let _hashes = env.mine_blocks(1, None).expect("failed to mine block"); - let best_hash = client + let best_hash = env + .client .get_best_block_hash() .expect("failed to get best block hash"); - let block = client.get_block(&best_hash).expect("failed to get block"); + let block = env + .client + .get_block(&best_hash) + .expect("failed to get block"); - let txid = &block.txdata[0].compute_txid(); + let expected_tx = &block.txdata[0]; + let txid = expected_tx.compute_txid(); - let tx = client - .get_raw_transaction(txid) + let result_tx = env + .client + .get_raw_transaction(&txid) .expect("failed to get raw transaction"); - assert_eq!(tx.compute_txid(), *txid); - assert!(!tx.input.is_empty()); - assert!(!tx.output.is_empty()); - node.stop().expect("failed to stop node"); + assert_eq!(result_tx, *expected_tx); + assert_eq!(result_tx.compute_txid(), txid); } #[test] fn test_get_raw_transaction_invalid_txid() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let fake_txid = Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); - let result = client.get_raw_transaction(&fake_txid); + let result = env.client.get_raw_transaction(&fake_txid); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_filter() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) .expect("failed to get genesis hash"); - let result = client.get_block_filter(&genesis_hash); - - match result { - Ok(filter) => { - assert!(!filter.filter.is_empty()); - } - Err(_) => { - println!("Block filters not enabled (requires -blockfilterindex=1)"); - } - } - node.stop().expect("failed to stop node"); + let result = client + .get_block_filter(&genesis_hash) + .expect("failed to get block filter"); + + assert!(!result.filter.is_empty()); } diff --git a/tests/testenv.rs b/tests/testenv.rs new file mode 100644 index 0000000..691976c --- /dev/null +++ b/tests/testenv.rs @@ -0,0 +1,62 @@ +use bdk_bitcoind_client::{Auth, Client}; +use bitcoin::{Address, BlockHash}; +use corepc_node::{exe_path, Conf, Node}; +use corepc_types::bitcoin; + +/// Test environment for running integration tests. +/// +/// [`TestEnv`] exposes the [`Client`] API defined by this crate to be tested against +/// a running [`corepc_node::Node`] instance. +#[derive(Debug)] +pub struct TestEnv { + /// [`bdk_bitcoind_client::Client`] + pub client: Client, + /// [`corepc_node::Node`] + pub node: Node, +} + +impl TestEnv { + /// Create new [`TestEnv`]. + /// + /// This will first look for the path of the `bitcoind` executable using [`corepc_node::exe_path`] + /// before returning a new [`TestEnv`] with [`Client`] connected to it. + /// + /// Note that [`Node`] also exposes its own RPC [`client`](Node::client) which may help with + /// creating different test cases, but be aware that this is different from the client we're + /// actually testing. + pub fn setup() -> anyhow::Result { + let exe = exe_path()?; + + let mut conf = Conf::default(); + conf.args.push("-blockfilterindex=1"); + conf.args.push("-txindex=1"); + + let node = Node::with_conf(exe, &conf)?; + + let rpc_url = node.rpc_url(); + let cookie_file = &node.params.cookie_file; + let auth = Auth::CookieFile(cookie_file.clone()); + let client = Client::with_auth(&rpc_url, auth)?; + + Ok(Self { client, node }) + } + + /// Mines `nblocks` blocks to the given `address`, or an address controlled + /// by the [`Node`] if not provided. + pub fn mine_blocks( + &self, + nblocks: usize, + address: Option
, + ) -> anyhow::Result> { + let address = match address { + Some(addr) => addr, + None => self.node.client.new_address()?, + }; + Ok(self + .node + .client + .generate_to_address(nblocks, &address)? + .into_model()? + .0) + } +} From f1db39a650031d09354babfefc898d0cfd2646fa Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 3 Feb 2026 22:34:29 -0500 Subject: [PATCH 3/3] ci: Run `test_rpc_client` integration tests --- .github/workflows/cont_integration.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 9b6d56c..2e321b8 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -79,6 +79,9 @@ jobs: - name: Run doc tests run: cargo test ${{ matrix.features }} --doc --verbose + - name: Run RPC client tests + run: cargo test --test test_rpc_client --verbose -- --test-threads=2 + # MSRV msrv: name: MSRV