Skip to content

Commit 41f08ea

Browse files
committed
refactor!: Sans-io Client + bitreq feature
The previous Client owned a `jsonrpc::Client<BitreqHttpTransport>` directly, coupling all users to the bitreq HTTP transport. Introduce a layered architecture: `crate::Client`: transport-agnostic type that manages request building and ID tracking. Callers supply a `send_fn` closure per call. `bitreq::Client`: batteries-included HTTP client behind the `bitreq` feature flag (included in default). Owns a `Box<dyn Transport>` and exposes all RPC methods. Auth and cookie-file parsing live here. `with_auth` now accepts an explicit `timeout: Duration` and is renamed to `with_auth_timeout`. `corepc-types` is now an optional dependency pulled in by `bitreq`. Error variants only reachable through bitreq code are gated behind `bitreq` feature.
1 parent 845b070 commit 41f08ea

11 files changed

Lines changed: 510 additions & 317 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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ edition = "2024"
1212
rust-version = "1.85.0"
1313

1414
[dependencies]
15-
corepc-types = { version = "0.12.0", features = ["default"]}
16-
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 }
1717

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

2121
[dev-dependencies]
2222
anyhow = { version = "1.0.66" }
23+
bdk_bitcoind_client = { path = ".", default-features = false, features = ["bitreq", "29_0"] }
2324
bitcoind = { version = "0.38.0", features = ["download", "29_0"] }
2425

2526
# These pins are needed for `Cargo-minimal.lock`:
@@ -28,7 +29,8 @@ filetime = { version = "0.2.8" } # blame: corepc-node
2829
log = { version = "0.4.14" } # blame: corepc-node
2930

3031
[features]
31-
default = ["28_0"]
32+
default = ["28_0", "bitreq"]
33+
bitreq = ["dep:corepc-types", "jsonrpc/bitreq_http"]
3234
30_0 = ["29_0"]
3335
29_0 = ["28_0"]
3436
28_0 = []

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

src/v28.rs renamed to src/bitreq/v28.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
// SPDX-License-Identifier: MIT OR Apache-2.0
22

3+
//! [`Client`] methods for Bitcoin Core v28.0 and earlier.
4+
35
use bitcoin::BlockHash;
46
use corepc_types::{
57
bitcoin,
68
model::{GetBlockHeaderVerbose, GetBlockVerboseOne},
79
v28,
810
};
9-
1011
use jsonrpc::serde_json::json;
1112

12-
use crate::{Client, Error};
13+
use super::Client;
14+
use crate::{Error, Rpc};
1315

1416
impl Client {
1517
/// Retrieves the verbose JSON representation of a block header (verbosity 1).
@@ -26,7 +28,7 @@ impl Client {
2628
block_hash: &BlockHash,
2729
) -> Result<GetBlockHeaderVerbose, Error> {
2830
let header_info: v28::GetBlockHeaderVerbose =
29-
self.call("getblockheader", &[json!(block_hash)])?;
31+
self.call(Rpc::GetBlockHeader, &[json!(block_hash)])?;
3032
header_info.into_model().map_err(Error::model)
3133
}
3234

@@ -41,7 +43,7 @@ impl Client {
4143
/// The verbose block data as a `GetBlockVerboseOne` struct.
4244
pub fn get_block_verbose(&self, block_hash: &BlockHash) -> Result<GetBlockVerboseOne, Error> {
4345
let block_info: v28::GetBlockVerboseOne =
44-
self.call("getblock", &[json!(block_hash), json!(1)])?;
46+
self.call(Rpc::GetBlock, &[json!(block_hash), json!(1)])?;
4547
block_info.into_model().map_err(Error::model)
4648
}
4749
}

0 commit comments

Comments
 (0)