Skip to content

Commit 7e7c56c

Browse files
committed
Merge #27: refactor!: Sans-io Client + bitreq feature
f85b4d2 docs: Update README.md (valued mammal) 41f08ea refactor!: Sans-io Client + bitreq feature (valued mammal) 845b070 build: Make version features additive (valued mammal) Pull request description: ### Description Refactors the library to a layered, sans-io architecture. The previous `Client` owned a `jsonrpc::Client<BitreqHttpTransport>` directly, coupling all users to the `bitreq` HTTP transport. This PR separates concerns: - **`Client`**: A transport-agnostic type that manages JSON-RPC request building and ID tracking. Callers supply the transport at each call site via a `send_fn: Fn(Request) -> Result<Response, E>` closure. - **`bitreq::Client`** (behind the `bitreq` feature): A batteries-included HTTP client backed by `jsonrpc`'s `bitreq_http` transport. Owns a `Box<dyn Transport>` and exposes all Bitcoin Core RPC methods. `Auth`, cookie-file parsing, and `with_auth_timeout` live here. - **`Rpc` enum**: Strongly-typed RPC method names whose `Display` impl produces the exact lowercase method string expected by Bitcoin Core. Additional cleanup: - `Error` variants that are only reachable through `bitreq` code are now gated behind `#[cfg(feature = "bitreq")]`. - `corepc-types` is now an optional dependency, only pulled in by `bitreq`. - `with_auth` now accepts an explicit `timeout: Duration` parameter instead of a hardcoded 60-second value. ### Notes to the reviewers The `bitreq` feature is included in `default`, so existing users who don't configure features explicitly will see no change in the available API surface (the `bitreq::Client` replaces the old top-level `Client` 1:1 for the common case). The new sans-io `Client` is an additive API available to users who need a custom transport. ### Changelog notice Added - `Rpc` enum for strongly-typed RPC method names. Changed - `crate::Client` is now a transport-agnostic core; callers supply the transport per call via a `send_fn` closure. - `bitreq::Client` replaces the old `Client` as the HTTP client (requires `bitreq` feature, enabled by default). - `Client::with_auth` renamed to `bitreq::Client::with_auth_timeout` and now takes an explicit `timeout: core::time::Duration` parameter. - `corepc-types` is now an optional dependency (required by `bitreq` feature). ACKs for top commit: tvpeter: tACK f85b4d2 Tree-SHA512: f08f042827897147e74814493c2ca2c612277da5a5facc5b4d33f56ffb6ccb67aaf91a5464f8535658e6f71fd456c6d96e8dd1e63d99bd81a829ca8e3663fe50
2 parents ea3e0a4 + f85b4d2 commit 7e7c56c

12 files changed

Lines changed: 523 additions & 327 deletions

File tree

