From 2821065d5c87ba9fbd85d7efb41050308961a0d0 Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Thu, 11 Jun 2026 15:41:25 +0100 Subject: [PATCH 1/7] Fix lint error There is a lint error: this expression creates a reference which is immediately dereferenced by the compiler. Fix it as suggested by the compiler. --- bitreq/src/connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitreq/src/connection.rs b/bitreq/src/connection.rs index fd704e09a..7fa2594c7 100644 --- a/bitreq/src/connection.rs +++ b/bitreq/src/connection.rs @@ -389,7 +389,7 @@ impl AsyncConnection { } #[cfg(not(feature = "proxy"))] - Self::tcp_connect(¶ms.host, params.port).await + Self::tcp_connect(params.host, params.port).await } async fn timeout>(timeout: Option, f: F) -> Result { From e26cd90b21d508351fbdbfc934642c70a526e7aa Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Fri, 6 Feb 2026 17:00:02 +0000 Subject: [PATCH 2/7] Set bitreq_http feature for sync only In preparation for adding bitreq_http_async feature to jsonrpc move the sync version to the client-sync feature so it is not always on with jsonrpc. --- client/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index bc78a905d..e43adf3ef 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -22,7 +22,7 @@ allowed_duplicates = ["base64"] [features] # Enable this feature to get a blocking JSON-RPC client. -client-sync = ["jsonrpc"] +client-sync = ["jsonrpc", "jsonrpc/bitreq_http"] [dependencies] bitcoin = { version = "0.32.0", default-features = false, features = ["std", "serde"] } @@ -31,6 +31,6 @@ serde = { version = "1.0.103", default-features = false, features = [ "derive", serde_json = { version = "1.0.117" } types = { package = "corepc-types", version = "0.14.0", path = "../types", default-features = false, features = ["std"] } -jsonrpc = { version = "0.20.0", path = "../jsonrpc", features = ["bitreq_http"], optional = true } +jsonrpc = { version = "0.20.0", path = "../jsonrpc", default-features = false, optional = true } [dev-dependencies] From ee0dbc555bab8c20b1feb80068c012ee9d9c2fc7 Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Mon, 15 Jun 2026 16:28:01 +0100 Subject: [PATCH 3/7] Move `into_json` and `log_response` to crate root These will both be used by the async client. Move them out of sync client in preparation. Edit the logging function so it doesn't panic. --- client/src/client_sync/mod.rs | 34 +---------------------------- client/src/lib.rs | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/client/src/client_sync/mod.rs b/client/src/client_sync/mod.rs index 38dd11702..17a5d7c6a 100644 --- a/client/src/client_sync/mod.rs +++ b/client/src/client_sync/mod.rs @@ -24,6 +24,7 @@ use std::io::{BufRead, BufReader}; use std::path::PathBuf; pub use crate::client_sync::error::Error; +pub(crate) use crate::{into_json, log_response}; /// Crate-specific Result type. /// @@ -156,36 +157,3 @@ macro_rules! impl_client_check_expected_server_version { } }; } - -/// Shorthand for converting a variable into a `serde_json::Value`. -fn into_json(val: T) -> Result -where - T: serde::ser::Serialize, -{ - Ok(serde_json::to_value(val)?) -} - -/// Helper to log an RPC response. -fn log_response(method: &str, resp: &Result) { - use log::Level::{Debug, Trace, Warn}; - - if log::log_enabled!(Warn) || log::log_enabled!(Debug) || log::log_enabled!(Trace) { - match resp { - Err(ref e) => - if log::log_enabled!(Debug) { - log::debug!(target: "corepc", "error: {}: {:?}", method, e); - }, - Ok(ref resp) => - if let Some(ref e) = resp.error { - if log::log_enabled!(Debug) { - log::debug!(target: "corepc", "response error for {}: {:?}", method, e); - } - } else if log::log_enabled!(Trace) { - let def = - serde_json::value::to_raw_value(&serde_json::value::Value::Null).unwrap(); - let result = resp.result.as_ref().unwrap_or(&def); - log::trace!(target: "corepc", "response for {}: {}", method, result); - }, - } - } -} diff --git a/client/src/lib.rs b/client/src/lib.rs index 4b34271e3..aecfd0265 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -11,3 +11,43 @@ pub extern crate types; #[cfg(feature = "client-sync")] #[macro_use] pub mod client_sync; + +/// Helper to log an RPC response. +#[cfg(any(feature = "client-sync", feature = "client-async"))] +pub(crate) fn log_response( + method: &str, + resp: &std::result::Result, +) { + use log::Level::{Debug, Trace, Warn}; + + if log::log_enabled!(Warn) || log::log_enabled!(Debug) || log::log_enabled!(Trace) { + match resp { + Err(ref e) => + if log::log_enabled!(Debug) { + log::debug!(target: "corepc", "error: {}: {:?}", method, e); + }, + Ok(ref resp) => + if let Some(ref e) = resp.error { + if log::log_enabled!(Debug) { + log::debug!(target: "corepc", "response error for {}: {:?}", method, e); + } + } else if log::log_enabled!(Trace) { + if let Ok(def) = + serde_json::value::to_raw_value(&serde_json::value::Value::Null) + { + let result = resp.result.as_ref().unwrap_or(&def); + log::trace!(target: "corepc", "response for {}: {}", method, result); + } + }, + } + } +} + +/// Shorthand for converting a variable into a `serde_json::Value`. +#[cfg(any(feature = "client-sync", feature = "client-async"))] +pub(crate) fn into_json(val: T) -> Result +where + T: serde::ser::Serialize, +{ + serde_json::to_value(val) +} From f1f45db741202b6cca19a0e51d66df3483a36bff Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Fri, 6 Feb 2026 17:03:10 +0000 Subject: [PATCH 4/7] Copy client_sync to client_async Create a new folder for the upcoming async client and copy in the existing client_sync code. Code copy only to make the next patch easier to review. --- client/src/client_async/error.rs | 112 ++++++++++++++++++++++ client/src/client_async/mod.rs | 159 +++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 client/src/client_async/error.rs create mode 100644 client/src/client_async/mod.rs diff --git a/client/src/client_async/error.rs b/client/src/client_async/error.rs new file mode 100644 index 000000000..331a81eeb --- /dev/null +++ b/client/src/client_async/error.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: CC0-1.0 + +use std::{error, fmt, io}; + +use bitcoin::hex; + +/// The error type for errors produced in this library. +#[derive(Debug)] +pub enum Error { + JsonRpc(jsonrpc::error::Error), + HexToArray(hex::HexToArrayError), + HexToBytes(hex::HexToBytesError), + Json(serde_json::error::Error), + BitcoinSerialization(bitcoin::consensus::encode::FromHexError), + Io(io::Error), + InvalidCookieFile, + /// The JSON result had an unexpected structure. + UnexpectedStructure, + /// The daemon returned an error string. + Returned(String), + /// The server version did not match what was expected. + ServerVersion(UnexpectedServerVersionError), + /// Missing user/password. + MissingUserPassword, +} + +impl From for Error { + fn from(e: jsonrpc::error::Error) -> Error { Error::JsonRpc(e) } +} + +impl From for Error { + fn from(e: hex::HexToArrayError) -> Self { Self::HexToArray(e) } +} + +impl From for Error { + fn from(e: hex::HexToBytesError) -> Self { Self::HexToBytes(e) } +} + +impl From for Error { + fn from(e: serde_json::error::Error) -> Error { Error::Json(e) } +} + +impl From for Error { + fn from(e: bitcoin::consensus::encode::FromHexError) -> Error { Error::BitcoinSerialization(e) } +} + +impl From for Error { + fn from(e: io::Error) -> Error { Error::Io(e) } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use Error::*; + + match *self { + JsonRpc(ref e) => write!(f, "JSON-RPC error: {}", e), + HexToArray(ref e) => write!(f, "hex to array decode error: {}", e), + HexToBytes(ref e) => write!(f, "hex to bytes decode error: {}", e), + Json(ref e) => write!(f, "JSON error: {}", e), + BitcoinSerialization(ref e) => write!(f, "Bitcoin serialization error: {}", e), + Io(ref e) => write!(f, "I/O error: {}", e), + InvalidCookieFile => write!(f, "invalid cookie file"), + UnexpectedStructure => write!(f, "the JSON result had an unexpected structure"), + Returned(ref s) => write!(f, "the daemon returned an error string: {}", s), + ServerVersion(ref e) => write!(f, "server version: {}", e), + MissingUserPassword => write!(f, "missing user and/or password"), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use Error::*; + + match *self { + JsonRpc(ref e) => Some(e), + HexToArray(ref e) => Some(e), + HexToBytes(ref e) => Some(e), + Json(ref e) => Some(e), + BitcoinSerialization(ref e) => Some(e), + Io(ref e) => Some(e), + ServerVersion(ref e) => Some(e), + InvalidCookieFile | UnexpectedStructure | Returned(_) | MissingUserPassword => None, + } + } +} + +/// Error returned when RPC client expects a different version than bitcoind reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnexpectedServerVersionError { + /// Version from server. + pub got: usize, + /// Expected server version. + pub expected: Vec, +} + +impl fmt::Display for UnexpectedServerVersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut expected = String::new(); + for version in &self.expected { + let v = format!(" {} ", version); + expected.push_str(&v); + } + write!(f, "unexpected bitcoind version, got: {} expected one of: {}", self.got, expected) + } +} + +impl error::Error for UnexpectedServerVersionError {} + +impl From for Error { + fn from(e: UnexpectedServerVersionError) -> Self { Self::ServerVersion(e) } +} diff --git a/client/src/client_async/mod.rs b/client/src/client_async/mod.rs new file mode 100644 index 000000000..17a5d7c6a --- /dev/null +++ b/client/src/client_async/mod.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! JSON-RPC clients for testing against specific versions of Bitcoin Core. + +mod error; +pub mod v17; +pub mod v18; +pub mod v19; +pub mod v20; +pub mod v21; +pub mod v22; +pub mod v23; +pub mod v24; +pub mod v25; +pub mod v26; +pub mod v27; +pub mod v28; +pub mod v29; +pub mod v30; +pub mod v31; + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; + +pub use crate::client_sync::error::Error; +pub(crate) use crate::{into_json, log_response}; + +/// Crate-specific Result type. +/// +/// Shorthand for `std::result::Result` with our crate-specific [`Error`] type. +pub type Result = std::result::Result; + +/// The different authentication methods for the client. +#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)] +pub enum Auth { + None, + UserPass(String, String), + CookieFile(PathBuf), +} + +impl Auth { + /// Convert into the arguments that jsonrpc::Client needs. + pub fn get_user_pass(self) -> Result<(Option, Option)> { + match self { + Auth::None => Ok((None, None)), + Auth::UserPass(u, p) => Ok((Some(u), Some(p))), + Auth::CookieFile(path) => { + let line = BufReader::new(File::open(path)?) + .lines() + .next() + .ok_or(Error::InvalidCookieFile)??; + let colon = line.find(':').ok_or(Error::InvalidCookieFile)?; + Ok((Some(line[..colon].into()), Some(line[colon + 1..].into()))) + } + } + } +} + +/// Defines a `jsonrpc::Client` using `bitreq`. +#[macro_export] +macro_rules! define_jsonrpc_bitreq_client { + ($version:literal) => { + use std::fmt; + + use $crate::client_sync::{log_response, Auth, Result}; + use $crate::client_sync::error::Error; + + /// Client implements a JSON-RPC client for the Bitcoin Core daemon or compatible APIs. + pub struct Client { + inner: jsonrpc::client::Client, + } + + impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter) -> core::fmt::Result { + write!( + f, + "corepc_client::client_sync::{}::Client({:?})", $version, self.inner + ) + } + } + + impl Client { + /// Creates a client to a bitcoind JSON-RPC server without authentication. + pub fn new(url: &str) -> Self { + let transport = jsonrpc::http::bitreq_http::Builder::new() + .url(url) + .expect("jsonrpc v0.19, this function does not error") + .timeout(std::time::Duration::from_secs(60)) + .build(); + let inner = jsonrpc::client::Client::with_transport(transport); + + Self { inner } + } + + /// Creates a client to a bitcoind JSON-RPC server with authentication. + pub fn new_with_auth(url: &str, auth: Auth) -> Result { + if matches!(auth, Auth::None) { + return Err(Error::MissingUserPassword); + } + let (user, pass) = auth.get_user_pass()?; + + let transport = jsonrpc::http::bitreq_http::Builder::new() + .url(url) + .expect("jsonrpc v0.19, this function does not error") + .timeout(std::time::Duration::from_secs(60)) + .basic_auth(user.unwrap(), pass) + .build(); + let inner = jsonrpc::client::Client::with_transport(transport); + + Ok(Self { inner }) + } + + /// Call an RPC `method` with given `args` list. + pub fn call serde::de::Deserialize<'a>>( + &self, + method: &str, + args: &[serde_json::Value], + ) -> Result { + let raw = serde_json::value::to_raw_value(args)?; + let req = self.inner.build_request(&method, Some(&*raw)); + if log::log_enabled!(log::Level::Debug) { + log::debug!(target: "corepc", "request: {} {}", method, serde_json::Value::from(args)); + } + + let resp = self.inner.send_request(req).map_err(Error::from); + log_response(method, &resp); + Ok(resp?.result()?) + } + } + } +} + +/// Implements the `check_expected_server_version()` on `Client`. +/// +/// Requires `Client` to be in scope and implement `server_version()`. +/// See and/or use `impl_client_v17__getnetworkinfo`. +/// +/// # Parameters +/// +/// - `$expected_versions`: An vector of expected server versions e.g., `[230100, 230200]`. +#[macro_export] +macro_rules! impl_client_check_expected_server_version { + ($expected_versions:expr) => { + impl Client { + /// Checks that the JSON-RPC endpoint is for a `bitcoind` instance with the expected version. + pub fn check_expected_server_version(&self) -> Result<()> { + let server_version = self.server_version()?; + if !$expected_versions.contains(&server_version) { + return Err($crate::client_sync::error::UnexpectedServerVersionError { + got: server_version, + expected: $expected_versions.to_vec(), + })?; + } + Ok(()) + } + } + }; +} From ccc3f0c626e0b851aa0af1f9af4b7ab9a9f693fe Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Wed, 10 Jun 2026 11:38:25 +0100 Subject: [PATCH 5/7] Make async corepc-client Edit the copy of the sync client created in the previous commit to be async. Update the readme and cargo.toml files. Replace macros with functions. There is only one async client so the macros are not needed anymore. Create a new module for the bdk client that has the required RPCs in it that return either the rust-bitcoin type or non-version specific model types. --- client/Cargo.toml | 2 + client/README.md | 13 ++- client/src/client_async/error.rs | 37 ++++++ client/src/client_async/mod.rs | 164 +++++++++------------------ client/src/client_async/rpcs.rs | 108 ++++++++++++++++++ client/src/lib.rs | 3 + integration_test/Cargo.toml | 2 + integration_test/tests/bdk_client.rs | 142 +++++++++++++++++++++++ 8 files changed, 360 insertions(+), 111 deletions(-) create mode 100644 client/src/client_async/rpcs.rs create mode 100644 integration_test/tests/bdk_client.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index e43adf3ef..19ff65e2e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -23,6 +23,8 @@ allowed_duplicates = ["base64"] [features] # Enable this feature to get a blocking JSON-RPC client. client-sync = ["jsonrpc", "jsonrpc/bitreq_http"] +# Enable this feature to get an async JSON-RPC client. +client-async = ["jsonrpc", "jsonrpc/bitreq_http_async", "jsonrpc/client_async"] [dependencies] bitcoin = { version = "0.32.0", default-features = false, features = ["std", "serde"] } diff --git a/client/README.md b/client/README.md index 0c09dedd8..a842b1555 100644 --- a/client/README.md +++ b/client/README.md @@ -1,7 +1,16 @@ # corepc-client -Rust client for the Bitcoin Core daemon's JSON-RPC API. Currently this -is only a blocking client and is intended to be used in integration testing. +Rust client for the Bitcoin Core daemon's JSON-RPC API. + +This crate provides: + +- A blocking client intended for integration testing (`client-sync`). +- An async client intended for production (`client-async`). + +## Features + +- `client-sync`: Blocking JSON-RPC client. +- `client-async`: Async JSON-RPC client. ## Minimum Supported Rust Version (MSRV) diff --git a/client/src/client_async/error.rs b/client/src/client_async/error.rs index 331a81eeb..27ba7c93d 100644 --- a/client/src/client_async/error.rs +++ b/client/src/client_async/error.rs @@ -3,6 +3,15 @@ use std::{error, fmt, io}; use bitcoin::hex; +use types::v17::{ + GetBlockHeaderError, GetBlockHeaderVerboseError, GetBlockVerboseOneError, + GetRawTransactionVerboseError, +}; +use types::v19::GetBlockFilterError; +use types::v29::{ + GetBlockHeaderVerboseError as GetBlockHeaderVerboseErrorV29, + GetBlockVerboseOneError as GetBlockVerboseOneErrorV29, +}; /// The error type for errors produced in this library. #[derive(Debug)] @@ -48,6 +57,34 @@ impl From for Error { fn from(e: io::Error) -> Error { Error::Io(e) } } +impl From for Error { + fn from(e: GetBlockHeaderError) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetBlockHeaderVerboseError) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetBlockVerboseOneError) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetRawTransactionVerboseError) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetBlockHeaderVerboseErrorV29) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetBlockVerboseOneErrorV29) -> Self { Self::Returned(e.to_string()) } +} + +impl From for Error { + fn from(e: GetBlockFilterError) -> Self { Self::Returned(e.to_string()) } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use Error::*; diff --git a/client/src/client_async/mod.rs b/client/src/client_async/mod.rs index 17a5d7c6a..6660e3675 100644 --- a/client/src/client_async/mod.rs +++ b/client/src/client_async/mod.rs @@ -1,29 +1,16 @@ // SPDX-License-Identifier: CC0-1.0 -//! JSON-RPC clients for testing against specific versions of Bitcoin Core. +//! Async JSON-RPC client for Bitcoin Core v25 to v30. mod error; -pub mod v17; -pub mod v18; -pub mod v19; -pub mod v20; -pub mod v21; -pub mod v22; -pub mod v23; -pub mod v24; -pub mod v25; -pub mod v26; -pub mod v27; -pub mod v28; -pub mod v29; -pub mod v30; -pub mod v31; +mod rpcs; +use std::fmt; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::PathBuf; -pub use crate::client_sync::error::Error; +pub use crate::client_async::error::Error; pub(crate) use crate::{into_json, log_response}; /// Crate-specific Result type. @@ -57,103 +44,62 @@ impl Auth { } } -/// Defines a `jsonrpc::Client` using `bitreq`. -#[macro_export] -macro_rules! define_jsonrpc_bitreq_client { - ($version:literal) => { - use std::fmt; - - use $crate::client_sync::{log_response, Auth, Result}; - use $crate::client_sync::error::Error; - - /// Client implements a JSON-RPC client for the Bitcoin Core daemon or compatible APIs. - pub struct Client { - inner: jsonrpc::client::Client, - } - - impl fmt::Debug for Client { - fn fmt(&self, f: &mut fmt::Formatter) -> core::fmt::Result { - write!( - f, - "corepc_client::client_sync::{}::Client({:?})", $version, self.inner - ) - } - } - - impl Client { - /// Creates a client to a bitcoind JSON-RPC server without authentication. - pub fn new(url: &str) -> Self { - let transport = jsonrpc::http::bitreq_http::Builder::new() - .url(url) - .expect("jsonrpc v0.19, this function does not error") - .timeout(std::time::Duration::from_secs(60)) - .build(); - let inner = jsonrpc::client::Client::with_transport(transport); - - Self { inner } - } - - /// Creates a client to a bitcoind JSON-RPC server with authentication. - pub fn new_with_auth(url: &str, auth: Auth) -> Result { - if matches!(auth, Auth::None) { - return Err(Error::MissingUserPassword); - } - let (user, pass) = auth.get_user_pass()?; - - let transport = jsonrpc::http::bitreq_http::Builder::new() - .url(url) - .expect("jsonrpc v0.19, this function does not error") - .timeout(std::time::Duration::from_secs(60)) - .basic_auth(user.unwrap(), pass) - .build(); - let inner = jsonrpc::client::Client::with_transport(transport); +/// Client implements an async JSON-RPC client for the Bitcoin Core daemon or compatible APIs. +pub struct Client { + inner: jsonrpc::client_async::Client, +} - Ok(Self { inner }) - } +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter) -> core::fmt::Result { + write!(f, "corepc_client::client_async::Client({:?})", self.inner) + } +} - /// Call an RPC `method` with given `args` list. - pub fn call serde::de::Deserialize<'a>>( - &self, - method: &str, - args: &[serde_json::Value], - ) -> Result { - let raw = serde_json::value::to_raw_value(args)?; - let req = self.inner.build_request(&method, Some(&*raw)); - if log::log_enabled!(log::Level::Debug) { - log::debug!(target: "corepc", "request: {} {}", method, serde_json::Value::from(args)); - } +impl Client { + /// Creates a client to a bitcoind JSON-RPC server without authentication. + pub fn new(url: &str) -> Self { + let transport = jsonrpc::bitreq_http_async::Builder::new() + .url(url) + .expect("this function does not error") + .timeout(std::time::Duration::from_secs(60)) + .build(); + let inner = jsonrpc::client_async::Client::with_transport(transport); + + Self { inner } + } - let resp = self.inner.send_request(req).map_err(Error::from); - log_response(method, &resp); - Ok(resp?.result()?) - } + /// Creates a client to a bitcoind JSON-RPC server with authentication. + pub fn new_with_auth(url: &str, auth: Auth) -> Result { + if matches!(auth, Auth::None) { + return Err(Error::MissingUserPassword); } + let (user, pass) = auth.get_user_pass()?; + let user = user.ok_or(Error::MissingUserPassword)?; + let transport = jsonrpc::bitreq_http_async::Builder::new() + .url(url) + .expect("this function does not error") + .timeout(std::time::Duration::from_secs(60)) + .basic_auth(user, pass) + .build(); + let inner = jsonrpc::client_async::Client::with_transport(transport); + + Ok(Self { inner }) } -} -/// Implements the `check_expected_server_version()` on `Client`. -/// -/// Requires `Client` to be in scope and implement `server_version()`. -/// See and/or use `impl_client_v17__getnetworkinfo`. -/// -/// # Parameters -/// -/// - `$expected_versions`: An vector of expected server versions e.g., `[230100, 230200]`. -#[macro_export] -macro_rules! impl_client_check_expected_server_version { - ($expected_versions:expr) => { - impl Client { - /// Checks that the JSON-RPC endpoint is for a `bitcoind` instance with the expected version. - pub fn check_expected_server_version(&self) -> Result<()> { - let server_version = self.server_version()?; - if !$expected_versions.contains(&server_version) { - return Err($crate::client_sync::error::UnexpectedServerVersionError { - got: server_version, - expected: $expected_versions.to_vec(), - })?; - } - Ok(()) - } + /// Call an RPC `method` with given `args` list. + pub async fn call serde::de::Deserialize<'a>>( + &self, + method: &str, + args: &[serde_json::Value], + ) -> Result { + let raw = serde_json::value::to_raw_value(args)?; + let req = self.inner.build_request(method, Some(&*raw)); + if log::log_enabled!(log::Level::Debug) { + log::debug!(target: "corepc", "request: {} {}", method, serde_json::Value::from(args)); } - }; + + let resp = self.inner.send_request(req).await.map_err(Error::from); + log_response(method, &resp); + Ok(resp?.result()?) + } } diff --git a/client/src/client_async/rpcs.rs b/client/src/client_async/rpcs.rs new file mode 100644 index 000000000..dfc487195 --- /dev/null +++ b/client/src/client_async/rpcs.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! RPC set used by BDK. +//! All functions return the version nonspecific, strongly typed types. + +use bitcoin::{block, Block, BlockHash, Transaction, Txid}; +use serde_json::value::RawValue; + +use crate::client_async::{into_json, Client, Result}; +use crate::types::model::{GetBlockFilter, GetBlockHeaderVerbose, GetBlockVerboseOne}; + +impl Client { + /// Gets a block by blockhash. + pub async fn get_block(&self, hash: &BlockHash) -> Result { + let json: crate::types::v25::GetBlockVerboseZero = + self.call("getblock", &[into_json(hash)?, into_json(0)?]).await?; + Ok(json.into_model()?.0) + } + + /// Gets the block count. + pub async fn get_block_count(&self) -> Result { + let json: crate::types::v25::GetBlockCount = self.call("getblockcount", &[]).await?; + Ok(json.into_model().0) + } + + /// Gets the block hash for a height. + pub async fn get_block_hash(&self, height: u32) -> Result { + let json: crate::types::v25::GetBlockHash = + self.call("getblockhash", &[into_json(height)?]).await?; + Ok(json.into_model()?.0) + } + + /// Gets the hash of the chain tip. + pub async fn get_best_block_hash(&self) -> Result { + let json: crate::types::v25::GetBestBlockHash = self.call("getbestblockhash", &[]).await?; + Ok(json.into_model()?.0) + } + + /// Gets the block header by blockhash. + pub async fn get_block_header(&self, hash: &BlockHash) -> Result { + let json: crate::types::v25::GetBlockHeader = + self.call("getblockheader", &[into_json(hash)?, into_json(false)?]).await?; + Ok(json.into_model()?.0) + } + + /// Gets the block header with verbose output. + pub async fn get_block_header_verbose( + &self, + hash: &BlockHash, + ) -> Result { + let raw: Box = + self.call("getblockheader", &[into_json(hash)?, into_json(true)?]).await?; + + if let Ok(json) = + serde_json::from_str::(raw.get()) + { + Ok(json.into_model()?) + } else { + let json: crate::types::v25::GetBlockHeaderVerbose = serde_json::from_str(raw.get())?; + Ok(json.into_model()?) + } + } + + /// Gets a block by blockhash with verbose set to 1. + pub async fn get_block_verbose(&self, hash: &BlockHash) -> Result { + let raw: Box = self.call("getblock", &[into_json(hash)?, into_json(1)?]).await?; + + if let Ok(json) = serde_json::from_str::(raw.get()) { + Ok(json.into_model()?) + } else { + let json: crate::types::v25::GetBlockVerboseOne = serde_json::from_str(raw.get())?; + Ok(json.into_model()?) + } + } + + /// Gets the block filter for a blockhash. + pub async fn get_block_filter(&self, hash: &BlockHash) -> Result { + let json: crate::types::v25::GetBlockFilter = + self.call("getblockfilter", &[into_json(hash)?]).await?; + Ok(json.into_model()?) + } + + /// Gets the transaction IDs currently in the mempool. + pub async fn get_raw_mempool(&self) -> Result> { + let json: crate::types::v25::GetRawMempool = self.call("getrawmempool", &[]).await?; + Ok(json.into_model()?.0) + } + + /// Gets the raw transaction by txid. + pub async fn get_raw_transaction(&self, txid: &Txid) -> Result { + let json: crate::types::v25::GetRawTransaction = + self.call("getrawtransaction", &[into_json(txid)?]).await?; + Ok(json.into_model()?.0) + } + + /// Returns the version integer reported by the server (e.g. `250200` for v25.2.0). + pub async fn server_version(&self) -> Result { + // Use a minimal type to read only the `version` field; the shape of other fields + // (e.g. `warnings` changed from String to Vec at v28) differs across the + // supported version range. + #[derive(serde::Deserialize)] + struct NetworkVersion { + version: usize, + } + let json: NetworkVersion = self.call("getnetworkinfo", &[]).await?; + Ok(json.version) + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index aecfd0265..141aeffae 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -12,6 +12,9 @@ pub extern crate types; #[macro_use] pub mod client_sync; +#[cfg(feature = "client-async")] +pub mod client_async; + /// Helper to log an RPC response. #[cfg(any(feature = "client-sync", feature = "client-async"))] pub(crate) fn log_response( diff --git a/integration_test/Cargo.toml b/integration_test/Cargo.toml index f08f2d227..81affbc7a 100644 --- a/integration_test/Cargo.toml +++ b/integration_test/Cargo.toml @@ -59,6 +59,7 @@ TODO = [] # This is a dirty hack while writing the tests. [dependencies] bitcoin = { version = "0.32.0", default-features = false, features = ["std", "serde"] } +corepc-client = { version = "0.15.0", path = "../client", default-features = false, features = ["client-async"] } env_logger = "0.9.0" bitcoind = { package = "bitcoind", version = "0.40.0", path = "../bitcoind", default-features = false } rand = "0.8.5" @@ -66,3 +67,4 @@ rand = "0.8.5" types = { package = "corepc-types", version = "0.14.0", path = "../types", features = ["serde-deny-unknown-fields"] } [dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/integration_test/tests/bdk_client.rs b/integration_test/tests/bdk_client.rs new file mode 100644 index 000000000..95ac0b5f7 --- /dev/null +++ b/integration_test/tests/bdk_client.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Tests for the async client. + +#![cfg(feature = "v30_and_below")] +#![cfg(not(feature = "v24_and_below"))] +#![allow(non_snake_case)] // Test names intentionally use double underscore. + +use bitcoin::address::KnownHrp; +use bitcoin::{Address, CompressedPublicKey, PrivateKey}; +use bitcoind::mtype; +use corepc_client::client_async::{Auth, Client, Error as AsyncClientError}; +use integration_test::{BitcoinD, BitcoinDExt as _, Wallet}; + +fn async_client_for(node: &BitcoinD) -> Client { + Client::new_with_auth(&node.rpc_url(), auth_for(node)).expect("async client") +} + +#[tokio::test] +async fn async__get_best_block_hash__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let model: Result = client.get_best_block_hash().await; + let model = model.unwrap(); + let expected = node.client.best_block_hash().expect("best_block_hash"); + assert_eq!(model, expected); +} + +#[tokio::test] +async fn async__get_block__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let best_hash = node.client.best_block_hash().expect("best_block_hash"); + + let model: Result = client.get_block(&best_hash).await; + let model = model.unwrap(); + assert_eq!(model.block_hash(), best_hash); + + let model: Result = + client.get_block_verbose(&best_hash).await; + let model = model.unwrap(); + assert_eq!(model.hash, best_hash); +} + +#[tokio::test] +async fn async__get_block_count__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let model: Result = client.get_block_count().await; + let model = model.unwrap(); + assert_eq!(model, 0); +} + +#[tokio::test] +#[cfg(not(feature = "v18_and_below"))] +async fn async__get_block_filter__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &["-blockfilterindex"]); + let client = async_client_for(&node); + + let best_hash = node.client.best_block_hash().expect("best_block_hash"); + let model: Result = + client.get_block_filter(&best_hash).await; + let model = model.unwrap(); + + assert!(!model.filter.is_empty()); + assert_eq!(model.header.to_string().len(), 64); +} + +#[tokio::test] +async fn async__get_block_hash__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let model: Result = client.get_block_hash(0).await; + let model = model.unwrap(); + let expected = node.client.best_block_hash().expect("best_block_hash"); + assert_eq!(model, expected); +} + +#[tokio::test] +async fn async__get_block_header__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let best_hash = node.client.best_block_hash().expect("best_block_hash"); + let model: Result = + client.get_block_header(&best_hash).await; + let model = model.unwrap(); + assert_eq!(model.block_hash(), best_hash); + + let model: Result = + client.get_block_header_verbose(&best_hash).await; + let model = model.unwrap(); + assert_eq!(model.hash, best_hash); + assert_eq!(model.height, 0); +} + +#[tokio::test] +async fn async__get_raw_mempool__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let model: Result, AsyncClientError> = client.get_raw_mempool().await; + let model = model.unwrap(); + assert!(model.is_empty()); +} + +#[tokio::test] +async fn async__get_raw_transaction__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &["-txindex"]); + let privkey = + PrivateKey::from_wif("cVt4o7BGAig1UXywgGSmARhxMdzP5qvQsxKkSsc1XEkw3tDTQFpy").expect("wif"); + let secp = bitcoin::secp256k1::Secp256k1::new(); + let pubkey = privkey.public_key(&secp); + let address = Address::p2wpkh(&CompressedPublicKey(pubkey.inner), KnownHrp::Regtest); + node.client.generate_to_address(1, &address).expect("generatetoaddress"); + + let client = async_client_for(&node); + let best_hash = node.client.best_block_hash().expect("best_block_hash"); + let block = client.get_block(&best_hash).await.expect("getblock"); + let txid = block.txdata[0].compute_txid(); + + let model: Result = + client.get_raw_transaction(&txid).await; + let model = model.unwrap(); + assert_eq!(model.compute_txid(), txid); +} + +fn auth_for(node: &BitcoinD) -> Auth { Auth::CookieFile(node.params.cookie_file.clone()) } + +#[tokio::test] +async fn async__server_version__returns_positive_integer() { + let node = BitcoinD::with_wallet(Wallet::None, &[]); + let client = async_client_for(&node); + + let version: Result = client.server_version().await; + let version = version.unwrap(); + assert!(version > 0); +} From e1232c8f40e58776a000c5f25cdf3ae14b18befc Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Thu, 11 Jun 2026 14:13:42 +0100 Subject: [PATCH 6/7] Use trait methods for RPCs Use trait methods instead of inherent methods to define the RPCs to better allow downstream clients that define their own RPCs. This helps when different implementations of the ones defined in the client are required. Move the error module to the crate root. Create a new `IntoModelError` error type that carries the RPC method name as context and the original conversion error as a boxed `source`. Use this error type in both sync and async clients. Assisted-by: GPT-5.4 --- client/src/client_async/error.rs | 75 +++++++++---------- client/src/client_async/mod.rs | 4 +- client/src/client_async/rpcs.rs | 105 +++++++++++++++++---------- integration_test/tests/bdk_client.rs | 2 +- 4 files changed, 109 insertions(+), 77 deletions(-) diff --git a/client/src/client_async/error.rs b/client/src/client_async/error.rs index 27ba7c93d..a6930c519 100644 --- a/client/src/client_async/error.rs +++ b/client/src/client_async/error.rs @@ -3,15 +3,6 @@ use std::{error, fmt, io}; use bitcoin::hex; -use types::v17::{ - GetBlockHeaderError, GetBlockHeaderVerboseError, GetBlockVerboseOneError, - GetRawTransactionVerboseError, -}; -use types::v19::GetBlockFilterError; -use types::v29::{ - GetBlockHeaderVerboseError as GetBlockHeaderVerboseErrorV29, - GetBlockVerboseOneError as GetBlockVerboseOneErrorV29, -}; /// The error type for errors produced in this library. #[derive(Debug)] @@ -27,6 +18,8 @@ pub enum Error { UnexpectedStructure, /// The daemon returned an error string. Returned(String), + /// A model conversion error. + Model(IntoModelError), /// The server version did not match what was expected. ServerVersion(UnexpectedServerVersionError), /// Missing user/password. @@ -57,34 +50,6 @@ impl From for Error { fn from(e: io::Error) -> Error { Error::Io(e) } } -impl From for Error { - fn from(e: GetBlockHeaderError) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetBlockHeaderVerboseError) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetBlockVerboseOneError) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetRawTransactionVerboseError) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetBlockHeaderVerboseErrorV29) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetBlockVerboseOneErrorV29) -> Self { Self::Returned(e.to_string()) } -} - -impl From for Error { - fn from(e: GetBlockFilterError) -> Self { Self::Returned(e.to_string()) } -} - impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use Error::*; @@ -99,6 +64,7 @@ impl fmt::Display for Error { InvalidCookieFile => write!(f, "invalid cookie file"), UnexpectedStructure => write!(f, "the JSON result had an unexpected structure"), Returned(ref s) => write!(f, "the daemon returned an error string: {}", s), + Model(ref e) => write!(f, "model conversion error: {e}"), ServerVersion(ref e) => write!(f, "server version: {}", e), MissingUserPassword => write!(f, "missing user and/or password"), } @@ -117,6 +83,7 @@ impl error::Error for Error { BitcoinSerialization(ref e) => Some(e), Io(ref e) => Some(e), ServerVersion(ref e) => Some(e), + Model(ref e) => Some(e), InvalidCookieFile | UnexpectedStructure | Returned(_) | MissingUserPassword => None, } } @@ -147,3 +114,37 @@ impl error::Error for UnexpectedServerVersionError {} impl From for Error { fn from(e: UnexpectedServerVersionError) -> Self { Self::ServerVersion(e) } } + +/// Error returned when converting an RPC response into a model type fails. +#[derive(Debug)] +pub struct IntoModelError { + context: &'static str, + source: Box, +} + +impl IntoModelError { + /// Creates a new model conversion error with caller-provided context. + pub fn new(context: &'static str, source: E) -> Self + where + E: error::Error + Send + Sync + 'static, + { + Self { context, source: Box::new(source) } + } + + /// Returns the context for the failed conversion. + pub fn context(&self) -> &'static str { self.context } +} + +impl fmt::Display for IntoModelError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "conversion of {} into a model type failed", self.context) + } +} + +impl error::Error for IntoModelError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { Some(&*self.source) } +} + +impl From for Error { + fn from(e: IntoModelError) -> Self { Self::Model(e) } +} diff --git a/client/src/client_async/mod.rs b/client/src/client_async/mod.rs index 6660e3675..adc00194e 100644 --- a/client/src/client_async/mod.rs +++ b/client/src/client_async/mod.rs @@ -10,7 +10,9 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::PathBuf; -pub use crate::client_async::error::Error; +pub use error::{Error, IntoModelError, UnexpectedServerVersionError}; +pub use rpcs::BitcoinRpcs; + pub(crate) use crate::{into_json, log_response}; /// Crate-specific Result type. diff --git a/client/src/client_async/rpcs.rs b/client/src/client_async/rpcs.rs index dfc487195..93bbb3439 100644 --- a/client/src/client_async/rpcs.rs +++ b/client/src/client_async/rpcs.rs @@ -1,100 +1,129 @@ // SPDX-License-Identifier: CC0-1.0 -//! RPC set used by BDK. +//! RPC methods for the async Bitcoin Core client. //! All functions return the version nonspecific, strongly typed types. use bitcoin::{block, Block, BlockHash, Transaction, Txid}; use serde_json::value::RawValue; -use crate::client_async::{into_json, Client, Result}; +use super::{into_json, Client, IntoModelError, Result}; use crate::types::model::{GetBlockFilter, GetBlockHeaderVerbose, GetBlockVerboseOne}; -impl Client { +/// Bitcoin Core RPC methods (v25 to v30). +/// +/// This trait exposes the Bitcoin Core RPC methods available on [`Client`]. Downstream users +/// can define their own extension traits with additional methods without risk of +/// name collision with the methods defined here. +// `async fn` in traits produces non-`Send` futures by default; we suppress this lint because +// `BitcoinRpcs` is intended only for use with concrete types (never `dyn BitcoinRpcs`). +#[allow(async_fn_in_trait)] +pub trait BitcoinRpcs { /// Gets a block by blockhash. - pub async fn get_block(&self, hash: &BlockHash) -> Result { + async fn get_block(&self, hash: &BlockHash) -> Result; + + /// Gets the block count. + async fn get_block_count(&self) -> Result; + + /// Gets the block hash for a height. + async fn get_block_hash(&self, height: u32) -> Result; + + /// Gets the hash of the chain tip. + async fn get_best_block_hash(&self) -> Result; + + /// Gets the block header by blockhash. + async fn get_block_header(&self, hash: &BlockHash) -> Result; + + /// Gets the block header with verbose output. + async fn get_block_header_verbose(&self, hash: &BlockHash) -> Result; + + /// Gets a block by blockhash with verbose set to 1. + async fn get_block_verbose(&self, hash: &BlockHash) -> Result; + + /// Gets the block filter for a blockhash. + async fn get_block_filter(&self, hash: &BlockHash) -> Result; + + /// Gets the transaction IDs currently in the mempool. + async fn get_raw_mempool(&self) -> Result>; + + /// Gets the raw transaction by txid. + async fn get_raw_transaction(&self, txid: &Txid) -> Result; + + /// Returns the version integer reported by the server (e.g. `250200` for v25.2.0). + async fn server_version(&self) -> Result; +} + +impl BitcoinRpcs for Client { + async fn get_block(&self, hash: &BlockHash) -> Result { let json: crate::types::v25::GetBlockVerboseZero = self.call("getblock", &[into_json(hash)?, into_json(0)?]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblock`", e))?.0) } - /// Gets the block count. - pub async fn get_block_count(&self) -> Result { + async fn get_block_count(&self) -> Result { let json: crate::types::v25::GetBlockCount = self.call("getblockcount", &[]).await?; Ok(json.into_model().0) } - /// Gets the block hash for a height. - pub async fn get_block_hash(&self, height: u32) -> Result { + async fn get_block_hash(&self, height: u32) -> Result { let json: crate::types::v25::GetBlockHash = self.call("getblockhash", &[into_json(height)?]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockhash`", e))?.0) } - /// Gets the hash of the chain tip. - pub async fn get_best_block_hash(&self) -> Result { + async fn get_best_block_hash(&self) -> Result { let json: crate::types::v25::GetBestBlockHash = self.call("getbestblockhash", &[]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getbestblockhash`", e))?.0) } - /// Gets the block header by blockhash. - pub async fn get_block_header(&self, hash: &BlockHash) -> Result { + async fn get_block_header(&self, hash: &BlockHash) -> Result { let json: crate::types::v25::GetBlockHeader = self.call("getblockheader", &[into_json(hash)?, into_json(false)?]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockheader`", e))?.0) } - /// Gets the block header with verbose output. - pub async fn get_block_header_verbose( - &self, - hash: &BlockHash, - ) -> Result { + async fn get_block_header_verbose(&self, hash: &BlockHash) -> Result { let raw: Box = self.call("getblockheader", &[into_json(hash)?, into_json(true)?]).await?; if let Ok(json) = serde_json::from_str::(raw.get()) { - Ok(json.into_model()?) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockheader` verbose", e))?) } else { let json: crate::types::v25::GetBlockHeaderVerbose = serde_json::from_str(raw.get())?; - Ok(json.into_model()?) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockheader` verbose", e))?) } } - /// Gets a block by blockhash with verbose set to 1. - pub async fn get_block_verbose(&self, hash: &BlockHash) -> Result { + async fn get_block_verbose(&self, hash: &BlockHash) -> Result { let raw: Box = self.call("getblock", &[into_json(hash)?, into_json(1)?]).await?; if let Ok(json) = serde_json::from_str::(raw.get()) { - Ok(json.into_model()?) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblock` verbose=1", e))?) } else { let json: crate::types::v25::GetBlockVerboseOne = serde_json::from_str(raw.get())?; - Ok(json.into_model()?) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblock` verbose=1", e))?) } } - /// Gets the block filter for a blockhash. - pub async fn get_block_filter(&self, hash: &BlockHash) -> Result { + async fn get_block_filter(&self, hash: &BlockHash) -> Result { let json: crate::types::v25::GetBlockFilter = self.call("getblockfilter", &[into_json(hash)?]).await?; - Ok(json.into_model()?) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockfilter`", e))?) } - /// Gets the transaction IDs currently in the mempool. - pub async fn get_raw_mempool(&self) -> Result> { + async fn get_raw_mempool(&self) -> Result> { let json: crate::types::v25::GetRawMempool = self.call("getrawmempool", &[]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getrawmempool`", e))?.0) } - /// Gets the raw transaction by txid. - pub async fn get_raw_transaction(&self, txid: &Txid) -> Result { + async fn get_raw_transaction(&self, txid: &Txid) -> Result { let json: crate::types::v25::GetRawTransaction = self.call("getrawtransaction", &[into_json(txid)?]).await?; - Ok(json.into_model()?.0) + Ok(json.into_model().map_err(|e| IntoModelError::new("`getrawtransaction`", e))?.0) } - /// Returns the version integer reported by the server (e.g. `250200` for v25.2.0). - pub async fn server_version(&self) -> Result { + async fn server_version(&self) -> Result { // Use a minimal type to read only the `version` field; the shape of other fields // (e.g. `warnings` changed from String to Vec at v28) differs across the // supported version range. diff --git a/integration_test/tests/bdk_client.rs b/integration_test/tests/bdk_client.rs index 95ac0b5f7..3d452d6a7 100644 --- a/integration_test/tests/bdk_client.rs +++ b/integration_test/tests/bdk_client.rs @@ -9,7 +9,7 @@ use bitcoin::address::KnownHrp; use bitcoin::{Address, CompressedPublicKey, PrivateKey}; use bitcoind::mtype; -use corepc_client::client_async::{Auth, Client, Error as AsyncClientError}; +use corepc_client::client_async::{Auth, BitcoinRpcs as _, Client, Error as AsyncClientError}; use integration_test::{BitcoinD, BitcoinDExt as _, Wallet}; fn async_client_for(node: &BitcoinD) -> Client { From 275000009d1436aa0835c3a1434e6c3e519f2de9 Mon Sep 17 00:00:00 2001 From: "Jamil Lambert, PhD" Date: Mon, 15 Jun 2026 16:05:20 +0100 Subject: [PATCH 7/7] Add async client getblockchaininfo and gettxout Add these two RPCs to the async client Trait and tests. Assisted-by: GPT-5.4 --- client/src/client_async/rpcs.rs | 53 ++++++++++++++++++++++++++-- integration_test/tests/bdk_client.rs | 31 ++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/client/src/client_async/rpcs.rs b/client/src/client_async/rpcs.rs index 93bbb3439..f12843961 100644 --- a/client/src/client_async/rpcs.rs +++ b/client/src/client_async/rpcs.rs @@ -3,11 +3,13 @@ //! RPC methods for the async Bitcoin Core client. //! All functions return the version nonspecific, strongly typed types. -use bitcoin::{block, Block, BlockHash, Transaction, Txid}; +use bitcoin::{block, Block, BlockHash, OutPoint, Transaction, Txid}; use serde_json::value::RawValue; use super::{into_json, Client, IntoModelError, Result}; -use crate::types::model::{GetBlockFilter, GetBlockHeaderVerbose, GetBlockVerboseOne}; +use crate::types::model::{ + GetBlockFilter, GetBlockHeaderVerbose, GetBlockVerboseOne, GetBlockchainInfo, GetTxOut, +}; /// Bitcoin Core RPC methods (v25 to v30). /// @@ -42,12 +44,22 @@ pub trait BitcoinRpcs { /// Gets the block filter for a blockhash. async fn get_block_filter(&self, hash: &BlockHash) -> Result; + /// Gets information about the current state of the blockchain. + async fn get_blockchain_info(&self) -> Result; + /// Gets the transaction IDs currently in the mempool. async fn get_raw_mempool(&self) -> Result>; /// Gets the raw transaction by txid. async fn get_raw_transaction(&self, txid: &Txid) -> Result; + /// Gets details about an unspent transaction output. + async fn get_tx_out( + &self, + outpoint: &OutPoint, + include_mempool: bool, + ) -> Result>; + /// Returns the version integer reported by the server (e.g. `250200` for v25.2.0). async fn server_version(&self) -> Result; } @@ -112,6 +124,21 @@ impl BitcoinRpcs for Client { Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockfilter`", e))?) } + async fn get_blockchain_info(&self) -> Result { + let raw: Box = self.call("getblockchaininfo", &[]).await?; + + if let Ok(json) = serde_json::from_str::(raw.get()) { + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockchaininfo`", e))?) + } else if let Ok(json) = + serde_json::from_str::(raw.get()) + { + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockchaininfo`", e))?) + } else { + let json: crate::types::v25::GetBlockchainInfo = serde_json::from_str(raw.get())?; + Ok(json.into_model().map_err(|e| IntoModelError::new("`getblockchaininfo`", e))?) + } + } + async fn get_raw_mempool(&self) -> Result> { let json: crate::types::v25::GetRawMempool = self.call("getrawmempool", &[]).await?; Ok(json.into_model().map_err(|e| IntoModelError::new("`getrawmempool`", e))?.0) @@ -123,6 +150,28 @@ impl BitcoinRpcs for Client { Ok(json.into_model().map_err(|e| IntoModelError::new("`getrawtransaction`", e))?.0) } + async fn get_tx_out( + &self, + outpoint: &OutPoint, + include_mempool: bool, + ) -> Result> { + let json: Option = self + .call( + "gettxout", + &[ + into_json(outpoint.txid)?, + into_json(outpoint.vout)?, + into_json(include_mempool)?, + ], + ) + .await?; + match json { + None => Ok(None), + Some(json) => + Ok(Some(json.into_model().map_err(|e| IntoModelError::new("`gettxout`", e))?)), + } + } + async fn server_version(&self) -> Result { // Use a minimal type to read only the `version` field; the shape of other fields // (e.g. `warnings` changed from String to Vec at v28) differs across the diff --git a/integration_test/tests/bdk_client.rs b/integration_test/tests/bdk_client.rs index 3d452d6a7..72713f17e 100644 --- a/integration_test/tests/bdk_client.rs +++ b/integration_test/tests/bdk_client.rs @@ -98,6 +98,19 @@ async fn async__get_block_header__modelled() { assert_eq!(model.height, 0); } +#[tokio::test] +async fn async__get_blockchain_info__modelled() { + let node = BitcoinD::with_wallet(Wallet::None, &["-prune=10000"]); + let client = async_client_for(&node); + + let model: Result = + client.get_blockchain_info().await; + let model = model.unwrap(); + + assert_eq!(model.blocks, 0); + assert!(model.pruned); +} + #[tokio::test] async fn async__get_raw_mempool__modelled() { let node = BitcoinD::with_wallet(Wallet::None, &[]); @@ -129,6 +142,24 @@ async fn async__get_raw_transaction__modelled() { assert_eq!(model.compute_txid(), txid); } +#[tokio::test] +async fn async__get_tx_out__modelled() { + let node = BitcoinD::with_wallet(Wallet::Default, &[]); + node.fund_wallet(); + let client = async_client_for(&node); + let (_address, tx) = node.create_mined_transaction(); + let txid = tx.compute_txid(); + + let model: Result, AsyncClientError> = + client.get_tx_out(&bitcoin::OutPoint { txid, vout: 1 }, true).await; + let model = model.unwrap().expect("unspent output"); + assert!(!model.coinbase); + + let missing: Result, AsyncClientError> = + client.get_tx_out(&bitcoin::OutPoint { txid, vout: 2 }, true).await; + assert!(missing.unwrap().is_none()); +} + fn auth_for(node: &BitcoinD) -> Auth { Auth::CookieFile(node.params.cookie_file.clone()) } #[tokio::test]