Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
866fd60
chore(deps): add corepc-types
storopoli Oct 1, 2025
b1ff454
refactor: use corepc-types ListTransactions
storopoli Oct 1, 2025
aeeb711
refactor: use corepc-types GetBlockchainInfo
storopoli Oct 1, 2025
deb995a
refactor: use corepc-types GetTransaction
storopoli Oct 1, 2025
5ec0ebc
refactor: use corepc-types ListUnspent
storopoli Oct 2, 2025
f0c4a2e
refactor: use corepc-types GetBlockVerbose{Zero,One}
storopoli Oct 2, 2025
2a47530
refactor: use corepc-types GetRawTransaction{,Verbose}
storopoli Oct 2, 2025
1396099
refactor: use corepc-types mempool stuff
storopoli Oct 2, 2025
f0323f5
refactor: use corepc-types GetTxOut
storopoli Oct 2, 2025
d49e301
refactor: rename CreateRawTransaction to CreateRawTransactionArguments
storopoli Oct 2, 2025
4a9a896
refactor: use corepc-types SubmitPackage
storopoli Oct 2, 2025
f9383b7
refactor: use corepc-types GetAddressInfo
storopoli Oct 2, 2025
80394e7
refactor: use corepc-types TestMempoolAccept
storopoli Oct 2, 2025
9145d9e
refactor: use corepc-types SignRawTransaction
storopoli Oct 2, 2025
334b7a2
refactor: use corepc-types ListDescriptor
storopoli Oct 2, 2025
3d075a4
refactor: use corepc-types CreateWallet
storopoli Oct 2, 2025
7fb98f0
refactor: use corepc-types PSBT stuff
storopoli Oct 2, 2025
2bd9e94
meta: feature-gate stuff
storopoli Oct 2, 2025
988c2b1
doc: fix docstrings
storopoli Oct 2, 2025
3f2077f
chore: clippy lints
storopoli Oct 2, 2025
d2a80d2
chore: fix module-level docs
storopoli Oct 2, 2025
e06869e
chore: cargo fmt
storopoli Oct 2, 2025
591f9ab
chore(deps): bump to corepc-types 0.10.1
storopoli Oct 9, 2025
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
13 changes: 7 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ categories = ["cryptography::cryptocurrencies"]
keywords = ["crypto", "bitcoin"]

[features]
default = ["29_0"]
29_0 = []

[dependencies]
base64 = "0.22.1"
bitcoin = { version = "0.32.6", features = ["serde", "base64"] }
corepc-types = "0.10.1"
hex = { package = "hex-conservative", version = "0.2.1" } # for optimization keep in sync with bitcoin
reqwest = { version = "0.12.22", default-features = false, features = [
"http2",
Expand All @@ -42,7 +45,7 @@ tracing = { version = "0.1.41", default-features = false }

[dev-dependencies]
anyhow = "1.0.100"
corepc-node = { version = "0.9.0", features = ["29_0", "download"] }
corepc-node = { version = "0.10.0", features = ["29_0", "download"] }
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }

[profile.release]
Expand Down
228 changes: 228 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
use std::{
fmt,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};

use crate::error::{BitcoinRpcError, ClientError};
use base64::{engine::general_purpose, Engine};
use reqwest::{
header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE},
Client as ReqwestClient,
};
use serde::{de, Deserialize, Serialize};
use serde_json::{json, value::Value};
use tokio::time::sleep;
use tracing::*;

#[cfg(feature = "29_0")]
pub mod v29;

/// This is an alias for the result type returned by the [`Client`].
pub type ClientResult<T> = Result<T, ClientError>;

/// The maximum number of retries for a request.
const DEFAULT_MAX_RETRIES: u8 = 3;

/// The maximum number of retries for a request.
const DEFAULT_RETRY_INTERVAL_MS: u64 = 1_000;

/// Custom implementation to convert a value to a `Value` type.
pub fn to_value<T>(value: T) -> ClientResult<Value>
where
T: Serialize,
{
serde_json::to_value(value)
.map_err(|e| ClientError::Param(format!("Error creating value: {e}")))
}

/// An `async` client for interacting with a `bitcoind` instance.
#[derive(Debug, Clone)]
pub struct Client {
/// The URL of the `bitcoind` instance.
url: String,

/// The underlying `async` HTTP client.
client: ReqwestClient,

/// The ID of the current request.
///
/// # Implementation Details
///
/// Using an [`Arc`] so that [`Client`] is [`Clone`].
id: Arc<AtomicUsize>,

/// The maximum number of retries for a request.
max_retries: u8,

/// Interval between retries for a request in ms.
retry_interval: u64,
}