Cargo-minimal.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
4747
version = "0.1.0"
4848
dependencies = [
4949
"anyhow",
50+
"bdk_bitcoind_client",
5051
"bitcoind",
5152
"corepc-types",
5253
"filetime",

Cargo-recent.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
4747
version = "0.1.0"
4848
dependencies = [
4949
"anyhow",
50+
"bdk_bitcoind_client",
5051
"bitcoind",
5152
"corepc-types",
5253
"filetime",

Cargo.toml

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,36 @@ readme = "README.md"
1111
edition = "2024"
1212
rust-version = "1.85.0"
1313

14-
[features]
15-
default = ["30_0"]
16-
30_0 = []
17-
29_0 = []
18-
28_0 = []
19-
2014
[dependencies]
21-
corepc-types = { version = "0.12.0", features = ["default"]}
22-
jsonrpc = { version = "0.20.0", features = ["bitreq_http"] }
15+
corepc-types = { version = "0.12.0", features = ["default"], optional = true }
16+
jsonrpc = { version = "0.20.0", default-features = false }
2317

2418
# These pins are needed for `Cargo-minimal.lock`:
2519
hex-conservative = { version = "0.2.1" } # blame: corepc-node
2620

2721
[dev-dependencies]
2822
anyhow = { version = "1.0.66" }
23+
bdk_bitcoind_client = { path = ".", default-features = false, features = ["bitreq", "29_0"] }
2924
bitcoind = { version = "0.38.0", features = ["download", "29_0"] }
3025

3126
# These pins are needed for `Cargo-minimal.lock`:
3227
tar = { version = "0.4.43" } # blame: corepc-node
3328
filetime = { version = "0.2.8" } # blame: corepc-node
3429
log = { version = "0.4.14" } # blame: corepc-node
3530

31+
[features]
32+
default = ["28_0", "bitreq"]
33+
bitreq = ["dep:corepc-types", "jsonrpc/bitreq_http"]
34+
30_0 = ["29_0"]
35+
29_0 = ["28_0"]
36+
28_0 = []
37+
3638
[package.metadata.rbmt.toolchains]
3739
stable = "1.95.0"
3840
nightly = "nightly"
3941

4042
[package.metadata.rbmt.test]
41-
#exclude_features = ["default"]
43+
exclude_features = ["default"]
4244

4345
# Allow multiple versions of the same package in the dependency tree.
4446
[package.metadata.rbmt.lint]

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# bdk-bitcoind-client
22

33
<p>
4-
<!-- <a href="https://crates.io/crates/bdk-bitcoind-client"><img src="https://img.shields.io/crates/v/bdk-bitcoind-client.svg"/></a> -->
5-
<!-- <a href="https://docs.rs/bdk-bitcoind-client"><img src="https://img.shields.io/badge/docs.rs-bdk-bitcoind-client-orange"/></a> -->
4+
<a href="https://crates.io/crates/bdk_bitcoind_client"><img src="https://img.shields.io/crates/v/bdk_bitcoind_client.svg"/></a>
5+
<a href="https://docs.rs/bdk_bitcoind_client"><img src="https://img.shields.io/badge/docs.rs-bdk_bitcoind_client-orange.svg"/></a>
66
<a href="https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/"><img src="https://img.shields.io/badge/rustc-1.85.0%2B-orange.svg"/></a>
77
<a href="https://github.com/bitcoindevkit/bdk-bitcoind-client/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-MIT%2FApache--2.0-red.svg"/></a>
88
<a href="https://github.com/bitcoindevkit/bdk-bitcoind-client/actions/workflows/cont_integration.yml"><img src="https://github.com/bitcoindevkit/bdk-bitcoind-client/actions/workflows/cont_integration.yml/badge.svg"></a>
@@ -53,9 +53,10 @@ fn main() -> anyhow::Result<()> {
5353
// Define how to authenticate with `bitcoind` (Cookie File or User/Pass)
5454
let auth = Auth::CookieFile(PathBuf::from("/path/to/regtest/.cookie"));
5555
let auth = Auth::UserPass("user".to_string(), "pass".to_string());
56+
let timeout = std::time::Duration::from_secs(15);
5657

5758
// Instantiate a JSON-RPC `Client`
58-
let client = Client::with_auth("http://127.0.0.1:18443", auth)?;
59+
let client = Client::with_auth_timeout("http://127.0.0.1:18443", auth, timeout)?;
5960

6061
// Perform blockchain queries to `bitcoind` using the `Client`
6162
let block_count = client.get_block_count()?;

src/bitreq.rs

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// SPDX-License-Identifier: MIT OR Apache-2.0
2+
3+
//! Bitcoin Core RPC client backed by the [`bitreq`] HTTP transport.
4+
//!
5+
//! [`bitreq`]: https://docs.rs/jsonrpc/latest/jsonrpc/http/bitreq_http/index.html
6+
7+
use std::{
8+
fs::File,
9+
io::{BufRead, BufReader},
10+
path::PathBuf,
11+
};
12+
13+
use corepc_types::{
14+
bitcoin::{
15+
Block, BlockHash, Transaction, Txid, block::Header, consensus::encode::deserialize_hex,
16+
},
17+
model, v30,
18+
};
19+
use jsonrpc::{
20+
Transport, bitreq_http, serde,
21+
serde_json::{Value, json},
22+
};
23+
24+
use crate::{Error, Rpc};
25+
26+
#[cfg(all(feature = "28_0", not(feature = "29_0")))]
27+
pub mod v28;
28+
29+
/// Authentication methods for the Bitcoin Core JSON-RPC server.
30+
#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
31+
pub enum Auth {
32+
/// Username and password authentication (RPC user/pass).
33+
UserPass(String, String),
34+
/// Authentication via a cookie file.
35+
CookieFile(PathBuf),
36+
}
37+
38+
impl Auth {
39+
/// Converts this `Auth` into an optional username and password pair.
40+
///
41+
/// # Errors
42+
///
43+
/// Returns an error if the `CookieFile` cannot be read or is invalid.
44+
pub fn get_user_pass(self) -> Result<(Option<String>, Option<String>), Error> {
45+
match self {
46+
Auth::UserPass(u, p) => Ok((Some(u), Some(p))),
47+
Auth::CookieFile(path) => {
48+
let line = BufReader::new(File::open(path)?)
49+
.lines()
50+
.next()
51+
.ok_or(Error::InvalidCookieFile)??;
52+
let colon = line.find(':').ok_or(Error::InvalidCookieFile)?;
53+
Ok((Some(line[..colon].into()), Some(line[colon + 1..].into())))
54+
}
55+
}
56+
}
57+
}
58+
59+
/// Bitcoin Core RPC client backed by the `bitreq` HTTP transport.
60+
pub struct Client {
61+
inner: crate::Client,
62+
transport: Box<dyn Transport>,
63+
}
64+
65+
impl std::fmt::Debug for Client {
66+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67+
f.debug_struct("Client")
68+
.field("inner", &self.inner)
69+
.finish_non_exhaustive()
70+
}
71+
}
72+
73+
impl Client {
74+
/// Creates a client connected to a Bitcoin Core RPC server with authentication and timeout.
75+
///
76+
/// # Errors
77+
///
78+
/// Returns an error if the URL is invalid or the cookie file cannot be read.
79+
pub fn with_auth_timeout(
80+
url: &str,
81+
auth: Auth,
82+
timeout: core::time::Duration,
83+
) -> Result<Self, Error> {
84+
let mut builder = bitreq_http::Builder::new()
85+
.url(url)
86+
.map_err(|e| Error::InvalidUrl(format!("{e}")))?
87+
.timeout(timeout);
88+
89+
let (user, pass) = auth.get_user_pass()?;
90+
if let Some(username) = user {
91+
builder = builder.basic_auth(username, pass);
92+
}
93+
94+
Ok(Self {
95+
inner: crate::Client::new(),
96+
transport: Box::new(builder.build()),
97+
})
98+
}
99+
100+
/// Creates a client using a custom transport.
101+
///
102+
/// Useful when you need manual control over TLS, proxies, or timeouts beyond
103+
/// what [`with_auth_timeout`](Self::with_auth_timeout) provides.
104+
pub fn with_transport<T>(transport: T) -> Self
105+
where
106+
T: Transport + 'static,
107+
{
108+
Self {
109+
inner: crate::Client::new(),
110+
transport: Box::new(transport),
111+
}
112+
}
113+
114+
/// Executes an RPC call through the configured transport.
115+
fn call<T>(&self, rpc: Rpc, params: &[Value]) -> Result<T, Error>
116+
where
117+
T: for<'de> serde::Deserialize<'de>,
118+
{
119+
let method = rpc.to_string();
120+
self.inner
121+
.call(&method, params, |req| self.transport.send_request(req))
122+
}
123+
}
124+
125+
/// `bitcoind` RPC methods.
126+
impl Client {
127+
/// Retrieves the raw block data for a given block hash (verbosity 0).
128+
///
129+
/// # Arguments
130+
///
131+
/// * `block_hash`: The hash of the block to retrieve.
132+
///
133+
/// # Returns
134+
///
135+
/// The deserialized `Block` struct.
136+
pub fn get_block(&self, block_hash: &BlockHash) -> Result<Block, Error> {
137+
self.call::<String>(Rpc::GetBlock, &[json!(block_hash), json!(0)])
138+
.and_then(|block_hex| deserialize_hex(&block_hex).map_err(Error::DecodeHex))
139+
}
140+
141+
/// Retrieves the hash of the best chain's block.
142+
///
143+
/// # Returns
144+
///
145+
/// The `BlockHash` of the chain tip.
146+
pub fn get_best_block_hash(&self) -> Result<BlockHash, Error> {
147+
self.call::<String>(Rpc::GetBestBlockHash, &[])
148+
.and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
149+
}
150+
151+
/// Retrieves the number of blocks in the longest chain.
152+
///
153+
/// # Returns
154+
///
155+
/// The block count as a `u32`.
156+
pub fn get_block_count(&self) -> Result<u32, Error> {
157+
self.call::<v30::GetBlockCount>(Rpc::GetBlockCount, &[])?
158+
.0
159+
.try_into()
160+
.map_err(Error::TryFromInt)
161+
}
162+
163+
/// Retrieves the [`BlockHash`] of the block at `height`.
164+
///
165+
/// # Arguments
166+
///
167+
/// * `height`: The block height.
168+
///
169+
/// # Returns
170+
///
171+
/// The [`BlockHash`] of the block at `height`.
172+
pub fn get_block_hash(&self, height: u32) -> Result<BlockHash, Error> {
173+
self.call::<String>(Rpc::GetBlockHash, &[json!(height)])
174+
.and_then(|blockhash_hex| blockhash_hex.parse().map_err(Error::HexToArray))
175+
}
176+
177+
/// Retrieves the Compact Block Filter (BIP-0158) with type `basic` for a block.
178+
///
179+
/// # Arguments
180+
///
181+
/// * `block_hash`: The hash of the block whose filter is requested.
182+
///
183+
/// # Returns
184+
///
185+
/// The `GetBlockFilter` structure containing the filter data for the block.
186+
pub fn get_block_filter(&self, block_hash: &BlockHash) -> Result<model::GetBlockFilter, Error> {
187+
let block_filter: v30::GetBlockFilter =
188+
self.call(Rpc::GetBlockFilter, &[json!(block_hash)])?;
189+
block_filter.into_model().map_err(Error::model)
190+
}
191+
192+
/// Retrieves the `Header` for a block given its `BlockHash`.
193+
///
194+
/// # Arguments
195+
///
196+
/// * `block_hash`: The hash of the block whose header is requested.
197+
///
198+
/// # Returns
199+
///
200+
/// The deserialized `Header` struct.
201+
pub fn get_block_header(&self, block_hash: &BlockHash) -> Result<Header, Error> {
202+
self.call::<String>(Rpc::GetBlockHeader, &[json!(block_hash), json!(false)])
203+
.and_then(|header_hex: String| deserialize_hex(&header_hex).map_err(Error::DecodeHex))
204+
}
205+
206+
/// Retrieves the `Txid`s for all transactions in the mempool.
207+
///
208+
/// # Returns
209+
///
210+
/// A vector of `Txid`s in the raw mempool.
211+
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>, Error> {
212+
self.call::<model::GetRawMempool>(Rpc::GetRawMempool, &[])
213+
.map(|txids| txids.0)
214+
}
215+
216+
/// Retrieves the raw transaction data for a given transaction ID.
217+
///
218+
/// # Arguments
219+
///
220+
/// * `txid`: The transaction ID to retrieve.
221+
///
222+
/// # Returns
223+
///
224+
/// The deserialized `Transaction` struct.
225+
pub fn get_raw_transaction(&self, txid: &Txid) -> Result<Transaction, Error> {
226+
self.call::<String>(Rpc::GetRawTransaction, &[json!(txid)])
227+
.and_then(|tx_hex| deserialize_hex(&tx_hex).map_err(Error::DecodeHex))
228+
}
229+
}
230+
231+
#[cfg(feature = "29_0")]
232+
use corepc_types::model::{GetBlockHeaderVerbose, GetBlockVerboseOne};
233+
234+
#[cfg(feature = "29_0")]
235+
impl Client {
236+
/// Retrieves the verbose JSON representation of a block header (verbosity 1).
237+
///
238+
/// # Arguments
239+
///
240+
/// * `block_hash`: The hash of the block to retrieve.
241+
///
242+
/// # Returns
243+
///
244+
/// The verbose header as a `GetBlockHeaderVerbose` struct.
245+
pub fn get_block_header_verbose(
246+
&self,
247+
block_hash: &BlockHash,
248+
) -> Result<GetBlockHeaderVerbose, Error> {
249+
let header_info: v30::GetBlockHeaderVerbose =
250+
self.call(Rpc::GetBlockHeader, &[json!(block_hash)])?;
251+
header_info.into_model().map_err(Error::model)
252+
}
253+
254+
/// Retrieves the verbose JSON representation of a block (verbosity 1).
255+
///
256+
/// # Arguments
257+
///
258+
/// * `block_hash`: The hash of the block to retrieve.
259+
///
260+
/// # Returns
261+
///
262+
/// The verbose block data as a `GetBlockVerboseOne` struct.
263+
pub fn get_block_verbose(&self, block_hash: &BlockHash) -> Result<GetBlockVerboseOne, Error> {
264+
let block_info: v30::GetBlockVerboseOne =
265+
self.call(Rpc::GetBlock, &[json!(block_hash), json!(1)])?;
266+
block_info.into_model().map_err(Error::model)
267+
}
268+
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn test_auth_user_pass_get_user_pass() {
276+
let auth = Auth::UserPass("user".to_string(), "pass".to_string());
277+
let result = auth.get_user_pass().expect("failed to get user pass");
278+
279+
assert_eq!(result, (Some("user".to_string()), Some("pass".to_string())));
280+
}
281+
282+
#[test]
283+
#[ignore = "modifies the local filesystem"]
284+
fn test_auth_cookie_file_get_user_pass() {
285+
let temp_dir = std::env::temp_dir();
286+
let cookie_path = temp_dir.join("test_auth_cookie");
287+
std::fs::write(&cookie_path, "testuser:testpass").expect("failed to write cookie");
288+
289+
let auth = Auth::CookieFile(cookie_path.clone());
290+
let result = auth.get_user_pass().expect("failed to get user pass");
291+
292+
assert_eq!(
293+
result,
294+
(Some("testuser".to_string()), Some("testpass".to_string()))
295+
);
296+
297+
std::fs::remove_file(cookie_path).ok();
298+
}
299+
300+
#[test]
301+
fn test_auth_invalid_cookie_file() {
302+
let cookie_path = PathBuf::from("/nonexistent/path/to/cookie");
303+
let auth = Auth::CookieFile(cookie_path);
304+
let result = auth.get_user_pass();
305+
assert!(matches!(result, Err(Error::Io(_))));
306+
}
307+
}

0 commit comments

Comments
 (0)