Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo-minimal.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
version = "0.1.0"
dependencies = [
"anyhow",
"bdk_bitcoind_client",
"bitcoind",
"corepc-types",
"filetime",
Expand Down
1 change: 1 addition & 0 deletions Cargo-recent.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ name = "bdk_bitcoind_client"
version = "0.1.0"
dependencies = [
"anyhow",
"bdk_bitcoind_client",
"bitcoind",
"corepc-types",
"filetime",
Expand Down
20 changes: 11 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,36 @@ 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"] }

# 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]
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# bdk-bitcoind-client

<p>
<!-- <a href="https://crates.io/crates/bdk-bitcoind-client"><img src="https://img.shields.io/crates/v/bdk-bitcoind-client.svg"/></a> -->
<!-- <a href="https://docs.rs/bdk-bitcoind-client"><img src="https://img.shields.io/badge/docs.rs-bdk-bitcoind-client-orange"/></a> -->
<a href="https://crates.io/crates/bdk_bitcoind_client"><img src="https://img.shields.io/crates/v/bdk_bitcoind_client.svg"/></a>
<a href="https://docs.rs/bdk_bitcoind_client"><img src="https://img.shields.io/badge/docs.rs-bdk_bitcoind_client-orange.svg"/></a>
<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>
<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>
<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>
Expand Down Expand Up @@ -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()?;
Expand Down
307 changes: 307 additions & 0 deletions src/bitreq.rs
Original file line number Diff line number Diff line change
@@ -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<String>, Option<String>), 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<dyn Transport>,
}

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<Self, Error> {
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<T>(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<T>(&self, rpc: Rpc, params: &[Value]) -> Result<T, Error>
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<Block, Error> {
self.call::<String>(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<BlockHash, Error> {
self.call::<String>(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<u32, Error> {
self.call::<v30::GetBlockCount>(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<BlockHash, Error> {
self.call::<String>(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<model::GetBlockFilter, Error> {
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<Header, Error> {
self.call::<String>(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<Vec<Txid>, Error> {
self.call::<model::GetRawMempool>(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<Transaction, Error> {
self.call::<String>(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<GetBlockHeaderVerbose, Error> {
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<GetBlockVerboseOne, Error> {
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(_))));
}
}
Loading
Loading