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 }) }