diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock
index 650f0a3..64c47e9 100644
--- a/Cargo-minimal.lock
+++ b/Cargo-minimal.lock
@@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
version = "0.1.0"
dependencies = [
"anyhow",
+ "bdk_bitcoind_client",
"bitcoind",
"corepc-types",
"filetime",
diff --git a/Cargo-recent.lock b/Cargo-recent.lock
index 48ac350..6409052 100644
--- a/Cargo-recent.lock
+++ b/Cargo-recent.lock
@@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
version = "0.1.0"
dependencies = [
"anyhow",
+ "bdk_bitcoind_client",
"bitcoind",
"corepc-types",
"filetime",
diff --git a/Cargo.toml b/Cargo.toml
index d188aa9..5cb8f9d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,34 +11,38 @@ readme = "README.md"
edition = "2024"
rust-version = "1.85.0"
-[features]
-default = ["30_0"]
-30_0 = []
-29_0 = []
-28_0 = []
-
[dependencies]
-corepc-types = { version = "0.12.0", features = ["default"]}
-jsonrpc = { version = "0.20.0", features = ["bitreq_http"] }
+corepc-types = { version = "0.12.0", features = ["default"], optional = true }
+jsonrpc = { version = "0.20.0", default-features = false }
# These pins are needed for `Cargo-minimal.lock`:
hex-conservative = { version = "0.2.1" } # blame: corepc-node
[dev-dependencies]
anyhow = { version = "1.0.66" }
+bdk_bitcoind_client = { path = ".", default-features = false, features = ["bitreq", "29_0"] }
bitcoind = { version = "0.38.0", features = ["download", "29_0"] }
+bitreq = { version = "0.3.5", features = ["async"] }
+tokio = { version = "1", features = ["full"] }
# These pins are needed for `Cargo-minimal.lock`:
tar = { version = "0.4.43" } # blame: corepc-node
filetime = { version = "0.2.8" } # blame: corepc-node
log = { version = "0.4.14" } # blame: corepc-node
+[features]
+default = ["28_0", "bitreq"]
+bitreq = ["dep:corepc-types", "jsonrpc/bitreq_http"]
+30_0 = ["29_0"]
+29_0 = ["28_0"]
+28_0 = []
+
[package.metadata.rbmt.toolchains]
stable = "1.95.0"
nightly = "nightly"
[package.metadata.rbmt.test]
-#exclude_features = ["default"]
+exclude_features = ["default"]
# Allow multiple versions of the same package in the dependency tree.
[package.metadata.rbmt.lint]
diff --git a/README.md b/README.md
index c58b0fb..dd21dc8 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# bdk-bitcoind-client
-
-
+
+
@@ -53,9 +53,10 @@ fn main() -> anyhow::Result<()> {
// Define how to authenticate with `bitcoind` (Cookie File or User/Pass)
let auth = Auth::CookieFile(PathBuf::from("/path/to/regtest/.cookie"));
let auth = Auth::UserPass("user".to_string(), "pass".to_string());
+ let timeout = std::time::Duration::from_secs(15);
// Instantiate a JSON-RPC `Client`
- let client = Client::with_auth("http://127.0.0.1:18443", auth)?;
+ let client = Client::with_auth_timeout("http://127.0.0.1:18443", auth, timeout)?;
// Perform blockchain queries to `bitcoind` using the `Client`
let block_count = client.get_block_count()?;
diff --git a/examples/call_async.rs b/examples/call_async.rs
new file mode 100644
index 0000000..495627c
--- /dev/null
+++ b/examples/call_async.rs
@@ -0,0 +1,50 @@
+// async client example
+
+use corepc_types::v29;
+use jsonrpc::base64::Engine;
+use jsonrpc::base64::engine::general_purpose::STANDARD as BASE64;
+use jsonrpc::bitreq;
+use jsonrpc::serde_json;
+
+const URL: &str = "http://127.0.0.1:18443";
+const RPC_COOKIE_PATH: &str = ".bitcoin/regtest/.cookie";
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Create cookie authentication
+ let cookie_file = std::env::var("RPC_COOKIE").unwrap_or(RPC_COOKIE_PATH.to_string());
+ let cookie = std::fs::read_to_string(cookie_file)?;
+ let auth_header = format!("Basic {}", BASE64.encode(cookie.as_bytes()));
+
+ // The RPC method to call
+ let rpc = bdk_bitcoind_client::Rpc::GetBestBlockHash;
+ let method = rpc.to_string();
+
+ // Create RPC client
+ let client = bdk_bitcoind_client::Client::new();
+
+ // The `send_fn` takes the request as a JSON value, sends it asynchronously,
+ // and parses the response as a `jsonrpc::Response`.
+ let send_fn = |value: serde_json::Value| {
+ let auth_header = auth_header.clone();
+ async move {
+ bitreq::post(URL)
+ .with_header("Authorization", auth_header)
+ .with_json(&value)?
+ .send_async()
+ .await?
+ .json::()
+ }
+ };
+
+ // Execute the RPC
+ let block_hash = client
+ .call_async::(&method, &[], send_fn)
+ .await?
+ .into_model()?
+ .0;
+
+ println!("{}", block_hash);
+
+ Ok(())
+}
diff --git a/src/bitreq.rs b/src/bitreq.rs
new file mode 100644
index 0000000..3156f71
--- /dev/null
+++ b/src/bitreq.rs
@@ -0,0 +1,307 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+//! Bitcoin Core RPC client backed by the [`bitreq`] HTTP transport.
+//!
+//! [`bitreq`]: https://docs.rs/jsonrpc/latest/jsonrpc/http/bitreq_http/index.html
+
+use std::{
+ fs::File,
+ io::{BufRead, BufReader},
+ path::PathBuf,
+};
+
+use corepc_types::{
+ bitcoin::{
+ Block, BlockHash, Transaction, Txid, block::Header, consensus::encode::deserialize_hex,
+ },
+ model, v30,
+};
+use jsonrpc::{
+ Transport, bitreq_http, serde,
+ serde_json::{Value, json},
+};
+
+use crate::{Error, Rpc};
+
+#[cfg(all(feature = "28_0", not(feature = "29_0")))]
+pub mod v28;
+
+/// Authentication methods for the Bitcoin Core JSON-RPC server.
+#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
+pub enum Auth {
+ /// Username and password authentication (RPC user/pass).
+ UserPass(String, String),
+ /// Authentication via a cookie file.
+ CookieFile(PathBuf),
+}
+
+impl Auth {
+ /// Converts this `Auth` into an optional username and password pair.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the `CookieFile` cannot be read or is invalid.
+ pub fn get_user_pass(self) -> Result<(Option, Option), Error> {
+ match self {
+ 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())))
+ }
+ }
+ }
+}
+
+/// Bitcoin Core RPC client backed by the `bitreq` HTTP transport.
+pub struct Client {
+ inner: crate::Client,
+ transport: Box,
+}
+
+impl std::fmt::Debug for Client {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Client")
+ .field("inner", &self.inner)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Client {
+ /// Creates a client connected to a Bitcoin Core RPC server with authentication and timeout.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the URL is invalid or the cookie file cannot be read.
+ pub fn with_auth_timeout(
+ url: &str,
+ auth: Auth,
+ timeout: core::time::Duration,
+ ) -> Result {
+ let mut builder = bitreq_http::Builder::new()
+ .url(url)
+ .map_err(|e| Error::InvalidUrl(format!("{e}")))?
+ .timeout(timeout);
+
+ let (user, pass) = auth.get_user_pass()?;
+ if let Some(username) = user {
+ builder = builder.basic_auth(username, pass);
+ }
+
+ Ok(Self {
+ inner: crate::Client::new(),
+ transport: Box::new(builder.build()),
+ })
+ }
+
+ /// Creates a client using a custom transport.
+ ///
+ /// Useful when you need manual control over TLS, proxies, or timeouts beyond
+ /// what [`with_auth_timeout`](Self::with_auth_timeout) provides.
+ pub fn with_transport(transport: T) -> Self
+ where
+ T: Transport + 'static,
+ {
+ Self {
+ inner: crate::Client::new(),
+ transport: Box::new(transport),
+ }
+ }
+
+ /// Executes an RPC call through the configured transport.
+ fn call(&self, rpc: Rpc, params: &[Value]) -> Result
+ where
+ T: for<'de> serde::Deserialize<'de>,
+ {
+ let method = rpc.to_string();
+ self.inner
+ .call(&method, params, |req| self.transport.send_request(req))
+ }
+}
+
+/// `bitcoind` RPC methods.
+impl Client {
+ /// Retrieves the raw block data for a given block hash (verbosity 0).
+ ///
+ /// # Arguments
+ ///
+ /// * `block_hash`: The hash of the block to retrieve.
+ ///
+ /// # Returns
+ ///
+ /// The deserialized `Block` struct.
+ pub fn get_block(&self, block_hash: &BlockHash) -> Result {
+ self.call::(Rpc::GetBlock, &[json!(block_hash), json!(0)])
+ .and_then(|block_hex| deserialize_hex(&block_hex).map_err(Error::DecodeHex))
+ }
+
+ /// Retrieves the hash of the best chain's block.
+ ///
+ /// # Returns
+ ///
+ /// The `BlockHash` of the chain tip.
+ pub fn get_best_block_hash(&self) -> Result {
+ self.call::(Rpc::GetBestBlockHash, &[])
+ .and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
+ }
+
+ /// Retrieves the number of blocks in the longest chain.
+ ///
+ /// # Returns
+ ///
+ /// The block count as a `u32`.
+ pub fn get_block_count(&self) -> Result {
+ self.call::(Rpc::GetBlockCount, &[])?
+ .0
+ .try_into()
+ .map_err(Error::TryFromInt)
+ }
+
+ /// Retrieves the [`BlockHash`] of the block at `height`.
+ ///
+ /// # Arguments
+ ///
+ /// * `height`: The block height.
+ ///
+ /// # Returns
+ ///
+ /// The [`BlockHash`] of the block at `height`.
+ pub fn get_block_hash(&self, height: u32) -> Result {
+ self.call::(Rpc::GetBlockHash, &[json!(height)])
+ .and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
+ }
+
+ /// Retrieves the Compact Block Filter (BIP-0158) with type `basic` for a block.
+ ///
+ /// # Arguments
+ ///
+ /// * `block_hash`: The hash of the block whose filter is requested.
+ ///
+ /// # Returns
+ ///
+ /// The `GetBlockFilter` structure containing the filter data for the block.
+ pub fn get_block_filter(&self, block_hash: &BlockHash) -> Result {
+ let block_filter: v30::GetBlockFilter =
+ self.call(Rpc::GetBlockFilter, &[json!(block_hash)])?;
+ block_filter.into_model().map_err(Error::model)
+ }
+
+ /// Retrieves the `Header` for a block given its `BlockHash`.
+ ///
+ /// # Arguments
+ ///
+ /// * `block_hash`: The hash of the block whose header is requested.
+ ///
+ /// # Returns
+ ///
+ /// The deserialized `Header` struct.
+ pub fn get_block_header(&self, block_hash: &BlockHash) -> Result {
+ self.call::(Rpc::GetBlockHeader, &[json!(block_hash), json!(false)])
+ .and_then(|header_hex: String| deserialize_hex(&header_hex).map_err(Error::DecodeHex))
+ }
+
+ /// Retrieves the `Txid`s for all transactions in the mempool.
+ ///
+ /// # Returns
+ ///
+ /// A vector of `Txid`s in the raw mempool.
+ pub fn get_raw_mempool(&self) -> Result, Error> {
+ self.call::(Rpc::GetRawMempool, &[])
+ .map(|txids| txids.0)
+ }
+
+ /// Retrieves the raw transaction data for a given transaction ID.
+ ///
+ /// # Arguments
+ ///
+ /// * `txid`: The transaction ID to retrieve.
+ ///
+ /// # Returns
+ ///
+ /// The deserialized `Transaction` struct.
+ pub fn get_raw_transaction(&self, txid: &Txid) -> Result {
+ self.call::(Rpc::GetRawTransaction, &[json!(txid)])
+ .and_then(|tx_hex| deserialize_hex(&tx_hex).map_err(Error::DecodeHex))
+ }
+}
+
+#[cfg(feature = "29_0")]
+use corepc_types::model::{GetBlockHeaderVerbose, GetBlockVerboseOne};
+
+#[cfg(feature = "29_0")]
+impl Client {
+ /// Retrieves the verbose JSON representation of a block header (verbosity 1).
+ ///
+ /// # Arguments
+ ///
+ /// * `block_hash`: The hash of the block to retrieve.
+ ///
+ /// # Returns
+ ///
+ /// The verbose header as a `GetBlockHeaderVerbose` struct.
+ pub fn get_block_header_verbose(
+ &self,
+ block_hash: &BlockHash,
+ ) -> Result {
+ let header_info: v30::GetBlockHeaderVerbose =
+ self.call(Rpc::GetBlockHeader, &[json!(block_hash)])?;
+ header_info.into_model().map_err(Error::model)
+ }
+
+ /// Retrieves the verbose JSON representation of a block (verbosity 1).
+ ///
+ /// # Arguments
+ ///
+ /// * `block_hash`: The hash of the block to retrieve.
+ ///
+ /// # Returns
+ ///
+ /// The verbose block data as a `GetBlockVerboseOne` struct.
+ pub fn get_block_verbose(&self, block_hash: &BlockHash) -> Result {
+ let block_info: v30::GetBlockVerboseOne =
+ self.call(Rpc::GetBlock, &[json!(block_hash), json!(1)])?;
+ block_info.into_model().map_err(Error::model)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_auth_user_pass_get_user_pass() {
+ let auth = Auth::UserPass("user".to_string(), "pass".to_string());
+ let result = auth.get_user_pass().expect("failed to get user pass");
+
+ assert_eq!(result, (Some("user".to_string()), Some("pass".to_string())));
+ }
+
+ #[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");
+ std::fs::write(&cookie_path, "testuser:testpass").expect("failed to write cookie");
+
+ let auth = Auth::CookieFile(cookie_path.clone());
+ let result = auth.get_user_pass().expect("failed to get user pass");
+
+ assert_eq!(
+ result,
+ (Some("testuser".to_string()), Some("testpass".to_string()))
+ );
+
+ std::fs::remove_file(cookie_path).ok();
+ }
+
+ #[test]
+ fn test_auth_invalid_cookie_file() {
+ let cookie_path = PathBuf::from("/nonexistent/path/to/cookie");
+ let auth = Auth::CookieFile(cookie_path);
+ let result = auth.get_user_pass();
+ assert!(matches!(result, Err(Error::Io(_))));
+ }
+}
diff --git a/src/client/v28.rs b/src/bitreq/v28.rs
similarity index 83%
rename from src/client/v28.rs
rename to src/bitreq/v28.rs
index baf64f7..28e52cc 100644
--- a/src/client/v28.rs
+++ b/src/bitreq/v28.rs
@@ -1,15 +1,17 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
+//! [`Client`] methods for Bitcoin Core v28.0 and earlier.
+
use bitcoin::BlockHash;
use corepc_types::{
bitcoin,
model::{GetBlockHeaderVerbose, GetBlockVerboseOne},
v28,
};
-
use jsonrpc::serde_json::json;
-use crate::{Client, Error};
+use super::Client;
+use crate::{Error, Rpc};
impl Client {
/// Retrieves the verbose JSON representation of a block header (verbosity 1).
@@ -26,7 +28,7 @@ impl Client {
block_hash: &BlockHash,
) -> Result {
let header_info: v28::GetBlockHeaderVerbose =
- self.call("getblockheader", &[json!(block_hash)])?;
+ self.call(Rpc::GetBlockHeader, &[json!(block_hash)])?;
header_info.into_model().map_err(Error::model)
}
@@ -41,7 +43,7 @@ impl Client {
/// The verbose block data as a `GetBlockVerboseOne` struct.
pub fn get_block_verbose(&self, block_hash: &BlockHash) -> Result {
let block_info: v28::GetBlockVerboseOne =
- self.call("getblock", &[json!(block_hash), json!(1)])?;
+ self.call(Rpc::GetBlock, &[json!(block_hash), json!(1)])?;
block_info.into_model().map_err(Error::model)
}
}
diff --git a/src/client.rs b/src/client.rs
index 55b44f2..ea80262 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -1,305 +1,108 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
-use std::{
- fs::File,
- io::{BufRead, BufReader},
- path::PathBuf,
-};
+use core::sync::atomic::{AtomicUsize, Ordering};
-use crate::error::Error;
-use crate::jsonrpc::bitreq_http::Builder;
-use corepc_types::{
- bitcoin::{
- Block, BlockHash, Transaction, Txid, block::Header, consensus::encode::deserialize_hex,
- },
- model::{GetBlockCount, GetBlockFilter, GetRawMempool},
- v30,
-};
-use jsonrpc::{
- Transport, serde,
- serde_json::{self, json},
-};
+use jsonrpc::serde;
+use jsonrpc::serde_json::value::RawValue;
+use jsonrpc::serde_json::{self, Value, json};
+use jsonrpc::{Request, Response};
-#[cfg(feature = "28_0")]
-pub mod v28;
+use crate::Error;
-/// Client authentication methods for the Bitcoin Core JSON-RPC server
-#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
-pub enum Auth {
- /// Username and password authentication (RPC user/pass)
- UserPass(String, String),
- /// Authentication via a cookie file
- CookieFile(PathBuf),
-}
+/// JSON-RPC protocol version.
+const JSONRPC: &str = "2.0";
-impl Auth {
- /// Converts `Auth` enum into the optional username and password strings
- /// required by JSON-RPC client transport.
- ///
- /// # Errors
- ///
- /// Returns an error if the `CookieFile` cannot be read or invalid
- pub fn get_user_pass(self) -> Result<(Option, Option), Error> {
- match self {
- 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())))
- }
- }
- }
-}
-
-/// Bitcoin Core JSON-RPC Client.
+/// Bitcoin Core JSON-RPC client (sans-io).
+///
+/// Manages request IDs and handles JSON-RPC request building and response
+/// deserialization. Does not perform any I/O — callers supply the transport
+/// via `send_fn` at each call site.
///
-/// A wrapper for JSON-RPC client for interacting with the `bitcoind` RPC interface.
-#[derive(Debug)]
+/// This type is the low-level building block used by transport-specific clients
+/// such as [`bitreq::Client`](crate::bitreq::Client). It can also be used
+/// directly when you need to supply your own transport.
+#[derive(Debug, Default)]
pub struct Client {
- /// The inner JSON-RPC client.
- inner: jsonrpc::Client,
+ id: AtomicUsize,
}
impl Client {
- /// Creates a client connection to a bitcoind JSON-RPC server with authentication.
- ///
- /// Requires authentication via username/password or cookie file.
- /// For connections without authentication, use `with_transport` instead.
- ///
- /// # Arguments
+ /// Creates a new [`Client`].
+ pub fn new() -> Self {
+ Self {
+ id: AtomicUsize::new(0),
+ }
+ }
+
+ /// Calls an RPC method using the provided `send_fn`.
///
- /// * `url` - URL of the RPC server
- /// * `auth` - authentication method (`UserPass` or `CookieFile`)
+ /// Builds a JSON-RPC [`Request`], passes it to `send_fn`, and deserializes
+ /// the [`Response`] into `T`.
///
/// # Errors
///
- /// * Returns `Error::InvalidUrl` if the URL is invalid.
- /// * Returns errors related to reading the cookie file.
- pub fn with_auth(url: &str, auth: Auth) -> Result {
- let mut builder = Builder::new()
- .url(url)
- .map_err(|e| Error::InvalidUrl(format!("{e}")))?
- .timeout(std::time::Duration::from_secs(60));
-
- let (user, pass) = auth.get_user_pass()?;
-
- if let Some(username) = user {
- builder = builder.basic_auth(username, pass);
- }
- Ok(Self {
- inner: jsonrpc::Client::with_transport(builder.build()),
- })
- }
-
- /// Creates a client to a bitcoind JSON-RPC server with transport.
- pub fn with_transport(transport: T) -> Self
+ /// Returns an error if JSON serialization fails, `send_fn` returns an error
+ /// (wrapped as [`Error::JsonRpc`] via a transport error), or the response
+ /// contains a JSON-RPC error (also wrapped as [`Error::JsonRpc`]).
+ pub fn call(
+ &self,
+ method: &str,
+ params: &[Value],
+ send_fn: impl Fn(Request) -> Result,
+ ) -> Result
where
- T: Transport,
+ T: for<'de> serde::Deserialize<'de>,
+ E: core::error::Error + Send + Sync + 'static,
{
- Self {
- inner: jsonrpc::Client::with_transport(transport),
+ let raw = if params.is_empty() {
+ None
+ } else {
+ Some(serde_json::value::to_raw_value(params)?)
+ };
+ let request = self.build_request(method, raw.as_deref());
+ let request_id = request.id.clone();
+ let response = send_fn(request).map_err(Error::transport)?;
+ if response.id != request_id {
+ return Err(Error::JsonRpc(jsonrpc::Error::NonceMismatch));
}
+ Ok(response.result()?)
}
- /// Calls the underlying RPC `method` with the given `args`.
- ///
- /// This is the generic function used by all specific RPC methods.
- pub fn call(&self, method: &str, args: &[serde_json::Value]) -> Result
+ /// Execute the RPC asynchronously.
+ pub async fn call_async(
+ &self,
+ method: &str,
+ params: &[Value],
+ send_fn: F,
+ ) -> Result
where
T: for<'de> serde::Deserialize<'de>,
+ E: core::error::Error + Send + Sync + 'static,
+ F: AsyncFn(Value) -> Result,
{
- let raw = serde_json::value::to_raw_value(args)?;
- let request = self.inner.build_request(method, Some(&*raw));
- let resp = self.inner.send_request(request)?;
-
- Ok(resp.result()?)
- }
-}
-
-/// `bitcoind` RPC methods implementation for `Client`.
-impl Client {
- /// Retrieves the raw block data for a given block hash (verbosity 0).
- ///
- /// # Arguments
- ///
- /// * `block_hash`: The hash of the block to retrieve.
- ///
- /// # Returns
- ///
- /// The deserialized `Block` struct.
- pub fn get_block(&self, block_hash: &BlockHash) -> Result {
- self.call::("getblock", &[json!(block_hash), json!(0)])
- .and_then(|block_hex| deserialize_hex(&block_hex).map_err(Error::DecodeHex))
- }
-
- /// Retrieves the hash of the best chain's block.
- ///
- /// # Returns
- ///
- /// The `BlockHash` of the chain tip.
- pub fn get_best_block_hash(&self) -> Result {
- self.call::("getbestblockhash", &[])
- .and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
- }
-
- /// Retrieves the number of blocks in the longest chain.
- ///
- /// # Returns
- ///
- /// The block count as a `u32`
- pub fn get_block_count(&self) -> Result {
- self.call::("getblockcount", &[])?
- .0
- .try_into()
- .map_err(Error::TryFromInt)
- }
-
- /// Retrieves the [`BlockHash`] of the block at `height`.
- ///
- /// # Arguments
- ///
- /// * `height`: The block height
- ///
- /// # Returns
- ///
- /// The [`BlockHash`] of the block at `height`
- pub fn get_block_hash(&self, height: u32) -> Result {
- self.call::("getblockhash", &[json!(height)])
- .and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
- }
-
- /// Retrieve the Compact Block Filter (BIP-0158) with type `basic` for the block given its `Blockhash`.
- ///
- /// # Arguments
- ///
- /// * `block_hash`: The hash of the block whose filter is requested
- ///
- /// # Returns
- ///
- /// The `GetBlockFilter` structure containing the filter data for the block
- pub fn get_block_filter(&self, block_hash: &BlockHash) -> Result {
- let block_filter: v30::GetBlockFilter =
- self.call("getblockfilter", &[json!(block_hash)])?;
- block_filter.into_model().map_err(Error::model)
- }
-
- /// Retrieves the `Header` for a `Block` given its `BlockHash`.
- ///
- /// # Arguments
- ///
- /// * `block_hash`: The hash of the block whose header is requested.
- ///
- /// # Returns
- ///
- /// The deserialized `Header` struct
- pub fn get_block_header(&self, block_hash: &BlockHash) -> Result {
- self.call::("getblockheader", &[json!(block_hash), json!(false)])
- .and_then(|header_hex: String| deserialize_hex(&header_hex).map_err(Error::DecodeHex))
- }
-
- /// Retrieves the `Txid`s for all transactions in the mempool.
- ///
- /// # Returns
- ///
- /// A vector of `Txid`s in the raw mempool
- pub fn get_raw_mempool(&self) -> Result, Error> {
- self.call::("getrawmempool", &[])
- .map(|txids| txids.0)
- }
-
- /// Retrieves the raw transaction data for a given transaction ID.
- ///
- /// # Arguments
- ///
- /// * `txid`: The transaction ID to retrieve.
- ///
- /// # Returns
- ///
- /// The deserialized `Transaction` struct
- pub fn get_raw_transaction(&self, txid: &Txid) -> Result {
- self.call::("getrawtransaction", &[json!(txid)])
- .and_then(|tx_hex| deserialize_hex(&tx_hex).map_err(Error::DecodeHex))
- }
-}
-
-#[cfg(not(feature = "28_0"))]
-use corepc_types::model::{GetBlockHeaderVerbose, GetBlockVerboseOne};
-
-#[cfg(not(feature = "28_0"))]
-impl Client {
- /// Retrieves the verbose JSON representation of a block header (verbosity 1).
- ///
- /// # Arguments
- ///
- /// * `block_hash`: The hash of the block to retrieve.
- ///
- /// # Returns
- ///
- /// The verbose header as a `GetBlockHeaderVerbose` struct.
- pub fn get_block_header_verbose(
- &self,
- block_hash: &BlockHash,
- ) -> Result {
- let header_info: v30::GetBlockHeaderVerbose =
- self.call("getblockheader", &[json!(block_hash)])?;
- header_info.into_model().map_err(Error::model)
- }
-
- /// Retrieves the verbose JSON representation of a block (verbosity 1).
- ///
- /// # Arguments
- ///
- /// * `block_hash`: The hash of the block to retrieve.
- ///
- /// # Returns
- ///
- /// The verbose block data as a `GetBlockVerboseOne` struct.
- pub fn get_block_verbose(&self, block_hash: &BlockHash) -> Result {
- let block_info: v30::GetBlockVerboseOne =
- self.call("getblock", &[json!(block_hash), json!(1)])?;
- block_info.into_model().map_err(Error::model)
- }
-}
-
-#[cfg(test)]
-mod test_auth {
- use super::*;
-
- #[test]
- fn test_auth_user_pass_get_user_pass() {
- let auth = Auth::UserPass("user".to_string(), "pass".to_string());
- let result = auth.get_user_pass().expect("failed to get user pass");
-
- assert_eq!(result, (Some("user".to_string()), Some("pass".to_string())));
- }
-
- #[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");
- std::fs::write(&cookie_path, "testuser:testpass").expect("failed to write cookie");
-
- let auth = Auth::CookieFile(cookie_path.clone());
- let result = auth.get_user_pass().expect("failed to get user pass");
-
- assert_eq!(
- result,
- (Some("testuser".to_string()), Some("testpass".to_string()))
- );
-
- std::fs::remove_file(cookie_path).ok();
+ let raw_value = if params.is_empty() {
+ None
+ } else {
+ Some(serde_json::value::to_raw_value(params)?)
+ };
+ let request = self.build_request(method, raw_value.as_deref());
+ let request_id = request.id.clone();
+ let value = serde_json::to_value(request)?;
+ let response = send_fn(value).await.map_err(Error::transport)?;
+ if response.id != request_id {
+ return Err(Error::JsonRpc(jsonrpc::Error::NonceMismatch));
+ }
+ Ok(response.result()?)
}
- #[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::Io(_))));
+ /// Builds a JSON-RPC [`Request`] with an auto-incremented ID.
+ fn build_request<'a>(&self, method: &'a str, params: Option<&'a RawValue>) -> Request<'a> {
+ let id = self.id.fetch_add(1, Ordering::Relaxed);
+ Request {
+ method,
+ params,
+ id: json!(id),
+ jsonrpc: Some(JSONRPC),
+ }
}
}
diff --git a/src/error.rs b/src/error.rs
index b65a2ab..79d1302 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -3,55 +3,69 @@
//! Error types for the Bitcoin RPC client.
use core::fmt;
-use core::num::TryFromIntError;
+#[cfg(feature = "bitreq")]
use std::io;
-use bitcoin::{consensus::encode::FromHexError, hex::HexToArrayError};
-use corepc_types::bitcoin;
+#[cfg(feature = "bitreq")]
+use corepc_types::bitcoin::{consensus::encode::FromHexError, hex::HexToArrayError};
use jsonrpc::serde_json;
/// Errors that can occur when using the Bitcoin RPC client.
#[derive(Debug)]
pub enum Error {
- /// Hex deserialization error
+ /// JSON-RPC error from the server.
+ JsonRpc(jsonrpc::Error),
+
+ /// JSON serialization/deserialization error.
+ Json(serde_json::Error),
+
+ /// Hex deserialization error.
+ #[cfg(feature = "bitreq")]
DecodeHex(FromHexError),
/// Error converting a version-specific RPC type into the model type.
+ #[cfg(feature = "bitreq")]
Model(Box),
/// Invalid or corrupted cookie file.
+ #[cfg(feature = "bitreq")]
InvalidCookieFile,
- /// The provided URL is syntactically incorrect
+ /// The provided URL is syntactically incorrect.
+ #[cfg(feature = "bitreq")]
InvalidUrl(String),
- /// JSON-RPC error from the server.
- JsonRpc(jsonrpc::Error),
-
/// Hash parsing error.
+ #[cfg(feature = "bitreq")]
HexToArray(HexToArrayError),
- /// JSON serialization/deserialization error.
- Json(serde_json::Error),
-
/// I/O error (e.g., reading cookie file, network issues).
+ #[cfg(feature = "bitreq")]
Io(io::Error),
/// Error when converting an integer type to a smaller type due to overflow.
- TryFromInt(TryFromIntError),
+ #[cfg(feature = "bitreq")]
+ TryFromInt(core::num::TryFromIntError),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
+ Error::JsonRpc(e) => write!(f, "JSON-RPC error: {e}"),
+ Error::Json(e) => write!(f, "JSON error: {e}"),
+ #[cfg(feature = "bitreq")]
Error::DecodeHex(e) => write!(f, "hex deserialization error: {e}"),
+ #[cfg(feature = "bitreq")]
Error::Model(e) => write!(f, "model conversion error: {e}"),
+ #[cfg(feature = "bitreq")]
Error::InvalidCookieFile => write!(f, "invalid or missing cookie file"),
+ #[cfg(feature = "bitreq")]
Error::InvalidUrl(e) => write!(f, "invalid RPC URL: {e}"),
+ #[cfg(feature = "bitreq")]
Error::HexToArray(e) => write!(f, "hash parsing error: {e}"),
- Error::JsonRpc(e) => write!(f, "JSON-RPC error: {e}"),
- Error::Json(e) => write!(f, "JSON error: {e}"),
+ #[cfg(feature = "bitreq")]
Error::Io(e) => write!(f, "I/O error: {e}"),
+ #[cfg(feature = "bitreq")]
Error::TryFromInt(e) => write!(f, "integer conversion overflow: {e}"),
}
}
@@ -60,16 +74,26 @@ impl fmt::Display for Error {
impl core::error::Error for Error {}
impl Error {
- /// Converts `e` to a [`Error::Model`] error.
+ /// Converts `e` to an [`Error::Model`] error.
+ #[cfg(feature = "bitreq")]
pub(crate) fn model(e: E) -> Self
where
E: core::error::Error + Send + Sync + 'static,
{
Self::Model(Box::new(e))
}
+
+ /// Wraps `e` as a [`jsonrpc::Error::Transport`] inside [`Error::JsonRpc`].
+ pub(crate) fn transport(e: E) -> Self
+ where
+ E: core::error::Error + Send + Sync + 'static,
+ {
+ Self::JsonRpc(jsonrpc::Error::Transport(Box::new(e)))
+ }
}
-// Conversions from other error types
+// Conversions from other error types.
+
impl From for Error {
fn from(e: jsonrpc::Error) -> Self {
Error::JsonRpc(e)
@@ -82,24 +106,28 @@ impl From for Error {
}
}
+#[cfg(feature = "bitreq")]
impl From for Error {
fn from(e: HexToArrayError) -> Self {
Error::HexToArray(e)
}
}
+#[cfg(feature = "bitreq")]
impl From for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
-impl From for Error {
- fn from(e: TryFromIntError) -> Self {
+#[cfg(feature = "bitreq")]
+impl From for Error {
+ fn from(e: core::num::TryFromIntError) -> Self {
Error::TryFromInt(e)
}
}
+#[cfg(feature = "bitreq")]
impl From for Error {
fn from(e: FromHexError) -> Self {
Error::DecodeHex(e)
diff --git a/src/lib.rs b/src/lib.rs
index 11667ad..1d95ef7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,15 +2,24 @@
//! Bitcoin Core RPC client library.
//!
-//! This crate provides a Rust client for interacting with Bitcoin Core's JSON-RPC interface.
-//! It supports multiple authentication methods and provides a type-safe interface for
-//! making RPC calls to a Bitcoin Core daemon.
+//! The top-level [`Client`] is transport-agnostic (sans-io): callers supply
+//! the transport at each call site via a `send_fn` closure.
+//!
+//! For a batteries-included HTTP client backed by the `bitreq` transport,
+//! enable the `bitreq` feature and use [`bitreq::Client`].
mod client;
mod error;
+mod rpc;
pub use client::*;
pub use error::*;
+pub use rpc::*;
+
+#[cfg(feature = "bitreq")]
+pub mod bitreq;
-pub use corepc_types;
pub use jsonrpc;
+
+#[cfg(feature = "bitreq")]
+pub use corepc_types;
diff --git a/src/rpc.rs b/src/rpc.rs
new file mode 100644
index 0000000..f6ab266
--- /dev/null
+++ b/src/rpc.rs
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+/// Strongly-typed Bitcoin Core RPC method names.
+///
+/// Each variant corresponds to a Bitcoin Core JSON-RPC method. The
+/// [`Display`](core::fmt::Display) implementation produces the exact lowercase
+/// method name string expected by Bitcoin Core.
+///
+/// These are used internally by [`bitreq::Client`](crate::bitreq::Client) methods
+/// and can also be passed directly to [`Client::call`](crate::Client::call)
+/// as the `method` argument via [`Rpc::to_string`](std::string::ToString::to_string).
+///
+/// See for the full RPC reference.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[non_exhaustive]
+pub enum Rpc {
+ /// `getbestblockhash` — returns the hash of the best (tip) block.
+ GetBestBlockHash,
+ /// `getblock` — returns block data for a given block hash.
+ GetBlock,
+ /// `getblockcount` — returns the height of the most-work fully-validated chain.
+ GetBlockCount,
+ /// `getblockfilter` — returns the BIP-158 compact block filter for a block.
+ GetBlockFilter,
+ /// `getblockhash` — returns the block hash at a given height.
+ GetBlockHash,
+ /// `getblockheader` — returns block header data for a given block hash.
+ GetBlockHeader,
+ /// `getrawmempool` — returns all transaction IDs in the memory pool.
+ GetRawMempool,
+ /// `getrawtransaction` — returns raw transaction data for a given txid.
+ GetRawTransaction,
+}
+
+impl core::fmt::Display for Rpc {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ let s = match self {
+ Self::GetBestBlockHash => "getbestblockhash",
+ Self::GetBlock => "getblock",
+ Self::GetBlockCount => "getblockcount",
+ Self::GetBlockFilter => "getblockfilter",
+ Self::GetBlockHash => "getblockhash",
+ Self::GetBlockHeader => "getblockheader",
+ Self::GetRawMempool => "getrawmempool",
+ Self::GetRawTransaction => "getrawtransaction",
+ };
+ write!(f, "{s}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rpc_display_lowercase() {
+ assert_eq!(Rpc::GetBestBlockHash.to_string(), "getbestblockhash");
+ assert_eq!(Rpc::GetBlock.to_string(), "getblock");
+ assert_eq!(Rpc::GetBlockCount.to_string(), "getblockcount");
+ assert_eq!(Rpc::GetBlockFilter.to_string(), "getblockfilter");
+ assert_eq!(Rpc::GetBlockHash.to_string(), "getblockhash");
+ assert_eq!(Rpc::GetBlockHeader.to_string(), "getblockheader");
+ assert_eq!(Rpc::GetRawMempool.to_string(), "getrawmempool");
+ assert_eq!(Rpc::GetRawTransaction.to_string(), "getrawtransaction");
+ }
+}
diff --git a/tests/rpc_client.rs b/tests/rpc_client.rs
index df97766..546dcbd 100644
--- a/tests/rpc_client.rs
+++ b/tests/rpc_client.rs
@@ -6,7 +6,7 @@
use core::str::FromStr;
-use bdk_bitcoind_client::{Auth, Client, Error};
+use bdk_bitcoind_client::bitreq::{Auth, Client};
use corepc_types::bitcoin::{Amount, BlockHash, Txid};
mod testenv;
@@ -16,20 +16,21 @@ use testenv::TestEnv;
#[test]
fn test_invalid_credentials() {
let env = TestEnv::setup().unwrap();
- let client = Client::with_auth(
+ let client = Client::with_auth_timeout(
&env.bitcoind.rpc_url(),
Auth::UserPass("wrong".to_string(), "credentials".to_string()),
+ std::time::Duration::from_secs(15),
)
.expect("client creation should succeed");
- let result: Result = client.get_best_block_hash();
+ let result = client.get_best_block_hash();
assert!(result.is_err());
}
#[test]
fn test_client_with_custom_transport() {
- use jsonrpc::http::bitreq_http::Builder;
+ use jsonrpc::bitreq_http::Builder;
let env = TestEnv::setup().unwrap();
@@ -155,6 +156,7 @@ fn test_get_block_after_mining() {
}
#[test]
+#[cfg(feature = "29_0")]
fn test_get_block_verbose() {
let env = TestEnv::setup().unwrap();
@@ -202,6 +204,7 @@ fn test_get_block_header() {
}
#[test]
+#[cfg(feature = "29_0")]
fn test_get_block_header_verbose() {
let TestEnv {
client,
diff --git a/tests/testenv.rs b/tests/testenv.rs
index 865faf5..4066d64 100644
--- a/tests/testenv.rs
+++ b/tests/testenv.rs
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
-use bdk_bitcoind_client::{Auth, Client};
+use bdk_bitcoind_client::bitreq::{Auth, Client};
use bitcoin::{Address, BlockHash};
use bitcoind::{BitcoinD, Conf, exe_path};
use corepc_types::bitcoin;
@@ -11,7 +11,7 @@ use corepc_types::bitcoin;
/// a running [`bitcoind::BitcoinD`] instance.
#[derive(Debug)]
pub struct TestEnv {
- /// [`bdk_bitcoind_client::Client`]
+ /// [`bdk_bitcoind_client::bitreq::Client`]
pub client: Client,
/// [`bitcoind::BitcoinD`]
pub bitcoind: BitcoinD,
@@ -38,7 +38,7 @@ impl TestEnv {
let rpc_url = bitcoind.rpc_url();
let cookie_file = &bitcoind.params.cookie_file;
let auth = Auth::CookieFile(cookie_file.clone());
- let client = Client::with_auth(&rpc_url, auth)?;
+ let client = Client::with_auth_timeout(&rpc_url, auth, std::time::Duration::from_secs(15))?;
Ok(Self { client, bitcoind })
}