Skip to content
Draft
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,619 changes: 1,413 additions & 206 deletions Cargo-minimal.lock

Large diffs are not rendered by default.

1,584 changes: 1,488 additions & 96 deletions Cargo-recent.lock

Large diffs are not rendered by default.

38 changes: 34 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ bitcoin = { version = "0.32", features = ["serde", "std"], default-features = fa
hex = { version = "0.2.2", package = "hex-conservative" }
bitreq = { version = "0.3.7", optional = true }
# Default runtime when running async tests
tokio = { version = "1", features = ["time"], optional = true }
tokio = { version = "1.47.1", features = ["time"], optional = true } # blame: ohttp-relay -> tokio ^1.47.1
bitcoin-ohttp = { version = "0.6.0", features = ["client", "rust-hpke"], default-features = false, optional = true}
url = {version = "2.5.7", optional = true}
bhttp = { version = "0.5.1", optional = true} # blame: bhttp >=0.6.0 requires rustc 1.81+, above our MSRV
# Pins needed for `Cargo-minimal.lock`:
thiserror = { version = "1.0.23", optional = true } # blame: bhttp -> thiserror <1.0.10's `#[from]` derive doesn't generate `From` impls; ohttp-relay -> tungstenite -> thiserror ^1.0.23
hkdf = { version = "0.12.2", optional = true } # blame: bitcoin-hpke v0.13.0 -> hkdf <0.12.2 lacks the 2-generic-param `Hkdf`/`HkdfExtract` API


[dev-dependencies]
tokio = { version = "1", features = ["full"] }
tokio = { version = "1.47.1", features = ["full"] } # blame: ohttp-relay -> tokio ^1.47.1
electrsd = { version = "0.40.0", default-features = false, features = ["legacy", "esplora_a33e97e1", "bitcoind_download", "bitcoind_29_0"] }
# These pins are needed for `Cargo-minimal.lock`:
tar = { version = "0.4.45" } # blame: electrsd v0.38.0 -> bitcoind -> tar
Expand All @@ -39,9 +46,15 @@ openssl = { version = "0.10.78" } # blame: match `openssl-sys`
native-tls = { version = "0.2.11" } # blame: bitreq v0.3.5 -> native-tls
tempfile = { version = "=3.15.0" } # blame: tempfile >=v3.16 requires getrandom >=v0.3
security-framework = { version = "=2.11.1" } # blame: rustls-native-certs 0.8.3 → security-framework 3.x (edition2024)
ohttp-relay = { git = "https://github.com/payjoin/ohttp-relay.git", rev = "73d1896", features = ["_test-util"]} # blame: pinned to v0.0.10 (MSRV 1.63.0); `main` has since bumped to rustc 1.85.0
hyper = {version = "1.8.1", features = ["full"]}
hyper-util = {version = "0.1.19"}
http-body-util = "0.1.3" # blame: ohttp-relay -> http-body-util ^0.1.3
bitcoin-ohttp = { version = "0.6.0", features = ["server"], default-features = false}

[features]
default = ["blocking", "blocking-https", "async", "async-https", "tokio"]
async-ohttp = ["async", "bitcoin-ohttp", "bhttp", "tokio", "url", "dep:thiserror", "dep:hkdf"]

tokio = ["dep:tokio"]
async = ["bitreq/async", "bitreq/proxy", "bitreq/json-using-serde", "tokio?/time"]
Expand All @@ -66,9 +79,26 @@ rustdoc-args = ["--cfg", "docsrs"]

[package.metadata.rbmt.lint]
allowed_duplicates = [
"getrandom",
"windows-sys",
"wit-bindgen",
# blame: bitcoin-ohttp's `rust-hpke` feature bundles its (dead-code) legacy
# aead/hkdf backend alongside the `bitcoin-hpke`-based backend that's
# actually used, pulling in two versions of the same crypto crates.
"aead",
"block-buffer",
"chacha20",
"chacha20poly1305",
"cipher",
"cpufeatures",
"digest",
"hkdf",
"hmac",
"poly1305",
"sha2",
"universal-hash",
# blame: bitcoin-ohttp's build-dependencies pull in their own serde/serde_core
# instance, separate from the one resolved for our normal dependency graph.
"serde",
"serde_core",
]

[package.metadata.rbmt.test]
Expand Down
121 changes: 99 additions & 22 deletions src/async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,51 @@ use bitcoin::{Address, Amount, Block, BlockHash, FeeRate, MerkleBlock, Script, T

use bitreq::{Client, Method, Proxy, Request, RequestExt, Response};

#[cfg(feature = "async-ohttp")]
use crate::ohttp::OhttpClient;
use crate::{
duration_to_timeout_secs, is_retryable, is_success, sat_per_vbyte_to_feerate, AddressStats,
BlockInfo, BlockStatus, Builder, Error, EsploraTx, MempoolRecentTx, MempoolStats, MerkleProof,
duration_to_timeout_secs, is_success, sat_per_vbyte_to_feerate, AddressStats, BlockInfo,
BlockStatus, Builder, Error, EsploraTx, MempoolRecentTx, MempoolStats, MerkleProof,
OutputStatus, ScriptHashStats, SubmitPackageResult, TxStatus, Utxo, BASE_BACKOFF_MILLIS,
};

/// A GET response body paired with its status code.
///
/// Regular requests produce this from a [`bitreq::Response`]. OHTTP-tunneled
/// requests produce it directly from the decapsulated response, since
/// [`bitreq::Response`] cannot be constructed outside the `bitreq` crate.
pub(crate) struct RawResponse {
pub(crate) status_code: u16,
pub(crate) body: Vec<u8>,
}

impl RawResponse {
fn is_success(&self) -> bool {
(200..300).contains(&self.status_code)
}

fn is_retryable(&self) -> bool {
crate::RETRYABLE_ERROR_CODES.contains(&self.status_code)
}

fn as_str(&self) -> Result<&str, Error> {
std::str::from_utf8(&self.body).map_err(|_| Error::InvalidResponse)
}

fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, Error> {
serde_json::from_slice(&self.body).map_err(Error::SerdeJson)
}
}

impl From<Response> for RawResponse {
fn from(response: Response) -> Self {
RawResponse {
status_code: response.status_code as u16,
body: response.into_bytes(),
}
}
}

// FIXME: (@oleonardolima) there's no `Debug` implementation for `bitreq::Client`.
/// An async client for interacting with an Esplora API server.
///
Expand Down Expand Up @@ -86,6 +125,9 @@ pub struct AsyncClient<S = DefaultSleeper> {
client: Client,
/// Marker for the sleeper implementation.
marker: PhantomData<S>,
/// Ohttp config
#[cfg(feature = "async-ohttp")]
ohttp_client: Option<OhttpClient>,
}

impl<S: Sleeper> AsyncClient<S> {
Expand All @@ -108,6 +150,8 @@ impl<S: Sleeper> AsyncClient<S> {
max_retries: builder.max_retries,
client: Client::new(builder.max_connections),
marker: PhantomData,
#[cfg(feature = "async-ohttp")]
ohttp_client: None,
})
}

Expand Down Expand Up @@ -150,17 +194,19 @@ impl<S: Sleeper> AsyncClient<S> {
Ok(request)
}

/// Sends a GET request to `url`, retrying on retryable status codes
/// Sends a GET request to `path`, retrying on retryable status codes
/// with exponential backoff until [`AsyncClient::max_retries`] is reached.
async fn get_with_retry(&self, path: &str) -> Result<Response, Error> {
///
/// When an [`Ohttp`](crate::ohttp::OhttpClient) client is configured, the
/// request is encapsulated and tunneled through the OHTTP relay instead
/// of being sent directly.
async fn get_with_retry(&self, path: &str) -> Result<RawResponse, Error> {
let mut delay = BASE_BACKOFF_MILLIS;
let mut attempts = 0;

let request = self.build_request(Method::Get, path)?;

loop {
match request.clone().send_async_with_client(&self.client).await? {
response if attempts < self.max_retries && is_retryable(&response) => {
match self.send_get(path).await? {
response if attempts < self.max_retries && response.is_retryable() => {
S::sleep(delay).await;
attempts += 1;
delay *= 2;
Expand All @@ -170,6 +216,29 @@ impl<S: Sleeper> AsyncClient<S> {
}
}

/// Sends a single (non-retried) GET request to `path`.
async fn send_get(&self, path: &str) -> Result<RawResponse, Error> {
#[cfg(feature = "async-ohttp")]
if let Some(ohttp_client) = &self.ohttp_client {
let target = format!("{}{}", self.url, path);
let (body, ctx) = ohttp_client.encapsulate("GET", &target, None)?;
let relay_request = Request::new(Method::Post, ohttp_client.relay_url().to_string())
.with_header("Content-Type", "message/ohttp-req")
.with_body(body);
let relay_response = relay_request.send_async_with_client(&self.client).await?;
return ohttp_client.decapsulate(ctx, relay_response.into_bytes());
}

let request = self.build_request(Method::Get, path)?;
Ok(request.send_async_with_client(&self.client).await?.into())
}

#[cfg(feature = "async-ohttp")]
pub(crate) fn set_ohttp_client(mut self, ohttp_client: OhttpClient) -> Self {
self.ohttp_client = Some(ohttp_client);
self
}

/// Makes a GET request to `path`, deserializing the response body as raw
/// bytes into `T` using [`bitcoin::consensus::Decodable`].
///
Expand All @@ -181,13 +250,15 @@ impl<S: Sleeper> AsyncClient<S> {
async fn get_response<T: Decodable>(&self, path: &str) -> Result<T, Error> {
let response = self.get_with_retry(path).await?;

if !is_success(&response) {
let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?;
if !response.is_success() {
let message = response.as_str().unwrap_or_default().to_string();
return Err(Error::HttpResponse { status, message });
return Err(Error::HttpResponse {
status: response.status_code,
message,
});
}

Ok(deserialize::<T>(response.as_bytes())?)
Ok(deserialize::<T>(&response.body)?)
}

/// Makes a GET request to `path`, returning `None` on a 404 response.
Expand Down Expand Up @@ -216,13 +287,15 @@ impl<S: Sleeper> AsyncClient<S> {
) -> Result<T, Error> {
let response = self.get_with_retry(path).await?;

if !is_success(&response) {
let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?;
if !response.is_success() {
let message = response.as_str().unwrap_or_default().to_string();
return Err(Error::HttpResponse { status, message });
return Err(Error::HttpResponse {
status: response.status_code,
message,
});
}

response.json::<T>().map_err(Error::BitReq)
response.json::<T>()
}

/// Makes a GET request to `path`, returning `None` on a 404 response.
Expand Down Expand Up @@ -251,10 +324,12 @@ impl<S: Sleeper> AsyncClient<S> {
async fn get_response_hex<T: Decodable>(&self, path: &str) -> Result<T, Error> {
let response = self.get_with_retry(path).await?;

if !is_success(&response) {
let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?;
if !response.is_success() {
let message = response.as_str().unwrap_or_default().to_string();
return Err(Error::HttpResponse { status, message });
return Err(Error::HttpResponse {
status: response.status_code,
message,
});
}

let hex_str = response.as_str()?;
Expand Down Expand Up @@ -283,10 +358,12 @@ impl<S: Sleeper> AsyncClient<S> {
async fn get_response_text(&self, path: &str) -> Result<String, Error> {
let response = self.get_with_retry(path).await?;

if !is_success(&response) {
let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?;
if !response.is_success() {
let message = response.as_str().unwrap_or_default().to_string();
return Err(Error::HttpResponse { status, message });
return Err(Error::HttpResponse {
status: response.status_code,
message,
});
}

Ok(response.as_str()?.to_string())
Expand Down
44 changes: 43 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ pub mod r#async;
#[cfg(feature = "blocking")]
pub mod blocking;

#[cfg(feature = "async-ohttp")]
pub(crate) mod ohttp;

pub use api::*;
#[cfg(any(feature = "blocking", feature = "async"))]
use bitreq::Response;
Expand Down Expand Up @@ -165,7 +168,7 @@ fn is_server_error(response: &Response) -> bool {
}

/// Check if the [`Response`] status code is retryable (429, 500, 503).
#[cfg(any(feature = "blocking", feature = "async"))]
#[cfg(feature = "blocking")]
fn is_retryable(response: &Response) -> bool {
RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16))
}
Expand Down Expand Up @@ -333,6 +336,30 @@ impl Builder {
pub fn build_async_with_sleeper<S: Sleeper>(self) -> Result<AsyncClient<S>, Error> {
AsyncClient::from_builder(self)
}

/// Build an [`AsyncClient`] that tunnels requests through an OHTTP relay
/// and gateway.
///
/// The key config is fetched from `ohttp_gateway_url` before the client
/// is returned, revealing the caller's network metadata to the gateway.
///
/// # Errors
///
/// Returns an [`Error`] if the key config cannot be fetched or decoded,
/// or if the async HTTP client cannot be constructed.
#[cfg(feature = "async-ohttp")]
pub async fn build_async_with_ohttp(
self,
ohttp_relay_url: &str,
ohttp_gateway_url: &str,
) -> Result<AsyncClient, Error> {
use crate::ohttp::OhttpClient;

let ohttp_client = OhttpClient::new(ohttp_relay_url, ohttp_gateway_url).await?;
Ok(self
.build_async_with_sleeper()?
.set_ohttp_client(ohttp_client))
}
}

/// Errors that can occur while building clients, sending requests, or decoding responses.
Expand Down Expand Up @@ -372,6 +399,15 @@ pub enum Error {
InvalidHttpHeaderValue(String),
/// The server sent an invalid response.
InvalidResponse,
/// Error from Ohttp library
#[cfg(feature = "async-ohttp")]
Ohttp(bitcoin_ohttp::Error),
/// Error when reading and writing to bhttp payloads
#[cfg(feature = "async-ohttp")]
Bhttp(bhttp::Error),
/// Error when parsing the URL
#[cfg(feature = "async-ohttp")]
UrlParsing(url::ParseError),
}

impl fmt::Display for Error {
Expand Down Expand Up @@ -404,6 +440,12 @@ impl fmt::Display for Error {
write!(f, "Invalid HTTP header value: {value}")
}
Error::InvalidResponse => write!(f, "The server sent an invalid response"),
#[cfg(feature = "async-ohttp")]
Error::Ohttp(e) => write!(f, "OHTTP error: {e}"),
#[cfg(feature = "async-ohttp")]
Error::Bhttp(e) => write!(f, "BHTTP error: {e}"),
#[cfg(feature = "async-ohttp")]
Error::UrlParsing(e) => write!(f, "Failed to parse URL: {e}"),
}
}
}
Expand Down
Loading
Loading