/// Response returned by the `bitcoind` RPC server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Response<R> {
pub result: Option<R>,
pub error: Option<BitcoinRpcError>,
pub id: u64,
}

impl Client {
/// Creates a new [`Client`] with the given URL, username, and password.
pub fn new(
url: String,
username: String,
password: String,
max_retries: Option<u8>,
retry_interval: Option<u64>,
) -> ClientResult<Self> {
if username.is_empty() || password.is_empty() {
return Err(ClientError::MissingUserPassword);
}

let user_pw = general_purpose::STANDARD.encode(format!("{username}:{password}"));
let authorization = format!("Basic {user_pw}")
.parse()
.map_err(|_| ClientError::Other("Error parsing header".to_string()))?;

let content_type = "application/json"
.parse()
.map_err(|_| ClientError::Other("Error parsing header".to_string()))?;
let headers =
HeaderMap::from_iter([(AUTHORIZATION, authorization), (CONTENT_TYPE, content_type)]);

trace!(headers = ?headers);

let client = ReqwestClient::builder()
.default_headers(headers)
.build()
.map_err(|e| ClientError::Other(format!("Could not create client: {e}")))?;

let id = Arc::new(AtomicUsize::new(0));

let max_retries = max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
let retry_interval = retry_interval.unwrap_or(DEFAULT_RETRY_INTERVAL_MS);

trace!(url = %url, "Created bitcoin client");

Ok(Self {
url,
client,
id,
max_retries,
retry_interval,
})
}

fn next_id(&self) -> usize {
self.id.fetch_add(1, Ordering::AcqRel)
}

async fn call<T: de::DeserializeOwned + fmt::Debug>(
&self,
method: &str,
params: &[Value],
) -> ClientResult<T> {
let mut retries = 0;
loop {
trace!(%method, ?params, %retries, "Calling bitcoin client");

let id = self.next_id();

let response = self
.client
.post(&self.url)
.json(&json!({
"jsonrpc": "1.0",
"id": id,
"method": method,
"params": params
}))
.send()
.await;
trace!(?response, "Response received");
match response {
Ok(resp) => {
// Check HTTP status code first before parsing body
let resp = match resp.error_for_status() {
Err(e) if e.is_status() => {
if let Some(status) = e.status() {
let reason =
status.canonical_reason().unwrap_or("Unknown").to_string();
return Err(ClientError::Status(status.as_u16(), reason));
} else {
return Err(ClientError::Other(e.to_string()));
}
}
Err(e) => {
return Err(ClientError::Other(e.to_string()));
}
Ok(resp) => resp,
};

let raw_response = resp
.text()
.await
.map_err(|e| ClientError::Parse(e.to_string()))?;
trace!(%raw_response, "Raw response received");
let data: Response<T> = serde_json::from_str(&raw_response)
.map_err(|e| ClientError::Parse(e.to_string()))?;
if let Some(err) = data.error {
return Err(ClientError::Server(err.code, err.message));
}
return data
.result
.ok_or_else(|| ClientError::Other("Empty data received".to_string()));
}
Err(err) => {
warn!(err = %err, "Error calling bitcoin client");

if err.is_body() {
// Body error is unrecoverable
return Err(ClientError::Body(err.to_string()));
} else if err.is_status() {
// Status error is unrecoverable
let e = match err.status() {
Some(code) => ClientError::Status(code.as_u16(), err.to_string()),
_ => ClientError::Other(err.to_string()),
};
return Err(e);
} else if err.is_decode() {
// Error decoding response, might be recoverable
let e = ClientError::MalformedResponse(err.to_string());
warn!(%e, "decoding error, retrying...");
} else if err.is_connect() {
// Connection error, might be recoverable
let e = ClientError::Connection(err.to_string());
warn!(%e, "connection error, retrying...");
} else if err.is_timeout() {
// Timeout error, might be recoverable
let e = ClientError::Timeout;
warn!(%e, "timeout error, retrying...");
} else if err.is_request() {
// General request error, might be recoverable
let e = ClientError::Request(err.to_string());
warn!(%e, "request error, retrying...");
} else if err.is_builder() {
// Request builder error is unrecoverable
return Err(ClientError::ReqBuilder(err.to_string()));
} else if err.is_redirect() {
// Redirect error is unrecoverable
return Err(ClientError::HttpRedirect(err.to_string()));
} else {
// Unknown error is unrecoverable
return Err(ClientError::Other("Unknown error".to_string()));
}
}
}
retries += 1;
if retries >= self.max_retries {
return Err(ClientError::MaxRetriesExceeded(self.max_retries));
}
sleep(Duration::from_millis(self.retry_interval)).await;
}
}
}
Loading
Loading