diff --git a/Cargo.toml b/Cargo.toml index 4d1cdc2..b52dcd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ license = "MIT OR Apache-2.0" homepage = "https://github.com/bitcoindevkit/rust-esplora-client" repository = "https://github.com/bitcoindevkit/rust-esplora-client" documentation = "https://docs.rs/esplora-client/" -description = "Bitcoin Esplora API client library. Supports plaintext, TLS and Onion servers. Blocking or async." +description = "Asynchronous and blocking clients and types for interacting with Esplora servers over HTTP" keywords = ["bitcoin", "esplora"] readme = "README.md" rust-version = "1.75.0" # MSRV diff --git a/README.md b/README.md index 195c11d..0c54476 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # rust-esplora-client -Bitcoin Esplora API client library. Supports plaintext, TLS and Onion servers. Blocking or async. -

@@ -10,6 +8,8 @@ Bitcoin Esplora API client library. Supports plaintext, TLS and Onion servers. B

+Asynchronous and blocking clients and types for interacting with Esplora servers over HTTP. + ## Developing This project uses [`just`](https://github.com/casey/just) for command running, and diff --git a/src/api.rs b/src/api.rs index a1ea398..dcd8f1c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,8 +1,13 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Structs from the Esplora API +//! # Esplora API //! -//! See: +//! This module implements the types and deserializers +//! needed to interact with an Esplora-compliant server. +//! +//! Refer to the [Esplora API] specification for the complete API reference. +//! +//! [Esplora API]: use bitcoin::hash_types; use serde::Deserialize; @@ -17,34 +22,35 @@ pub use bitcoin::{ Wtxid, }; -/// Information about an input from a [`Transaction`]. +/// An input to a [`Transaction`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Vin { - /// The [`Txid`] of the previous [`Transaction`] this input spends from. + /// The [`Txid`] of the [`Transaction`] that created this output. pub txid: Txid, - /// The output index of the previous output in the [`Transaction`] that created it. + /// The index of this output in the [`Transaction`] that created it. pub vout: u32, - /// The previous output amount and ScriptPubKey. - /// `None` if this is a coinbase input. + /// This input's previous output [`Amount`] and [script pubkey][Script]. + /// + /// `None` if this input spends a coinbase output. pub prevout: Option, - /// The ScriptSig authorizes spending this input. + /// The [`Script`] that unlocks this input. pub scriptsig: ScriptBuf, - /// The Witness that authorizes spending this input, if this is a SegWit spend. + /// The Witness that unlocks this input. #[serde(deserialize_with = "deserialize_witness", default)] pub witness: Vec>, - /// The sequence value for this input, used to set RBF and Locktime behavior. + /// The sequence value for this input. pub sequence: u32, /// Whether this is a coinbase input. pub is_coinbase: bool, } -/// Information about a [`Transaction`]s output. +/// An output from a [`Transaction`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Vout { - /// The value of the output, in satoshis. + /// The output's [`Amount`]. #[serde(with = "bitcoin::amount::serde::as_sat")] pub value: Amount, - /// The ScriptPubKey that the output is locked to, as a [`ScriptBuf`]. + /// The [script pubkey][Script] this output is locked to. pub scriptpubkey: ScriptBuf, } @@ -53,35 +59,36 @@ pub struct Vout { pub struct TxStatus { /// Whether the [`Transaction`] is confirmed or not. pub confirmed: bool, - /// The block height the [`Transaction`] was confirmed in. + /// The block height that confirmed the [`Transaction`]. pub block_height: Option, - /// The [`BlockHash`] of the block the [`Transaction`] was confirmed in. + /// The [`BlockHash`] of the block that confirmed the [`Transaction`]. + /// + /// `None` if the [`Transaction`] was confirmed by the genesis block. pub block_hash: Option, - /// The time that the block was mined at, as a UNIX timestamp. - /// Note: this timestamp is set by the miner and may not reflect the exact time of mining. + /// The UNIX timestamp of the block that confirmed the [`Transaction`], as claimed by the + /// miner. pub block_time: Option, } -/// A Merkle inclusion proof for a transaction, given it's [`Txid`]. +/// A Merkle inclusion proof for a [`Transaction`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct MerkleProof { - /// The height of the block the [`Transaction`] was confirmed in. + /// The block height that confirmed the [`Transaction`]. pub block_height: u32, - /// A list of transaction hashes the current hash is paired with, - /// recursively, in order to trace up to obtain the Merkle root of the - /// [`Block`], deepest pairing first. + /// The Merkle proof of inclusion of a [`Transaction`] in a [`Block`]. + /// + /// Elements are returned left-to-right and bottom-to-top. pub merkle: Vec, - /// The 0-based index of the position of the [`Transaction`] in the - /// ordered list of [`Transaction`]s in the [`Block`]. + /// The 0-indexed position of the [`Transaction`] in the [`Block`]. pub pos: usize, } -/// The spend status of a [`TxOut`]. +/// The status of a [`TxOut`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct OutputStatus { - /// Whether the [`TxOut`] is spent or not. + /// Whether the [`TxOut`] is spent. pub spent: bool, - /// The [`Txid`] that spent this [`TxOut`]. + /// The [`Txid`] of the [`Transaction`] that spent this [`TxOut`]. pub txid: Option, /// The input index of this [`TxOut`] in the [`Transaction`] that spent it. pub vin: Option, @@ -89,19 +96,24 @@ pub struct OutputStatus { pub status: Option, } -/// Information about a [`Block`]s status. +/// The status of a [`Block`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct BlockStatus { - /// Whether this [`Block`] belongs to the chain with the most - /// Proof-of-Work (false for [`Block`]s that belong to a stale chain). + /// Whether this [`Block`] belongs to the chain with the most Proof-of-Work. pub in_best_chain: bool, /// The height of this [`Block`]. pub height: Option, - /// The [`BlockHash`] of the [`Block`] that builds on top of this one. + /// The [`BlockHash`] of the [`Block`] that builds on top of this [`Block`]. pub next_best: Option, } -/// A [`Transaction`] in the format returned by Esplora. +/// A transaction in the format returned by Esplora. +/// +/// Unlike the native `rust-bitcoin` [`Transaction`], [`EsploraTx`] +/// includes additional metadata such as the [`TxStatus`], transaction fee, +/// and transaction [`Weight`], as indexed and reported by Esplora servers. +/// +/// To convert it into a [`Transaction`], use [`EsploraTx::to_tx`] or `.into()`. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct EsploraTx { /// The [`Txid`] of the [`Transaction`]. @@ -117,46 +129,57 @@ pub struct EsploraTx { pub vout: Vec, /// The [`Transaction`] size in raw bytes (NOT virtual bytes). pub size: usize, - /// The [`Transaction`]'s weight units. + /// The [`Transaction`]'s weight. pub weight: Weight, /// The confirmation status of the [`Transaction`]. pub status: TxStatus, - /// The fee amount paid by the [`Transaction`], in satoshis. + /// The fee paid by the [`Transaction`], in satoshis. #[serde(with = "bitcoin::amount::serde::as_sat")] pub fee: Amount, } -/// Information about a bitcoin [`Block`]. +/// A summary of a [`Block`]. +/// +/// Contains additional metadata about a [`Block`], but not the whole [`Block`]. +/// +/// For the complete [`Block`] contents, use the `get_block_by_hash` client method. #[derive(Debug, Clone, Deserialize)] pub struct BlockInfo { - /// The [`Block`]'s [`BlockHash`]. + /// The [`BlockHash`] of this [`Block`]. pub id: BlockHash, - /// The [`Block`]'s height. + /// The block height of this [`Block`]. pub height: u32, - /// The [`Block`]'s version. + /// The version of this [`Block`]. pub version: block::Version, - /// The [`Block`]'s UNIX timestamp. + /// The UNIX timestamp of this [`Block`], as claimed by the miner. pub timestamp: u64, - /// The [`Block`]'s [`Transaction`] count. + /// The [`Transaction`] count for this [`Block`]. pub tx_count: u64, - /// The [`Block`]'s size, in bytes. + /// The size of this [`Block`], in bytes. pub size: usize, - /// The [`Block`]'s weight. + /// The [`Weight`] of this [`Block`]. pub weight: Weight, - /// The Merkle root of the transactions in the block. + /// The Merkle root of this [`Block`]. pub merkle_root: hash_types::TxMerkleNode, - /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis block). + /// The [`BlockHash`] of the previous [`Block`]. + /// + /// `None` for the genesis block. pub previousblockhash: Option, - /// The [`Block`]'s MTP (Median Time Past). + /// The Median Time Past value for this [`Block`]. pub mediantime: u64, - /// The [`Block`]'s nonce value. + /// This [`Block`]'s nonce. pub nonce: u32, - /// The [`Block`]'s `bits` value as a [`CompactTarget`]. + /// The [`Block`]'s `bits` value, encoded as a [`CompactTarget`]. pub bits: CompactTarget, /// The [`Block`]'s difficulty target value. pub difficulty: f64, } +/// A manual `PartialEq` implementation is required +/// since [`BlockInfo::difficulty`] is an `f64`. +/// +/// This treats two `NaN` difficulty values as equal, +/// allowing [`BlockInfo`] to implement [`Eq`] correctly. impl PartialEq for BlockInfo { fn eq(&self, other: &Self) -> bool { let Self { difficulty: d1, .. } = self; @@ -179,12 +202,12 @@ impl PartialEq for BlockInfo { } impl Eq for BlockInfo {} -/// Time-related information about a [`Block`]. +/// The UNIX timestamp and height of a [`Block`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct BlockTime { - /// The [`Block`]'s timestamp. + /// The UNIX timestamp of the [`Block`], as claimed by the miner. pub timestamp: u64, - /// The [`Block`]'s height. + /// The block height of the [`Block`]. pub height: u32, } @@ -193,81 +216,82 @@ pub struct BlockTime { #[deprecated(since = "0.13.0", note = "use `BlockInfo` instead")] #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct BlockSummary { - /// The [`Block`]'s hash. + /// The [`BlockHash`] of the [`Block`]. pub id: BlockHash, - /// The [`Block`]'s timestamp and height. + /// The UNIX timestamp and height of the [`Block`]. #[serde(flatten)] pub time: BlockTime, - /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis [`Block`]). + /// The [`BlockHash`] of the previous [`Block`]. + /// + /// `None` for the genesis block. pub previousblockhash: Option, - /// The Merkle root of the [`Block`]'s [`Transaction`]s. + /// The Merkle root of this [`Block`]. pub merkle_root: TxMerkleNode, } /// Statistics about an [`Address`]. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct AddressStats { - /// The [`Address`]. + /// The [`Address`], as a [`String`]. pub address: String, /// The summary of confirmed [`Transaction`]s for this [`Address`]. pub chain_stats: AddressTxsSummary, - /// The summary of mempool [`Transaction`]s for this [`Address`]. + /// The summary of unconfirmed mempool [`Transaction`]s for this [`Address`]. pub mempool_stats: AddressTxsSummary, } -/// A summary of [`Transaction`]s in which an [`Address`] was involved. +/// A summary of [`Transaction`]s in which an [`Address`] is involved. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] pub struct AddressTxsSummary { - /// The number of funded [`TxOut`]s. + /// The current number of funded [`TxOut`]s for this [`Address`]. pub funded_txo_count: u32, - /// The sum of the funded [`TxOut`]s, in satoshis. + /// The total [`Amount`] of funded [`TxOut`]s for this [`Address`]. #[serde(with = "bitcoin::amount::serde::as_sat")] pub funded_txo_sum: Amount, - /// The number of spent [`TxOut`]s. + /// The number of spent [`TxOut`]s for this [`Address`]. pub spent_txo_count: u32, - /// The sum of the spent [`TxOut`]s, in satoshis. + /// The total [`Amount`] of spent [`TxOut`]s for this [`Address`]. #[serde(with = "bitcoin::amount::serde::as_sat")] pub spent_txo_sum: Amount, - /// The total number of [`Transaction`]s. + /// The total number of [`Transaction`]s for this [`Address`]. pub tx_count: u32, } -/// Statistics about a particular [`Script`] hash's confirmed and mempool transactions. +/// Statistics about a [scripthash](Script)'s confirmed and mempool [`Transaction`]s. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] pub struct ScriptHashStats { - /// The summary of confirmed [`Transaction`]s for this [`Script`] hash. + /// The summary of confirmed [`Transaction`]s for this [scripthash](Script). pub chain_stats: ScriptHashTxsSummary, - /// The summary of mempool [`Transaction`]s for this [`Script`] hash. + /// The summary of mempool [`Transaction`]s for this [scripthash](Script). pub mempool_stats: ScriptHashTxsSummary, } -/// Contains a summary of the [`Transaction`]s for a particular [`Script`] hash. +/// A summary of [`Transaction`]s for a particular [scripthash](Script). pub type ScriptHashTxsSummary = AddressTxsSummary; -/// Information about a [`TxOut`]'s status: confirmation status, -/// confirmation height, confirmation block hash and confirmation block time. +/// The confirmation status of a [`TxOut`]. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] pub struct UtxoStatus { - /// Whether or not the [`TxOut`] is confirmed. + /// Whether the [`TxOut`] is confirmed. pub confirmed: bool, /// The block height in which the [`TxOut`] was confirmed. pub block_height: Option, /// The block hash in which the [`TxOut`] was confirmed. pub block_hash: Option, - /// The UNIX timestamp in which the [`TxOut`] was confirmed. + /// The UNIX timestamp in which the [`TxOut`] was confirmed, as reported by the miner. pub block_time: Option, } -/// Information about an [`TxOut`]'s outpoint, confirmation status and value. +/// An unspent [`TxOut`], including its outpoint, confirmation status and value. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] pub struct Utxo { - /// The [`Txid`] of the [`Transaction`] that created the [`TxOut`]. + /// The [`Txid`] of the [`Transaction`] that created this [`TxOut`]. pub txid: Txid, - /// The output index of the [`TxOut`] in the [`Transaction`] that created it. + /// The output index of this [`TxOut`] in the [`Transaction`] that created it. pub vout: u32, - /// The confirmation status of the [`TxOut`]. + /// The confirmation status of this [`TxOut`]. pub status: UtxoStatus, - /// The value of the [`TxOut`], in satoshis. + /// The value of this [`TxOut`]. #[serde(with = "bitcoin::amount::serde::as_sat")] pub value: Amount, } @@ -275,18 +299,22 @@ pub struct Utxo { /// Statistics about the mempool. #[derive(Clone, Debug, PartialEq, Deserialize)] pub struct MempoolStats { - /// The number of [`Transaction`]s in the mempool. + /// The number of [`Transaction`]s currently in the mempool. pub count: usize, /// The total size of mempool [`Transaction`]s, in virtual bytes. pub vsize: usize, - /// The total fee paid by mempool [`Transaction`]s, in satoshis. + /// The total fee paid by mempool [`Transaction`]s. #[serde(with = "bitcoin::amount::serde::as_sat")] pub total_fee: Amount, /// The mempool's fee rate distribution histogram. /// - /// An array of `(feerate, vsize)` tuples, where each entry's `vsize` is the total vsize - /// of [`Transaction`]s paying more than `feerate` but less than the previous entry's `feerate` + /// An array of `(feerate, vsize)` tuples, where each entry's + /// `vsize` is the total vsize of [`Transaction`]s paying more + /// than `feerate` but less than the previous entry's `feerate` /// (except for the first entry, which has no upper bound). + /// + /// The Esplora API reports `vsize` in virtual bytes. This field + /// currently stores that raw value in [`Weight`]. #[serde(deserialize_with = "deserialize_fee_histogram")] pub fee_histogram: Vec<(FeeRate, Weight)>, } @@ -294,75 +322,81 @@ pub struct MempoolStats { /// A [`Transaction`] that recently entered the mempool. #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub struct MempoolRecentTx { - /// The [`Transaction`]'s ID, as a [`Txid`]. + /// The [`Transaction`]'s [`Txid`]. pub txid: Txid, - /// The [`Amount`] of fees paid by the transaction, in satoshis. + /// The fee paid by the [`Transaction`]. #[serde(with = "bitcoin::amount::serde::as_sat")] pub fee: Amount, /// The [`Transaction`]'s size, in virtual bytes. pub vsize: usize, - /// Combined [`Amount`] of the [`Transaction`], in satoshis. + /// The combined value of the [`Transaction`]'s outputs. #[serde(with = "bitcoin::amount::serde::as_sat")] pub value: Amount, } -/// The result for a broadcasted package of [`Transaction`]s. +/// The global result of a [`Transaction`] package submission. #[derive(Deserialize, Debug)] pub struct SubmitPackageResult { - /// The transaction package result message. "success" indicates all transactions were accepted - /// into or are already in the mempool. + /// The [`Transaction`] package result message. + /// + /// "success" indicates all transactions were + /// accepted or are already in the mempool. pub package_msg: String, - /// Transaction results keyed by [`Wtxid`]. + /// The list of individual [`Transaction`] broadcast + /// results, keyed by each [`Transaction`]'s [`Wtxid`]. #[serde(rename = "tx-results")] pub tx_results: HashMap, - /// List of txids of replaced transactions. + /// The list of [`Txid`]s of replaced [`Transaction`]s. #[serde(rename = "replaced-transactions")] pub replaced_transactions: Option>, } -/// The result [`Transaction`] for a broadcasted package of [`Transaction`]s. +/// A per-transaction result of a [`Transaction`] package submission. #[derive(Deserialize, Debug)] pub struct TxResult { - /// The transaction id. + /// The [`Transaction`]'s [`Txid`]. pub txid: Txid, - /// The [`Wtxid`] of a different transaction with the same [`Txid`] but different witness found - /// in the mempool. + /// The [`Wtxid`] of a different [`Transaction`] with the same [`Txid`], + /// but different Witness found in the mempool. /// - /// If set, this means the submitted transaction was ignored. + /// If `Some`, means the submitted [`Transaction`] was ignored. #[serde(rename = "other-wtxid")] pub other_wtxid: Option, - /// Sigops-adjusted virtual transaction size. + /// `sigops`-adjusted transaction size, in virtual bytes. pub vsize: Option, - /// Transaction fees. + /// The effective fee paid by the [`Transaction`]. pub fees: Option, - /// The transaction error string, if it was rejected by the mempool + /// The [`Transaction`] submission error string. pub error: Option, } -/// The mempool fees for a resulting [`Transaction`] broadcasted by a package of [`Transaction`]s. +/// The fees for a [`Transaction`] submitted as part of a package. #[derive(Deserialize, Debug)] pub struct MempoolFeesSubmitPackage { - /// Transaction fee. + /// The base fee paid by the [`Transaction`]. #[serde(with = "bitcoin::amount::serde::as_btc")] pub base: Amount, - /// The effective feerate. + /// The effective feerate paid by this [`Transaction`]. /// - /// Will be `None` if the transaction was already in the mempool. For example, the package - /// feerate and/or feerate with modified fees from the `prioritisetransaction` JSON-RPC method. + /// Is `None` if the transaction was already in the mempool. #[serde( rename = "effective-feerate", default, deserialize_with = "deserialize_feerate" )] pub effective_feerate: Option, - /// If [`Self::effective_feerate`] is provided, this holds the [`Wtxid`]s of the transactions - /// whose fees and vsizes are included in effective-feerate. + /// If [`Self::effective_feerate`] is provided, holds the + /// [`Wtxid`]s of the transactions whose fees and virtual + /// sizes are included in effective-feerate. #[serde(rename = "effective-includes")] pub effective_includes: Option>, } impl EsploraTx { - /// Convert a transaction from the format returned by Esplora into a [`Transaction`]. + /// Convert this [`EsploraTx`] into a [`Transaction`]. + /// + /// This will drop the Esplora-specific metadata (fee, weight, confirmation status) + /// and reconstructs the [`Transaction`] from its inputs and outputs. pub fn to_tx(&self) -> Transaction { Transaction { version: transaction::Version::non_standard(self.version), @@ -393,7 +427,10 @@ impl EsploraTx { } } - /// Get the confirmation time from an [`EsploraTx`]. + /// Get the confirmation time of this [`EsploraTx`]. + /// + /// If the transaction is confirmed, returns its [`BlockTime`] containing + /// confirmation height and UNIX timestamp. If not, returns `None`. pub fn confirmation_time(&self) -> Option { match self.status { TxStatus { @@ -406,15 +443,18 @@ impl EsploraTx { } } - /// Get a list of the [`EsploraTx`]'s previous outputs. + /// Get the previous [`TxOut`]s spent by this transaction's inputs. + /// + /// Returns one [`Option`] per input, in order. + /// `None` if the input spends a coinbase output. pub fn previous_outputs(&self) -> Vec> { self.vin .iter() .cloned() .map(|vin| { - vin.prevout.map(|po| TxOut { - script_pubkey: po.scriptpubkey, - value: po.value, + vin.prevout.map(|prevout| TxOut { + script_pubkey: prevout.scriptpubkey, + value: prevout.value, }) }) .collect() @@ -433,6 +473,11 @@ impl From<&EsploraTx> for Transaction { } } +/// Deserializes a witness from a list of hex-encoded strings. +/// +/// The Esplora API represents witness data as an array of hex strings, +/// e.g. `["deadbeef", "cafebabe"]`. This deserializer decodes each string +/// into raw bytes. fn deserialize_witness<'de, D>(d: D) -> Result>, D::Error> where D: serde::de::Deserializer<'de>, @@ -444,6 +489,13 @@ where .map_err(serde::de::Error::custom) } +/// Deserializes an optional [`FeeRate`] from an `f64` BTC/kvB value. +/// +/// The Esplora API expresses effective feerates as BTC per kilovirtual-byte. +/// This deserializer converts it to sat/kwu as required by [`FeeRate`]. +/// +/// Returns `None` if the value is absent, and an error if the resulting +/// feerate would overflow. fn deserialize_feerate<'de, D>(d: D) -> Result, D::Error> where D: serde::de::Deserializer<'de>, @@ -461,6 +513,11 @@ where Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64))) } +/// Deserializes a mempool fee histogram from `(sat/vB, vsize)` entries. +/// +/// The Esplora API expresses fee histogram buckets as feerates in satoshis per +/// virtual byte paired with each bucket's virtual size. This deserializer +/// converts each feerate to sat/kwu as required by [`FeeRate`]. fn deserialize_fee_histogram<'de, D>(d: D) -> Result, D::Error> where D: serde::de::Deserializer<'de>, diff --git a/src/async.rs b/src/async.rs index 6bb5154..ce3a8fc 100644 --- a/src/async.rs +++ b/src/async.rs @@ -1,6 +1,34 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Esplora by way of `reqwest` HTTP client. +//! # Asynchronous Esplora Client +//! +//! This module implements [`AsyncClient`], an asynchronous HTTP client for +//! interacting with an [Esplora] server by way of [`bitreq`]. +//! +//! Use this client from async applications and libraries. Each method returns a +//! future that sends the request, waits for the response, and decodes the body +//! into the requested type. +//! +//! The client is configured through [`Builder`], including the +//! base URL, proxy, socket timeout, custom headers, retry count, and maximum +//! number of cached connections. Retry sleeping is abstracted through +//! [`Sleeper`], so runtimes other than Tokio can provide their own sleep +//! implementation. +//! +//! # Example +//! +//! ```rust,ignore +//! # use esplora_client::{Builder, r#async::AsyncClient}; +//! # async fn example() -> Result<(), esplora_client::Error> { +//! +//! let client = Builder::new("https://mempool.space/api").build_async()?; +//! let height = client.get_height().await?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md use std::collections::{HashMap, HashSet}; use std::fmt::Debug; @@ -26,28 +54,54 @@ use crate::{ #[allow(deprecated)] use crate::BlockSummary; -/// An async client for interacting with an Esplora API server. // FIXME: (@oleonardolima) there's no `Debug` implementation for `bitreq::Client`. +/// An async client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. The client stores +/// the server base URL and request configuration, then exposes convenience +/// methods for the transaction, block, address, scripthash, fee-estimate, and +/// mempool endpoints. +/// +/// The generic parameter `S` determines the asynchronous runtime used for +/// sleeping between retries. Defaults to the Tokio-backed [`DefaultSleeper`]. +/// +/// # Retries +/// +/// Failed requests are automatically retried up to `max_retries` times +/// (configured via [`Builder`]) with exponential backoff, but only for +/// retryable HTTP status codes. See [`crate::RETRYABLE_ERROR_CODES`] for the +/// full list. #[derive(Clone)] pub struct AsyncClient { - /// The URL of the Esplora Server. + /// The URL of the Esplora server. url: String, - /// The proxy is ignored when targeting `wasm32`. + /// The URL of the proxy host. + /// + /// NOTE: The proxy is ignored when targeting `wasm32`. proxy: Option, - /// Socket timeout. + /// Per-request socket timeout, in seconds. timeout: Option, - /// HTTP headers to set on every request made to Esplora server + /// HTTP headers to set on every request made to the Esplora server. headers: HashMap, - /// Number of times to retry a request + /// Maximum number of retry attempts for retryable responses. max_retries: usize, /// The inner [`bitreq::Client`] HTTP client to cache connections. client: Client, - /// Marker for the type of sleeper used + /// Marker for the sleeper implementation. marker: PhantomData, } impl AsyncClient { /// Build an [`AsyncClient`] from a [`Builder`]. + /// + /// Configures the underlying [`bitreq::Client`] with + /// proxy, timeout, and headers specified in the [`Builder`]. + /// No network request is made until a client method is awaited. + /// + /// # Errors + /// + /// Returns an [`Error`] if the HTTP client fails to build, + /// or if any of the provided header names or values are invalid. pub fn from_builder(builder: Builder) -> Result { Ok(AsyncClient { url: builder.base_url, @@ -60,17 +114,25 @@ impl AsyncClient { }) } - /// Get the underlying base URL. + /// Return the base URL of the Esplora server this client connects to. + /// + /// The returned value is the exact string provided to [`Builder::new`]. pub fn url(&self) -> &str { &self.url } - /// Get the underlying [`Client`]. + /// Return the underlying [`bitreq::Client`]. + /// + /// This can be useful for callers that need access to shared connection + /// state managed by the HTTP client. pub fn client(&self) -> &Client { &self.client } /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + /// + /// Configures the request with the proxy, timeout, and headers set on + /// this client. Used internally by all other request helper methods. pub(crate) fn build_request(&self, method: Method, path: &str) -> Result { let mut request = Request::new(method, format!("{}{}", self.url, path)); @@ -91,8 +153,8 @@ impl AsyncClient { Ok(request) } - /// Sends a GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. + /// Sends a GET request to `url`, retrying on retryable status codes + /// with exponential backoff until [`AsyncClient::max_retries`] is reached. async fn get_with_retry(&self, path: &str) -> Result { let mut delay = BASE_BACKOFF_MILLIS; let mut attempts = 0; @@ -111,17 +173,14 @@ impl AsyncClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implement [`bitcoin::consensus::Decodable`]. + /// Makes a GET request to `path`, deserializing the response body as raw + /// bytes into `T` using [`bitcoin::consensus::Decodable`]. /// - /// It should be used when requesting Esplora endpoints that can be directly - /// deserialized to native `rust-bitcoin` types, which implements - /// [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// Use this for endpoints that return raw binary Bitcoin data. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`bitcoin::consensus::Decodable`] deserialization. + /// Returns an [`Error`] if the request fails or deserialization fails. async fn get_response(&self, path: &str) -> Result { let response = self.get_with_retry(path).await?; @@ -134,11 +193,9 @@ impl AsyncClient { Ok(deserialize::(response.as_bytes())?) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. - /// - /// It uses [`AsyncEsploraClient::get_response`] internally. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// See [`AsyncEsploraClient::get_response`] above for full documentation. + /// Delegates to [`Self::get_response`]. See its documentation for details. async fn get_opt_response(&self, path: &str) -> Result, Error> { match self.get_response::(path).await { Ok(res) => Ok(Some(res)), @@ -147,16 +204,15 @@ impl AsyncClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implements [`serde::de::DeserializeOwned`]. + /// Makes a GET request to `path`, deserializing the response body as JSON + /// into `T` using [`serde::de::DeserializeOwned`]. /// - /// It should be used when requesting Esplora endpoints that have a specific - /// defined API, mostly defined in [`crate::api`]. + /// Use this for endpoints that return Esplora-specific JSON types, as + /// defined in [`crate::api`]. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`serde::de::DeserializeOwned`] deserialization. + /// Returns an [`Error`] if the request fails or JSON deserialization fails. async fn get_response_json( &self, path: &str, @@ -172,12 +228,9 @@ impl AsyncClient { response.json::().map_err(Error::BitReq) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// It uses [`AsyncEsploraClient::get_response_json`] internally. - /// - /// See [`AsyncEsploraClient::get_response_json`] above for full - /// documentation. + /// Delegates to [`Self::get_response_json`]. See its documentation for details. async fn get_opt_response_json( &self, url: &str, @@ -189,17 +242,15 @@ impl AsyncClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implements [`bitcoin::consensus::Decodable`]. + /// Makes a GET request to `path`, deserializing the hex-encoded response + /// body into `T` using [`bitcoin::consensus::Decodable`]. /// - /// It should be used when requesting Esplora endpoints that are expected - /// to return a hex string decodable to native `rust-bitcoin` types which - /// implement [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// Use this for endpoints that return hex-encoded Bitcoin data. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`bitcoin::consensus::Decodable`] deserialization. + /// Returns an [`Error`] if the request fails, hex decoding fails, + /// or consensus deserialization fails. async fn get_response_hex(&self, path: &str) -> Result { let response = self.get_with_retry(path).await?; @@ -213,12 +264,9 @@ impl AsyncClient { Ok(deserialize(&Vec::from_hex(hex_str)?)?) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. - /// - /// It uses [`AsyncEsploraClient::get_response_hex`] internally. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// See [`AsyncEsploraClient::get_response_hex`] above for full - /// documentation. + /// Delegates to [`Self::get_response_hex`]. See its documentation for details. async fn get_opt_response_hex(&self, path: &str) -> Result, Error> { match self.get_response_hex(path).await { Ok(res) => Ok(Some(res)), @@ -227,14 +275,14 @@ impl AsyncClient { } } - /// Make an HTTP GET request to given URL, deserializing to `String`. + /// Makes a GET request to `path`, returning the response body as a [`String`]. /// - /// It should be used when requesting Esplora endpoints that can return - /// `String` formatted data that can be parsed downstream. + /// Use this for endpoints that return plain text data that needs further + /// parsing downstream. /// /// # Errors /// - /// This function will return an error either from the HTTP client. + /// Returns an [`Error`] if the request fails. async fn get_response_text(&self, path: &str) -> Result { let response = self.get_with_retry(path).await?; @@ -247,12 +295,9 @@ impl AsyncClient { Ok(response.as_str()?.to_string()) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// It uses [`AsyncEsploraClient::get_response_text`] internally. - /// - /// See [`AsyncEsploraClient::get_response_text`] above for full - /// documentation. + /// Delegates to [`Self::get_response_text`]. See its documentation for details. async fn get_opt_response_text(&self, path: &str) -> Result, Error> { match self.get_response_text(path).await { Ok(s) => Ok(Some(s)), @@ -261,8 +306,10 @@ impl AsyncClient { } } - /// Make an HTTP POST request to given URL, converting any `T` that - /// implement [`Into`] and setting query parameters, if any. + /// Make an HTTP POST request to `path` with `body`. + /// + /// Configures query parameters, if any, and returns the raw response after + /// checking the HTTP status code. /// /// # Errors /// @@ -291,12 +338,17 @@ impl AsyncClient { Ok(response) } - /// Get a [`Transaction`] option given its [`Txid`] + /// Get a raw [`Transaction`] given its [`Txid`]. + /// + /// Returns `None` if the transaction is not found. pub async fn get_tx(&self, txid: &Txid) -> Result, Error> { self.get_opt_response(&format!("/tx/{txid}/raw")).await } - /// Get a [`Transaction`] given its [`Txid`]. + /// Get a raw [`Transaction`] given its [`Txid`]. + /// + /// Returns an [`Error::TransactionNotFound`] if the transaction is not found. + /// Prefer [`Self::get_tx`] if you want to handle the not-found case explicitly. pub async fn get_tx_no_opt(&self, txid: &Txid) -> Result { match self.get_tx(txid).await { Ok(Some(tx)) => Ok(tx), @@ -305,8 +357,10 @@ impl AsyncClient { } } - /// Get a [`Txid`] of a transaction given its index in a block with a given - /// hash. + /// Get the [`Txid`] of the transaction at position `index` within the + /// block identified by `block_hash`. + /// + /// Returns `None` if the block or index is not found. pub async fn get_txid_at_block_index( &self, block_hash: &BlockHash, @@ -321,56 +375,79 @@ impl AsyncClient { } } - /// Get the status of a [`Transaction`] given its [`Txid`]. + /// Get the confirmation status of a [`Transaction`] given its [`Txid`]. + /// + /// Returns a [`TxStatus`] containing whether the transaction is confirmed, + /// and if so, the block height, hash, and timestamp it was confirmed in. pub async fn get_tx_status(&self, txid: &Txid) -> Result { self.get_response_json(&format!("/tx/{txid}/status")).await } - /// Get transaction info given its [`Txid`]. + /// Get an [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], returns the Esplora-specific [`EsploraTx`] + /// type, which includes additional metadata such as confirmation status, + /// fee, and weight. Returns `None` if the transaction is not found. pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { self.get_opt_response_json(&format!("/tx/{txid}")).await } - /// Get the spend status of a [`Transaction`]'s outputs, given its [`Txid`]. + /// Get the spend status of all outputs in a [`Transaction`], given its [`Txid`]. + /// + /// Returns a [`Vec`] of [`OutputStatus`], one per output, ordered as they + /// appear in the [`Transaction`]. pub async fn get_tx_outspends(&self, txid: &Txid) -> Result, Error> { self.get_response_json(&format!("/tx/{txid}/outspends")) .await } - /// Get a [`BlockHeader`] given a particular block hash. + /// Get the [`BlockHeader`] of a [`Block`] given its [`BlockHash`]. pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { self.get_response_hex(&format!("/block/{block_hash}/header")) .await } - /// Get the [`BlockStatus`] given a particular [`BlockHash`]. + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. + /// + /// Returns a [`BlockStatus`] indicating whether this [`Block`] is part of + /// the best chain, its height, and the [`BlockHash`] of the next [`Block`], + /// if any. pub async fn get_block_status(&self, block_hash: &BlockHash) -> Result { self.get_response_json(&format!("/block/{block_hash}/status")) .await } - /// Get a [`Block`] given a particular [`BlockHash`]. + /// Get the full [`Block`] with the given [`BlockHash`]. + /// + /// Returns `None` if the [`Block`] is not found. pub async fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { self.get_opt_response(&format!("/block/{block_hash}/raw")) .await } - /// Get a merkle inclusion proof for a [`Transaction`] with the given - /// [`Txid`]. + /// Get a Merkle inclusion proof for a [`Transaction`] given its [`Txid`]. + /// + /// Returns a [`MerkleProof`] that can be used to verify the transaction's + /// inclusion in a block. Returns `None` if the transaction is not found or + /// is unconfirmed. pub async fn get_merkle_proof(&self, tx_hash: &Txid) -> Result, Error> { self.get_opt_response_json(&format!("/tx/{tx_hash}/merkle-proof")) .await } - /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the - /// given [`Txid`]. + /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] given its [`Txid`]. + /// + /// Returns `None` if the transaction is not found or is unconfirmed. pub async fn get_merkle_block(&self, tx_hash: &Txid) -> Result, Error> { self.get_opt_response_hex(&format!("/tx/{tx_hash}/merkleblock-proof")) .await } - /// Get the spending status of an output given a [`Txid`] and the output - /// index. + /// Get the spend status of a specific output, identified by its [`Txid`] + /// and output index. + /// + /// Returns an [`OutputStatus`] indicating whether the output has been + /// spent, and if so, by which transaction. Returns `None` if not found. pub async fn get_output_status( &self, txid: &Txid, @@ -380,7 +457,14 @@ impl AsyncClient { .await } - /// Broadcast a [`Transaction`] to Esplora + /// Broadcast a [`Transaction`] to the Esplora server. + /// + /// The transaction is serialized and sent as a hex-encoded string. + /// Returns the [`Txid`] of the broadcasted transaction. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the transaction. pub async fn broadcast(&self, transaction: &Transaction) -> Result { let body = serialize::(transaction).to_lower_hex_string(); let response = self.post_request_bytes("/tx", body, None).await?; @@ -388,14 +472,17 @@ impl AsyncClient { Ok(txid) } - /// Broadcast a package of [`Transaction`]s to Esplora. + /// Broadcast a package of [`Transaction`]s to the Esplora server. + /// + /// Returns a [`SubmitPackageResult`] containing the result for each + /// transaction in the package, keyed by [`bitcoin::Wtxid`]. /// - /// If `maxfeerate` is provided, any transaction whose - /// fee is higher will be rejected. + /// Optionally, `maxfeerate` and `maxburnamount` can be provided to reject + /// transactions that exceed these thresholds. /// - /// If `maxburnamount` is provided, any transaction - /// with higher provably unspendable outputs amount - /// will be rejected. + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the package. pub async fn submit_package( &self, transactions: &[Transaction], @@ -431,7 +518,7 @@ impl AsyncClient { Ok(result) } - /// Get the current height of the blockchain tip + /// Get the block height of the current blockchain tip. pub async fn get_height(&self) -> Result { self.get_response_text("/blocks/tip/height") .await @@ -445,31 +532,38 @@ impl AsyncClient { .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } - /// Get the [`BlockHash`] of a specific block height + /// Get the [`BlockHash`] of a [`Block`] given its height. pub async fn get_block_hash(&self, block_height: u32) -> Result { self.get_response_text(&format!("/block-height/{block_height}")) .await .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } - /// Get information about a specific address, includes confirmed balance and transactions in - /// the mempool. + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool transaction + /// summaries for the given address, including funded and spent output + /// counts and their total values. pub async fn get_address_stats(&self, address: &Address) -> Result { let path = format!("/address/{address}"); self.get_response_json(&path).await } - /// Get statistics about a particular [`Script`] hash's confirmed and mempool transactions. + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing transaction summaries for the + /// SHA256 hash of the given [`Script`]. pub async fn get_scripthash_stats(&self, script: &Script) -> Result { let script_hash = sha256::Hash::hash(script.as_bytes()); let path = format!("/scripthash/{script_hash}"); self.get_response_json(&path).await } - /// Get transaction history for the specified address, sorted with newest first. + /// Get confirmed transaction history for an [`Address`], sorted newest first. /// /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. - /// More can be requested by specifying the last txid seen by the previous query. + /// To paginate, pass the [`Txid`] of the last transaction seen in the + /// previous response as `last_seen`. pub async fn get_address_txs( &self, address: &Address, @@ -483,7 +577,7 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. + /// Get unconfirmed mempool [`EsploraTx`]s for an [`Address`], sorted newest first. pub async fn get_mempool_address_txs( &self, address: &Address, @@ -493,10 +587,10 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get transaction history for the specified address/scripthash, - /// sorted with newest first. Returns 25 transactions per page. - /// More can be requested by specifying the last txid seen by the previous - /// query. + /// Get confirmed transaction history for a [`Script`] hash, sorted newest first. + /// + /// Returns 25 transactions per page. To paginate, pass the [`Txid`] of the + /// last transaction seen in the previous response as `last_seen`. pub async fn get_scripthash_txs( &self, script: &Script, @@ -511,8 +605,7 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get mempool [`Transaction`] history for the - /// specified [`Script`] hash, sorted with newest first. + /// Get unconfirmed mempool [`EsploraTx`]s for a [`Script`] hash, sorted newest first. pub async fn get_mempool_scripthash_txs( &self, script: &Script, @@ -523,12 +616,15 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get statistics about the mempool. + /// Get global statistics about the mempool. + /// + /// Returns a [`MempoolStats`] containing the transaction count, total + /// virtual size, total fees, and fee rate histogram. pub async fn get_mempool_stats(&self) -> Result { self.get_response_json("/mempool").await } - /// Get a list of the last 10 [`Transaction`]s to enter the mempool. + /// Get the last 10 [`MempoolRecentTx`]s to enter the mempool. pub async fn get_mempool_recent_txs(&self) -> Result, Error> { self.get_response_json("/mempool/recent").await } @@ -551,25 +647,33 @@ impl AsyncClient { Ok(estimates) } - /// Get a summary about a [`Block`], given its [`BlockHash`]. + /// Get a [`BlockInfo`] summary for the [`Block`] with the given [`BlockHash`]. + /// + /// [`BlockInfo`] includes metadata such as the height, timestamp, + /// [`Transaction`] count, size, and [`Weight`](bitcoin::Weight). + /// + /// This method does not return the full [`Block`]. pub async fn get_block_info(&self, blockhash: &BlockHash) -> Result { let path = format!("/block/{blockhash}"); self.get_response_json(&path).await } - /// Get all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. + /// Get all [`Txid`]s of [`Transaction`]s included in the [`Block`] with the + /// given [`BlockHash`]. pub async fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { let path = format!("/block/{blockhash}/txids"); self.get_response_json(&path).await } - /// Get up to 25 [`Transaction`]s from a [`Block`], given its [`BlockHash`], - /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// Get up to 25 [`EsploraTx`]s from the [`Block`] with the given + /// [`BlockHash`], starting at `start_index`. + /// + /// If `start_index` is `None`, starts from the first transaction (index 0). /// - /// The `start_index` value MUST be a multiple of 25, - /// else an error will be returned by Esplora. + /// Note that `start_index` must be a multiple of 25, otherwise the server + /// will return an error. pub async fn get_block_txs( &self, blockhash: &BlockHash, @@ -583,11 +687,13 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. + /// Get [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. /// /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. #[allow(deprecated)] #[deprecated(since = "0.13.0", note = "use `get_block_infos` instead")] pub async fn get_blocks(&self, height: Option) -> Result, Error> { @@ -602,11 +708,19 @@ impl AsyncClient { Ok(blocks) } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. + /// Get [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. /// /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidResponse`] if the server returns an empty list. + /// + /// This method does not return the full [`Block`]. pub async fn get_block_infos(&self, height: Option) -> Result, Error> { let path = match height { Some(height) => format!("/blocks/{height}"), @@ -619,14 +733,14 @@ impl AsyncClient { Ok(blocks) } - /// Get all UTXOs locked to an address. + /// Get all confirmed [`Utxo`]s locked to the given [`Address`]. pub async fn get_address_utxos(&self, address: &Address) -> Result, Error> { let path = format!("/address/{address}/utxo"); self.get_response_json(&path).await } - /// Get all [`Utxo`]s locked to a [`Script`]. + /// Get all confirmed [`Utxo`]s locked to the given [`Script`]. pub async fn get_scripthash_utxos(&self, script: &Script) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); let path = format!("/scripthash/{script_hash}/utxo"); @@ -635,15 +749,24 @@ impl AsyncClient { } } -/// Sleeper trait that allows any async runtime to be used. +/// A trait for abstracting over async sleep implementations. +/// +/// [`AsyncClient`] uses this trait to wait between retry attempts without +/// committing the client type to a specific async runtime. +/// +/// The only provided implementation is [`DefaultSleeper`], which is backed by Tokio. +/// Custom implementations can be provided to support other runtimes. pub trait Sleeper: 'static { - /// The `Future` type returned by the sleep function. + /// The [`Future`](std::future::Future) type returned by [`Sleeper::sleep`]. type Sleep: std::future::Future; - /// Create a `Future` that completes after the specified [`Duration`]. + /// Return a [`Future`](std::future::Future) that completes after `duration`. fn sleep(dur: Duration) -> Self::Sleep; } -/// The default `Sleeper` implementation using the underlying async runtime. +/// The default [`Sleeper`] implementation, backed by [`tokio::time::sleep`]. +/// +/// This type is available when the `tokio` feature is enabled or while running +/// tests. #[derive(Debug, Clone, Copy)] pub struct DefaultSleeper; diff --git a/src/blocking.rs b/src/blocking.rs index 391981a..63f19cb 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -1,6 +1,31 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Esplora by way of `minreq` HTTP client. +//! # Blocking Esplora Client +//! +//! This module implements [`BlockingClient`], a synchronous HTTP client for +//! interacting with an [Esplora] server by way of [`bitreq`]. +//! +//! Use this client from synchronous applications, command-line tools, tests, +//! or code paths where blocking the current thread is acceptable. Each method +//! sends the request immediately and returns only after the response body has +//! been read and decoded. +//! +//! The client is configured through [`Builder`], including the +//! base URL, proxy, socket timeout, custom headers, and retry count. +//! +//! # Example +//! +//! ```rust,no_run +//! # fn example() -> Result<(), esplora_client::Error> { +//! +//! let client = esplora_client::Builder::new("https://mempool.space/api").build_blocking(); +//! let height = client.get_height()?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; @@ -25,23 +50,43 @@ use crate::{ #[allow(deprecated)] use crate::BlockSummary; -/// A blocking client for interacting with an Esplora API server. +/// A synchronous client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. The client stores +/// the server base URL and request configuration, then exposes convenience +/// methods for the transaction, block, address, scripthash, fee-estimate, and +/// mempool endpoints. +/// +/// Each method blocks the current thread until the HTTP request completes and +/// the response is parsed into the requested type. +/// +/// # Retries +/// +/// Failed requests are automatically retried up to `max_retries` times +/// (configured via [`Builder`]) with exponential backoff, but only for +/// retryable HTTP status codes. See [`crate::RETRYABLE_ERROR_CODES`] for the +/// full list. #[derive(Debug, Clone)] pub struct BlockingClient { /// The URL of the Esplora server. url: String, - /// The proxy is ignored when targeting `wasm32`. + /// The URL of the proxy host. + /// + /// NOTE: The proxy is ignored when targeting `wasm32`. pub proxy: Option, - /// Socket timeout. + /// Per-request socket timeout, in seconds. pub timeout: Option, - /// HTTP headers to set on every request made to Esplora server + /// HTTP headers to set on every request made to the Esplora server. pub headers: HashMap, - /// Number of times to retry a request + /// Maximum number of retry attempts for retryable responses. pub max_retries: usize, } impl BlockingClient { - /// Build a blocking client from a [`Builder`] + /// Build a [`BlockingClient`] from a [`Builder`]. + /// + /// This consumes the builder configuration and stores it on the client. + /// No network request is made until a client method is called. pub fn from_builder(builder: Builder) -> Self { Self { url: builder.base_url, @@ -52,12 +97,17 @@ impl BlockingClient { } } - /// Get the underlying base URL. + /// Return the base URL of the Esplora server this client connects to. + /// + /// The returned value is the exact string provided to [`Builder::new`]. pub fn url(&self) -> &str { &self.url } /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + /// + /// Configures the request with the proxy, timeout, and headers set on + /// this client. Used internally by all other request helper methods. pub(crate) fn build_request(&self, method: Method, path: &str) -> Result { let mut request = Request::new(method, format!("{}{}", self.url, path)); @@ -76,8 +126,10 @@ impl BlockingClient { Ok(request) } - /// Make an HTTP POST request to given URL, converting any `T` that - /// implement [`Into`] and setting query parameters, if any. + /// Make an HTTP POST request to `path` with `body`. + /// + /// Configures query parameters, if any, and returns the raw response after + /// checking the HTTP status code. /// /// # Errors /// @@ -106,8 +158,8 @@ impl BlockingClient { Ok(response) } - /// Makes a HTTP GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. + /// Sends a GET request to `url`, retrying on retryable status codes + /// with exponential backoff until [`BlockingClient::max_retries`] is reached. fn get_with_retry(&self, url: &str) -> Result { let mut delay = BASE_BACKOFF_MILLIS; let mut attempts = 0; @@ -124,17 +176,14 @@ impl BlockingClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implement [`bitcoin::consensus::Decodable`]. + /// Makes a GET request to `path`, deserializing the response body as + /// raw bytes into `T` using [`bitcoin::consensus::Decodable`]. /// - /// It should be used when requesting Esplora endpoints that can be directly - /// deserialized to native `rust-bitcoin` types, which implements - /// [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// Use this for endpoints that return raw binary Bitcoin data. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`bitcoin::consensus::Decodable`] deserialization. + /// Returns an [`Error`] if the request fails or deserialization fails. fn get_response(&self, path: &str) -> Result { let response = self.get_with_retry(path)?; @@ -147,11 +196,9 @@ impl BlockingClient { Ok(deserialize::(response.as_bytes())?) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// It uses [`BlockingClient::get_response`] internally. - /// - /// See [`BlockingClient::get_response`] above for full documentation. + /// Delegates to [`Self::get_response`]. See its documentation for details. fn get_opt_response(&self, path: &str) -> Result, Error> { match self.get_response(path) { Ok(response) => Ok(Some(response)), @@ -160,17 +207,15 @@ impl BlockingClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implements [`bitcoin::consensus::Decodable`]. + /// Makes a GET request to `path`, deserializing the hex-encoded response + /// body into `T` using [`bitcoin::consensus::Decodable`]. /// - /// It should be used when requesting Esplora endpoints that are expected - /// to return a hex string decodable to native `rust-bitcoin` types which - /// implement [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// Use this for endpoints that return hex-encoded Bitcoin data. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`bitcoin::consensus::Decodable`] deserialization. + /// Returns an [`Error`] if the request fails, hex decoding fails, + /// or consensus deserialization fails. fn get_response_hex(&self, path: &str) -> Result { let response = self.get_with_retry(path)?; @@ -184,12 +229,9 @@ impl BlockingClient { deserialize(&Vec::from_hex(hex_str)?).map_err(Error::BitcoinEncoding) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. - /// - /// It uses [`BlockingClient::get_response_hex`] internally. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// See [`BlockingClient::get_response_hex`] above for full - /// documentation. + /// Delegates to [`Self::get_response_hex`]. See its documentation for details. fn get_opt_response_hex(&self, path: &str) -> Result, Error> { match self.get_response_hex(path) { Ok(res) => Ok(Some(res)), @@ -198,16 +240,15 @@ impl BlockingClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implements [`serde::de::DeserializeOwned`]. + /// Makes a GET request to `path`, deserializing the response body as JSON + /// into `T` using [`serde::de::DeserializeOwned`]. /// - /// It should be used when requesting Esplora endpoints that have a specific - /// defined API, mostly defined in [`crate::api`]. + /// Use this for endpoints that return Esplora-specific JSON types, as + /// defined in [`crate::api`]. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// [`serde::de::DeserializeOwned`] deserialization. + /// Returns an [`Error`] if the request fails or JSON deserialization fails. fn get_response_json<'a, T: serde::de::DeserializeOwned>( &'a self, path: &'a str, @@ -223,12 +264,9 @@ impl BlockingClient { response.json::().map_err(Error::BitReq) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. - /// - /// It uses [`BlockingClient::get_response_json`] internally. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// See [`BlockingClient::get_response_json`] above for full - /// documentation. + /// Delegates to [`Self::get_response_json`]. See its documentation for details. fn get_opt_response_json( &self, path: &str, @@ -240,14 +278,14 @@ impl BlockingClient { } } - /// Make an HTTP GET request to given URL, deserializing to `String`. + /// Makes a GET request to `path`, returning the response body as a [`String`]. /// - /// It should be used when requesting Esplora endpoints that can return - /// `String` formatted data that can be parsed downstream. + /// Use this for endpoints that return plain text data that needs further + /// parsing downstream. /// /// # Errors /// - /// This function will return an error either from the HTTP client. + /// Returns an [`Error`] if the request fails. fn get_response_text(&self, path: &str) -> Result { let response = self.get_with_retry(path)?; @@ -260,12 +298,9 @@ impl BlockingClient { Ok(response.as_str()?.to_string()) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// It uses [`BlockingClient::get_response_text`] internally. - /// - /// See [`BlockingClient::get_response_text`] above for full - /// documentation. + /// Delegates to [`Self::get_response_text`]. See its documentation for details. fn get_opt_response_text(&self, path: &str) -> Result, Error> { match self.get_response_text(path) { Ok(s) => Ok(Some(s)), @@ -274,12 +309,17 @@ impl BlockingClient { } } - /// Get a [`Transaction`] option given its [`Txid`] + /// Get a raw [`Transaction`] given its [`Txid`]. + /// + /// Returns `None` if the transaction is not found. pub fn get_tx(&self, txid: &Txid) -> Result, Error> { self.get_opt_response(&format!("/tx/{txid}/raw")) } - /// Get a [`Transaction`] given its [`Txid`]. + /// Get a raw [`Transaction`] given its [`Txid`]. + /// + /// Returns an [`Error::TransactionNotFound`] if the transaction is not found. + /// Prefer [`Self::get_tx`] if you want to handle the not-found case explicitly. pub fn get_tx_no_opt(&self, txid: &Txid) -> Result { match self.get_tx(txid) { Ok(Some(tx)) => Ok(tx), @@ -288,8 +328,10 @@ impl BlockingClient { } } - /// Get a [`Txid`] of a transaction given its index in a block with a given - /// hash. + /// Get the [`Txid`] of the transaction at position `index` within the + /// block identified by `block_hash`. + /// + /// Returns `None` if the block or index is not found. pub fn get_txid_at_block_index( &self, block_hash: &BlockHash, @@ -301,50 +343,73 @@ impl BlockingClient { } } - /// Get the status of a [`Transaction`] given its [`Txid`]. + /// Get the confirmation status of a [`Transaction`] given its [`Txid`]. + /// + /// Returns a [`TxStatus`] containing whether the transaction is confirmed, + /// and if so, the block height, hash, and timestamp it was confirmed in. pub fn get_tx_status(&self, txid: &Txid) -> Result { self.get_response_json(&format!("/tx/{txid}/status")) } - /// Get transaction info given its [`Txid`]. + /// Get an [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], returns the Esplora-specific [`EsploraTx`] + /// type, which includes additional metadata such as confirmation status, + /// fee, and weight. Returns `None` if the transaction is not found. pub fn get_tx_info(&self, txid: &Txid) -> Result, Error> { self.get_opt_response_json(&format!("/tx/{txid}")) } - /// Get the spend status of a [`Transaction`]'s outputs, given its [`Txid`]. + /// Get the spend status of all outputs in a [`Transaction`], given its [`Txid`]. + /// + /// Returns a [`Vec`] of [`OutputStatus`], one per output, ordered as they + /// appear in the [`Transaction`]. pub fn get_tx_outspends(&self, txid: &Txid) -> Result, Error> { self.get_response_json(&format!("/tx/{txid}/outspends")) } - /// Get a [`BlockHeader`] given a particular [`BlockHash`]. + /// Get the [`BlockHeader`] of a [`Block`] given its [`BlockHash`]. pub fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { self.get_response_hex(&format!("/block/{block_hash}/header")) } - /// Get the [`BlockStatus`] given a particular [`BlockHash`]. + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. + /// + /// Returns a [`BlockStatus`] indicating whether this [`Block`] is part of + /// the best chain, its height, and the [`BlockHash`] of the next [`Block`], + /// if any. pub fn get_block_status(&self, block_hash: &BlockHash) -> Result { self.get_response_json(&format!("/block/{block_hash}/status")) } - /// Get a [`Block`] given a particular [`BlockHash`]. + /// Get the full [`Block`] with the given [`BlockHash`]. + /// + /// Returns `None` if the [`Block`] is not found. pub fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { self.get_opt_response(&format!("/block/{block_hash}/raw")) } - /// Get a merkle inclusion proof for a [`Transaction`] with the given - /// [`Txid`]. + /// Get a Merkle inclusion proof for a [`Transaction`] given its [`Txid`]. + /// + /// Returns a [`MerkleProof`] that can be used to verify the transaction's + /// inclusion in a block. Returns `None` if the transaction is not found or + /// is unconfirmed. pub fn get_merkle_proof(&self, txid: &Txid) -> Result, Error> { self.get_opt_response_json(&format!("/tx/{txid}/merkle-proof")) } - /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the - /// given [`Txid`]. + /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] given its [`Txid`]. + /// + /// Returns `None` if the transaction is not found or is unconfirmed. pub fn get_merkle_block(&self, txid: &Txid) -> Result, Error> { self.get_opt_response_hex(&format!("/tx/{txid}/merkleblock-proof")) } - /// Get the spending status of an output given a [`Txid`] and the output - /// index. + /// Get the spend status of a specific output, identified by its [`Txid`] + /// and output index. + /// + /// Returns an [`OutputStatus`] indicating whether the output has been + /// spent, and if so, by which transaction. Returns `None` if not found. pub fn get_output_status( &self, txid: &Txid, @@ -353,7 +418,14 @@ impl BlockingClient { self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")) } - /// Broadcast a [`Transaction`] to Esplora + /// Broadcast a [`Transaction`] to the Esplora server. + /// + /// The transaction is serialized and sent as a hex-encoded string. + /// Returns the [`Txid`] of the broadcasted transaction. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the transaction. pub fn broadcast(&self, transaction: &Transaction) -> Result { let body = serialize::(transaction).to_lower_hex_string(); @@ -363,14 +435,17 @@ impl BlockingClient { Ok(txid) } - /// Broadcast a package of [`Transaction`]s to Esplora. + /// Broadcast a package of [`Transaction`]s to the Esplora server. + /// + /// Returns a [`SubmitPackageResult`] containing the result for each + /// transaction in the package, keyed by [`bitcoin::Wtxid`]. /// - /// If `maxfeerate` is provided, any transaction whose - /// fee is higher will be rejected. + /// Optionally, `maxfeerate` and `maxburnamount` can be provided to reject + /// transactions that exceed these thresholds. /// - /// If `maxburnamount` is provided, any transaction - /// with higher provably unspendable outputs amount - /// will be rejected. + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the package. pub fn submit_package( &self, transactions: &[Transaction], @@ -404,7 +479,7 @@ impl BlockingClient { Ok(result) } - /// Get the height of the current blockchain tip. + /// Get the block height of the current blockchain tip. pub fn get_height(&self) -> Result { self.get_response_text("/blocks/tip/height") .map(|s| u32::from_str(s.as_str()).map_err(Error::Parsing))? @@ -416,25 +491,28 @@ impl BlockingClient { .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } - /// Get the [`BlockHash`] of a specific block height + /// Get the [`BlockHash`] of a [`Block`] given its height. pub fn get_block_hash(&self, block_height: u32) -> Result { self.get_response_text(&format!("/block-height/{block_height}")) .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } - /// Get statistics about the mempool. + /// Get global statistics about the mempool. + /// + /// Returns a [`MempoolStats`] containing the transaction count, total + /// virtual size, total fees, and fee rate histogram. pub fn get_mempool_stats(&self) -> Result { self.get_response_json("/mempool") } - /// Get a list of the last 10 [`Transaction`]s to enter the mempool. + /// Get the last 10 [`MempoolRecentTx`]s to enter the mempool. pub fn get_mempool_recent_txs(&self) -> Result, Error> { self.get_response_json("/mempool/recent") } - /// Get the full list of [`Txid`]s in the mempool. + /// Get the full list of [`Txid`]s currently in the mempool. /// - /// The order of the txids is arbitrary and does not match bitcoind's. + /// The order of the returned [`Txid`]s is arbitrary. pub fn get_mempool_txids(&self) -> Result, Error> { self.get_response_json("/mempool/txids") } @@ -450,25 +528,31 @@ impl BlockingClient { Ok(estimates) } - /// Get information about a specific address, includes confirmed balance and transactions in - /// the mempool. + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool transaction + /// summaries for the given address, including funded and spent output + /// counts and their total values. pub fn get_address_stats(&self, address: &Address) -> Result { let path = format!("/address/{address}"); self.get_response_json(&path) } - /// Get statistics about a particular [`Script`] hash's confirmed and mempool transactions. + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing transaction summaries for the + /// SHA256 hash of the given [`Script`]. pub fn get_scripthash_stats(&self, script: &Script) -> Result { let script_hash = sha256::Hash::hash(script.as_bytes()); let path = format!("/scripthash/{script_hash}"); self.get_response_json(&path) } - /// Get transaction history for the specified address, sorted with newest - /// first. + /// Get confirmed transaction history for an [`Address`], sorted newest first. /// /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. - /// More can be requested by specifying the last txid seen by the previous query. + /// To paginate, pass the [`Txid`] of the last transaction seen in the + /// previous response as `last_seen`. pub fn get_address_txs( &self, address: &Address, @@ -482,17 +566,17 @@ impl BlockingClient { self.get_response_json(&path) } - /// Get mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. + /// Get unconfirmed mempool [`EsploraTx`]s for an [`Address`], sorted newest first. pub fn get_mempool_address_txs(&self, address: &Address) -> Result, Error> { let path = format!("/address/{address}/txs/mempool"); self.get_response_json(&path) } - /// Get transaction history for the specified scripthash, - /// sorted with newest first. Returns 25 transactions per page. - /// More can be requested by specifying the last txid seen by the previous - /// query. + /// Get confirmed transaction history for a [`Script`] hash, sorted newest first. + /// + /// Returns 25 transactions per page. To paginate, pass the [`Txid`] of the + /// last transaction seen in the previous response as `last_seen`. pub fn get_scripthash_txs( &self, script: &Script, @@ -506,8 +590,7 @@ impl BlockingClient { self.get_response_json(&path) } - /// Get mempool [`Transaction`] history for the - /// specified [`Script`] hash, sorted with newest first. + /// Get unconfirmed mempool [`EsploraTx`]s for a [`Script`] hash, sorted newest first. pub fn get_mempool_scripthash_txs(&self, script: &Script) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); let path = format!("/scripthash/{script_hash:x}/txs/mempool"); @@ -515,25 +598,33 @@ impl BlockingClient { self.get_response_json(&path) } - /// Get a summary about a [`Block`], given its [`BlockHash`]. + /// Get a [`BlockInfo`] summary for the [`Block`] with the given [`BlockHash`]. + /// + /// [`BlockInfo`] includes metadata such as the height, timestamp, + /// [`Transaction`] count, size, and [`Weight`](bitcoin::Weight). + /// + /// This method does not return the full [`Block`]. pub fn get_block_info(&self, blockhash: &BlockHash) -> Result { let path = format!("/block/{blockhash}"); self.get_response_json(&path) } - /// Get all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. + /// Get all [`Txid`]s of [`Transaction`]s included in the [`Block`] with the + /// given [`BlockHash`]. pub fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { let path = format!("/block/{blockhash}/txids"); self.get_response_json(&path) } - /// Get up to 25 [`Transaction`]s from a [`Block`], given its [`BlockHash`], - /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// Get up to 25 [`EsploraTx`]s from the [`Block`] with the given + /// [`BlockHash`], starting at `start_index`. /// - /// The `start_index` value MUST be a multiple of 25, - /// else an error will be returned by Esplora. + /// If `start_index` is `None`, starts from the first transaction (index 0). + /// + /// Note that `start_index` must be a multiple of 25, otherwise the server + /// will return an error. pub fn get_block_txs( &self, blockhash: &BlockHash, @@ -547,11 +638,13 @@ impl BlockingClient { self.get_response_json(&path) } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. + /// Get [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. /// /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. #[allow(deprecated)] #[deprecated(since = "0.13.0", note = "use `get_block_infos` instead")] pub fn get_blocks(&self, height: Option) -> Result, Error> { @@ -566,11 +659,19 @@ impl BlockingClient { Ok(blocks) } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. + /// Get [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. /// /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidResponse`] if the server returns an empty list. + /// + /// This method does not return the full [`Block`]. pub fn get_block_infos(&self, height: Option) -> Result, Error> { let path = match height { Some(height) => format!("/blocks/{height}"), @@ -583,14 +684,14 @@ impl BlockingClient { Ok(blocks) } - /// Get all UTXOs locked to an address. + /// Get all confirmed [`Utxo`]s locked to the given [`Address`]. pub fn get_address_utxos(&self, address: &Address) -> Result, Error> { let path = format!("/address/{address}/utxo"); self.get_response_json(&path) } - /// Get all [`Utxo`]s locked to a [`Script`]. + /// Get all confirmed [`Utxo`]s locked to the given [`Script`]. pub fn get_scripthash_utxos(&self, script: &Script) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); let path = format!("/scripthash/{script_hash}/utxo"); diff --git a/src/lib.rs b/src/lib.rs index 41d03e3..5926a35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,50 +1,68 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! An extensible blocking/async Esplora client +//! # Esplora Client //! -//! This library provides an extensible blocking and -//! async Esplora client to query Esplora's backend. +//! A client library for querying [Esplora] HTTP APIs from Rust. //! -//! The library provides the possibility to build a blocking -//! or async client, both using [`bitreq`]. -//! The library supports communicating to Esplora via a proxy -//! and also using TLS (SSL) for secure communication. +//! The crate exposes a shared set of Esplora response types in [`api`], plus +//! optional blocking and async clients built on [`bitreq`]. Both clients use the +//! same [`Builder`] configuration for the base URL, proxy, timeout, custom +//! headers, and retry policy. //! +//! # Client Modes //! -//! ## Usage +//! Enable the `blocking` feature to use `BlockingClient`, whose methods block +//! the current thread until each request completes. Enable the `async` feature +//! to use `AsyncClient`, whose methods return futures and require an async +//! runtime. The default async sleeper is backed by Tokio when the `tokio` +//! feature is enabled; custom runtimes can supply their own `Sleeper`. //! -//! You can create a blocking client as follows: +//! # Examples //! -//! ```no_run -//! # #[cfg(feature = "blocking")] -//! # { +//! Create a blocking client: +//! +//! ```rust,ignore //! use esplora_client::Builder; -//! let builder = Builder::new("https://blockstream.info/testnet/api"); -//! let blocking_client = builder.build_blocking(); -//! # Ok::<(), esplora_client::Error>(()); -//! # } +//! +//! fn main() -> Result<(), esplora_client::Error> { +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let blocking_client = builder.build_blocking(); +//! let height = blocking_client.get_height()?; +//! +//! Ok(()) +//! } //! ``` //! -//! Here is an example of how to create an asynchronous client. +//! Create an async client: //! -//! ```no_run -//! # #[cfg(all(feature = "async", feature = "tokio"))] -//! # { +//! ```rust,ignore //! use esplora_client::Builder; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), esplora_client::Error> { //! let builder = Builder::new("https://blockstream.info/testnet/api"); -//! let async_client = builder.build_async(); -//! # Ok::<(), esplora_client::Error>(()); -//! # } +//! let async_client = builder.build_async()?; +//! let height = async_client.get_height().await?; +//! +//! Ok(()) +//! } //! ``` //! -//! ## Features +//! # Retries //! -//! By default the library enables all features. To specify -//! specific features, set `default-features` to `false` in your `Cargo.toml` -//! and specify the features you want. This will look like this: +//! Both clients retry responses with status codes listed in +//! [`RETRYABLE_ERROR_CODES`]. Retry attempts use exponential backoff starting +//! at 256 milliseconds and are controlled by [`Builder::max_retries`]. //! -//! `esplora-client = { version = "*", default-features = false, features = -//! ["blocking"] }` +//! # Features +//! +//! By default the crate enables all features. To select only the pieces you +//! need, set `default-features = false` in `Cargo.toml` and list the desired +//! features explicitly: +//! +//! ```toml +//! esplora-client = { version = "*", default-features = false, features = ["blocking"] } +//! ``` //! //! * `blocking` enables [`bitreq`], the blocking client with proxy. //! * `blocking-https` enables [`bitreq`], the blocking client with proxy and TLS (SSL) capabilities @@ -53,8 +71,8 @@ //! capabilities using the `rustls` backend. //! * `blocking-https-native` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using the platform's native TLS backend (likely OpenSSL). -//! * `blocking-https-bundled` enables [`bitreq`], the blocking client with proxy and TLS (SSL) -//! capabilities using a bundled OpenSSL library backend. +//! * `blocking-https-rustls-probe` enables [`bitreq`], the blocking client with proxy and TLS (SSL) +//! capabilities using `rustls` and probed system roots. //! * `async` enables [`bitreq`], the async client with proxy capabilities. //! * `async-https` enables [`bitreq`], the async client with support for proxying and TLS (SSL) //! using the default [`bitreq`] TLS backend. @@ -62,12 +80,12 @@ //! (SSL) using the platform's native TLS backend (likely OpenSSL). //! * `async-https-rustls` enables [`bitreq`], the async client with support for proxying and TLS //! (SSL) using the `rustls` TLS backend. -//! * `async-https-rustls-manual-roots` enables [`bitreq`], the async client with support for -//! proxying and TLS (SSL) using the `rustls` TLS backend without using the default root -//! certificates. +//! * `async-https-rustls-probe` enables [`bitreq`], the async client with support for proxying and +//! TLS (SSL) using `rustls` and probed system roots. +//! * `tokio` enables the default async sleeper used by [`Builder::build_async`]. //! -//! [`dont remove this line or cargo doc will break`]: https://example.com -#![cfg_attr(not(feature = "bitreq"), doc = "[`bitreq`]: https://docs.rs/bitreq")] +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md +//! [`bitreq`]: https://docs.rs/bitreq #![allow(clippy::result_large_err)] #![warn(missing_docs)] #![allow(deprecated)] @@ -95,65 +113,65 @@ pub use blocking::BlockingClient; #[cfg(feature = "async")] pub use r#async::AsyncClient; -/// Response status codes for which the request may be retried. +/// HTTP response status codes for which a request may be retried. pub const RETRYABLE_ERROR_CODES: [u16; 3] = [ 429, // TOO_MANY_REQUESTS 500, // INTERNAL_SERVER_ERROR 503, // SERVICE_UNAVAILABLE ]; -/// Base backoff in milliseconds. +/// Base delay used by the exponential retry backoff. #[cfg(any(feature = "blocking", feature = "async"))] const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); -/// Default max retries. +/// Default maximum number of retry attempts per request. const DEFAULT_MAX_RETRIES: usize = 6; -/// Default max cached connections +/// Default maximum number of cached connections for the async client. #[cfg(feature = "async")] const DEFAULT_MAX_CONNECTIONS: usize = 10; -/// Check if [`Response`] status is within 100-199. +/// Check if the [`Response`] status code is informational (100-199). #[allow(unused)] #[cfg(any(feature = "blocking", feature = "async"))] fn is_informational(response: &Response) -> bool { (100..200).contains(&response.status_code) } -/// Check if [`Response`] status is within 200-299. +/// Check if the [`Response`] status code is successful (200-299). #[cfg(any(feature = "blocking", feature = "async"))] fn is_success(response: &Response) -> bool { (200..300).contains(&response.status_code) } -/// Check if [`Response`] status is within 300-399. +/// Check if the [`Response`] status code is a redirection (300-399). #[allow(unused)] #[cfg(any(feature = "blocking", feature = "async"))] fn is_redirection(response: &Response) -> bool { (300..400).contains(&response.status_code) } -/// Check if [`Response`] status is within 400-499. +/// Check if the [`Response`] status code is a client error (400-499). #[allow(unused)] #[cfg(any(feature = "blocking", feature = "async"))] fn is_client_error(response: &Response) -> bool { (400..500).contains(&response.status_code) } -/// Check if [`Response`] status is within 500-599. +/// Check if the [`Response`] status code is a server error (500-599). #[allow(unused)] #[cfg(any(feature = "blocking", feature = "async"))] fn is_server_error(response: &Response) -> bool { (500..600).contains(&response.status_code) } -/// Check if [`Response`] status is within the retryable ones. +/// Check if the [`Response`] status code is retryable (429, 500, 503). #[cfg(any(feature = "blocking", feature = "async"))] fn is_retryable(response: &Response) -> bool { RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16)) } -/// Returns the [`FeeRate`] for the given confirmation target in blocks. +/// Return the [`FeeRate`] for the given confirmation target in blocks. /// /// Selects the highest confirmation target from `estimates` that is at or /// below `target_blocks`, and returns its [`FeeRate`]. Returns `None` if no @@ -166,7 +184,7 @@ pub fn convert_fee_rate(target_blocks: usize, estimates: HashMap) .map(|(_, feerate)| feerate) } -/// Converts a [`HashMap`] of fee estimates expressed as sat/vB ([`f64`]) into a [`FeeRate`]. +/// Convert a [`HashMap`] of fee estimates expressed as sat/vB ([`f64`]) into [`FeeRate`]s. pub fn sat_per_vbyte_to_feerate(estimates: HashMap) -> HashMap { estimates .into_iter() @@ -174,37 +192,58 @@ pub fn sat_per_vbyte_to_feerate(estimates: HashMap) -> HashMap://:@host:`. /// /// Note that the format of this value and the supported protocols change - /// slightly between the blocking version of the client (using `minreq`) - /// and the async version (using `bitreq`). For more details check with - /// the documentation of the two crates. Both of them are compiled with - /// the `socks` feature enabled. + /// slightly by target and enabled transport features. See [bitreq]'s + /// proxy documentation for the accepted schemes. /// /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, - /// Socket timeout. + /// Per-request socket timeout, in seconds. pub timeout: Option, - /// HTTP headers to set on every request made to Esplora server. + /// HTTP headers to set on every request made to the Esplora server. pub headers: HashMap, - /// Max retries + /// Maximum number of retry attempts for retryable HTTP responses. pub max_retries: usize, - /// The maximum number of cached connections. + /// Maximum number of cached connections for the async client. #[cfg(feature = "async")] pub max_connections: usize, } impl Builder { - /// Instantiate a new builder + /// Create a [`Builder`] for an Esplora server base URL. + /// + /// The URL is stored exactly as provided and request paths are appended to + /// it. Do not include a trailing slash unless your server expects one. pub fn new(base_url: &str) -> Self { Builder { base_url: base_url.to_string(), @@ -217,94 +256,110 @@ impl Builder { } } - /// Set the proxy of the builder + /// Set the proxy URL used for requests. + /// + /// The proxy is ignored when targeting `wasm32`. pub fn proxy(mut self, proxy: &str) -> Self { self.proxy = Some(proxy.to_string()); self } - /// Set the timeout of the builder + /// Set the per-request socket timeout, in seconds. pub fn timeout(mut self, timeout: u64) -> Self { self.timeout = Some(timeout); self } - /// Add a header to set on each request + /// Add or replace an HTTP header sent with every request. pub fn header(mut self, key: &str, value: &str) -> Self { self.headers.insert(key.to_string(), value.to_string()); self } - /// Set the maximum number of times to retry a request if the response status - /// is one of [`RETRYABLE_ERROR_CODES`]. + /// Set the maximum number of retry attempts for retryable responses. + /// + /// Only responses whose status code is listed in + /// [`RETRYABLE_ERROR_CODES`] are retried. pub fn max_retries(mut self, count: usize) -> Self { self.max_retries = count; self } - /// Set the maximum number of cached connections in the client. + /// Set the maximum number of cached connections in the async client. #[cfg(feature = "async")] pub fn max_connections(mut self, count: usize) -> Self { self.max_connections = count; self } - /// Build a blocking client from builder + /// Build a [`BlockingClient`] from this configuration. #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient { BlockingClient::from_builder(self) } - /// Build an asynchronous client from builder + /// Build an [`AsyncClient`] from this configuration. + /// + /// This uses `DefaultSleeper`, which is backed by Tokio. + /// + /// # Errors + /// + /// Returns an [`Error`] if the async HTTP client cannot be constructed. #[cfg(all(feature = "async", feature = "tokio"))] pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } - /// Build an asynchronous client from builder where the returned client uses a - /// user-defined [`Sleeper`]. + /// Build an [`AsyncClient`] with a user-defined [`Sleeper`]. + /// + /// Use this when integrating with an async runtime other than Tokio or when + /// tests need a custom sleep implementation. + /// + /// # Errors + /// + /// Returns an [`Error`] if the async HTTP client cannot be constructed. #[cfg(feature = "async")] pub fn build_async_with_sleeper(self) -> Result, Error> { AsyncClient::from_builder(self) } } -/// Errors that can happen during a request to `Esplora` servers. +/// Errors that can occur while building clients, sending requests, or decoding responses. #[derive(Debug)] pub enum Error { - /// Error during `bitreq` HTTP request + /// Error during a [`bitreq`] HTTP request. #[cfg(any(feature = "blocking", feature = "async"))] BitReq(bitreq::Error), - /// Error during JSON (de)serialization + /// Error during JSON serialization or deserialization. SerdeJson(serde_json::Error), - /// HTTP response error + /// Non-successful HTTP response from the Esplora server. HttpResponse { /// The HTTP status code returned by the server. status: u16, - /// The error message content. + /// The response body returned by the server. message: String, }, - /// Invalid number returned + /// Invalid integer returned by the server. Parsing(std::num::ParseIntError), - /// Invalid status code, unable to convert to `u16` + /// Invalid status code, unable to convert to `u16`. StatusCode(TryFromIntError), - /// Invalid Bitcoin data returned + /// Invalid Bitcoin consensus data returned by the server. BitcoinEncoding(bitcoin::consensus::encode::Error), - /// Invalid hex data returned (attempting to create an array) + /// Invalid fixed-size hex data returned by the server. HexToArray(bitcoin::hex::HexToArrayError), - /// Invalid hex data returned (attempting to create a vector) + /// Invalid variable-length hex data returned by the server. HexToBytes(bitcoin::hex::HexToBytesError), - /// Transaction not found + /// Transaction not found. TransactionNotFound(Txid), - /// Block Header height not found + /// Block header height not found. HeaderHeightNotFound(u32), - /// Block Header hash not found + /// Block header hash not found. HeaderHashNotFound(BlockHash), - /// Invalid HTTP Header name specified + /// Invalid HTTP header name specified in [`Builder::header`]. InvalidHttpHeaderName(String), - /// Invalid HTTP Header value specified + /// Invalid HTTP header value specified in [`Builder::header`]. InvalidHttpHeaderValue(String), - /// The server sent an invalid response + /// The server sent an invalid response. InvalidResponse, }