diff --git a/src/api.rs b/src/api.rs index 78ec784..e06d7c8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,7 @@ // Bitcoin Dev Kit // Written in 2020 by Alekos Filini // -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// Copyright (c) 2020-2026 Bitcoin Dev Kit Developers // // This file is licensed under the Apache License, Version 2.0 or the MIT license @@ -9,33 +9,30 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Structs from the Esplora API +//! Structs and types returned by the Esplora API. //! -//! See: +//! This module defines the data structures used to deserialize responses from +//! an [Esplora](https://github.com/Blockstream/esplora) server. These types +//! are used throughout the [`crate::blocking`] and [`crate::async`] clients. +//! +//! See the [Esplora API documentation](https://github.com/Blockstream/esplora/blob/master/API.md) +//! for the full API reference. use bitcoin::hash_types; use serde::Deserialize; use std::collections::HashMap; pub use bitcoin::consensus::{deserialize, serialize}; -use bitcoin::hash_types::TxMerkleNode; pub use bitcoin::hex::FromHex; pub use bitcoin::{ absolute, block, transaction, Address, Amount, Block, BlockHash, CompactTarget, FeeRate, - OutPoint, Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness, - Wtxid, + OutPoint, Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxMerkleNode, TxOut, Txid, Weight, + Witness, Wtxid, }; -/// Information about a previous output. -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct PrevOut { - /// The value of the previous output, in satoshis. - pub value: u64, - /// The ScriptPubKey that the previous output is locked to, as a [`ScriptBuf`]. - pub scriptpubkey: ScriptBuf, -} +// ----> TRANSACTION -/// 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. @@ -44,24 +41,25 @@ pub struct Vin { pub vout: u32, /// The previous output amount and ScriptPubKey. /// `None` if this is a coinbase input. - pub prevout: Option, - /// The ScriptSig authorizes spending this input. + pub prevout: Option, + /// The ScriptSig that authorizes spending this input. pub scriptsig: ScriptBuf, - /// The Witness that authorizes spending this input, if this is a SegWit spend. + /// The witness that authorizes spending this input, if this is a SegWit spend. #[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, used to set RBF and locktime rules. 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. - pub value: u64, - /// The ScriptPubKey that the output is locked to, as a [`ScriptBuf`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub value: Amount, + /// The ScriptPubKey that the output is locked to. pub scriptpubkey: ScriptBuf, } @@ -75,11 +73,124 @@ pub struct TxStatus { /// The [`BlockHash`] of the block the [`Transaction`] was confirmed in. 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. pub block_time: Option, } -/// A Merkle inclusion proof for a transaction, given it's [`Txid`]. +/// A transaction in the format returned by Esplora. +/// +/// Unlike the native [`Transaction`] type from `rust-bitcoin`, this struct +/// includes additional metadata such as the confirmation status, fee, and +/// weight, as reported by the Esplora API. +/// +/// Use [`EsploraTx::to_tx`] or `.into()` to convert it to a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EsploraTx { + /// The [`Txid`] of the [`Transaction`]. + pub txid: Txid, + /// The version number of the [`Transaction`]. + pub version: i32, + /// The locktime of the [`Transaction`]. + /// Sets a time or height after which the [`Transaction`] can be mined. + pub locktime: u32, + /// The array of inputs in the [`Transaction`]. + pub vin: Vec, + /// The array of outputs in the [`Transaction`]. + pub vout: Vec, + /// The [`Transaction`] size in raw bytes (NOT virtual bytes). + pub size: usize, + /// The [`Transaction`]'s weight. + pub weight: Weight, + /// The confirmation status of the [`Transaction`]. + pub status: TxStatus, + /// The fee paid by the [`Transaction`], in satoshis. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub fee: Amount, +} + +impl EsploraTx { + /// Convert this [`EsploraTx`] into a `rust-bitcoin` [`Transaction`]. + /// + /// Drops 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), + lock_time: bitcoin::absolute::LockTime::from_consensus(self.locktime), + input: self + .vin + .iter() + .cloned() + .map(|vin| TxIn { + previous_output: OutPoint { + txid: vin.txid, + vout: vin.vout, + }, + script_sig: vin.scriptsig, + sequence: bitcoin::Sequence(vin.sequence), + witness: Witness::from_slice(&vin.witness), + }) + .collect(), + output: self + .vout + .iter() + .cloned() + .map(|vout| TxOut { + value: vout.value, + script_pubkey: vout.scriptpubkey, + }) + .collect(), + } + } + + /// Get the confirmation time of this [`EsploraTx`]. + /// + /// Returns a [`BlockTime`] containing the block height and timestamp if the + /// [`Transaction`] is confirmed, or `None` if it is unconfirmed. + pub fn confirmation_time(&self) -> Option { + match self.status { + TxStatus { + confirmed: true, + block_height: Some(height), + block_time: Some(timestamp), + .. + } => Some(BlockTime { timestamp, height }), + _ => None, + } + } + + /// Get the previous [`TxOut`]s spent by this [`EsploraTx`]'s inputs. + /// + /// Returns a [`Vec`] of [`Option`], one per input, in order. + /// Each entry is `None` if the input is a coinbase input (which has no previous output). + pub fn previous_outputs(&self) -> Vec> { + self.vin + .iter() + .cloned() + .map(|vin| { + vin.prevout.map(|prevout| TxOut { + script_pubkey: prevout.scriptpubkey, + value: prevout.value, + }) + }) + .collect() + } +} + +impl From for Transaction { + fn from(tx: EsploraTx) -> Self { + tx.to_tx() + } +} + +impl From<&EsploraTx> for Transaction { + fn from(tx: &EsploraTx) -> Self { + tx.to_tx() + } +} + +/// A Merkle inclusion proof for a [`Transaction`], given its [`Txid`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct MerkleProof { /// The height of the block the [`Transaction`] was confirmed in. @@ -96,9 +207,9 @@ pub struct MerkleProof { /// The spend status of a [`TxOut`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct OutputStatus { - /// Whether the [`TxOut`] is spent or not. + /// Whether the [`TxOut`] has been spent or not. 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, @@ -106,11 +217,22 @@ pub struct OutputStatus { pub status: Option, } -/// Information about a [`Block`]s status. +// ----> BLOCK + +/// The timestamp and height of a [`Block`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct BlockTime { + /// The [`Block`]'s timestamp. + pub timestamp: u64, + /// The [`Block`]'s height. + pub height: u32, +} + +/// 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). + /// Proof-of-Work (`false` for [`Block`]s that belong to a stale chain). pub in_best_chain: bool, /// The height of this [`Block`]. pub height: Option, @@ -118,31 +240,27 @@ pub struct BlockStatus { pub next_best: Option, } -/// A [`Transaction`] in the format returned by Esplora. -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct Tx { - /// The [`Txid`] of the [`Transaction`]. - pub txid: Txid, - /// The version number of the [`Transaction`]. - pub version: i32, - /// The locktime of the [`Transaction`]. - /// Sets a time or height after which the [`Transaction`] can be mined. - pub locktime: u32, - /// The array of inputs in the [`Transaction`]. - pub vin: Vec, - /// The array of outputs in the [`Transaction`]. - pub vout: Vec, - /// The [`Transaction`] size in raw bytes (NOT virtual bytes). - pub size: usize, - /// The [`Transaction`]'s weight units. - pub weight: u64, - /// The confirmation status of the [`Transaction`]. - pub status: TxStatus, - /// The fee amount paid by the [`Transaction`], in satoshis. - pub fee: u64, +// TODO(@luisschwab): remove on `v0.14.0` +/// Summary about a [`Block`]. +#[deprecated(since = "0.12.3", note = "use `BlockInfo` instead")] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BlockSummary { + /// The [`Block`]'s hash. + pub id: BlockHash, + /// The [`Block`]'s timestamp and height. + #[serde(flatten)] + pub time: BlockTime, + /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis [`Block`]). + pub previousblockhash: Option, + /// The Merkle root of the [`Block`]'s [`Transaction`]s. + pub merkle_root: TxMerkleNode, } -/// Information about a bitcoin [`Block`]. +/// A summary of a bitcoin [`Block`]. +/// +/// Contains block metadata as returned by the Esplora API, but not the +/// full block contents. Use the client's `get_block_by_hash` to retrieve +/// the full [`Block`]. #[derive(Debug, Clone, Deserialize)] pub struct BlockInfo { /// The [`Block`]'s [`BlockHash`]. @@ -158,8 +276,8 @@ pub struct BlockInfo { /// The [`Block`]'s size, in bytes. pub size: usize, /// The [`Block`]'s weight. - pub weight: u64, - /// The Merkle root of the transactions in the block. + pub weight: Weight, + /// The Merkle root of the [`Transaction`]s in the [`Block`]. pub merkle_root: hash_types::TxMerkleNode, /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis block). pub previousblockhash: Option, @@ -170,9 +288,13 @@ pub struct BlockInfo { /// The [`Block`]'s `bits` value as a [`CompactTarget`]. pub bits: CompactTarget, /// The [`Block`]'s difficulty target value. + /// + /// Uses a manual [`PartialEq`] impl because [`f64`] does not implement [`Eq`]. pub difficulty: f64, } +// Manual PartialEq impl required because `difficulty` is an `f64`, which does not implement `Eq`. +// `NaN` values are considered equal to each other for the purposes of this comparison. impl PartialEq for BlockInfo { fn eq(&self, other: &Self) -> bool { let Self { difficulty: d1, .. } = self; @@ -195,37 +317,19 @@ impl PartialEq for BlockInfo { } impl Eq for BlockInfo {} -/// Time-related information about a [`Block`]. -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct BlockTime { - /// The [`Block`]'s timestamp. - pub timestamp: u64, - /// The [`Block`]'s height. - pub height: u32, -} - -/// Summary about a [`Block`]. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BlockSummary { - /// The [`Block`]'s hash. - pub id: BlockHash, - /// The [`Block`]'s timestamp and height. - #[serde(flatten)] - pub time: BlockTime, - /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis [`Block`]). - pub previousblockhash: Option, - /// The Merkle root of the [`Block`]'s [`Transaction`]s. - pub merkle_root: TxMerkleNode, -} +// ----> ADDRESS /// Statistics about an [`Address`]. +/// +/// The address is stored as a [`String`] rather than an [`Address`] because +/// the Esplora API returns it without network context. #[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, } @@ -234,55 +338,65 @@ pub struct AddressStats { pub struct AddressTxsSummary { /// The number of funded [`TxOut`]s. pub funded_txo_count: u32, - /// The sum of the funded [`TxOut`]s, in satoshis. - pub funded_txo_sum: u64, + /// The total value of funded [`TxOut`]s, in satoshis. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub funded_txo_sum: Amount, /// The number of spent [`TxOut`]s. pub spent_txo_count: u32, - /// The sum of the spent [`TxOut`]s, in satoshis. - pub spent_txo_sum: u64, + /// The total value of spent [`TxOut`]s, in satoshis. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub spent_txo_sum: Amount, /// The total number of [`Transaction`]s. pub tx_count: u32, } -/// Statistics about a particular [`Script`] hash's confirmed and mempool transactions. +// ----> SCRIPT HASH + +/// Statistics about a [`Script`] hash's confirmed and mempool transactions. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] pub struct ScriptHashStats { /// The summary of confirmed [`Transaction`]s for this [`Script`] hash. pub chain_stats: ScriptHashTxsSummary, - /// The summary of mempool [`Transaction`]s for this [`Script`] hash. + /// The summary of unconfirmed mempool [`Transaction`]s for this [`Script`] hash. pub mempool_stats: ScriptHashTxsSummary, } -/// Contains a summary of the [`Transaction`]s for a particular [`Script`] hash. +/// A summary of [`Transaction`]s for a particular [`Script`] hash. +/// +/// Identical in structure to [`AddressTxsSummary`]. pub type ScriptHashTxsSummary = AddressTxsSummary; -/// Information about a [`TxOut`]'s status: confirmation status, -/// confirmation height, confirmation block hash and confirmation block time. +// ----> UTXO + +/// 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. + /// The [`BlockHash`] of the block in which the [`TxOut`] was confirmed. pub block_hash: Option, - /// The UNIX timestamp in which the [`TxOut`] was confirmed. + /// The UNIX timestamp of the block in which the [`TxOut`] was confirmed. 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`] as an [`Amount`]. + /// The value of this [`TxOut`], in satoshis. + #[serde(with = "bitcoin::amount::serde::as_sat")] pub value: Amount, } +// ----> MEMPOOL + /// Statistics about the mempool. #[derive(Clone, Debug, PartialEq, Deserialize)] pub struct MempoolStats { @@ -291,7 +405,8 @@ pub struct MempoolStats { /// The total size of mempool [`Transaction`]s, in virtual bytes. pub vsize: usize, /// The total fee paid by mempool [`Transaction`]s, in satoshis. - pub total_fee: u64, + #[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 @@ -303,37 +418,40 @@ 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. - pub fee: u64, + /// The fee paid by the [`Transaction`], in satoshis. + #[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. - pub value: u64, + /// The total output value of the [`Transaction`], in satoshis. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub value: Amount, } -/// The result for a broadcasted package of [`Transaction`]s. +/// The result of broadcasting a package of [`Transaction`]s. #[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 into or are already in the mempool. pub package_msg: String, /// Transaction results keyed by [`Wtxid`]. #[serde(rename = "tx-results")] pub tx_results: HashMap, - /// List of txids of replaced transactions. + /// List of [`Txid`]s of transactions replaced by this package. #[serde(rename = "replaced-transactions")] pub replaced_transactions: Option>, } -/// The result [`Transaction`] for a broadcasted package of [`Transaction`]s. +/// The result for a single [`Transaction`] in a broadcasted package. #[derive(Deserialize, Debug)] pub struct TxResult { - /// The transaction id. + /// The [`Txid`] of the [`Transaction`]. 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. #[serde(rename = "other-wtxid")] @@ -342,103 +460,45 @@ pub struct TxResult { pub vsize: Option, /// Transaction fees. pub fees: Option, - /// The transaction error string, if it was rejected by the mempool + /// The error string if the [`Transaction`] was rejected by the mempool. 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 transaction fee, in BTC. #[serde(with = "bitcoin::amount::serde::as_btc")] pub base: Amount, /// The effective feerate. /// - /// 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. + /// `None` if the transaction was already in the mempool. May reflect the package + /// feerate and/or feerate with modified fees from the `prioritisetransaction` RPC method. #[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. + /// The [`Wtxid`]s of the transactions whose fees and vsizes are included in + /// [`Self::effective_feerate`], if it is present. #[serde(rename = "effective-includes")] pub effective_includes: Option>, } -impl Tx { - /// Convert a transaction from the format returned by Esplora into a `rust-bitcoin` - /// [`Transaction`]. - pub fn to_tx(&self) -> Transaction { - Transaction { - version: transaction::Version::non_standard(self.version), - lock_time: bitcoin::absolute::LockTime::from_consensus(self.locktime), - input: self - .vin - .iter() - .cloned() - .map(|vin| TxIn { - previous_output: OutPoint { - txid: vin.txid, - vout: vin.vout, - }, - script_sig: vin.scriptsig, - sequence: bitcoin::Sequence(vin.sequence), - witness: Witness::from_slice(&vin.witness), - }) - .collect(), - output: self - .vout - .iter() - .cloned() - .map(|vout| TxOut { - value: Amount::from_sat(vout.value), - script_pubkey: vout.scriptpubkey, - }) - .collect(), - } - } - - /// Get the confirmation time from a [`Tx`]. - pub fn confirmation_time(&self) -> Option { - match self.status { - TxStatus { - confirmed: true, - block_height: Some(height), - block_time: Some(timestamp), - .. - } => Some(BlockTime { timestamp, height }), - _ => None, - } - } - - /// Get a list of the [`Tx`]'s previous outputs. - pub fn previous_outputs(&self) -> Vec> { - self.vin - .iter() - .cloned() - .map(|vin| { - vin.prevout.map(|po| TxOut { - script_pubkey: po.scriptpubkey, - value: Amount::from_sat(po.value), - }) - }) - .collect() - } - - /// Get the weight of a [`Tx`]. - pub fn weight(&self) -> Weight { - Weight::from_wu(self.weight) - } - - /// Get the fee paid by a [`Tx`]. - pub fn fee(&self) -> Amount { - Amount::from_sat(self.fee) - } +/// Converts a [`HashMap`] of fee estimates in sat/vbyte (`f64`) to [`FeeRate`]. +pub(crate) fn sat_per_vbyte_to_feerate(estimates: HashMap) -> HashMap { + estimates + .into_iter() + .map(|(k, v)| (k, FeeRate::from_sat_per_kwu((v * 250_000.0).round() as u64))) + .collect() } +/// 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>, @@ -450,6 +510,13 @@ where .map_err(serde::de::Error::custom) } +/// Deserializes an optional [`FeeRate`] from a BTC/kvB `f64` value. +/// +/// The Esplora API expresses effective feerates as BTC per kilovirtual-byte. +/// This deserializer converts 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>, diff --git a/src/async.rs b/src/async.rs index fb6d56d..d04fb0e 100644 --- a/src/async.rs +++ b/src/async.rs @@ -1,7 +1,7 @@ // Bitcoin Dev Kit // Written in 2020 by Alekos Filini // -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// Copyright (c) 2020-2026 Bitcoin Dev Kit Developers // // This file is licensed under the Apache License, Version 2.0 or the MIT license @@ -9,9 +9,25 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Esplora by way of `reqwest` HTTP client. +//! Async Esplora client. +//! +//! This module provides [`AsyncClient`], an async HTTP client for interacting +//! with an [Esplora](https://github.com/Blockstream/esplora/blob/master/API.md) +//! server, built on top of [`reqwest`]. +//! +//! # 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(()) +//! # } +//! ``` use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::marker::PhantomData; use std::str::FromStr; use std::time::Duration; @@ -21,20 +37,64 @@ use bitcoin::consensus::encode::serialize_hex; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; -use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; - -#[allow(unused_imports)] -use log::{debug, error, info, trace}; +use bitcoin::{Address, Block, BlockHash, FeeRate, MerkleBlock, Script, Transaction, Txid}; use reqwest::{header, Body, Client, Response}; use crate::{ - AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, - MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus, - Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, + sat_per_vbyte_to_feerate, AddressStats, Amount, BlockInfo, BlockStatus, Builder, Error, + EsploraTx, MempoolRecentTx, MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, + SubmitPackageResult, TxStatus, Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, }; +// TODO(@luisschwab): remove on `v0.14.0` +#[allow(deprecated)] +use crate::BlockSummary; + +/// Returns `true` if the given HTTP status code should trigger a retry. +/// +/// See [`RETRYABLE_ERROR_CODES`] for the list of retryable status codes. +fn is_status_retryable(status: reqwest::StatusCode) -> bool { + RETRYABLE_ERROR_CODES.contains(&status.as_u16()) +} + +/// A trait for abstracting over async sleep implementations, +/// allowing [`AsyncClient`] to be used with any async runtime. +/// +/// The only provided implementation is [`DefaultSleeper`], which uses Tokio. +/// Custom implementations can be provided to support other runtimes. +pub trait Sleeper: 'static { + /// The [`Future`] type returned by [`Sleeper::sleep`]. + type Sleep: Future; + /// Returns a [`Future`] that completes after `duration`. + fn sleep(duration: Duration) -> Self::Sleep; +} + +/// The default [`Sleeper`] implementation, backed by [`tokio::time::sleep`]. +#[derive(Debug, Clone, Copy)] +pub struct DefaultSleeper; + +#[cfg(any(test, feature = "tokio"))] +impl Sleeper for DefaultSleeper { + type Sleep = tokio::time::Sleep; + + fn sleep(duration: Duration) -> Self::Sleep { + tokio::time::sleep(duration) + } +} + /// An async client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. The generic +/// parameter `S` determines the async runtime used for sleeping between +/// retries — it defaults to [`DefaultSleeper`], which uses Tokio. +/// +/// # 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 [`RETRYABLE_ERROR_CODES`] for the +/// full list. #[derive(Debug, Clone)] pub struct AsyncClient { /// The URL of the Esplora Server. @@ -48,7 +108,17 @@ pub struct AsyncClient { } impl AsyncClient { + // ----> CLIENT + /// Build an [`AsyncClient`] from a [`Builder`]. + /// + /// Configures the underlying [`reqwest::Client`] with the proxy, timeout, + /// and headers specified in the [`Builder`]. + /// + /// # 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 { let mut client_builder = Client::builder(); @@ -82,7 +152,9 @@ impl AsyncClient { }) } - /// Build an [`AsyncClient`] from a [`Client`]. + /// Build an [`AsyncClient`] from an existing [`Client`] and a base URL. + /// + /// Uses [`crate::DEFAULT_MAX_RETRIES`] for the retry count. pub fn from_client(url: String, client: Client) -> Self { AsyncClient { url, @@ -92,17 +164,44 @@ impl AsyncClient { } } - /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implement [`bitcoin::consensus::Decodable`]. + /// Returns the underlying [`Client`]. + pub fn client(&self) -> &Client { + &self.client + } + + /// Returns the base URL of the Esplora server this client connects to. + pub fn url(&self) -> &str { + &self.url + } + + // ----> INTERNAL + + /// 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, url: &str) -> Result { + let mut delay = BASE_BACKOFF_MILLIS; + let mut attempts = 0; + + loop { + match self.client.get(url).send().await? { + resp if attempts < self.max_retries && is_status_retryable(resp.status()) => { + S::sleep(delay).await; + attempts += 1; + delay *= 2; + } + resp => return Ok(resp), + } + } + } + + /// 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 url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; @@ -117,11 +216,9 @@ impl AsyncClient { Ok(deserialize::(&response.bytes().await?)?) } - /// 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`] internally. - /// - /// 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)), @@ -130,16 +227,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, @@ -157,12 +253,9 @@ impl AsyncClient { response.json::().await.map_err(Error::Reqwest) } - /// Make an HTTP GET request to given URL, deserializing to `Option`. - /// - /// It uses [`AsyncEsploraClient::get_response_json`] internally. + /// Makes a GET request to `path`, returning `None` on a 404 response. /// - /// 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, @@ -174,17 +267,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 url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; @@ -200,12 +291,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)), @@ -214,14 +302,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 url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; @@ -236,12 +324,9 @@ impl AsyncClient { Ok(response.text().await?) } - /// 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)), @@ -250,13 +335,11 @@ impl AsyncClient { } } - /// Make an HTTP POST request to given URL, converting any `T` that - /// implement [`Into`] and setting query parameters, if any. + /// Makes a POST request to `path` with `body`, optionally attaching query parameters. /// /// # Errors /// - /// This function will return an error either from the HTTP client, or the - /// response's [`serde_json`] deserialization. + /// Returns an [`Error`] if the request fails or the server returns a non-success status. async fn post_request_bytes>( &self, path: &str, @@ -282,12 +365,79 @@ impl AsyncClient { Ok(response) } - /// Get a [`Transaction`] option given its [`Txid`] + // ----> TRANSACTION + + /// 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?; + let txid = Txid::from_str(&response.text().await?).map_err(Error::HexToArray)?; + Ok(txid) + } + + /// Broadcast a package of [`Transaction`]s to the Esplora server. + /// + /// Returns a [`SubmitPackageResult`] containing the result for each + /// transaction in the package, keyed by [`Wtxid`](bitcoin::Wtxid). + /// + /// Optionally, `maxfeerate` (as a [`FeeRate`]) and `maxburnamount` + /// (as an [`Amount`]) can be provided to reject transactions that + /// exceed these thresholds. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the package. + pub async fn submit_package( + &self, + transactions: &[Transaction], + maxfeerate: Option, + maxburnamount: Option, + ) -> Result { + let serialized_txs = transactions + .iter() + .map(|tx| serialize_hex(&tx)) + .collect::>(); + + let mut queryparams = HashSet::<(&str, String)>::new(); + + // Esplora expects `maxfeerate` in sats/vB. + if let Some(maxfeerate) = maxfeerate { + queryparams.insert(("maxfeerate", maxfeerate.to_sat_per_vb_ceil().to_string())); + } + // Esplora expects `maxburnamount` in BTC. + if let Some(maxburnamount) = maxburnamount { + queryparams.insert(("maxburnamount", maxburnamount.to_btc().to_string())); + } + + let response = self + .post_request_bytes( + "/txs/package", + serde_json::to_string(&serialized_txs).map_err(Error::SerdeJson)?, + Some(queryparams), + ) + .await?; + + Ok(response.json::().await?) + } + + /// 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`]. + /// + /// 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), @@ -296,8 +446,36 @@ impl AsyncClient { } } - /// Get a [`Txid`] of a transaction given its index in a block with a given - /// hash. + /// Get an [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], this 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 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 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 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, @@ -312,56 +490,29 @@ impl AsyncClient { } } - /// Get the status of a [`Transaction`] given its [`Txid`]. - 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`]. - 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`]. - 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. - 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`]. - 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`]. - 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, @@ -371,53 +522,9 @@ impl AsyncClient { .await } - /// Broadcast a [`Transaction`] to Esplora - 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?; - let txid = Txid::from_str(&response.text().await?).map_err(Error::HexToArray)?; - Ok(txid) - } - - /// Broadcast a package of [`Transaction`]s to Esplora. - /// - /// If `maxfeerate` is provided, any transaction whose - /// fee is higher will be rejected. - /// - /// If `maxburnamount` is provided, any transaction - /// with higher provably unspendable outputs amount - /// will be rejected. - pub async fn submit_package( - &self, - transactions: &[Transaction], - maxfeerate: Option, - maxburnamount: Option, - ) -> Result { - let serialized_txs = transactions - .iter() - .map(|tx| serialize_hex(&tx)) - .collect::>(); + // ----> BLOCK - let mut queryparams = HashSet::<(&str, String)>::new(); - if let Some(maxfeerate) = maxfeerate { - queryparams.insert(("maxfeerate", maxfeerate.to_string())); - } - if let Some(maxburnamount) = maxburnamount { - queryparams.insert(("maxburnamount", maxburnamount.to_string())); - } - - let response = self - .post_request_bytes( - "/txs/package", - serde_json::to_string(&serialized_txs).map_err(Error::SerdeJson)?, - Some(queryparams), - ) - .await?; - - Ok(response.json::().await?) - } - - /// 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 @@ -428,161 +535,215 @@ impl AsyncClient { pub async fn get_tip_hash(&self) -> Result { self.get_response_text("/blocks/tip/hash") .await - .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? + .map(|hash| BlockHash::from_str(&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))? + .map(|hash| BlockHash::from_str(&hash).map_err(Error::HexToArray))? } - /// Get information about a specific address, includes confirmed balance and transactions in - /// the mempool. - pub async fn get_address_stats(&self, address: &Address) -> Result { - let path = format!("/address/{address}"); - self.get_response_json(&path).await + /// 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 statistics about a particular [`Script`] hash's confirmed and mempool transactions. - 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 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 transaction history for the specified address, sorted with newest first. + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. /// - /// 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. - pub async fn get_address_txs( - &self, - address: &Address, - last_seen: Option, - ) -> Result, Error> { - let path = match last_seen { - Some(last_seen) => format!("/address/{address}/txs/chain/{last_seen}"), - None => format!("/address/{address}/txs"), + /// 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 + } + + // TODO(@luisschwab): remove on `v0.14.0` + /// Gets some recent block summaries starting at the tip or at `height` if + /// provided. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + #[allow(deprecated)] + #[deprecated(since = "0.12.3", note = "use `get_block_infos` instead")] + pub async fn get_blocks(&self, height: Option) -> Result, Error> { + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), }; + let blocks: Vec = self.get_response_json(&path).await?; + if blocks.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(blocks) + } + + /// 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 mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. - pub async fn get_mempool_address_txs(&self, address: &Address) -> Result, Error> { - let path = format!("/address/{address}/txs/mempool"); + /// 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 number of blocks returned depends on the backend: + /// - Esplora returns 10 [`Block`]s. + /// - [Mempool.space](https://mempool.space/docs/api/rest#get-blocks) returns 10 [`Block`]s. + /// + /// # 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}"), + None => "/blocks".to_string(), + }; + let block_infos: Vec = self.get_response_json(&path).await?; + if block_infos.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(block_infos) + } + + /// 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 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. - pub async fn scripthash_txs( + /// 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). + /// + /// Note that `start_index` **MUST** be a multiple of 25, + /// otherwise the server will return an error. + pub async fn get_block_txs( &self, - script: &Script, - last_seen: Option, - ) -> Result, Error> { - let script_hash = sha256::Hash::hash(script.as_bytes()); - let path = match last_seen { - Some(last_seen) => format!("/scripthash/{script_hash:x}/txs/chain/{last_seen}"), - None => format!("/scripthash/{script_hash:x}/txs"), + blockhash: &BlockHash, + start_index: Option, + ) -> Result, Error> { + let path = match start_index { + None => format!("/block/{blockhash}/txs"), + Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), }; self.get_response_json(&path).await } - /// Get mempool [`Transaction`] history for the - /// specified [`Script`] hash, sorted with newest first. - pub async 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"); + /// Get fee estimates for a range of confirmation targets. + /// + /// Returns a [`HashMap`] where the key is the confirmation target in blocks + /// and the value is the estimated [`FeeRate`]. + pub async fn get_fee_estimates(&self) -> Result, Error> { + let estimates_raw: HashMap = self.get_response_json("/fee-estimates").await?; + let estimates = sat_per_vbyte_to_feerate(estimates_raw); - self.get_response_json(&path).await + Ok(estimates) } - /// Get statistics about the mempool. - pub async fn get_mempool_stats(&self) -> Result { - self.get_response_json("/mempool").await - } + // ----> ADDRESS - /// Get a list of the last 10 [`Transaction`]s to enter the mempool. - pub async fn get_mempool_recent_txs(&self) -> Result, Error> { - self.get_response_json("/mempool/recent").await + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool + /// [transaction summaries](crate::api::AddressTxsSummary) 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 the full list of [`Txid`]s in the mempool. + /// Get confirmed transaction history for an [`Address`], sorted newest first. /// - /// The order of the [`Txid`]s is arbitrary. - pub async fn get_mempool_txids(&self) -> Result, Error> { - self.get_response_json("/mempool/txids").await - } + /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. + /// 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, + last_seen: Option, + ) -> Result, Error> { + let path = match last_seen { + Some(last_seen) => format!("/address/{address}/txs/chain/{last_seen}"), + None => format!("/address/{address}/txs"), + }; - /// Get a map where the key is the confirmation target (in number of - /// blocks) and the value is the estimated feerate (in sat/vB). - pub async fn get_fee_estimates(&self) -> Result, Error> { - self.get_response_json("/fee-estimates").await + self.get_response_json(&path).await } - /// Get a summary about a [`Block`], given its [`BlockHash`]. - pub async fn get_block_info(&self, blockhash: &BlockHash) -> Result { - let path = format!("/block/{blockhash}"); + /// 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 [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. - pub async fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { - let path = format!("/block/{blockhash}/txids"); + /// Get unconfirmed mempool [`EsploraTx`]s for an [`Address`], sorted newest first. + pub async fn get_mempool_address_txs( + &self, + address: &Address, + ) -> Result, Error> { + let path = format!("/address/{address}/txs/mempool"); 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`). - /// - /// The `start_index` value MUST be a multiple of 25, - /// else an error will be returned by Esplora. - pub async fn get_block_txs( - &self, - blockhash: &BlockHash, - start_index: Option, - ) -> Result, Error> { - let path = match start_index { - None => format!("/block/{blockhash}/txs"), - Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), - }; + // ----> SCRIPT HASH + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing + /// [transaction summaries](crate::api::AddressTxsSummary) + /// 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 } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. + /// Get confirmed transaction history for a [`Script`] hash, sorted newest first. /// - /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. - pub async fn get_blocks(&self, height: Option) -> Result, Error> { - let path = match height { - Some(height) => format!("/blocks/{height}"), - None => "/blocks".to_string(), + /// 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, + last_seen: Option, + ) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = match last_seen { + Some(last_seen) => format!("/scripthash/{script_hash:x}/txs/chain/{last_seen}"), + None => format!("/scripthash/{script_hash:x}/txs"), }; - let blocks: Vec = self.get_response_json(&path).await?; - if blocks.is_empty() { - return Err(Error::InvalidResponse); - } - Ok(blocks) - } - - /// Get all UTXOs locked to an 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"); @@ -590,56 +751,36 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get the underlying base URL. - pub fn url(&self) -> &str { - &self.url - } + /// Get unconfirmed mempool [`EsploraTx`]s for a [`Script`] hash, sorted newest first. + pub async 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"); - /// Get the underlying [`Client`]. - pub fn client(&self) -> &Client { - &self.client + self.get_response_json(&path).await } - /// Sends a GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. - async fn get_with_retry(&self, url: &str) -> Result { - let mut delay = BASE_BACKOFF_MILLIS; - let mut attempts = 0; + // ----> MEMPOOL - loop { - match self.client.get(url).send().await? { - resp if attempts < self.max_retries && is_status_retryable(resp.status()) => { - S::sleep(delay).await; - attempts += 1; - delay *= 2; - } - resp => return Ok(resp), - } - } + /// 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 } -} - -fn is_status_retryable(status: reqwest::StatusCode) -> bool { - RETRYABLE_ERROR_CODES.contains(&status.as_u16()) -} - -/// Sleeper trait that allows any async runtime to be used. -pub trait Sleeper: 'static { - /// The `Future` type returned by the sleep function. - type Sleep: std::future::Future; - /// Create a `Future` that completes after the specified [`Duration`]. - fn sleep(dur: Duration) -> Self::Sleep; -} -/// The default `Sleeper` implementation using the underlying async runtime. -#[derive(Debug, Clone, Copy)] -pub struct DefaultSleeper; - -#[cfg(any(test, feature = "tokio"))] -impl Sleeper for DefaultSleeper { - type Sleep = tokio::time::Sleep; + /// 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 + } - fn sleep(dur: std::time::Duration) -> Self::Sleep { - tokio::time::sleep(dur) + /// Get the full list of [`Txid`]s currently in the mempool. + /// + /// The order of the returned [`Txid`]s is arbitrary. + pub async fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids").await } } diff --git a/src/blocking.rs b/src/blocking.rs index ff54d7f..a3b0fe4 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -1,7 +1,7 @@ // Bitcoin Dev Kit // Written in 2020 by Alekos Filini // -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// Copyright (c) 2020-2026 Bitcoin Dev Kit Developers // // This file is licensed under the Apache License, Version 2.0 or the MIT license @@ -9,32 +9,73 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Esplora by way of `minreq` HTTP client. +//! Blocking Esplora client. +//! +//! This module provides [`BlockingClient`], a blocking HTTP client for interacting +//! with an [Esplora](https://github.com/Blockstream/esplora/blob/master/API.md) +//! server, built on top of [`minreq`]. +//! +//! # Example +//! +//! ```rust,ignore +//! # use esplora_client::Builder; +//! let client = Builder::new("https://mempool.space/api").build_blocking(); +//! let height = client.get_height()?; +//! # Ok::<(), esplora_client::Error>(()) +//! ``` use std::collections::HashMap; use std::convert::TryFrom; use std::str::FromStr; use std::thread; -use bitcoin::consensus::encode::serialize_hex; -#[allow(unused_imports)] -use log::{debug, error, info, trace}; - use minreq::{Proxy, Request, Response}; use bitcoin::block::Header as BlockHeader; +use bitcoin::consensus::encode::serialize_hex; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; -use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; +use bitcoin::{Address, Amount, Block, BlockHash, FeeRate, MerkleBlock, Script, Transaction, Txid}; use crate::{ - AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, - MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus, - Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, + sat_per_vbyte_to_feerate, AddressStats, BlockInfo, BlockStatus, Builder, Error, EsploraTx, + MempoolRecentTx, MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, + TxStatus, Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, }; +// TODO(@luisschwab): remove on `v0.14.0` +#[allow(deprecated)] +use crate::BlockSummary; + +/// Returns `true` if the given HTTP status code indicates a successful response. +fn is_status_ok(status: i32) -> bool { + status == 200 +} + +/// Returns `true` if the given HTTP status code indicates a resource was not found. +fn is_status_not_found(status: i32) -> bool { + status == 404 +} + +/// Returns `true` if the given HTTP status code should trigger a retry. +/// +/// See [`RETRYABLE_ERROR_CODES`] for the list of retryable status codes. +fn is_status_retryable(status: i32) -> bool { + let status = status as u16; + RETRYABLE_ERROR_CODES.contains(&status) +} + /// A blocking client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. +/// +/// # 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 [`RETRYABLE_ERROR_CODES`] for the +/// full list. #[derive(Debug, Clone)] pub struct BlockingClient { /// The URL of the Esplora server. @@ -50,7 +91,9 @@ pub struct BlockingClient { } impl BlockingClient { - /// Build a blocking client from a [`Builder`] + // ----> CLIENT + + /// Build a [`BlockingClient`] from a [`Builder`]. pub fn from_builder(builder: Builder) -> Self { Self { url: builder.base_url, @@ -61,12 +104,21 @@ impl BlockingClient { } } - /// Get the underlying base URL. + /// Returns the base URL of the Esplora server this client connects to. pub fn url(&self) -> &str { &self.url } - /// Perform a raw HTTP GET request with the given URI `path`. + // ----> INTERNAL + + /// Performs a raw HTTP GET request to the given `path`. + /// + /// Configures the request with the proxy, timeout, and headers set on + /// this client. Used internally by all other GET helper methods. + /// + /// # Errors + /// + /// Returns an [`Error`] if the proxy configuration is invalid. pub fn get_request(&self, path: &str) -> Result { let mut request = minreq::get(format!("{}{}", self.url, path)); @@ -88,6 +140,31 @@ impl BlockingClient { Ok(request) } + /// 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; + + loop { + match self.get_request(url)?.send()? { + resp if attempts < self.max_retries && is_status_retryable(resp.status_code) => { + thread::sleep(delay); + attempts += 1; + delay *= 2; + } + resp => return Ok(resp), + } + } + } + + /// Makes a POST request to `path` with `body`. + /// + /// Configures the request with the proxy and timeout set on this client. + /// + /// # Errors + /// + /// Returns an [`Error`] if the proxy configuration is invalid. fn post_request(&self, path: &str, body: T) -> Result where T: Into>, @@ -106,6 +183,16 @@ impl BlockingClient { Ok(request) } + /// Makes a GET request to `path`, deserializing the response body as + /// raw bytes into `T` using [`bitcoin::consensus::Decodable`]. + /// + /// Use this for endpoints that return raw binary Bitcoin data. + /// + /// Returns `None` on a 404 response. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or deserialization fails. fn get_opt_response(&self, path: &str) -> Result, Error> { match self.get_with_retry(path) { Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), @@ -119,22 +206,38 @@ impl BlockingClient { } } - fn get_opt_response_txid(&self, path: &str) -> Result, Error> { - match self.get_with_retry(path) { - Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), + /// Makes a GET request to `path`, deserializing the response body as + /// JSON into `T` using [`serde::de::DeserializeOwned`]. + /// + /// Use this for endpoints that return Esplora-specific JSON types, + /// as defined in [`crate::api`]. + /// + /// # Errors + /// + /// 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, + ) -> Result { + let response = self.get_with_retry(path); + match response { Ok(resp) if !is_status_ok(resp.status_code) => { let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => Ok(Some( - Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, - )), + Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), Err(e) => Err(e), } } - fn get_opt_response_hex(&self, path: &str) -> Result, Error> { + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// Delegates to [`Self::get_response_json`]. See its documentation for details. + fn get_opt_response_json( + &self, + path: &str, + ) -> Result, Error> { match self.get_with_retry(path) { Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), Ok(resp) if !is_status_ok(resp.status_code) => { @@ -142,17 +245,20 @@ impl BlockingClient { let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => { - let hex_str = resp.as_str().map_err(Error::Minreq)?; - let hex_vec = Vec::from_hex(hex_str)?; - deserialize::(&hex_vec) - .map_err(Error::BitcoinEncoding) - .map(|r| Some(r)) - } + Ok(resp) => Ok(Some(resp.json::()?)), Err(e) => Err(e), } } + /// Makes a GET request to `path`, deserializing the hex-encoded response + /// body into `T` using [`bitcoin::consensus::Decodable`]. + /// + /// Use this for endpoints that return hex-encoded Bitcoin data. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails, hex decoding fails, + /// or consensus deserialization fails. fn get_response_hex(&self, path: &str) -> Result { match self.get_with_retry(path) { Ok(resp) if !is_status_ok(resp.status_code) => { @@ -169,26 +275,42 @@ impl BlockingClient { } } - fn get_response_json<'a, T: serde::de::DeserializeOwned>( - &'a self, - path: &'a str, - ) -> Result { - let response = self.get_with_retry(path); - match response { + /// Makes a GET request to `path`, deserializing a [`Txid`] from the + /// hex-encoded response body. + /// + /// Returns `None` on a 404 response. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the response cannot be + /// parsed as a [`Txid`]. + fn get_opt_response_txid(&self, path: &str) -> Result, Error> { + match self.get_with_retry(path) { + Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), Ok(resp) if !is_status_ok(resp.status_code) => { let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), + Ok(resp) => Ok(Some( + Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, + )), Err(e) => Err(e), } } - fn get_opt_response_json( - &self, - path: &str, - ) -> Result, Error> { + /// Makes a GET request to `path`, deserializing the hex-encoded response + /// body into `T` using [`bitcoin::consensus::Decodable`]. + /// + /// Use this for endpoints that return hex-encoded Bitcoin data. + /// + /// Returns `None` on a 404 response. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails, hex decoding fails, + /// or consensus deserialization fails. + fn get_opt_response_hex(&self, path: &str) -> Result, Error> { match self.get_with_retry(path) { Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), Ok(resp) if !is_status_ok(resp.status_code) => { @@ -196,11 +318,25 @@ impl BlockingClient { let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => Ok(Some(resp.json::()?)), + Ok(resp) => { + let hex_str = resp.as_str().map_err(Error::Minreq)?; + let hex_vec = Vec::from_hex(hex_str)?; + deserialize::(&hex_vec) + .map_err(Error::BitcoinEncoding) + .map(|r| Some(r)) + } Err(e) => Err(e), } } + /// Makes a GET request to `path`, returning the response body as a [`String`]. + /// + /// Use this for endpoints that return plain text data that needs + /// further parsing downstream. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails. fn get_response_str(&self, path: &str) -> Result { match self.get_with_retry(path) { Ok(resp) if !is_status_ok(resp.status_code) => { @@ -213,83 +349,16 @@ impl BlockingClient { } } - /// Get a [`Transaction`] option given its [`Txid`] - pub fn get_tx(&self, txid: &Txid) -> Result, Error> { - self.get_opt_response(&format!("/tx/{txid}/raw")) - } - - /// Get a [`Transaction`] given its [`Txid`]. - pub fn get_tx_no_opt(&self, txid: &Txid) -> Result { - match self.get_tx(txid) { - Ok(Some(tx)) => Ok(tx), - Ok(None) => Err(Error::TransactionNotFound(*txid)), - Err(e) => Err(e), - } - } - - /// Get a [`Txid`] of a transaction given its index in a block with a given - /// hash. - pub fn get_txid_at_block_index( - &self, - block_hash: &BlockHash, - index: usize, - ) -> Result, Error> { - self.get_opt_response_txid(&format!("/block/{block_hash}/txid/{index}")) - } - - /// Get the status of a [`Transaction`] given its [`Txid`]. - pub fn get_tx_status(&self, txid: &Txid) -> Result { - self.get_response_json(&format!("/tx/{txid}/status")) - } - - /// Get transaction info given its [`Txid`]. - 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`]. - 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`]. - 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`]. - 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`]. - 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`]. - 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`]. - 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. - pub fn get_output_status( - &self, - txid: &Txid, - index: u64, - ) -> Result, Error> { - self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")) - } + // ----> TRANSACTION - /// 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 request = self.post_request( "/tx", @@ -313,19 +382,23 @@ impl BlockingClient { } } - /// Broadcast a package of [`Transaction`]s to Esplora. + /// Broadcast a package of [`Transaction`]s to the Esplora server. /// - /// If `maxfeerate` is provided, any transaction whose - /// fee is higher will be rejected. + /// Returns a [`SubmitPackageResult`] containing the result for each + /// transaction in the package, keyed by [`Wtxid`](bitcoin::Wtxid). /// - /// If `maxburnamount` is provided, any transaction - /// with higher provably unspendable outputs amount - /// will be rejected. + /// Optionally, `maxfeerate` (as a [`FeeRate`]) and `maxburnamount` + /// (as an [`Amount`]) can be provided to reject transactions that + /// exceed these thresholds. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the package. pub fn submit_package( &self, transactions: &[Transaction], - maxfeerate: Option, - maxburnamount: Option, + maxfeerate: Option, + maxburnamount: Option, ) -> Result { let serialized_txs = transactions .iter() @@ -339,12 +412,14 @@ impl BlockingClient { .into_bytes(), )?; + // Esplora expects `maxfeerate` in sats/vB. if let Some(maxfeerate) = maxfeerate { - request = request.with_param("maxfeerate", maxfeerate.to_string()) + request = request.with_param("maxfeerate", maxfeerate.to_sat_per_vb_ceil().to_string()) } + // Esplora expects `maxburnamount` in BTC. if let Some(maxburnamount) = maxburnamount { - request = request.with_param("maxburnamount", maxburnamount.to_string()) + request = request.with_param("maxburnamount", maxburnamount.to_btc().to_string()) } match request.send() { @@ -358,7 +433,94 @@ impl BlockingClient { } } - /// Get the height of the current blockchain tip. + /// 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`]. + /// + /// 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), + Ok(None) => Err(Error::TransactionNotFound(*txid)), + Err(e) => Err(e), + } + } + + /// Get a [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], this 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 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 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 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, + index: usize, + ) -> Result, Error> { + self.get_opt_response_txid(&format!("/block/{block_hash}/txid/{index}")) + } + + /// 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`] 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 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, + index: u64, + ) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")) + } + + // ----> BLOCK + + /// Get the block height of the current blockchain tip. pub fn get_height(&self) -> Result { self.get_response_str("/blocks/tip/height") .map(|s| u32::from_str(s.as_str()).map_err(Error::Parsing))? @@ -370,192 +532,239 @@ 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_str(&format!("/block-height/{block_height}")) .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } - /// Get statistics about the mempool. - pub fn get_mempool_stats(&self) -> Result { - self.get_response_json("/mempool") + /// 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 a list of the last 10 [`Transaction`]s to enter the mempool. - pub fn get_mempool_recent_txs(&self) -> Result, Error> { - self.get_response_json("/mempool/recent") + /// 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 the full list of [`Txid`]s in the mempool. + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. /// - /// The order of the txids is arbitrary and does not match bitcoind's. - pub fn get_mempool_txids(&self) -> Result, Error> { - self.get_response_json("/mempool/txids") + /// 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 map where the key is the confirmation target (in number of - /// blocks) and the value is the estimated feerate (in sat/vB). - pub fn get_fee_estimates(&self) -> Result, Error> { - self.get_response_json("/fee-estimates") + // TODO(@luisschwab): remove on `v0.14.0` + /// Gets some recent block summaries starting at the tip or at `height` if + /// provided. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + #[allow(deprecated)] + #[deprecated(since = "0.12.3", note = "use `get_block_infos` instead")] + pub fn get_blocks(&self, height: Option) -> Result, Error> { + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), + }; + let blocks: Vec = self.get_response_json(&path)?; + if blocks.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(blocks) } - /// Get information about a specific address, includes confirmed balance and transactions in - /// the mempool. - pub fn get_address_stats(&self, address: &Address) -> Result { - let path = format!("/address/{address}"); + /// 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 statistics about a particular [`Script`] hash's confirmed and mempool transactions. - pub fn get_scripthash_stats(&self, script: &Script) -> Result { - let script_hash = sha256::Hash::hash(script.as_bytes()); - let path = format!("/scripthash/{script_hash}"); + /// 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 number of blocks returned depends on the backend: + /// - Esplora returns 10 [`Block`]s. + /// - [Mempool.space](https://mempool.space/docs/api/rest#get-blocks) returns 10 [`Block`]s. + /// + /// # 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}"), + None => "/blocks".to_string(), + }; + let block_infos: Vec = self.get_response_json(&path)?; + if block_infos.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(block_infos) + } + + /// 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 transaction history for the specified address, sorted with newest - /// first. + /// Get up to 25 [`EsploraTx`]s from the block with the given [`BlockHash`], + /// starting at `start_index`. /// - /// 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. - pub fn get_address_txs( + /// 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, - address: &Address, - last_seen: Option, - ) -> Result, Error> { - let path = match last_seen { - Some(last_seen) => format!("/address/{address}/txs/chain/{last_seen}"), - None => format!("/address/{address}/txs"), + blockhash: &BlockHash, + start_index: Option, + ) -> Result, Error> { + let path = match start_index { + None => format!("/block/{blockhash}/txs"), + Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), }; self.get_response_json(&path) } - /// Get mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. - pub fn get_mempool_address_txs(&self, address: &Address) -> Result, Error> { - let path = format!("/address/{address}/txs/mempool"); + /// Get fee estimates for a range of confirmation targets. + /// + /// Returns a [`HashMap`] where the key is the confirmation target in blocks + /// and the value is the estimated [`FeeRate`]. + pub fn get_fee_estimates(&self) -> Result, Error> { + let estimates_raw: HashMap = self.get_response_json("/fee-estimates")?; + let estimates = sat_per_vbyte_to_feerate(estimates_raw); + Ok(estimates) + } + + // ----> ADDRESS + + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool + /// [transaction summaries](crate::api::AddressTxsSummary) 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 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. - pub fn scripthash_txs( + /// Get confirmed transaction history for an [`Address`], sorted newest first. + /// + /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. + /// To paginate, pass the [`Txid`] of the last transaction seen in the previous + /// response as `last_seen`. + pub fn get_address_txs( &self, - script: &Script, + address: &Address, last_seen: Option, - ) -> Result, Error> { - let script_hash = sha256::Hash::hash(script.as_bytes()); + ) -> Result, Error> { let path = match last_seen { - Some(last_seen) => format!("/scripthash/{script_hash:x}/txs/chain/{last_seen}"), - None => format!("/scripthash/{script_hash:x}/txs"), + Some(last_seen) => format!("/address/{address}/txs/chain/{last_seen}"), + None => format!("/address/{address}/txs"), }; + self.get_response_json(&path) } - /// Get mempool [`Transaction`] history for the - /// specified [`Script`] hash, sorted with 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"); + /// 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 a summary about a [`Block`], given its [`BlockHash`]. - pub fn get_block_info(&self, blockhash: &BlockHash) -> Result { - let path = format!("/block/{blockhash}"); + /// 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 all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. - pub fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { - let path = format!("/block/{blockhash}/txids"); + // ----> SCRIPT HASH + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing + /// [transaction summaries](crate::api::AddressTxsSummary) + /// 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 up to 25 [`Transaction`]s from a [`Block`], given its [`BlockHash`], - /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// Get confirmed transaction history for a [`Script`] hash, sorted newest first. /// - /// The `start_index` value MUST be a multiple of 25, - /// else an error will be returned by Esplora. - pub fn get_block_txs( + /// 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_script_hash_txs( &self, - blockhash: &BlockHash, - start_index: Option, - ) -> Result, Error> { - let path = match start_index { - None => format!("/block/{blockhash}/txs"), - Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), + script: &Script, + last_seen: Option, + ) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = match last_seen { + Some(last_seen) => format!("/scripthash/{script_hash:x}/txs/chain/{last_seen}"), + None => format!("/scripthash/{script_hash:x}/txs"), }; self.get_response_json(&path) } - /// Gets some recent block summaries starting at the tip or at `height` if - /// provided. - /// - /// The maximum number of summaries returned depends on the backend itself: - /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. - pub fn get_blocks(&self, height: Option) -> Result, Error> { - let path = match height { - Some(height) => format!("/blocks/{height}"), - None => "/blocks".to_string(), - }; - let blocks: Vec = self.get_response_json(&path)?; - if blocks.is_empty() { - return Err(Error::InvalidResponse); - } - Ok(blocks) - } - - /// Get all UTXOs locked to an address. - pub fn get_address_utxos(&self, address: &Address) -> Result, Error> { - let path = format!("/address/{address}/utxo"); + /// 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"); self.get_response_json(&path) } - /// Get all [`Utxo`]s locked to a [`Script`]. - pub fn get_scripthash_utxos(&self, script: &Script) -> Result, Error> { + /// 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}/utxo"); + let path = format!("/scripthash/{script_hash:x}/txs/mempool"); self.get_response_json(&path) } - /// Sends a GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. - fn get_with_retry(&self, url: &str) -> Result { - let mut delay = BASE_BACKOFF_MILLIS; - let mut attempts = 0; + // ----> MEMPOOL - loop { - match self.get_request(url)?.send()? { - resp if attempts < self.max_retries && is_status_retryable(resp.status_code) => { - thread::sleep(delay); - attempts += 1; - delay *= 2; - } - resp => return Ok(resp), - } - } + /// 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") } -} - -fn is_status_ok(status: i32) -> bool { - status == 200 -} -fn is_status_not_found(status: i32) -> bool { - status == 404 -} + /// 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") + } -fn is_status_retryable(status: i32) -> bool { - let status = status as u16; - RETRYABLE_ERROR_CODES.contains(&status) + /// Get the full list of [`Txid`]s currently in the mempool. + /// + /// The order of the returned [`Txid`]s is arbitrary. + pub fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids") + } } diff --git a/src/lib.rs b/src/lib.rs index 2d1ab60..c403227 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,79 +1,71 @@ -//! An extensible blocking/async Esplora client +//! An extensible blocking and async Esplora client. //! -//! This library provides an extensible blocking and -//! async Esplora client to query Esplora's backend. +//! This library provides a blocking client built on [`minreq`] and an async +//! client built on [`reqwest`] for interacting with an +//! [Esplora](https://github.com/Blockstream/esplora) server. //! -//! The library provides the possibility to build a blocking -//! client using [`minreq`] and an async client using [`reqwest`]. -//! The library supports communicating to Esplora via a proxy -//! and also using TLS (SSL) for secure communication. +//! Both clients support communicating via a proxy and TLS (SSL). //! +//! # Blocking Client //! -//! ## Usage -//! -//! You can create a blocking client as follows: -//! -//! ```no_run -//! # #[cfg(feature = "blocking")] -//! # { +//! ```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>(()); -//! # } +//! let client = Builder::new("https://mempool.space/api").build_blocking(); +//! let height = client.get_height().unwrap(); //! ``` //! -//! Here is an example of how to create an asynchronous client. +//! # Async Client //! -//! ```no_run -//! # #[cfg(all(feature = "async", feature = "tokio"))] -//! # { +//! ```rust,ignore //! use esplora_client::Builder; -//! let builder = Builder::new("https://blockstream.info/testnet/api"); -//! let async_client = builder.build_async(); -//! # Ok::<(), esplora_client::Error>(()); -//! # } +//! async fn example() { +//! let client = Builder::new("https://mempool.space/api") +//! .build_async() +//! .unwrap(); +//! let height = client.get_height().await.unwrap(); +//! } //! ``` //! -//! ## Features +//! # Features +//! +//! By default, all features are enabled. To use a specific feature +//! combination, set `default-features = false` and explicitly enable +//! the desired features in your `Cargo.toml` manifest: +//! +//! `esplora-client = { version = "*", default-features = false, features = ["blocking"] }` +//! +//! ### Blocking //! -//! 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: +//! | Feature | Description | +//! |---------|-------------| +//! | `blocking` | Enables the blocking client with proxy support. | +//! | `blocking-https` | Enables the blocking client with proxy and TLS using the default [`minreq`](https://docs.rs/minreq) backend. | +//! | `blocking-https-rustls` | Enables the blocking client with proxy and TLS using [`rustls`](https://docs.rs/rustls). | +//! | `blocking-https-native` | Enables the blocking client with proxy and TLS using the platform's native TLS backend. | +//! | `blocking-https-bundled` | Enables the blocking client with proxy and TLS using a bundled OpenSSL backend. | //! -//! `esplora-client = { version = "*", default-features = false, features = -//! ["blocking"] }` +//! ### Async //! -//! * `blocking` enables [`minreq`], the blocking client with proxy. -//! * `blocking-https` enables [`minreq`], the blocking client with proxy and TLS (SSL) capabilities -//! using the default [`minreq`] backend. -//! * `blocking-https-rustls` enables [`minreq`], the blocking client with proxy and TLS (SSL) -//! capabilities using the `rustls` backend. -//! * `blocking-https-native` enables [`minreq`], the blocking client with proxy and TLS (SSL) -//! capabilities using the platform's native TLS backend (likely OpenSSL). -//! * `blocking-https-bundled` enables [`minreq`], the blocking client with proxy and TLS (SSL) -//! capabilities using a bundled OpenSSL library backend. -//! * `async` enables [`reqwest`], the async client with proxy capabilities. -//! * `async-https` enables [`reqwest`], the async client with support for proxying and TLS (SSL) -//! using the default [`reqwest`] TLS backend. -//! * `async-https-native` enables [`reqwest`], the async client with support for proxying and TLS -//! (SSL) using the platform's native TLS backend (likely OpenSSL). -//! * `async-https-rustls` enables [`reqwest`], the async client with support for proxying and TLS -//! (SSL) using the `rustls` TLS backend. -//! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for -//! proxying and TLS (SSL) using the `rustls` TLS backend without using the default root -//! certificates. +//! | Feature | Description | +//! |---------|-------------| +//! | `async` | Enables the async client with proxy support. | +//! | `tokio` | Enables the Tokio runtime for the async client. | +//! | `async-https` | Enables the async client with proxy and TLS using the default [`reqwest`](https://docs.rs/reqwest) backend. | +//! | `async-https-native` | Enables the async client with proxy and TLS using the platform's native TLS backend. | +//! | `async-https-rustls` | Enables the async client with proxy and TLS using [`rustls`](https://docs.rs/rustls). | +//! | `async-https-rustls-manual-roots` | Enables the async client with proxy and TLS using `rustls` without default root certificates. | //! -//! [`dont remove this line or cargo doc will break`]: https://example.com +//! [`dont remove the 2 lines below or `cargo doc` will break`]: https://example.com #![cfg_attr(not(feature = "minreq"), doc = "[`minreq`]: https://docs.rs/minreq")] #![cfg_attr(not(feature = "reqwest"), doc = "[`reqwest`]: https://docs.rs/reqwest")] #![allow(clippy::result_large_err)] #![warn(missing_docs)] +use core::fmt; +use core::fmt::Display; +use core::fmt::Formatter; +use core::time::Duration; use std::collections::HashMap; -use std::fmt; -use std::num::TryFromIntError; -use std::time::Duration; #[cfg(feature = "async")] pub use r#async::Sleeper; @@ -98,52 +90,79 @@ pub const RETRYABLE_ERROR_CODES: [u16; 3] = [ ]; /// Base backoff in milliseconds. -const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); +#[doc(hidden)] +pub const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); /// Default max retries. -const DEFAULT_MAX_RETRIES: usize = 6; +#[doc(hidden)] +pub const DEFAULT_MAX_RETRIES: usize = 6; -/// Get a fee value in sats/vbytes from the estimates -/// that matches the confirmation target set as parameter. +/// Returns 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 +/// matching estimate is found. +/// +/// # Example +/// +/// ```rust +/// use bitcoin::FeeRate; +/// use esplora_client::convert_fee_rate; +/// use std::collections::HashMap; +/// +/// let mut estimates = HashMap::new(); +/// estimates.insert(1u16, FeeRate::from_sat_per_vb(10).unwrap()); +/// estimates.insert(6u16, FeeRate::from_sat_per_vb(5).unwrap()); /// -/// Returns `None` if no feerate estimate is found at or below `target` -/// confirmations. -pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option { +/// assert_eq!( +/// convert_fee_rate(6, estimates.clone()), +/// Some(FeeRate::from_sat_per_vb(5).unwrap()) +/// ); +/// assert_eq!( +/// convert_fee_rate(1, estimates.clone()), +/// Some(FeeRate::from_sat_per_vb(10).unwrap()) +/// ); +/// assert_eq!(convert_fee_rate(0, estimates), None); +/// ``` +pub fn convert_fee_rate(target_blocks: usize, estimates: HashMap) -> Option { estimates .into_iter() - .filter(|(k, _)| *k as usize <= target) + .filter(|(k, _)| *k as usize <= target_blocks) .max_by_key(|(k, _)| *k) - .map(|(_, v)| v as f32) + .map(|(_, feerate)| feerate) } /// A builder for an [`AsyncClient`] or [`BlockingClient`]. +/// +/// Use [`Builder::new`] to create a new builder, configure it with the +/// chainable methods, then call [`Builder::build_blocking`] or +/// [`Builder::build_async`] to construct the client. #[derive(Debug, Clone)] pub struct Builder { /// The URL of the Esplora server. pub base_url: String, - /// Optional URL of the proxy to use to make requests to the Esplora server + /// Optional URL of the proxy to use to make requests to the Esplora server. /// /// The string should be formatted as: /// `://:@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 `reqwest`). For more details check with - /// the documentation of the two crates. Both of them are compiled with - /// the `socks` feature enabled. + /// slightly between the blocking client (using [`minreq`]) and the async + /// client (using [`reqwest`]). Both are compiled with the `socks` feature + /// enabled. /// /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, - /// Socket timeout. + /// The socket's 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 times to retry a request. pub max_retries: usize, } impl Builder { - /// Instantiate a new builder + /// Create a new [`Builder`] with the given Esplora server URL. pub fn new(base_url: &str) -> Self { Builder { base_url: base_url.to_string(), @@ -154,95 +173,110 @@ impl Builder { } } - /// Set the proxy of the builder + /// Set the proxy URL. + /// + /// See [`Builder::proxy`] for the expected format. pub fn proxy(mut self, proxy: &str) -> Self { self.proxy = Some(proxy.to_string()); self } - /// Set the timeout of the builder + /// Set the socket's 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 an HTTP header to set on 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 times to retry a request. + /// + /// Retries are only attempted for responses + /// with status codes defined in [`RETRYABLE_ERROR_CODES`]. pub fn max_retries(mut self, count: usize) -> Self { self.max_retries = count; self } - /// Build a blocking client from builder + /// Build a [`BlockingClient`] from this [`Builder`]. #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient { BlockingClient::from_builder(self) } - /// Build an asynchronous client from builder + /// Build an [`AsyncClient`] from this [`Builder`]. + /// + /// # Errors + /// + /// Returns an [`Error`] if the underlying [`reqwest::Client`] fails to build. #[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`] from this [`Builder`] with a custom [`Sleeper`]. + /// + /// Use this instead of [`Builder::build_async`] when you want to use a + /// runtime other than Tokio for sleeping between retries. + /// + /// # Errors + /// + /// Returns an [`Error`] if the underlying [`reqwest::Client`] fails to build. #[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 during a request to an Esplora server. #[derive(Debug)] pub enum Error { - /// Error during `minreq` HTTP request + /// A [`minreq`] error occurred during a blocking HTTP request. #[cfg(feature = "blocking")] Minreq(minreq::Error), - /// Error during `reqwest` HTTP request + /// A [`reqwest`] error occurred during an async HTTP request. #[cfg(feature = "async")] Reqwest(reqwest::Error), - /// Error during JSON (de)serialization + /// An error occurred during JSON serialization or deserialization. SerdeJson(serde_json::Error), - /// HTTP response error + /// The server returned a non-success HTTP status code. HttpResponse { /// The HTTP status code returned by the server. status: u16, - /// The error message content. + /// The error message returned by the server. message: String, }, - /// Invalid number returned - Parsing(std::num::ParseIntError), - /// Invalid status code, unable to convert to `u16` - StatusCode(TryFromIntError), - /// Invalid Bitcoin data returned + /// Failed to parse an integer from the server response. + Parsing(core::num::ParseIntError), + /// Failed to convert an HTTP status code to `u16`. + StatusCode(core::num::TryFromIntError), + /// Failed to decode a Bitcoin consensus-encoded value. BitcoinEncoding(bitcoin::consensus::encode::Error), - /// Invalid hex data returned (attempting to create an array) + /// Failed to decode a hex string into a fixed-size array. HexToArray(bitcoin::hex::HexToArrayError), - /// Invalid hex data returned (attempting to create a vector) + /// Failed to decode a hex string into a vector of bytes. HexToBytes(bitcoin::hex::HexToBytesError), - /// Transaction not found + /// The requested [`Transaction`] was not found. TransactionNotFound(Txid), - /// Block Header height not found + /// No [`block header`](bitcoin::blockdata::block::Header) was found at the given height. HeaderHeightNotFound(u32), - /// Block Header hash not found + /// No [`block header`](bitcoin::blockdata::block::Header) was found with the given + /// [`BlockHash`]. HeaderHashNotFound(BlockHash), - /// Invalid HTTP Header name specified + /// The specified HTTP header name is invalid. InvalidHttpHeaderName(String), - /// Invalid HTTP Header value specified + /// The specified HTTP header value is invalid. InvalidHttpHeaderValue(String), - /// The server sent an invalid response + /// The server returned an invalid or unexpected response. InvalidResponse, } -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{self:?}") } } @@ -252,7 +286,7 @@ macro_rules! impl_error { impl_error!($from, $to, Error); }; ( $from:ty, $to:ident, $impl_for:ty ) => { - impl std::convert::From<$from> for $impl_for { + impl core::convert::From<$from> for $impl_for { fn from(err: $from) -> Self { <$impl_for>::$to(err) } @@ -266,7 +300,7 @@ impl_error!(::minreq::Error, Minreq, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); impl_error!(serde_json::Error, SerdeJson, Error); -impl_error!(std::num::ParseIntError, Parsing, Error); +impl_error!(core::num::ParseIntError, Parsing, Error); impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error); impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error); @@ -473,51 +507,63 @@ mod test { } #[test] - fn feerate_parsing() { - let esplora_fees = serde_json::from_str::>( + fn test_feerate_parsing() { + let esplora_fees_raw = serde_json::from_str::>( r#"{ - "25": 1.015, - "5": 2.3280000000000003, - "12": 2.0109999999999997, - "15": 1.018, - "17": 1.018, - "11": 2.0109999999999997, - "3": 3.01, - "2": 4.9830000000000005, - "6": 2.2359999999999998, - "21": 1.018, - "13": 1.081, - "7": 2.2359999999999998, - "8": 2.2359999999999998, - "16": 1.018, - "20": 1.018, - "22": 1.017, - "23": 1.017, - "504": 1, - "9": 2.2359999999999998, - "14": 1.018, - "10": 2.0109999999999997, - "24": 1.017, - "1008": 1, - "1": 4.9830000000000005, - "4": 2.3280000000000003, - "19": 1.018, - "144": 1, - "18": 1.018 -} -"#, + "1": 1.952, + "2": 1.952, + "3": 1.199, + "4": 1.013, + "5": 1.013, + "6": 1.013, + "7": 1.013, + "8": 1.013, + "9": 1.013, + "10": 1.013, + "11": 1.013, + "12": 1.013, + "13": 0.748, + "14": 0.748, + "15": 0.748, + "16": 0.748, + "17": 0.748, + "18": 0.748, + "19": 0.748, + "20": 0.748, + "21": 0.748, + "22": 0.748, + "23": 0.748, + "24": 0.748, + "25": 0.748, + "144": 0.693, + "504": 0.693, + "1008": 0.693 +}"#, ) .unwrap(); + + // Convert fees from sat/vB (`f64`) to `FeeRate`. + // Note that `get_fee_estimates` already returns `HashMap`. + let esplora_fees = sat_per_vbyte_to_feerate(esplora_fees_raw); + assert!(convert_fee_rate(1, HashMap::new()).is_none()); - assert_eq!(convert_fee_rate(6, esplora_fees.clone()).unwrap(), 2.236); assert_eq!( - convert_fee_rate(26, esplora_fees.clone()).unwrap(), - 1.015, - "should inherit from value for 25" + convert_fee_rate(6, esplora_fees.clone()), + Some(FeeRate::from_sat_per_kwu( + (1.013_f64 * 250_000.0).round() as u64 + )), + "should inherit from value for target=6" + ); + assert_eq!( + convert_fee_rate(26, esplora_fees.clone()), + Some(FeeRate::from_sat_per_kwu( + (0.748_f64 * 250_000.0).round() as u64 + )), + "should inherit from value for target=25" ); assert!( convert_fee_rate(0, esplora_fees).is_none(), - "should not return feerate for 0 target" + "should not return feerate for target=0" ); } @@ -635,8 +681,8 @@ mod test { assert_eq!(tx_info.txid, txid); assert_eq!(tx_info.to_tx(), tx_exp); assert_eq!(tx_info.size, tx_exp.total_size()); - assert_eq!(tx_info.weight(), tx_exp.weight()); - assert_eq!(tx_info.fee(), tx_res.fee.unwrap().unsigned_abs()); + assert_eq!(tx_info.weight, tx_exp.weight()); + assert_eq!(tx_info.fee, tx_res.fee.unwrap().unsigned_abs()); assert!(tx_info.status.confirmed); assert_eq!(tx_info.status.block_height, Some(tx_block_height)); assert_eq!(tx_info.status.block_hash, tx_res.block_hash); @@ -725,6 +771,42 @@ mod test { assert_eq!(expected, block_status_async); } + // TODO(@luisschwab): remove on `v0.14.0` + #[allow(deprecated)] + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_blocks() { + let env = TestEnv::new(); + let (blocking_client, async_client) = env.setup_clients(); + + let start_height = env.bitcoind_client().get_block_count().unwrap().0; + let blocks1 = blocking_client.get_blocks(None).unwrap(); + let blocks_async1 = async_client.get_blocks(None).await.unwrap(); + assert_eq!(blocks1[0].time.height, start_height as u32); + assert_eq!(blocks1, blocks_async1); + env.mine_and_wait(1); + + let blocks2 = blocking_client.get_blocks(None).unwrap(); + let blocks_async2 = async_client.get_blocks(None).await.unwrap(); + assert_eq!(blocks2, blocks_async2); + assert_ne!(blocks2, blocks1); + + let blocks3 = blocking_client + .get_blocks(Some(start_height as u32)) + .unwrap(); + let blocks_async3 = async_client + .get_blocks(Some(start_height as u32)) + .await + .unwrap(); + assert_eq!(blocks3, blocks_async3); + assert_eq!(blocks3[0].time.height, start_height as u32); + assert_eq!(blocks3, blocks1); + + let blocks_genesis = blocking_client.get_blocks(Some(0)).unwrap(); + let blocks_genesis_async = async_client.get_blocks(Some(0)).await.unwrap(); + assert_eq!(blocks_genesis, blocks_genesis_async); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_block_by_hash() { @@ -952,7 +1034,7 @@ mod test { #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] - async fn test_scripthash_txs() { + async fn test_get_script_hash_txs() { let env = TestEnv::new(); let (blocking_client, async_client) = env.setup_clients(); @@ -973,20 +1055,20 @@ mod test { .unwrap() .tx; let script = &expected_tx.output[0].script_pubkey; - let scripthash_txs_txids: Vec = blocking_client - .scripthash_txs(script, None) + let script_hash_txs_txids_blocking: Vec = blocking_client + .get_script_hash_txs(script, None) .unwrap() .iter() .map(|tx| tx.txid) .collect(); - let scripthash_txs_txids_async: Vec = async_client - .scripthash_txs(script, None) + let script_hash_txs_txids_async: Vec = async_client + .get_scripthash_txs(script, None) .await .unwrap() .iter() .map(|tx| tx.txid) .collect(); - assert_eq!(scripthash_txs_txids, scripthash_txs_txids_async); + assert_eq!(script_hash_txs_txids_blocking, script_hash_txs_txids_async); } #[cfg(all(feature = "blocking", feature = "async"))] @@ -1063,35 +1145,35 @@ mod test { #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] - async fn test_get_blocks() { + async fn test_get_block_infos() { let env = TestEnv::new(); let (blocking_client, async_client) = env.setup_clients(); let start_height = env.bitcoind_client().get_block_count().unwrap().0; - let blocks1 = blocking_client.get_blocks(None).unwrap(); - let blocks_async1 = async_client.get_blocks(None).await.unwrap(); - assert_eq!(blocks1[0].time.height, start_height as u32); + let blocks1 = blocking_client.get_block_infos(None).unwrap(); + let blocks_async1 = async_client.get_block_infos(None).await.unwrap(); + assert_eq!(blocks1[0].height, start_height as u32); assert_eq!(blocks1, blocks_async1); env.mine_and_wait(1); - let blocks2 = blocking_client.get_blocks(None).unwrap(); - let blocks_async2 = async_client.get_blocks(None).await.unwrap(); + let blocks2 = blocking_client.get_block_infos(None).unwrap(); + let blocks_async2 = async_client.get_block_infos(None).await.unwrap(); assert_eq!(blocks2, blocks_async2); assert_ne!(blocks2, blocks1); let blocks3 = blocking_client - .get_blocks(Some(start_height as u32)) + .get_block_infos(Some(start_height as u32)) .unwrap(); let blocks_async3 = async_client - .get_blocks(Some(start_height as u32)) + .get_block_infos(Some(start_height as u32)) .await .unwrap(); assert_eq!(blocks3, blocks_async3); - assert_eq!(blocks3[0].time.height, start_height as u32); + assert_eq!(blocks3[0].height, start_height as u32); assert_eq!(blocks3, blocks1); - let blocks_genesis = blocking_client.get_blocks(Some(0)).unwrap(); - let blocks_genesis_async = async_client.get_blocks(Some(0)).await.unwrap(); + let blocks_genesis = blocking_client.get_block_infos(Some(0)).unwrap(); + let blocks_genesis_async = async_client.get_block_infos(Some(0)).await.unwrap(); assert_eq!(blocks_genesis, blocks_genesis_async); } @@ -1208,7 +1290,10 @@ mod test { let address_stats_async = async_client.get_address_stats(&address).await.unwrap(); assert_eq!(address_stats_blocking, address_stats_async); assert_eq!(address_stats_async.chain_stats.funded_txo_count, 1); - assert_eq!(address_stats_async.chain_stats.funded_txo_sum, 1000); + assert_eq!( + address_stats_async.chain_stats.funded_txo_sum, + Amount::from_sat(1000) + ); } #[cfg(all(feature = "blocking", feature = "async"))] @@ -1269,7 +1354,7 @@ mod test { ); assert_eq!( scripthash_stats_blocking_legacy.chain_stats.funded_txo_sum, - 1000 + Amount::from_sat(1000) ); assert_eq!(scripthash_stats_blocking_legacy.chain_stats.tx_count, 1); @@ -1289,7 +1374,7 @@ mod test { scripthash_stats_blocking_p2sh_segwit .chain_stats .funded_txo_sum, - 1000 + Amount::from_sat(1000) ); assert_eq!( scripthash_stats_blocking_p2sh_segwit.chain_stats.tx_count, @@ -1310,7 +1395,7 @@ mod test { ); assert_eq!( scripthash_stats_blocking_bech32.chain_stats.funded_txo_sum, - 1000 + Amount::from_sat(1000) ); assert_eq!(scripthash_stats_blocking_bech32.chain_stats.tx_count, 1); @@ -1328,7 +1413,7 @@ mod test { ); assert_eq!( scripthash_stats_blocking_bech32m.chain_stats.funded_txo_sum, - 1000 + Amount::from_sat(1000) ); assert_eq!(scripthash_stats_blocking_bech32m.chain_stats.tx_count, 1); }