Skip to content

Commit 8be8113

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 and std. Introduce a layered architecture: `crate::Client`: transport-agnostic, no_std-compatible core 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`. Crate is `#![no_std]` by default. The `bitreq` feature enables std. `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 1ffcc9d commit 8be8113

11 files changed

Lines changed: 518 additions & 315 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: 6 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,9 @@ 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+
std = []
34+
bitreq = ["dep:corepc-types", "std", "jsonrpc/bitreq_http"]
3235
30_0 = ["29_0"]
3336
29_0 = ["28_0"]
3437
28_0 = []

src/bitreq.rs

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

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`](super::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)