From f9883d9da80538c0279ba84629b5e571acba5acb Mon Sep 17 00:00:00 2001 From: elnafateh Date: Tue, 3 Nov 2026 15:22:50 +0100 Subject: [PATCH] Add dynamic Esplora header providers --- Cargo.toml | 4 + src/builder.rs | 97 +- src/chain/esplora.rs | 116 +- src/chain/mod.rs | 12 +- src/ffi/types.rs | 49 + src/lib.rs | 2 + src/wallet/mod.rs | 1 + vendor/esplora-client-0.12.3/Cargo.toml | 145 ++ vendor/esplora-client-0.12.3/LICENSE | 21 + vendor/esplora-client-0.12.3/README.md | 23 + vendor/esplora-client-0.12.3/src/api.rs | 461 +++++++ vendor/esplora-client-0.12.3/src/async.rs | 682 ++++++++++ vendor/esplora-client-0.12.3/src/blocking.rs | 552 ++++++++ vendor/esplora-client-0.12.3/src/lib.rs | 1264 ++++++++++++++++++ vendor/esplora-client-0.13.0/Cargo.toml | 234 ++++ vendor/esplora-client-0.13.0/LICENSE-APACHE | 176 +++ vendor/esplora-client-0.13.0/LICENSE-MIT | 21 + vendor/esplora-client-0.13.0/LICENSE.md | 14 + vendor/esplora-client-0.13.0/README.md | 66 + vendor/esplora-client-0.13.0/src/api.rs | 524 ++++++++ vendor/esplora-client-0.13.0/src/async.rs | 764 +++++++++++ vendor/esplora-client-0.13.0/src/blocking.rs | 682 ++++++++++ vendor/esplora-client-0.13.0/src/lib.rs | 457 +++++++ 23 files changed, 6356 insertions(+), 11 deletions(-) create mode 100644 vendor/esplora-client-0.12.3/Cargo.toml create mode 100644 vendor/esplora-client-0.12.3/LICENSE create mode 100644 vendor/esplora-client-0.12.3/README.md create mode 100644 vendor/esplora-client-0.12.3/src/api.rs create mode 100644 vendor/esplora-client-0.12.3/src/async.rs create mode 100644 vendor/esplora-client-0.12.3/src/blocking.rs create mode 100644 vendor/esplora-client-0.12.3/src/lib.rs create mode 100644 vendor/esplora-client-0.13.0/Cargo.toml create mode 100644 vendor/esplora-client-0.13.0/LICENSE-APACHE create mode 100644 vendor/esplora-client-0.13.0/LICENSE-MIT create mode 100644 vendor/esplora-client-0.13.0/LICENSE.md create mode 100644 vendor/esplora-client-0.13.0/README.md create mode 100644 vendor/esplora-client-0.13.0/src/api.rs create mode 100644 vendor/esplora-client-0.13.0/src/async.rs create mode 100644 vendor/esplora-client-0.13.0/src/blocking.rs create mode 100644 vendor/esplora-client-0.13.0/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ba98aebdd6..50c2f99537 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,10 @@ prost = { version = "0.11.6", default-features = false, optional = true} #bitcoin-payment-instructions = { version = "0.6" } bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions", rev = "3dbe11bdf8916639df42476501fb40f87b253e84", optional = true } +[patch.crates-io] +esplora-client-012 = { package = "esplora-client", path = "vendor/esplora-client-0.12.3" } +esplora-client-013 = { package = "esplora-client", path = "vendor/esplora-client-0.13.0" } + [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } diff --git a/src/builder.rs b/src/builder.rs index 21f15508b4..9d6f53e083 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -120,12 +120,13 @@ use crate::{Node, NodeMetrics, PersistedNodeMetrics}; const LSPS_HARDENED_CHILD_INDEX: u32 = 577; const PERSISTER_MAX_PENDING_UPDATES: u64 = 100; -#[derive(Debug, Clone)] +#[derive(Clone)] enum ChainDataSourceConfig { #[cfg(feature = "chain-esplora")] Esplora { server_url: String, headers: HashMap, + header_provider: Option>, sync_config: Option, }, #[cfg(feature = "chain-electrum")] @@ -141,6 +142,44 @@ enum ChainDataSourceConfig { }, } +impl std::fmt::Debug for ChainDataSourceConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #[cfg(feature = "chain-esplora")] + Self::Esplora { server_url, headers, header_provider, sync_config } => f + .debug_struct("Esplora") + .field("server_url", server_url) + .field("headers", headers) + .field("has_header_provider", &header_provider.is_some()) + .field("sync_config", sync_config) + .finish(), + #[cfg(feature = "chain-electrum")] + Self::Electrum { server_url, sync_config } => f + .debug_struct("Electrum") + .field("server_url", server_url) + .field("sync_config", sync_config) + .finish(), + #[cfg(feature = "chain-bitcoind")] + Self::Bitcoind { + rpc_host, + rpc_port, + rpc_user, + rpc_password: _, + rest_client_config, + wallet_rescan_from_height, + } => f + .debug_struct("Bitcoind") + .field("rpc_host", rpc_host) + .field("rpc_port", rpc_port) + .field("rpc_user", rpc_user) + .field("rpc_password", &"[REDACTED]") + .field("rest_client_config", rest_client_config) + .field("wallet_rescan_from_height", wallet_rescan_from_height) + .finish(), + } + } +} + #[derive(Debug, Clone)] enum GossipSourceConfig { P2PNetwork, @@ -405,6 +444,7 @@ impl NodeBuilder { self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora { server_url, headers: Default::default(), + header_provider: None, sync_config, }); self @@ -422,8 +462,34 @@ impl NodeBuilder { &mut self, server_url: String, headers: HashMap, sync_config: Option, ) -> &mut Self { - self.chain_data_source_config = - Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }); + self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora { + server_url, + headers, + header_provider: None, + sync_config, + }); + self + } + + /// Configures Esplora requests to obtain headers from `header_provider` at request time. + /// + /// The provider is shared by wallet synchronization and LDK transaction synchronization. It is + /// called for every HTTP request, allowing applications to refresh short-lived credentials + /// while the node is running. Provider errors fail the corresponding Esplora request. + /// + /// If no `sync_config` is given, default values are used. See [`EsploraSyncConfig`] for more + /// information. + #[cfg(feature = "chain-esplora")] + pub fn set_chain_source_esplora_with_header_provider( + &mut self, server_url: String, header_provider: Arc, + sync_config: Option, + ) -> &mut Self { + self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora { + server_url, + headers: Default::default(), + header_provider: Some(header_provider), + sync_config, + }); self } @@ -1064,6 +1130,22 @@ impl Builder { ) { self.inner.write().expect("lock").set_chain_source_esplora(server_url, sync_config); } + + /// Configures Esplora requests to obtain headers from `header_provider` at request time. + /// + /// The provider is shared by wallet synchronization and LDK transaction synchronization and is + /// called for every HTTP request. Provider errors fail the corresponding request. + pub fn set_chain_source_esplora_with_header_provider( + &self, server_url: String, header_provider: Arc, + sync_config: Option, + ) { + let adapter = Arc::new(crate::ffi::EsploraHeaderProviderAdapter::new(header_provider)); + self.inner.write().expect("lock").set_chain_source_esplora_with_header_provider( + server_url, + adapter, + sync_config, + ); + } } #[cfg(all(feature = "uniffi", feature = "chain-esplora"))] @@ -1645,11 +1727,17 @@ fn build_with_store_internal( let (chain_source, chain_tip_opt) = match chain_data_source_config { #[cfg(feature = "chain-esplora")] - Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => { + Some(ChainDataSourceConfig::Esplora { + server_url, + headers, + header_provider, + sync_config, + }) => { let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default()); ChainSource::new_esplora( server_url.clone(), headers.clone(), + header_provider.clone(), sync_config, Arc::clone(&fee_estimator), Arc::clone(&tx_broadcaster), @@ -1724,6 +1812,7 @@ fn build_with_store_internal( ChainSource::new_esplora( server_url.clone(), HashMap::new(), + None, sync_config, Arc::clone(&fee_estimator), Arc::clone(&tx_broadcaster), diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 1c13f141fb..27ecfa37b7 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -10,6 +10,25 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +/// Supplies current HTTP headers for each Esplora request. +/// +/// Implementations can refresh short-lived credentials while the node is running. The provider is +/// called separately by both Esplora clients and its errors fail the corresponding request. +#[async_trait::async_trait] +pub trait EsploraHeaderProvider: Send + Sync { + /// Return the headers to attach to the next Esplora request. + async fn get_headers(&self) -> Result, String>; +} + +fn to_client_header_provider( + provider: Arc, +) -> esplora_client::HeaderProvider { + Arc::new(move || { + let provider = Arc::clone(&provider); + Box::pin(async move { provider.get_headers().await }) + }) +} + use bdk_esplora::EsploraAsyncExt; use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, Txid}; @@ -49,7 +68,8 @@ pub(super) struct EsploraChainSource { impl EsploraChainSource { pub(crate) fn new( - server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, + server_url: String, headers: HashMap, + header_provider: Option>, sync_config: EsploraSyncConfig, fee_estimator: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc, ) -> Result { @@ -66,10 +86,21 @@ impl EsploraChainSource { ldk_client_builder = ldk_client_builder.header(header_name, header_value); } - let esplora_client = client_builder.build_async().map_err(|e| { + let esplora_client = if let Some(provider) = header_provider.as_ref() { + client_builder + .build_async_with_header_provider(to_client_header_provider(Arc::clone(provider))) + } else { + client_builder.build_async() + } + .map_err(|e| { log_error!(logger, "Failed to build Esplora client: {}", e); })?; - let ldk_esplora_client = ldk_client_builder.build_async().map_err(|e| { + let ldk_esplora_client = if let Some(provider) = header_provider { + ldk_client_builder.build_async_with_header_provider(to_client_header_provider(provider)) + } else { + ldk_client_builder.build_async() + } + .map_err(|e| { log_error!(logger, "Failed to build LDK Esplora client: {}", e); })?; let tx_sync = @@ -530,6 +561,85 @@ impl EsploraChainSource { } } +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + + use super::{to_client_header_provider, EsploraHeaderProvider}; + use std::collections::HashMap; + use std::sync::Arc; + + struct RotatingHeaderProvider { + next_token: AtomicUsize, + } + + #[async_trait::async_trait] + impl EsploraHeaderProvider for RotatingHeaderProvider { + async fn get_headers(&self) -> Result, String> { + let token = self.next_token.fetch_add(1, Ordering::SeqCst) + 1; + Ok(HashMap::from([("Authorization".to_string(), format!("Bearer {token}"))])) + } + } + + fn start_esplora_server(request_count: usize) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let mut requests = Vec::with_capacity(request_count); + for _ in 0..request_count { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let bytes_read = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..bytes_read]); + if bytes_read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + requests.push(String::from_utf8(request).unwrap()); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n1", + ) + .unwrap(); + } + requests + }); + (format!("http://{address}"), server) + } + + #[tokio::test] + async fn dynamic_headers_refresh_for_both_esplora_clients() { + let (server_url, server) = start_esplora_server(2); + let provider: Arc = + Arc::new(RotatingHeaderProvider { next_token: AtomicUsize::new(0) }); + + let bdk_client = esplora_client::Builder::new(&server_url) + .build_async_with_header_provider(to_client_header_provider(Arc::clone(&provider))) + .unwrap(); + assert_eq!(bdk_client.get_height().await.unwrap(), 1); + + let ldk_client = ldk_esplora_client::Builder::new(&server_url) + .build_async_with_header_provider(to_client_header_provider(provider)) + .unwrap(); + assert_eq!(ldk_client.get_height().await.unwrap(), 1); + + let requests = server.join().unwrap(); + assert!( + requests[0].contains("authorization: Bearer 1\r\n") + || requests[0].contains("Authorization: Bearer 1\r\n") + ); + assert!( + requests[1].contains("authorization: Bearer 2\r\n") + || requests[1].contains("Authorization: Bearer 2\r\n") + ); + } +} + impl Filter for EsploraChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { self.tx_sync.register_tx(txid, script_pubkey); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index e1f7e2ad41..11bedced80 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -11,6 +11,8 @@ pub(crate) mod bitcoind; mod electrum; #[cfg(feature = "chain-esplora")] mod esplora; +#[cfg(feature = "chain-esplora")] +pub use self::esplora::EsploraHeaderProvider; #[cfg(feature = "chain-esplora")] use std::collections::HashMap; @@ -225,14 +227,16 @@ impl Listen for ChainListener { impl ChainSource { #[cfg(feature = "chain-esplora")] pub(crate) fn new_esplora( - server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, - fee_estimator: Arc, tx_broadcaster: Arc, - kv_store: Arc, config: Arc, logger: Arc, - node_metrics: Arc, + server_url: String, headers: HashMap, + header_provider: Option>, + sync_config: EsploraSyncConfig, fee_estimator: Arc, + tx_broadcaster: Arc, kv_store: Arc, config: Arc, + logger: Arc, node_metrics: Arc, ) -> Result<(Self, Option), ()> { let esplora_chain_source = EsploraChainSource::new( server_url, headers, + header_provider, sync_config, fee_estimator, kv_store, diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 6d39bdaa46..ec8850b133 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -134,6 +134,55 @@ pub trait VssHeaderProvider: Send + Sync { ) -> Result, VssHeaderProviderError>; } +/// Error returned when an Esplora header provider cannot supply headers. +#[derive(Debug, uniffi::Error)] +pub enum EsploraHeaderProviderError { + /// Providing headers failed. + ProviderError { + /// Description of the failure. + error: String, + }, +} + +impl std::fmt::Display for EsploraHeaderProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProviderError { error } => write!(f, "Esplora header provider failed: {error}"), + } + } +} + +impl std::error::Error for EsploraHeaderProviderError {} + +/// Supplies current HTTP headers for each Esplora request from a foreign language. +/// +/// This callback is synchronous because UniFFI 0.29 does not support async foreign traits. It +/// should return cached/current headers promptly; token acquisition and refresh can run separately. +#[uniffi::export(with_foreign)] +pub trait EsploraHeaderProvider: Send + Sync { + /// Returns the headers to use for the next Esplora request. + fn get_headers(&self) -> Result, EsploraHeaderProviderError>; +} + +pub(crate) struct EsploraHeaderProviderAdapter { + inner: Arc, +} + +impl EsploraHeaderProviderAdapter { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[async_trait::async_trait] +impl crate::EsploraHeaderProvider for EsploraHeaderProviderAdapter { + async fn get_headers(&self) -> Result, String> { + self.inner.get_headers().map_err(|e| match e { + EsploraHeaderProviderError::ProviderError { error } => error, + }) + } +} + /// An adapter that wraps the local [`VssHeaderProvider`] and implements the upstream /// [`VssClientHeaderProvider`] trait. #[cfg(feature = "storage-vss")] diff --git a/src/lib.rs b/src/lib.rs index 9793081caf..5438f760ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,8 @@ compile_error!("at least one chain source feature must be enabled"); mod balance; mod builder; mod chain; +#[cfg(feature = "chain-esplora")] +pub use crate::chain::EsploraHeaderProvider; pub mod config; mod connection; mod data_store; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c5fc5c80ee..bc1d756411 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -4879,6 +4879,7 @@ mod tests { let (chain_source, _) = ChainSource::new_esplora( "http://localhost:1".to_string(), HashMap::new(), + None, EsploraSyncConfig::default(), Arc::clone(&fee_estimator), Arc::clone(&broadcaster), diff --git a/vendor/esplora-client-0.12.3/Cargo.toml b/vendor/esplora-client-0.12.3/Cargo.toml new file mode 100644 index 0000000000..284979497b --- /dev/null +++ b/vendor/esplora-client-0.12.3/Cargo.toml @@ -0,0 +1,145 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.63.0" +name = "esplora-client" +version = "0.12.3" +authors = [ + "Alekos Filini ", + "Bitcoin Dev Kit Developers", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Bitcoin Esplora API client library. Supports plaintext, TLS and Onion servers. Blocking or async" +homepage = "https://github.com/bitcoindevkit/rust-esplora-client" +documentation = "https://docs.rs/esplora-client/" +readme = "README.md" +keywords = [ + "bitcoin", + "esplora", +] +license = "MIT" +repository = "https://github.com/bitcoindevkit/rust-esplora-client" + +[features] +async = [ + "reqwest", + "reqwest/socks", + "tokio?/time", +] +async-https = [ + "async", + "reqwest/default-tls", +] +async-https-native = [ + "async", + "reqwest/native-tls", +] +async-https-rustls = [ + "async", + "reqwest/rustls-tls", +] +async-https-rustls-manual-roots = [ + "async", + "reqwest/rustls-tls-manual-roots", +] +blocking = [ + "minreq", + "minreq/proxy", +] +blocking-https = [ + "blocking", + "minreq/https", +] +blocking-https-bundled = [ + "blocking", + "minreq/https-bundled", +] +blocking-https-native = [ + "blocking", + "minreq/https-native", +] +blocking-https-rustls = [ + "blocking", + "minreq/https-rustls", +] +default = [ + "blocking", + "async", + "async-https", + "tokio", +] +tokio = ["dep:tokio"] + +[lib] +name = "esplora_client" +path = "src/lib.rs" + +[dependencies.bitcoin] +version = "0.32" +features = [ + "serde", + "std", +] +default-features = false + +[dependencies.hex] +version = "0.2" +package = "hex-conservative" + +[dependencies.log] +version = "^0.4" + +[dependencies.minreq] +version = "2.11.0" +features = ["json-using-serde"] +optional = true + +[dependencies.reqwest] +version = "0.12" +features = ["json"] +optional = true +default-features = false + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0" +features = ["alloc"] +default-features = false + +[dependencies.tokio] +version = "1" +features = ["time"] +optional = true + +[dev-dependencies.electrsd] +version = "0.33.0" +features = [ + "legacy", + "esplora_a33e97e1", + "corepc-node_28_0", +] + +[dev-dependencies.lazy_static] +version = "1.4.0" + +[dev-dependencies.tokio] +version = "1.20.1" +features = ["full"] diff --git a/vendor/esplora-client-0.12.3/LICENSE b/vendor/esplora-client-0.12.3/LICENSE new file mode 100644 index 0000000000..88378ad414 --- /dev/null +++ b/vendor/esplora-client-0.12.3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Magical Bitcoin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/esplora-client-0.12.3/README.md b/vendor/esplora-client-0.12.3/README.md new file mode 100644 index 0000000000..49b10a58b7 --- /dev/null +++ b/vendor/esplora-client-0.12.3/README.md @@ -0,0 +1,23 @@ +# rust-esplora-client + +Bitcoin Esplora API client library. Supports plaintext, TLS and Onion servers. Blocking or async. + +

+ Crate Info + MIT Licensed + CI Status + Coverage Status + API Docs + Rustc Version 1.63.0+ + Chat on Discord +

+ +## Minimum Supported Rust Version (MSRV) + +This library should compile with any combination of features with Rust 1.63.0. + +To build with the MSRV you will need to pin dependencies: + +```shell +bash ci/pin-msrv.sh +``` diff --git a/vendor/esplora-client-0.12.3/src/api.rs b/vendor/esplora-client-0.12.3/src/api.rs new file mode 100644 index 0000000000..27424bd40d --- /dev/null +++ b/vendor/esplora-client-0.12.3/src/api.rs @@ -0,0 +1,461 @@ +// Bitcoin Dev Kit +// Written in 2020 by Alekos Filini +// +// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Structs from the Esplora API +//! +//! See: + +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, +}; + +/// 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, +} + +/// Information about an input from a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Vin { + /// The [`Txid`] of the previous [`Transaction`] this input spends from. + pub txid: Txid, + /// The output index of the previous output in the [`Transaction`] that created it. + 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 scriptsig: ScriptBuf, + /// 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. + pub sequence: u32, + /// Whether this is a coinbase input. + pub is_coinbase: bool, +} + +/// Information about a [`Transaction`]s output. +#[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`]. + pub scriptpubkey: ScriptBuf, +} + +/// The confirmation status of a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TxStatus { + /// Whether the [`Transaction`] is confirmed or not. + pub confirmed: bool, + /// The block height the [`Transaction`] was confirmed in. + pub block_height: Option, + /// 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`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct MerkleProof { + /// The height of the block the [`Transaction`] was confirmed in. + pub block_height: u32, + /// A list of transaction hashes the current hash is paired with, + /// recursively, in order to trace up to obtain the Merkle root of the + /// [`Block`], deepest pairing first. + pub merkle: Vec, + /// The 0-based index of the position of the [`Transaction`] in the + /// ordered list of [`Transaction`]s in the [`Block`]. + pub pos: usize, +} + +/// The spend status of a [`TxOut`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct OutputStatus { + /// Whether the [`TxOut`] is spent or not. + pub spent: bool, + /// The [`Txid`] that spent this [`TxOut`]. + pub txid: Option, + /// The input index of this [`TxOut`] in the [`Transaction`] that spent it. + pub vin: Option, + /// Information about the [`Transaction`] that spent this [`TxOut`]. + pub status: Option, +} + +/// Information about a [`Block`]s status. +#[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). + pub in_best_chain: bool, + /// The height of this [`Block`]. + pub height: Option, + /// The [`BlockHash`] of the [`Block`] that builds on top of this one. + 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, +} + +/// Information about a bitcoin [`Block`]. +#[derive(Debug, Clone, Deserialize)] +pub struct BlockInfo { + /// The [`Block`]'s [`BlockHash`]. + pub id: BlockHash, + /// The [`Block`]'s height. + pub height: u32, + /// The [`Block`]'s version. + pub version: block::Version, + /// The [`Block`]'s UNIX timestamp. + pub timestamp: u64, + /// The [`Block`]'s [`Transaction`] count. + pub tx_count: u64, + /// 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 merkle_root: hash_types::TxMerkleNode, + /// The [`BlockHash`] of the previous [`Block`] (`None` for the genesis block). + pub previousblockhash: Option, + /// The [`Block`]'s MTP (Median Time Past). + pub mediantime: u64, + /// The [`Block`]'s nonce value. + pub nonce: u32, + /// The [`Block`]'s `bits` value as a [`CompactTarget`]. + pub bits: CompactTarget, + /// The [`Block`]'s difficulty target value. + pub difficulty: f64, +} + +impl PartialEq for BlockInfo { + fn eq(&self, other: &Self) -> bool { + let Self { difficulty: d1, .. } = self; + let Self { difficulty: d2, .. } = other; + + self.id == other.id + && self.height == other.height + && self.version == other.version + && self.timestamp == other.timestamp + && self.tx_count == other.tx_count + && self.size == other.size + && self.weight == other.weight + && self.merkle_root == other.merkle_root + && self.previousblockhash == other.previousblockhash + && self.mediantime == other.mediantime + && self.nonce == other.nonce + && self.bits == other.bits + && ((d1.is_nan() && d2.is_nan()) || (d1 == d2)) + } +} +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, +} + +/// Statistics about an [`Address`]. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct AddressStats { + /// The [`Address`]. + 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`]. + pub mempool_stats: AddressTxsSummary, +} + +/// A summary of [`Transaction`]s in which an [`Address`] was involved. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +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 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 number of [`Transaction`]s. + pub tx_count: u32, +} + +/// Statistics about a particular [`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. + pub mempool_stats: ScriptHashTxsSummary, +} + +/// Contains a summary of the [`Transaction`]s for a particular [`Script`] hash. +pub type ScriptHashTxsSummary = AddressTxsSummary; + +/// Information about a [`TxOut`]'s status: confirmation status, +/// confirmation height, confirmation block hash and confirmation block time. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct UtxoStatus { + /// Whether or not the [`TxOut`] is confirmed. + pub confirmed: bool, + /// The block height in which the [`TxOut`] was confirmed. + pub block_height: Option, + /// The block hash in which the [`TxOut`] was confirmed. + pub block_hash: Option, + /// The UNIX timestamp in which the [`TxOut`] was confirmed. + pub block_time: Option, +} + +/// Information about an [`TxOut`]'s outpoint, confirmation status and value. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct Utxo { + /// The [`Txid`] of the [`Transaction`] that created the [`TxOut`]. + pub txid: Txid, + /// The output index of the [`TxOut`] in the [`Transaction`] that created it. + pub vout: u32, + /// The confirmation status of the [`TxOut`]. + pub status: UtxoStatus, + /// The value of the [`TxOut`] as an [`Amount`]. + pub value: Amount, +} + +/// Statistics about the mempool. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct MempoolStats { + /// The number of [`Transaction`]s in the mempool. + pub count: usize, + /// The total size of mempool [`Transaction`]s, in virtual bytes. + pub vsize: usize, + /// The total fee paid by mempool [`Transaction`]s, in satoshis. + pub total_fee: u64, + /// The mempool's fee rate distribution histogram. + /// + /// An array of `(feerate, vsize)` tuples, where each entry's `vsize` is the total vsize + /// of [`Transaction`]s paying more than `feerate` but less than the previous entry's `feerate` + /// (except for the first entry, which has no upper bound). + pub fee_histogram: Vec<(f64, usize)>, +} + +/// A [`Transaction`] that recently entered the mempool. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct MempoolRecentTx { + /// The [`Transaction`]'s ID, as a [`Txid`]. + pub txid: Txid, + /// The [`Amount`] of fees paid by the transaction, in satoshis. + pub fee: u64, + /// The [`Transaction`]'s size, in virtual bytes. + pub vsize: usize, + /// Combined [`Amount`] of the [`Transaction`], in satoshis. + pub value: u64, +} + +/// The result for a broadcasted 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. + pub package_msg: String, + /// Transaction results keyed by [`Wtxid`]. + #[serde(rename = "tx-results")] + pub tx_results: HashMap, + /// List of txids of replaced transactions. + #[serde(rename = "replaced-transactions")] + pub replaced_transactions: Option>, +} + +/// The result [`Transaction`] for a broadcasted package of [`Transaction`]s. +#[derive(Deserialize, Debug)] +pub struct TxResult { + /// The transaction id. + pub txid: Txid, + /// 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")] + pub other_wtxid: Option, + /// Sigops-adjusted virtual transaction size. + pub vsize: Option, + /// Transaction fees. + pub fees: Option, + /// The transaction error string, if it was rejected by the mempool + pub error: Option, +} + +/// The mempool fees for a resulting [`Transaction`] broadcasted by a package of [`Transaction`]s. +#[derive(Deserialize, Debug)] +pub struct MempoolFeesSubmitPackage { + /// Transaction fee. + #[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. + #[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. + #[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) + } +} + +fn deserialize_witness<'de, D>(d: D) -> Result>, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + let list = Vec::::deserialize(d)?; + list.into_iter() + .map(|hex_str| Vec::::from_hex(&hex_str)) + .collect::>, _>>() + .map_err(serde::de::Error::custom) +} + +fn deserialize_feerate<'de, D>(d: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Error; + + let btc_per_kvb = match Option::::deserialize(d)? { + Some(v) => v, + None => return Ok(None), + }; + let sat_per_kwu = btc_per_kvb * 25_000_000.0; + if sat_per_kwu.is_infinite() { + return Err(D::Error::custom("feerate overflow")); + } + Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64))) +} diff --git a/vendor/esplora-client-0.12.3/src/async.rs b/vendor/esplora-client-0.12.3/src/async.rs new file mode 100644 index 0000000000..851d4e6619 --- /dev/null +++ b/vendor/esplora-client-0.12.3/src/async.rs @@ -0,0 +1,682 @@ +// Bitcoin Dev Kit +// Written in 2020 by Alekos Filini +// +// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Esplora by way of `reqwest` HTTP client. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; +use std::time::Duration; + +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}; + +#[allow(unused_imports)] +use log::{debug, error, info, trace}; + +use reqwest::{header, Body, Client, Response}; + +use crate::{ + AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, HeaderProvider, + MempoolRecentTx, MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, + Tx, TxStatus, Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, +}; + +/// An async client for interacting with an Esplora API server. +#[derive(Clone)] +pub struct AsyncClient { + /// The URL of the Esplora Server. + url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. + client: Client, + /// Number of times to retry a request + max_retries: usize, + header_provider: Option, + /// Marker for the type of sleeper used + marker: PhantomData, +} + +impl AsyncClient { + /// Build an [`AsyncClient`] from a [`Builder`]. + pub fn from_builder(builder: Builder) -> Result { + let mut client_builder = Client::builder(); + + #[cfg(not(target_arch = "wasm32"))] + if let Some(proxy) = &builder.proxy { + client_builder = client_builder.proxy(reqwest::Proxy::all(proxy)?); + } + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = builder.timeout { + client_builder = client_builder.timeout(core::time::Duration::from_secs(timeout)); + } + + if !builder.headers.is_empty() { + let mut headers = header::HeaderMap::new(); + for (k, v) in builder.headers { + let header_name = header::HeaderName::from_lowercase(k.to_lowercase().as_bytes()) + .map_err(|_| Error::InvalidHttpHeaderName(k))?; + let header_value = header::HeaderValue::from_str(&v) + .map_err(|_| Error::InvalidHttpHeaderValue(v))?; + headers.insert(header_name, header_value); + } + client_builder = client_builder.default_headers(headers); + } + + Ok(AsyncClient { + url: builder.base_url, + client: client_builder.build()?, + max_retries: builder.max_retries, + header_provider: None, + marker: PhantomData, + }) + } + + /// Build an [`AsyncClient`] that obtains additional headers for each request. + pub fn from_builder_with_header_provider( + builder: Builder, header_provider: HeaderProvider, + ) -> Result { + let mut client = Self::from_builder(builder)?; + client.header_provider = Some(header_provider); + Ok(client) + } + + /// Build an [`AsyncClient`] from a [`Client`]. + pub fn from_client(url: String, client: Client) -> Self { + AsyncClient { + url, + client, + max_retries: crate::DEFAULT_MAX_RETRIES, + header_provider: None, + marker: PhantomData, + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implement [`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]`. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Decodable`] deserialization. + async fn get_response(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.get_with_retry(&url).await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(deserialize::(&response.bytes().await?)?) + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response`] internally. + /// + /// See [`AsyncEsploraClient::get_response`] above for full documentation. + async fn get_opt_response(&self, path: &str) -> Result, Error> { + match self.get_response::(path).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implements [`serde::de::DeserializeOwned`]. + /// + /// It should be used when requesting Esplora endpoints that have a specific + /// defined API, mostly defined in [`crate::api`]. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`serde::de::DeserializeOwned`] deserialization. + async fn get_response_json( + &self, path: &str, + ) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.get_with_retry(&url).await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + 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. + /// + /// See [`AsyncEsploraClient::get_response_json`] above for full + /// documentation. + async fn get_opt_response_json( + &self, url: &str, + ) -> Result, Error> { + match self.get_response_json(url).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implements [`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]`. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Decodable`] deserialization. + async fn get_response_hex(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.get_with_retry(&url).await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + let hex_str = response.text().await?; + 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. + /// + /// See [`AsyncEsploraClient::get_response_hex`] above for full + /// documentation. + async fn get_opt_response_hex(&self, path: &str) -> Result, Error> { + match self.get_response_hex(path).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `String`. + /// + /// It should be used when requesting Esplora endpoints that can return + /// `String` formatted data that can be parsed downstream. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client. + async fn get_response_text(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.get_with_retry(&url).await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(response.text().await?) + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response_text`] internally. + /// + /// See [`AsyncEsploraClient::get_response_text`] above for full + /// documentation. + async fn get_opt_response_text(&self, path: &str) -> Result, Error> { + match self.get_response_text(path).await { + Ok(s) => Ok(Some(s)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP POST request to given URL, converting any `T` that + /// implement [`Into`] and setting query parameters, if any. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// response's [`serde_json`] deserialization. + async fn post_request_bytes>( + &self, path: &str, body: T, query_params: Option>, + ) -> Result { + let url: String = format!("{}{}", self.url, path); + let mut request = self.client.post(url).body(body); + + if let Some(header_provider) = &self.header_provider { + let headers = header_provider().await.map_err(Error::HeaderProvider)?; + let mut header_map = reqwest::header::HeaderMap::new(); + for (name, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error::InvalidHttpHeaderName(name.clone()))?; + let header_value = reqwest::header::HeaderValue::from_str(&value) + .map_err(|_| Error::InvalidHttpHeaderValue(value.clone()))?; + header_map.insert(header_name, header_value); + } + request = request.headers(header_map); + } + + for param in query_params.unwrap_or_default() { + request = request.query(¶m); + } + + let response = request.send().await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(response) + } + + /// Get a [`Transaction`] option given its [`Txid`] + 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`]. + pub async fn get_tx_no_opt(&self, txid: &Txid) -> Result { + match self.get_tx(txid).await { + 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 async fn get_txid_at_block_index( + &self, block_hash: &BlockHash, index: usize, + ) -> Result, Error> { + match self.get_opt_response_text(&format!("/block/{block_hash}/txid/{index}")).await? { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), + } + } + + /// 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`]. + 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`]. + 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. + pub async fn get_output_status( + &self, txid: &Txid, index: u64, + ) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")).await + } + + /// Broadcast a [`Transaction`] to Esplora. + pub async fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> { + let body = serialize::(transaction).to_lower_hex_string(); + match self.post_request_bytes("/tx", body, None).await { + Ok(_resp) => Ok(()), + Err(e) => Err(e), + } + } + + /// 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 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 serialized_txs = transactions.iter().map(|tx| serialize_hex(&tx)).collect::>(); + + let response = self + .post_request_bytes( + "/txs/package", + serde_json::to_string(&serialized_txs).unwrap_or_default(), + Some(queryparams), + ) + .await?; + + Ok(response.json::().await?) + } + + /// Get the current height of the blockchain tip + pub async fn get_height(&self) -> Result { + self.get_response_text("/blocks/tip/height") + .await + .map(|height| u32::from_str(&height).map_err(Error::Parsing))? + } + + /// Get the [`BlockHash`] of the current blockchain tip. + 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))? + } + + /// Get the [`BlockHash`] of a specific block height + pub async fn get_block_hash(&self, block_height: u32) -> Result { + self.get_response_text(&format!("/block-height/{block_height}")) + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? + } + + /// Get information about a specific address, includes confirmed balance and transactions in + /// the mempool. + pub async fn get_address_stats(&self, address: &Address) -> Result { + let path = format!("/address/{address}"); + self.get_response_json(&path).await + } + + /// Get statistics about a particular [`Script`] hash's confirmed and mempool transactions. + pub async fn get_scripthash_stats(&self, script: &Script) -> Result { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}"); + self.get_response_json(&path).await + } + + /// Get transaction history for the specified address, sorted with newest first. + /// + /// 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"), + }; + + 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"); + + 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( + &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"), + }; + + 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"); + + self.get_response_json(&path).await + } + + /// Get statistics about the mempool. + pub async fn get_mempool_stats(&self) -> Result { + self.get_response_json("/mempool").await + } + + /// Get a list of the last 10 [`Transaction`]s to enter the mempool. + pub async fn get_mempool_recent_txs(&self) -> Result, Error> { + self.get_response_json("/mempool/recent").await + } + + /// Get the full list of [`Txid`]s in the mempool. + /// + /// The order of the [`Txid`]s is arbitrary. + pub async fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids").await + } + + /// 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 + } + + /// Get a summary about a [`Block`], given its [`BlockHash`]. + pub async fn get_block_info(&self, blockhash: &BlockHash) -> Result { + let path = format!("/block/{blockhash}"); + + self.get_response_json(&path).await + } + + /// Get all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. + pub async fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { + let path = format!("/block/{blockhash}/txids"); + + self.get_response_json(&path).await + } + + /// Get up to 25 [`Transaction`]s from a [`Block`], given its [`BlockHash`], + /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// + /// 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}"), + }; + + self.get_response_json(&path).await + } + + /// 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`. + #[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 summaries about recent blocks as [`BlockInfo`]s, + /// starting at the tip, or at `height`, if provided. + /// + /// The maximum number of elements returned depends on the backend itself: + /// - Esplora returns `10` + /// - [mempool.space](https://mempool.space/docs/api) returns `15` + 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 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`]. + 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"); + + self.get_response_json(&path).await + } + + /// Get the underlying base URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Get the underlying [`Client`]. + pub fn client(&self) -> &Client { + &self.client + } + + /// 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; + + loop { + let mut request = self.client.get(url); + if let Some(header_provider) = &self.header_provider { + let headers = header_provider().await.map_err(Error::HeaderProvider)?; + let mut header_map = reqwest::header::HeaderMap::new(); + for (name, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error::InvalidHttpHeaderName(name.clone()))?; + let header_value = reqwest::header::HeaderValue::from_str(&value) + .map_err(|_| Error::InvalidHttpHeaderValue(value.clone()))?; + header_map.insert(header_name, header_value); + } + request = request.headers(header_map); + } + match request.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), + } + } + } +} + +impl fmt::Debug for AsyncClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AsyncClient") + .field("url", &self.url) + .field("max_retries", &self.max_retries) + .finish_non_exhaustive() + } +} + +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; + + fn sleep(dur: std::time::Duration) -> Self::Sleep { + tokio::time::sleep(dur) + } +} diff --git a/vendor/esplora-client-0.12.3/src/blocking.rs b/vendor/esplora-client-0.12.3/src/blocking.rs new file mode 100644 index 0000000000..9d345cdc90 --- /dev/null +++ b/vendor/esplora-client-0.12.3/src/blocking.rs @@ -0,0 +1,552 @@ +// Bitcoin Dev Kit +// Written in 2020 by Alekos Filini +// +// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Esplora by way of `minreq` HTTP client. + +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::{deserialize, serialize, Decodable}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::{DisplayHex, FromHex}; +use bitcoin::{Address, Block, BlockHash, 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, +}; + +/// A blocking client for interacting with an Esplora API server. +#[derive(Debug, Clone)] +pub struct BlockingClient { + /// The URL of the Esplora server. + url: String, + /// The proxy is ignored when targeting `wasm32`. + pub proxy: Option, + /// Socket timeout. + pub timeout: Option, + /// HTTP headers to set on every request made to Esplora server + pub headers: HashMap, + /// Number of times to retry a request + pub max_retries: usize, +} + +impl BlockingClient { + /// Build a blocking client from a [`Builder`] + pub fn from_builder(builder: Builder) -> Self { + Self { + url: builder.base_url, + proxy: builder.proxy, + timeout: builder.timeout, + headers: builder.headers, + max_retries: builder.max_retries, + } + } + + /// Get the underlying base URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Perform a raw HTTP GET request with the given URI `path`. + pub fn get_request(&self, path: &str) -> Result { + let mut request = minreq::get(format!("{}{}", self.url, path)); + + if let Some(proxy) = &self.proxy { + let proxy = Proxy::new(proxy.as_str())?; + request = request.with_proxy(proxy); + } + + if let Some(timeout) = &self.timeout { + request = request.with_timeout(*timeout); + } + + if !self.headers.is_empty() { + for (key, value) in &self.headers { + request = request.with_header(key, value); + } + } + + Ok(request) + } + + fn post_request(&self, path: &str, body: T) -> Result + where + T: Into>, + { + let mut request = minreq::post(format!("{}{}", self.url, path)).with_body(body); + + if let Some(proxy) = &self.proxy { + let proxy = Proxy::new(proxy.as_str())?; + request = request.with_proxy(proxy); + } + + if let Some(timeout) = &self.timeout { + request = request.with_timeout(*timeout); + } + + Ok(request) + } + + 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), + 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(deserialize::(resp.as_bytes())?)), + Err(e) => Err(e), + } + } + + 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(Some( + Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, + )), + Err(e) => Err(e), + } + } + + 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) => { + 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) => { + 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), + } + } + + fn get_response_hex(&self, path: &str) -> Result { + match self.get_with_retry(path) { + 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) => { + 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) + }, + Err(e) => Err(e), + } + } + + 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(resp.json::().map_err(Error::Minreq)?), + Err(e) => Err(e), + } + } + + 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) => { + 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(resp.json::()?)), + Err(e) => Err(e), + } + } + + fn get_response_str(&self, path: &str) -> Result { + match self.get_with_retry(path) { + 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.as_str()?.to_string()), + Err(e) => Err(e), + } + } + + /// 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}")) + } + + /// Broadcast a [`Transaction`] to Esplora. + pub fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> { + let request = self.post_request( + "/tx", + serialize(transaction).to_lower_hex_string().as_bytes().to_vec(), + )?; + + match request.send() { + 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(()), + Err(e) => Err(Error::Minreq(e)), + } + } + + /// 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 fn submit_package( + &self, transactions: &[Transaction], maxfeerate: Option, maxburnamount: Option, + ) -> Result { + let serialized_txs = transactions.iter().map(|tx| serialize_hex(&tx)).collect::>(); + + let mut request = self.post_request( + "/txs/package", + serde_json::to_string(&serialized_txs).unwrap_or_default().into_bytes(), + )?; + + if let Some(maxfeerate) = maxfeerate { + request = request.with_param("maxfeerate", maxfeerate.to_string()) + } + + if let Some(maxburnamount) = maxburnamount { + request = request.with_param("maxburnamount", maxburnamount.to_string()) + } + + match request.send() { + 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)?), + Err(e) => Err(Error::Minreq(e)), + } + } + + /// Get the 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))? + } + + /// Get the [`BlockHash`] of the current blockchain tip. + pub fn get_tip_hash(&self) -> Result { + self.get_response_str("/blocks/tip/hash") + .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? + } + + /// Get the [`BlockHash`] of a specific block 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 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 list of [`Txid`]s in the mempool. + /// + /// 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") + } + + /// 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") + } + + /// 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}"); + 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}"); + self.get_response_json(&path) + } + + /// Get transaction history for the specified address, sorted with newest + /// first. + /// + /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. + /// More can be requested by specifying the last txid seen by the previous query. + pub 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"), + }; + + 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"); + + 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( + &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"), + }; + 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"); + + 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}"); + + 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"); + + 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`). + /// + /// The `start_index` value MUST be a multiple of 25, + /// else an error will be returned by Esplora. + pub 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}"), + }; + + 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`. + #[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 summaries about recent blocks as [`BlockInfo`]s, + /// starting at the tip, or at `height`, if provided. + /// + /// The maximum number of elements returned depends on the backend itself: + /// - Esplora returns `10` + /// - [mempool.space](https://mempool.space/docs/api) returns `15` + pub fn get_block_infos(&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"); + + self.get_response_json(&path) + } + + /// Get all [`Utxo`]s locked to a [`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) + } + + /// 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; + + 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), + } + } + } +} + +fn is_status_ok(status: i32) -> bool { + status == 200 +} + +fn is_status_not_found(status: i32) -> bool { + status == 404 +} + +fn is_status_retryable(status: i32) -> bool { + let status = status as u16; + RETRYABLE_ERROR_CODES.contains(&status) +} diff --git a/vendor/esplora-client-0.12.3/src/lib.rs b/vendor/esplora-client-0.12.3/src/lib.rs new file mode 100644 index 0000000000..edb28fa021 --- /dev/null +++ b/vendor/esplora-client-0.12.3/src/lib.rs @@ -0,0 +1,1264 @@ +//! An extensible blocking/async Esplora client +//! +//! This library provides an extensible blocking and +//! async Esplora client to query Esplora's backend. +//! +//! 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. +//! +//! +//! ## Usage +//! +//! You can create a blocking client as follows: +//! +//! ```no_run +//! # #[cfg(feature = "blocking")] +//! # { +//! use esplora_client::Builder; +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let blocking_client = builder.build_blocking(); +//! # Ok::<(), esplora_client::Error>(()); +//! # } +//! ``` +//! +//! Here is an example of how to create an asynchronous client. +//! +//! ```no_run +//! # #[cfg(all(feature = "async", feature = "tokio"))] +//! # { +//! use esplora_client::Builder; +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let async_client = builder.build_async(); +//! # Ok::<(), esplora_client::Error>(()); +//! # } +//! ``` +//! +//! ## Features +//! +//! 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: +//! +//! `esplora-client = { version = "*", default-features = false, features = +//! ["blocking"] }` +//! +//! * `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. +//! +//! [`dont remove this line 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 std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::num::TryFromIntError; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(feature = "async")] +pub use r#async::Sleeper; + +pub mod api; +#[cfg(feature = "async")] +pub mod r#async; +#[cfg(feature = "blocking")] +pub mod blocking; + +pub use api::*; +#[cfg(feature = "blocking")] +pub use blocking::BlockingClient; +#[cfg(feature = "async")] +pub use r#async::AsyncClient; + +/// Future returned by a dynamic Esplora header provider. +#[cfg(feature = "async")] +pub type HeaderProviderFuture = + Pin, String>> + Send>>; + +/// Callback used to obtain HTTP headers for each Esplora request. +#[cfg(feature = "async")] +pub type HeaderProvider = Arc HeaderProviderFuture + Send + Sync>; + +/// Response status codes for which the request may be retried. +pub const RETRYABLE_ERROR_CODES: [u16; 3] = [ + 429, // TOO_MANY_REQUESTS + 500, // INTERNAL_SERVER_ERROR + 503, // SERVICE_UNAVAILABLE +]; + +/// Base backoff in milliseconds. +const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); + +/// Default max retries. +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 `None` if no feerate estimate is found at or below `target` +/// confirmations. +pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option { + estimates + .into_iter() + .filter(|(k, _)| *k as usize <= target) + .max_by_key(|(k, _)| *k) + .map(|(_, v)| v as f32) +} + +/// A builder for an [`AsyncClient`] or [`BlockingClient`]. +#[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 + /// + /// 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. + /// + /// The proxy is ignored when targeting `wasm32`. + pub proxy: Option, + /// Socket timeout. + pub timeout: Option, + /// HTTP headers to set on every request made to Esplora server. + pub headers: HashMap, + /// Max retries + pub max_retries: usize, +} + +impl Builder { + /// Instantiate a new builder + pub fn new(base_url: &str) -> Self { + Builder { + base_url: base_url.to_string(), + proxy: None, + timeout: None, + headers: HashMap::new(), + max_retries: DEFAULT_MAX_RETRIES, + } + } + + /// Set the proxy of the builder + pub fn proxy(mut self, proxy: &str) -> Self { + self.proxy = Some(proxy.to_string()); + self + } + + /// Set the timeout of the builder + pub fn timeout(mut self, timeout: u64) -> Self { + self.timeout = Some(timeout); + self + } + + /// Add a header to set on each 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`]. + pub fn max_retries(mut self, count: usize) -> Self { + self.max_retries = count; + self + } + + /// Build a blocking client from builder + #[cfg(feature = "blocking")] + pub fn build_blocking(self) -> BlockingClient { + BlockingClient::from_builder(self) + } + + /// Build an asynchronous client from builder + #[cfg(all(feature = "async", feature = "tokio"))] + pub fn build_async(self) -> Result { + AsyncClient::from_builder(self) + } + + /// Build an [`AsyncClient`] that fetches additional HTTP headers for each request. + #[cfg(all(feature = "async", feature = "tokio"))] + pub fn build_async_with_header_provider( + self, header_provider: HeaderProvider, + ) -> Result { + AsyncClient::from_builder_with_header_provider(self, header_provider) + } + + /// Build an asynchronous client from builder where the returned client uses a + /// user-defined [`Sleeper`]. + #[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. +#[derive(Debug)] +pub enum Error { + /// Error during `minreq` HTTP request + #[cfg(feature = "blocking")] + Minreq(::minreq::Error), + /// Error during reqwest HTTP request + #[cfg(feature = "async")] + Reqwest(::reqwest::Error), + /// HTTP response error + HttpResponse { + /// The HTTP status code returned by the server. + status: u16, + /// The error message content. + message: String, + }, + /// Invalid number returned + Parsing(std::num::ParseIntError), + /// Invalid status code, unable to convert to `u16` + StatusCode(TryFromIntError), + /// Invalid Bitcoin data returned + BitcoinEncoding(bitcoin::consensus::encode::Error), + /// Invalid hex data returned (attempting to create an array) + HexToArray(bitcoin::hex::HexToArrayError), + /// Invalid hex data returned (attempting to create a vector) + HexToBytes(bitcoin::hex::HexToBytesError), + /// Transaction not found + TransactionNotFound(Txid), + /// Block Header height not found + HeaderHeightNotFound(u32), + /// Block Header hash not found + HeaderHashNotFound(BlockHash), + /// Invalid HTTP Header name specified + InvalidHttpHeaderName(String), + /// Invalid HTTP Header value specified + InvalidHttpHeaderValue(String), + /// The dynamic HTTP header provider failed. + #[cfg(feature = "async")] + HeaderProvider(String), + /// The server sent an invalid response + InvalidResponse, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +macro_rules! impl_error { + ( $from:ty, $to:ident ) => { + impl_error!($from, $to, Error); + }; + ( $from:ty, $to:ident, $impl_for:ty ) => { + impl std::convert::From<$from> for $impl_for { + fn from(err: $from) -> Self { + <$impl_for>::$to(err) + } + } + }; +} + +impl std::error::Error for Error {} +#[cfg(feature = "blocking")] +impl_error!(::minreq::Error, Minreq, Error); +#[cfg(feature = "async")] +impl_error!(::reqwest::Error, Reqwest, Error); +impl_error!(std::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); + +#[cfg(test)] +mod test { + use super::*; + use electrsd::{corepc_node, ElectrsD}; + use lazy_static::lazy_static; + use std::env; + use std::str::FromStr; + use tokio::sync::Mutex; + #[cfg(all(feature = "blocking", feature = "async"))] + use { + bitcoin::{hashes::Hash, Amount}, + corepc_node::AddressType, + electrsd::electrum_client::ElectrumApi, + std::time::Duration, + tokio::sync::OnceCell, + }; + + lazy_static! { + static ref BITCOIND: corepc_node::Node = { + let bitcoind_exe = env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let conf = corepc_node::Conf::default(); + corepc_node::Node::with_conf(bitcoind_exe, &conf).unwrap() + }; + static ref ELECTRSD: ElectrsD = { + let electrs_exe = env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect( + "you need to provide env var ELECTRS_EXE or specify an electrsd version feature", + ); + let mut conf = electrsd::Conf::default(); + conf.http_enabled = true; + ElectrsD::with_conf(electrs_exe, &BITCOIND, &conf).unwrap() + }; + static ref MINER: Mutex<()> = Mutex::new(()); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + static PREMINE: OnceCell<()> = OnceCell::const_new(); + + #[cfg(all(feature = "blocking", feature = "async"))] + async fn setup_clients() -> (BlockingClient, AsyncClient) { + setup_clients_with_headers(HashMap::new()).await + } + + #[cfg(all(feature = "blocking", feature = "async"))] + async fn setup_clients_with_headers( + headers: HashMap, + ) -> (BlockingClient, AsyncClient) { + PREMINE + .get_or_init(|| async { + let _miner = MINER.lock().await; + generate_blocks_and_wait(101); + }) + .await; + + let esplora_url = ELECTRSD.esplora_url.as_ref().unwrap(); + + let mut builder = Builder::new(&format!("http://{esplora_url}")); + if !headers.is_empty() { + builder.headers = headers; + } + + let blocking_client = builder.build_blocking(); + + let builder_async = Builder::new(&format!("http://{esplora_url}")); + + #[cfg(feature = "tokio")] + let async_client = builder_async.build_async().unwrap(); + + #[cfg(not(feature = "tokio"))] + let async_client = builder_async.build_async_with_sleeper::().unwrap(); + + (blocking_client, async_client) + } + + #[cfg(all(feature = "blocking", feature = "async"))] + fn generate_blocks_and_wait(num: usize) { + let cur_height = BITCOIND.client.get_block_count().unwrap().0; + generate_blocks(num); + wait_for_block(cur_height as usize + num); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + fn generate_blocks(num: usize) { + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let _block_hashes = BITCOIND.client.generate_to_address(num, &address).unwrap(); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + fn wait_for_block(min_height: usize) { + let mut header = ELECTRSD.client.block_headers_subscribe().unwrap(); + loop { + if header.height >= min_height { + break; + } + header = exponential_backoff_poll(|| { + ELECTRSD.trigger().unwrap(); + ELECTRSD.client.ping().unwrap(); + ELECTRSD.client.block_headers_pop().unwrap() + }); + } + } + + #[cfg(all(feature = "blocking", feature = "async"))] + fn exponential_backoff_poll(mut poll: F) -> T + where + F: FnMut() -> Option, + { + let mut delay = Duration::from_millis(64); + loop { + match poll() { + Some(data) => break data, + None if delay.as_millis() < 512 => delay = delay.mul_f32(2.0), + None => {}, + } + + std::thread::sleep(delay); + } + } + + #[test] + fn feerate_parsing() { + let esplora_fees = 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 +} +"#, + ) + .unwrap(); + 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" + ); + assert!( + convert_fee_rate(0, esplora_fees).is_none(), + "should not return feerate for 0 target" + ); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx = blocking_client.get_tx(&txid).unwrap(); + let tx_async = async_client.get_tx(&txid).await.unwrap(); + assert_eq!(tx, tx_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_no_opt() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx_no_opt = blocking_client.get_tx_no_opt(&txid).unwrap(); + let tx_no_opt_async = async_client.get_tx_no_opt(&txid).await.unwrap(); + assert_eq!(tx_no_opt, tx_no_opt_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_status() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx_status = blocking_client.get_tx_status(&txid).unwrap(); + let tx_status_async = async_client.get_tx_status(&txid).await.unwrap(); + assert_eq!(tx_status, tx_status_async); + assert!(tx_status.confirmed); + + // Bogus txid returns a TxStatus with false, None, None, None + let txid = Txid::hash(b"ayyyy lmao"); + let tx_status = blocking_client.get_tx_status(&txid).unwrap(); + let tx_status_async = async_client.get_tx_status(&txid).await.unwrap(); + assert_eq!(tx_status, tx_status_async); + assert!(!tx_status.confirmed); + assert!(tx_status.block_height.is_none()); + assert!(tx_status.block_hash.is_none()); + assert!(tx_status.block_time.is_none()); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_info() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx_res = BITCOIND.client.get_transaction(txid).unwrap().into_model().unwrap(); + let tx_exp: Transaction = tx_res.tx; + let tx_block_height = BITCOIND + .client + .get_block_header_verbose(&tx_res.block_hash.unwrap()) + .unwrap() + .into_model() + .unwrap() + .height; + + let tx_info = blocking_client.get_tx_info(&txid).unwrap().expect("must get tx"); + let tx_info_async = async_client.get_tx_info(&txid).await.unwrap().expect("must get tx"); + assert_eq!(tx_info, tx_info_async); + 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!(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); + assert_eq!(tx_info.status.block_time, tx_res.block_time.map(|bt| bt as u64)); + + let txid = Txid::hash(b"not exist"); + assert_eq!(blocking_client.get_tx_info(&txid).unwrap(), None); + assert_eq!(async_client.get_tx_info(&txid).await.unwrap(), None); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_header_by_hash() { + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BITCOIND.client.get_block_hash(23).unwrap().block_hash().unwrap(); + + let block_header = blocking_client.get_header_by_hash(&block_hash).unwrap(); + let block_header_async = async_client.get_header_by_hash(&block_hash).await.unwrap(); + assert_eq!(block_header, block_header_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_status() { + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BITCOIND.client.get_block_hash(21).unwrap().block_hash().unwrap(); + let next_block_hash = BITCOIND.client.get_block_hash(22).unwrap().block_hash().unwrap(); + + let expected = + BlockStatus { in_best_chain: true, height: Some(21), next_best: Some(next_block_hash) }; + + let block_status = blocking_client.get_block_status(&block_hash).unwrap(); + let block_status_async = async_client.get_block_status(&block_hash).await.unwrap(); + assert_eq!(expected, block_status); + assert_eq!(expected, block_status_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_non_existing_block_status() { + // Esplora returns the same status for orphaned blocks as for non-existing + // blocks: non-existing: https://blockstream.info/api/block/0000000000000000000000000000000000000000000000000000000000000000/status + // orphaned: https://blockstream.info/api/block/000000000000000000181b1a2354620f66868a723c0c4d5b24e4be8bdfc35a7f/status + // (Here the block is cited as orphaned: https://bitcoinchain.com/block_explorer/block/000000000000000000181b1a2354620f66868a723c0c4d5b24e4be8bdfc35a7f/ ) + // For this reason, we only test for the non-existing case here. + + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BlockHash::all_zeros(); + + let expected = BlockStatus { in_best_chain: false, height: None, next_best: None }; + + let block_status = blocking_client.get_block_status(&block_hash).unwrap(); + let block_status_async = async_client.get_block_status(&block_hash).await.unwrap(); + assert_eq!(expected, block_status); + assert_eq!(expected, block_status_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_by_hash() { + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BITCOIND.client.get_block_hash(21).unwrap().block_hash().unwrap(); + + let expected = Some(BITCOIND.client.get_block(block_hash).unwrap()); + + let block = blocking_client.get_block_by_hash(&block_hash).unwrap(); + let block_async = async_client.get_block_by_hash(&block_hash).await.unwrap(); + assert_eq!(expected, block); + assert_eq!(expected, block_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_that_errors_are_propagated() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx = blocking_client.get_tx(&txid).unwrap(); + let async_res = async_client.broadcast(tx.as_ref().unwrap()).await; + let blocking_res = blocking_client.broadcast(tx.as_ref().unwrap()); + assert!(async_res.is_err()); + assert!(matches!( + async_res.unwrap_err(), + Error::HttpResponse { status: 400, message } if message.contains("-27") + )); + assert!(blocking_res.is_err()); + assert!(matches!( + blocking_res.unwrap_err(), + Error::HttpResponse { status: 400, message } if message.contains("-27") + )); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_by_hash_not_existing() { + let (blocking_client, async_client) = setup_clients().await; + + let block = blocking_client.get_block_by_hash(&BlockHash::all_zeros()).unwrap(); + let block_async = async_client.get_block_by_hash(&BlockHash::all_zeros()).await.unwrap(); + assert!(block.is_none()); + assert!(block_async.is_none()); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_merkle_proof() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let merkle_proof = blocking_client.get_merkle_proof(&txid).unwrap().unwrap(); + let merkle_proof_async = async_client.get_merkle_proof(&txid).await.unwrap().unwrap(); + assert_eq!(merkle_proof, merkle_proof_async); + assert!(merkle_proof.pos > 0); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_merkle_block() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let merkle_block = blocking_client.get_merkle_block(&txid).unwrap().unwrap(); + let merkle_block_async = async_client.get_merkle_block(&txid).await.unwrap().unwrap(); + assert_eq!(merkle_block, merkle_block_async); + + let mut matches = vec![txid]; + let mut indexes = vec![]; + let root = merkle_block.txn.extract_matches(&mut matches, &mut indexes).unwrap(); + assert_eq!(root, merkle_block.header.merkle_root); + assert_eq!(indexes.len(), 1); + assert!(indexes[0] > 0); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_output_status() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let output_status = blocking_client.get_output_status(&txid, 1).unwrap().unwrap(); + let output_status_async = async_client.get_output_status(&txid, 1).await.unwrap().unwrap(); + + assert_eq!(output_status, output_status_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_height() { + let (blocking_client, async_client) = setup_clients().await; + let block_height = blocking_client.get_height().unwrap(); + let block_height_async = async_client.get_height().await.unwrap(); + assert!(block_height > 0); + assert_eq!(block_height, block_height_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tip_hash() { + let (blocking_client, async_client) = setup_clients().await; + let tip_hash = blocking_client.get_tip_hash().unwrap(); + let tip_hash_async = async_client.get_tip_hash().await.unwrap(); + assert_eq!(tip_hash, tip_hash_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_hash() { + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BITCOIND.client.get_block_hash(21).unwrap().block_hash().unwrap(); + + let block_hash_blocking = blocking_client.get_block_hash(21).unwrap(); + let block_hash_async = async_client.get_block_hash(21).await.unwrap(); + assert_eq!(block_hash, block_hash_blocking); + assert_eq!(block_hash, block_hash_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_txid_at_block_index() { + let (blocking_client, async_client) = setup_clients().await; + + let block_hash = BITCOIND.client.get_block_hash(23).unwrap().block_hash().unwrap(); + + let txid_at_block_index = + blocking_client.get_txid_at_block_index(&block_hash, 0).unwrap().unwrap(); + let txid_at_block_index_async = + async_client.get_txid_at_block_index(&block_hash, 0).await.unwrap().unwrap(); + assert_eq!(txid_at_block_index, txid_at_block_index_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_fee_estimates() { + let (blocking_client, async_client) = setup_clients().await; + let fee_estimates = blocking_client.get_fee_estimates().unwrap(); + let fee_estimates_async = async_client.get_fee_estimates().await.unwrap(); + assert_eq!(fee_estimates.len(), fee_estimates_async.len()); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_scripthash_txs() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let expected_tx = BITCOIND.client.get_transaction(txid).unwrap().into_model().unwrap().tx; + let script = &expected_tx.output[0].script_pubkey; + let scripthash_txs_txids: Vec = blocking_client + .scripthash_txs(script, None) + .unwrap() + .iter() + .map(|tx| tx.txid) + .collect(); + let scripthash_txs_txids_async: Vec = async_client + .scripthash_txs(script, None) + .await + .unwrap() + .iter() + .map(|tx| tx.txid) + .collect(); + assert_eq!(scripthash_txs_txids, scripthash_txs_txids_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_info() { + let (blocking_client, async_client) = setup_clients().await; + + // Genesis block `BlockHash` on regtest. + let blockhash_genesis = + BlockHash::from_str("0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206") + .unwrap(); + + let block_info_blocking = blocking_client.get_block_info(&blockhash_genesis).unwrap(); + let block_info_async = async_client.get_block_info(&blockhash_genesis).await.unwrap(); + + assert_eq!(block_info_async, block_info_blocking); + assert_eq!(block_info_async.id, blockhash_genesis); + assert_eq!(block_info_async.height, 0); + assert_eq!(block_info_async.previousblockhash, None); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_txids() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + // Create 5 transactions and mine a block. + let txids: Vec<_> = (0..5) + .map(|_| { + BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap() + }) + .collect(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + // Get the block hash at the chain's tip. + let blockhash = blocking_client.get_tip_hash().unwrap(); + + let txids_async = async_client.get_block_txids(&blockhash).await.unwrap(); + let txids_blocking = blocking_client.get_block_txids(&blockhash).unwrap(); + + assert_eq!(txids_async, txids_blocking); + + // Compare expected and received (skipping the coinbase TXID). + for expected_txid in txids.iter() { + assert!(txids_async.contains(expected_txid)); + } + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_txs() { + let (blocking_client, async_client) = setup_clients().await; + + let _miner = MINER.lock().await; + let blockhash = blocking_client.get_tip_hash().unwrap(); + + let txs_blocking = blocking_client.get_block_txs(&blockhash, None).unwrap(); + let txs_async = async_client.get_block_txs(&blockhash, None).await.unwrap(); + + assert_ne!(txs_blocking.len(), 0); + assert_eq!(txs_blocking.len(), txs_async.len()); + } + + #[allow(deprecated)] + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_blocks() { + let (blocking_client, async_client) = setup_clients().await; + let start_height = 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); + generate_blocks_and_wait(10); + 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_infos() { + let (blocking_client, async_client) = setup_clients().await; + + let start_height = BITCOIND.client.get_block_count().unwrap().0; + + let blocks_blocking_0 = blocking_client.get_block_infos(None).unwrap(); + let blocks_async_0 = async_client.get_block_infos(None).await.unwrap(); + assert_eq!(blocks_blocking_0[0].height, start_height as u32); + assert_eq!(blocks_blocking_0, blocks_async_0); + + generate_blocks_and_wait(10); + + let blocks_blocking_1 = blocking_client.get_block_infos(None).unwrap(); + let blocks_async_1 = async_client.get_block_infos(None).await.unwrap(); + assert_eq!(blocks_blocking_1, blocks_async_1); + assert_ne!(blocks_blocking_0, blocks_blocking_1); + + let blocks_blocking_2 = blocking_client.get_block_infos(Some(start_height as u32)).unwrap(); + let blocks_async_3 = async_client.get_block_infos(Some(start_height as u32)).await.unwrap(); + assert_eq!(blocks_blocking_2, blocks_async_3); + assert_eq!(blocks_blocking_2[0].height, start_height as u32); + assert_eq!(blocks_blocking_2, blocks_blocking_0); + + let blocks_blocking_genesis = blocking_client.get_block_infos(Some(0)).unwrap(); + let blocks_async_genesis = async_client.get_block_infos(Some(0)).await.unwrap(); + assert_eq!(blocks_blocking_genesis, blocks_async_genesis); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_with_http_header() { + let headers = + [("Authorization".to_string(), "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==".to_string())] + .into(); + let (blocking_client, async_client) = setup_clients_with_headers(headers).await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let tx = blocking_client.get_tx(&txid).unwrap(); + let tx_async = async_client.get_tx(&txid).await.unwrap(); + assert_eq!(tx, tx_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_address_stats() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + + let address_stats_blocking = blocking_client.get_address_stats(&address).unwrap(); + 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, 0); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let address_stats_blocking = blocking_client.get_address_stats(&address).unwrap(); + 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); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_scripthash_stats() { + let (blocking_client, async_client) = setup_clients().await; + + // Create an address of each type. + let address_legacy = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let address_p2sh_segwit = + BITCOIND.client.new_address_with_type(AddressType::P2shSegwit).unwrap(); + let address_bech32 = BITCOIND.client.new_address_with_type(AddressType::Bech32).unwrap(); + let address_bech32m = BITCOIND.client.new_address_with_type(AddressType::Bech32m).unwrap(); + + // Send a transaction to each address. + let _txid = BITCOIND + .client + .send_to_address(&address_legacy, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_p2sh_segwit, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_bech32, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_bech32m, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + // Derive each addresses script. + let script_legacy = address_legacy.script_pubkey(); + let script_p2sh_segwit = address_p2sh_segwit.script_pubkey(); + let script_bech32 = address_bech32.script_pubkey(); + let script_bech32m = address_bech32m.script_pubkey(); + + // P2PKH + let scripthash_stats_blocking_legacy = + blocking_client.get_scripthash_stats(&script_legacy).unwrap(); + let scripthash_stats_async_legacy = + async_client.get_scripthash_stats(&script_legacy).await.unwrap(); + assert_eq!(scripthash_stats_blocking_legacy, scripthash_stats_async_legacy); + assert_eq!(scripthash_stats_blocking_legacy.chain_stats.funded_txo_sum, 1000); + assert_eq!(scripthash_stats_blocking_legacy.chain_stats.tx_count, 1); + + // P2SH-P2WSH + let scripthash_stats_blocking_p2sh_segwit = + blocking_client.get_scripthash_stats(&script_p2sh_segwit).unwrap(); + let scripthash_stats_async_p2sh_segwit = + async_client.get_scripthash_stats(&script_p2sh_segwit).await.unwrap(); + assert_eq!(scripthash_stats_blocking_p2sh_segwit, scripthash_stats_async_p2sh_segwit); + assert_eq!(scripthash_stats_blocking_p2sh_segwit.chain_stats.funded_txo_sum, 1000); + assert_eq!(scripthash_stats_blocking_p2sh_segwit.chain_stats.tx_count, 1); + + // P2WPKH / P2WSH + let scripthash_stats_blocking_bech32 = + blocking_client.get_scripthash_stats(&script_bech32).unwrap(); + let scripthash_stats_async_bech32 = + async_client.get_scripthash_stats(&script_bech32).await.unwrap(); + assert_eq!(scripthash_stats_blocking_bech32, scripthash_stats_async_bech32); + assert_eq!(scripthash_stats_blocking_bech32.chain_stats.funded_txo_sum, 1000); + assert_eq!(scripthash_stats_blocking_bech32.chain_stats.tx_count, 1); + + // P2TR + let scripthash_stats_blocking_bech32m = + blocking_client.get_scripthash_stats(&script_bech32m).unwrap(); + let scripthash_stats_async_bech32m = + async_client.get_scripthash_stats(&script_bech32m).await.unwrap(); + assert_eq!(scripthash_stats_blocking_bech32m, scripthash_stats_async_bech32m); + assert_eq!(scripthash_stats_blocking_bech32m.chain_stats.funded_txo_sum, 1000); + assert_eq!(scripthash_stats_blocking_bech32m.chain_stats.tx_count, 1); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_address_txs() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let address_txs_blocking = blocking_client.get_address_txs(&address, None).unwrap(); + let address_txs_async = async_client.get_address_txs(&address, None).await.unwrap(); + + assert_eq!(address_txs_blocking, address_txs_async); + assert_eq!(address_txs_async[0].txid, txid); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_address_utxos() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(21000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let address_utxos_blocking = blocking_client.get_address_utxos(&address).unwrap(); + let address_utxos_async = async_client.get_address_utxos(&address).await.unwrap(); + + assert_ne!(address_utxos_blocking.len(), 0); + assert_ne!(address_utxos_async.len(), 0); + assert_eq!(address_utxos_blocking, address_utxos_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_scripthash_utxos() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + let script = address.script_pubkey(); + + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(21000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let scripthash_utxos_blocking = blocking_client.get_scripthash_utxos(&script).unwrap(); + let scripthash_utxos_async = async_client.get_scripthash_utxos(&script).await.unwrap(); + + assert_ne!(scripthash_utxos_blocking.len(), 0); + assert_ne!(scripthash_utxos_async.len(), 0); + assert_eq!(scripthash_utxos_blocking, scripthash_utxos_async); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_outspends() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(21000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let outspends_blocking = blocking_client.get_tx_outspends(&txid).unwrap(); + let outspends_async = async_client.get_tx_outspends(&txid).await.unwrap(); + + // Assert that there are 2 outputs: 21K sat and (coinbase - 21K sat). + assert_eq!(outspends_blocking.len(), 2); + assert_eq!(outspends_async.len(), 2); + assert_eq!(outspends_blocking, outspends_async); + + // Assert that both outputs are returned as unspent (spent == false). + assert!(outspends_blocking.iter().all(|output| !output.spent)); + } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_mempool_methods() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND.client.new_address_with_type(AddressType::Legacy).unwrap(); + + for _ in 0..5 { + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + } + + // Wait for transactions to propagate to electrs' mempool. + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + + // Test `get_mempool_stats` + let stats_blocking = blocking_client.get_mempool_stats().unwrap(); + let stats_async = async_client.get_mempool_stats().await.unwrap(); + assert_eq!(stats_blocking, stats_async); + assert!(stats_blocking.count >= 5); + + // Test `get_mempool_recent_txs` + let recent_blocking = blocking_client.get_mempool_recent_txs().unwrap(); + let recent_async = async_client.get_mempool_recent_txs().await.unwrap(); + assert_eq!(recent_blocking, recent_async); + assert!(recent_blocking.len() <= 10); + assert!(!recent_blocking.is_empty()); + + // Test `get_mempool_txids` + let txids_blocking = blocking_client.get_mempool_txids().unwrap(); + let txids_async = async_client.get_mempool_txids().await.unwrap(); + assert_eq!(txids_blocking, txids_async); + assert!(txids_blocking.len() >= 5); + + // Test `get_mempool_scripthash_txs` + let script = address.script_pubkey(); + let scripthash_txs_blocking = blocking_client.get_mempool_scripthash_txs(&script).unwrap(); + let scripthash_txs_async = async_client.get_mempool_scripthash_txs(&script).await.unwrap(); + assert_eq!(scripthash_txs_blocking, scripthash_txs_async); + assert_eq!(scripthash_txs_blocking.len(), 5); + + // Test `get_mempool_address_txs` + let mempool_address_txs_blocking = + blocking_client.get_mempool_address_txs(&address).unwrap(); + let mempool_address_txs_async = + async_client.get_mempool_address_txs(&address).await.unwrap(); + assert_eq!(mempool_address_txs_blocking, mempool_address_txs_async); + assert_eq!(mempool_address_txs_blocking.len(), 5); + } +} diff --git a/vendor/esplora-client-0.13.0/Cargo.toml b/vendor/esplora-client-0.13.0/Cargo.toml new file mode 100644 index 0000000000..e018c8b1d1 --- /dev/null +++ b/vendor/esplora-client-0.13.0/Cargo.toml @@ -0,0 +1,234 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.75.0" +name = "esplora-client" +version = "0.13.0" +authors = [ + "Alekos Filini ", + "Bitcoin Dev Kit Developers", +] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Asynchronous and blocking clients and types for interacting with Esplora servers over HTTP" +homepage = "https://github.com/bitcoindevkit/rust-esplora-client" +documentation = "https://docs.rs/esplora-client/" +readme = "README.md" +keywords = [ + "bitcoin", + "esplora", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/bitcoindevkit/rust-esplora-client" + +[package.metadata.rbmt.toolchains] +stable = "1.96.0" +nightly = "nightly-2026-05-08" + +[package.metadata.rbmt.lint] +allowed_duplicates = [ + "getrandom", + "windows-sys", + "wit-bindgen", +] + +[package.metadata.rbmt.test] +exclude_features = [ + "tokio", + "async-https", + "async-https-native", + "async-https-rustls", + "async-https-rustls-probe", + "blocking-https", + "blocking-https-native", + "blocking-https-rustls", + "blocking-https-rustls-probe", +] +exact_features = [ + [ + "async", + "blocking", +], + [ + "async-https", + "blocking-https", + "tokio", +], + [ + "async-https-native", + "blocking-https-native", + "tokio", +], + [ + "async-https-rustls", + "blocking-https-rustls", + "tokio", +], + [ + "async-https-rustls-probe", + "blocking-https-rustls-probe", + "tokio", +], +] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +async = [ + "bitreq/async", + "bitreq/proxy", + "bitreq/json-using-serde", + "tokio?/time", +] +async-https = [ + "async", + "bitreq/async-https", +] +async-https-native = [ + "async", + "bitreq/async-https-native-tls", +] +async-https-rustls = [ + "async", + "bitreq/async-https-rustls", +] +async-https-rustls-probe = [ + "async", + "bitreq/async-https-rustls-probe", +] +blocking = [ + "bitreq/proxy", + "bitreq/json-using-serde", +] +blocking-https = [ + "blocking", + "bitreq/https", +] +blocking-https-native = [ + "blocking", + "bitreq/https-native-tls", +] +blocking-https-rustls = [ + "blocking", + "bitreq/https-rustls", +] +blocking-https-rustls-probe = [ + "blocking", + "bitreq/https-rustls-probe", +] +default = [ + "blocking", + "blocking-https", + "async", + "async-https", + "tokio", +] +tokio = ["dep:tokio"] + +[lib] +name = "esplora_client" +path = "src/lib.rs" + +[[test]] +name = "address" +path = "tests/address.rs" + +[[test]] +name = "block" +path = "tests/block.rs" + +[[test]] +name = "mempool" +path = "tests/mempool.rs" + +[[test]] +name = "scripthash" +path = "tests/scripthash.rs" + +[[test]] +name = "transaction" +path = "tests/transaction.rs" + +[dependencies.bitcoin] +version = "0.32" +features = [ + "serde", + "std", +] +default-features = false + +[dependencies.bitreq] +version = "0.3.7" +optional = true + +[dependencies.hex] +version = "0.2.2" +package = "hex-conservative" + +[dependencies.serde] +version = "1.0.194" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0.117" +features = ["std"] +default-features = false + +[dependencies.tokio] +version = "1" +features = ["time"] +optional = true + +[dev-dependencies.electrsd] +version = "0.40.0" +features = [ + "legacy", + "esplora_a33e97e1", + "bitcoind_download", + "bitcoind_29_0", +] +default-features = false + +[dev-dependencies.log] +version = "0.4.29" + +[dev-dependencies.native-tls] +version = "0.2.11" + +[dev-dependencies.openssl] +version = "0.10.78" + +[dev-dependencies.openssl-sys] +version = "0.9.114" + +[dev-dependencies.security-framework] +version = "=2.11.1" + +[dev-dependencies.tar] +version = "0.4.45" + +[dev-dependencies.tempfile] +version = "=3.15.0" + +[dev-dependencies.tokio] +version = "1" +features = ["full"] diff --git a/vendor/esplora-client-0.13.0/LICENSE-APACHE b/vendor/esplora-client-0.13.0/LICENSE-APACHE new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/vendor/esplora-client-0.13.0/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/vendor/esplora-client-0.13.0/LICENSE-MIT b/vendor/esplora-client-0.13.0/LICENSE-MIT new file mode 100644 index 0000000000..88378ad414 --- /dev/null +++ b/vendor/esplora-client-0.13.0/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Magical Bitcoin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/esplora-client-0.13.0/LICENSE.md b/vendor/esplora-client-0.13.0/LICENSE.md new file mode 100644 index 0000000000..93f34957d9 --- /dev/null +++ b/vendor/esplora-client-0.13.0/LICENSE.md @@ -0,0 +1,14 @@ +This software is licensed under [Apache 2.0](LICENSE-APACHE) or +[MIT](LICENSE-MIT), at your option. + +Some files retain their own copyright notice, however, for full authorship +information, see version control history. + +Except as otherwise noted in individual files, all files in this repository are +licensed under the Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or +) or the MIT license ([LICENSE-MIT](LICENSE-MIT) or +), at your option. + +You may not use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of this software or any files in this repository except in +accordance with one or both of these licenses. diff --git a/vendor/esplora-client-0.13.0/README.md b/vendor/esplora-client-0.13.0/README.md new file mode 100644 index 0000000000..0c54476c4a --- /dev/null +++ b/vendor/esplora-client-0.13.0/README.md @@ -0,0 +1,66 @@ +# rust-esplora-client + +

+ + + + + +

+ +Asynchronous and blocking clients and types for interacting with Esplora servers over HTTP. + +## Developing + +This project uses [`just`](https://github.com/casey/just) for command running, and +[`cargo-rbmt`](https://github.com/rust-bitcoin/rust-bitcoin-maintainer-tools/tree/master/cargo-rbmt) +to manage everything related to `cargo`, such as formatting, linting, testing and CI. To install them, run: + +```console +~$ cargo install just + +~$ cargo install cargo-rbmt +``` + +A `justfile` is provided for convenience. Run `just` to see available commands: + +```console +~$ just +> rust-esplora-client +> Bitcoin Esplora API client library + +Available recipes: + audit # Run `cargo audit` [alias: a] + build # Build `rust-esplora-client` [alias: b] + check # Check code formatting, compilation, and linting [alias: c] + check-sigs # Checks whether all commits in this branch are signed [alias: cs] + doc # Generate documentation [alias: d] + doc-open # Generate and open documentation [alias: do] + fmt # Format code [alias: f] + lock # Regenerate Cargo-recent.lock and Cargo-minimal.lock [alias: l] + pre-push # Run pre-push checks [alias: p] + test # Run tests [alias: t] + zizmor # Run Zizmor Static Analysis [alias: z] +``` + +## Minimum Supported Rust Version + +This library should compile with any combination of features on Rust 1.75.0. + +To build with the MSRV toolchain, copy `Cargo-minimal.lock` to `Cargo.lock`. + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/vendor/esplora-client-0.13.0/src/api.rs b/vendor/esplora-client-0.13.0/src/api.rs new file mode 100644 index 0000000000..6955649a88 --- /dev/null +++ b/vendor/esplora-client-0.13.0/src/api.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! # Esplora API +//! +//! This module implements the types and deserializers +//! needed to interact with an Esplora-compliant server. +//! +//! Refer to the [Esplora API] specification for the complete API reference. +//! +//! [Esplora API]: + +use serde::Deserialize; +use std::collections::HashMap; + +pub use bitcoin::consensus::{deserialize, serialize}; +use bitcoin::hash_types; +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, Sequence, Transaction, TxIn, TxOut, Txid, Weight, + Witness, Wtxid, +}; + +/// An input to a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Vin { + /// The [`Txid`] of the [`Transaction`] that created this output. + pub txid: Txid, + /// The index of this output in the [`Transaction`] that created it. + pub vout: u32, + /// This input's previous output [`Amount`] and [script pubkey][Script]. + /// + /// `None` if this input spends a coinbase output. + pub prevout: Option, + /// The [`Script`] that unlocks this input. + pub scriptsig: ScriptBuf, + /// The [`Witness`] that unlocks this input. + #[serde(default)] + pub witness: Witness, + /// The sequence value for this input. + pub sequence: Sequence, + /// Whether this is a coinbase input. + pub is_coinbase: bool, +} + +/// An output from a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Vout { + /// The output's [`Amount`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub value: Amount, + /// The [script pubkey][Script] this output is locked to. + pub scriptpubkey: ScriptBuf, +} + +/// The confirmation status of a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TxStatus { + /// Whether the [`Transaction`] is confirmed or not. + pub confirmed: bool, + /// The block height that confirmed the [`Transaction`]. + pub block_height: Option, + /// The [`BlockHash`] of the block that confirmed the [`Transaction`]. + /// + /// `None` if the [`Transaction`] was confirmed by the genesis block. + pub block_hash: Option, + /// The UNIX timestamp of the block that confirmed the [`Transaction`], as claimed by the + /// miner. + pub block_time: Option, +} + +/// A Merkle inclusion proof for a [`Transaction`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct MerkleProof { + /// The block height that confirmed the [`Transaction`]. + pub block_height: u32, + /// The Merkle proof of inclusion of a [`Transaction`] in a [`Block`]. + /// + /// Elements are returned left-to-right and bottom-to-top. + pub merkle: Vec, + /// The 0-indexed position of the [`Transaction`] in the [`Block`]. + pub pos: usize, +} + +/// The status of a [`TxOut`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct OutputStatus { + /// Whether the [`TxOut`] is spent. + pub spent: bool, + /// 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, + /// Information about the [`Transaction`] that spent this [`TxOut`]. + pub status: Option, +} + +/// 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. + pub in_best_chain: bool, + /// The height of this [`Block`]. + pub height: Option, + /// The [`BlockHash`] of the [`Block`] that builds on top of this [`Block`]. + pub next_best: Option, +} + +/// A transaction in the format returned by Esplora. +/// +/// Unlike the native `rust-bitcoin` [`Transaction`], [`EsploraTx`] +/// includes additional metadata such as the [`TxStatus`], transaction fee, +/// and transaction [`Weight`], as indexed and reported by Esplora servers. +/// +/// To convert it into a [`Transaction`], use [`EsploraTx::to_tx`] or `.into()`. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EsploraTx { + /// The [`Txid`] of the [`Transaction`]. + pub txid: Txid, + /// The version of the [`Transaction`]. + pub version: transaction::Version, + /// The locktime of the [`Transaction`]. + /// Sets a time or height after which the [`Transaction`] can be mined. + pub locktime: absolute::LockTime, + /// 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, +} + +/// A summary of a [`Block`]. +/// +/// Contains additional metadata about a [`Block`], but not the whole [`Block`]. +/// +/// For the complete [`Block`] contents, use the `get_block_by_hash` client method. +#[derive(Debug, Clone, Deserialize)] +pub struct BlockInfo { + /// The [`BlockHash`] of this [`Block`]. + pub id: BlockHash, + /// The block height of this [`Block`]. + pub height: u32, + /// The version of this [`Block`]. + pub version: block::Version, + /// The UNIX timestamp of this [`Block`], as claimed by the miner. + pub timestamp: u64, + /// The [`Transaction`] count for this [`Block`]. + pub tx_count: u64, + /// The size of this [`Block`], in bytes. + pub size: usize, + /// The [`Weight`] of this [`Block`]. + pub weight: Weight, + /// The Merkle root of this [`Block`]. + pub merkle_root: hash_types::TxMerkleNode, + /// The [`BlockHash`] of the previous [`Block`]. + /// + /// `None` for the genesis block. + pub previousblockhash: Option, + /// The Median Time Past value for this [`Block`]. + pub mediantime: u64, + /// This [`Block`]'s nonce. + pub nonce: u32, + /// The [`Block`]'s `bits` value, encoded as a [`CompactTarget`]. + pub bits: CompactTarget, + /// The [`Block`]'s difficulty target value. + pub difficulty: f64, +} + +/// A manual `PartialEq` implementation is required +/// since [`BlockInfo::difficulty`] is an `f64`. +/// +/// This treats two `NaN` difficulty values as equal, +/// allowing [`BlockInfo`] to implement [`Eq`] correctly. +impl PartialEq for BlockInfo { + fn eq(&self, other: &Self) -> bool { + let Self { difficulty: d1, .. } = self; + let Self { difficulty: d2, .. } = other; + + self.id == other.id + && self.height == other.height + && self.version == other.version + && self.timestamp == other.timestamp + && self.tx_count == other.tx_count + && self.size == other.size + && self.weight == other.weight + && self.merkle_root == other.merkle_root + && self.previousblockhash == other.previousblockhash + && self.mediantime == other.mediantime + && self.nonce == other.nonce + && self.bits == other.bits + && ((d1.is_nan() && d2.is_nan()) || (d1 == d2)) + } +} +impl Eq for BlockInfo {} + +/// The UNIX timestamp and height of a [`Block`]. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct BlockTime { + /// The UNIX timestamp of the [`Block`], as claimed by the miner. + pub timestamp: u64, + /// The block height of the [`Block`]. + pub height: u32, +} + +/// Summary about a [`Block`]. +#[allow(deprecated)] +#[deprecated(since = "0.13.0", note = "use `BlockInfo` instead")] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BlockSummary { + /// The [`BlockHash`] of the [`Block`]. + pub id: BlockHash, + /// The UNIX timestamp and height of the [`Block`]. + #[serde(flatten)] + pub time: BlockTime, + /// The [`BlockHash`] of the previous [`Block`]. + /// + /// `None` for the genesis block. + pub previousblockhash: Option, + /// The Merkle root of this [`Block`]. + pub merkle_root: TxMerkleNode, +} + +/// Statistics about an [`Address`]. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct AddressStats { + /// The [`Address`]. + #[serde(deserialize_with = "deserialize_address_assume_checked")] + pub address: Address, + /// The summary of confirmed [`Transaction`]s for this [`Address`]. + pub chain_stats: AddressTxsSummary, + /// The summary of unconfirmed mempool [`Transaction`]s for this [`Address`]. + pub mempool_stats: AddressTxsSummary, +} + +/// A summary of [`Transaction`]s in which an [`Address`] is involved. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct AddressTxsSummary { + /// The current number of funded [`TxOut`]s for this [`Address`]. + pub funded_txo_count: u32, + /// The total [`Amount`] of funded [`TxOut`]s for this [`Address`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub funded_txo_sum: Amount, + /// The number of spent [`TxOut`]s for this [`Address`]. + pub spent_txo_count: u32, + /// The total [`Amount`] of spent [`TxOut`]s for this [`Address`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub spent_txo_sum: Amount, + /// The total number of [`Transaction`]s for this [`Address`]. + pub tx_count: u32, +} + +/// Statistics about a [scripthash](Script)'s confirmed and mempool [`Transaction`]s. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct ScriptHashStats { + /// The summary of confirmed [`Transaction`]s for this [scripthash](Script). + pub chain_stats: ScriptHashTxsSummary, + /// The summary of mempool [`Transaction`]s for this [scripthash](Script). + pub mempool_stats: ScriptHashTxsSummary, +} + +/// A summary of [`Transaction`]s for a particular [scripthash](Script). +pub type ScriptHashTxsSummary = AddressTxsSummary; + +/// The confirmation status of a [`TxOut`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct UtxoStatus { + /// Whether the [`TxOut`] is confirmed. + pub confirmed: bool, + /// The block height in which the [`TxOut`] was confirmed. + pub block_height: Option, + /// The block hash in which the [`TxOut`] was confirmed. + pub block_hash: Option, + /// The UNIX timestamp in which the [`TxOut`] was confirmed, as reported by the miner. + pub block_time: Option, +} + +/// 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 this [`TxOut`]. + pub txid: Txid, + /// The output index of this [`TxOut`] in the [`Transaction`] that created it. + pub vout: u32, + /// The confirmation status of this [`TxOut`]. + pub status: UtxoStatus, + /// The value of this [`TxOut`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub value: Amount, +} + +/// Statistics about the mempool. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct MempoolStats { + /// The number of [`Transaction`]s currently in the mempool. + pub count: usize, + /// The total size of mempool [`Transaction`]s, in virtual bytes. + pub vsize: usize, + /// The total fee paid by mempool [`Transaction`]s. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub total_fee: Amount, + /// The mempool's fee rate distribution histogram. + /// + /// An array of `(feerate, vsize)` tuples, where each entry's + /// `vsize` is the total vsize of [`Transaction`]s paying more + /// than `feerate` but less than the previous entry's `feerate` + /// (except for the first entry, which has no upper bound). + /// + /// The Esplora API reports `vsize` in virtual bytes. This field + /// currently stores that raw value in [`Weight`]. + #[serde(deserialize_with = "deserialize_fee_histogram")] + pub fee_histogram: Vec<(FeeRate, Weight)>, +} + +/// A [`Transaction`] that recently entered the mempool. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct MempoolRecentTx { + /// The [`Transaction`]'s [`Txid`]. + pub txid: Txid, + /// The fee paid by the [`Transaction`]. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub fee: Amount, + /// The [`Transaction`]'s size, in virtual bytes. + pub vsize: usize, + /// The combined value of the [`Transaction`]'s outputs. + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub value: Amount, +} + +/// The global result of a [`Transaction`] package submission. +#[derive(Deserialize, Debug)] +pub struct SubmitPackageResult { + /// The [`Transaction`] package result message. + /// + /// "success" indicates all transactions were + /// accepted or are already in the mempool. + pub package_msg: String, + /// The list of individual [`Transaction`] broadcast + /// results, keyed by each [`Transaction`]'s [`Wtxid`]. + #[serde(rename = "tx-results")] + pub tx_results: HashMap, + /// The list of [`Txid`]s of replaced [`Transaction`]s. + #[serde(rename = "replaced-transactions")] + pub replaced_transactions: Option>, +} + +/// A per-transaction result of a [`Transaction`] package submission. +#[derive(Deserialize, Debug)] +pub struct TxResult { + /// The [`Transaction`]'s [`Txid`]. + pub txid: Txid, + /// The [`Wtxid`] of a different [`Transaction`] with the same [`Txid`], + /// but different Witness found in the mempool. + /// + /// If `Some`, means the submitted [`Transaction`] was ignored. + #[serde(rename = "other-wtxid")] + pub other_wtxid: Option, + /// `sigops`-adjusted transaction size, in virtual bytes. + pub vsize: Option, + /// The effective fee paid by the [`Transaction`]. + pub fees: Option, + /// The [`Transaction`] submission error string. + pub error: Option, +} + +/// The fees for a [`Transaction`] submitted as part of a package. +#[derive(Deserialize, Debug)] +pub struct MempoolFeesSubmitPackage { + /// The base fee paid by the [`Transaction`]. + #[serde(with = "bitcoin::amount::serde::as_btc")] + pub base: Amount, + /// The effective feerate paid by this [`Transaction`]. + /// + /// Is `None` if the transaction was already in the mempool. + #[serde(rename = "effective-feerate", default, deserialize_with = "deserialize_feerate")] + pub effective_feerate: Option, + /// If [`Self::effective_feerate`] is provided, holds the + /// [`Wtxid`]s of the transactions whose fees and virtual + /// sizes are included in effective-feerate. + #[serde(rename = "effective-includes")] + pub effective_includes: Option>, +} + +impl EsploraTx { + /// Convert this [`EsploraTx`] into a [`Transaction`]. + /// + /// This will drop the Esplora-specific metadata (fee, weight, confirmation status) + /// and reconstructs the [`Transaction`] from its inputs and outputs. + pub fn to_tx(&self) -> Transaction { + Transaction { + version: self.version, + lock_time: self.locktime, + input: self + .vin + .iter() + .cloned() + .map(|vin| TxIn { + previous_output: OutPoint { txid: vin.txid, vout: vin.vout }, + script_sig: vin.scriptsig, + sequence: vin.sequence, + witness: 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`]. + /// + /// If the transaction is confirmed, returns its [`BlockTime`] containing + /// confirmation height and UNIX timestamp. If not, returns `None`. + pub fn confirmation_time(&self) -> Option { + match self.status { + TxStatus { + confirmed: true, + block_height: Some(height), + block_time: Some(timestamp), + .. + } => Some(BlockTime { timestamp, height }), + _ => None, + } + } + + /// Get the previous [`TxOut`]s spent by this transaction's inputs. + /// + /// Returns one [`Option`] per input, in order. + /// `None` if the input spends a coinbase output. + pub fn previous_outputs(&self) -> Vec> { + self.vin + .iter() + .cloned() + .map(|vin| { + vin.prevout.map(|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() + } +} + +/// Deserializes an [`Address`] from an Esplora address string. +/// +/// Esplora returns address strings without separately providing the expected +/// network, so this deserializer parses the address and assumes the embedded +/// network marker is correct. +fn deserialize_address_assume_checked<'de, D>(d: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + let address = Address::::deserialize(d)?; + Ok(address.assume_checked()) +} + +/// Deserializes an optional [`FeeRate`] from an `f64` BTC/kvB value. +/// +/// The Esplora API expresses effective feerates as BTC per kilovirtual-byte. +/// This deserializer converts it to sat/kwu as required by [`FeeRate`]. +/// +/// Returns `None` if the value is absent, and an error if the resulting +/// feerate would overflow. +fn deserialize_feerate<'de, D>(d: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Error; + + let btc_per_kvb = match Option::::deserialize(d)? { + Some(v) => v, + None => return Ok(None), + }; + let sat_per_kwu = btc_per_kvb * 25_000_000.0; + if sat_per_kwu.is_infinite() { + return Err(D::Error::custom("feerate overflow")); + } + Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64))) +} + +/// Deserializes a mempool fee histogram from `(sat/vB, vsize)` entries. +/// +/// The Esplora API expresses fee histogram buckets as feerates in satoshis per +/// virtual byte paired with each bucket's virtual size. This deserializer +/// converts each feerate to sat/kwu as required by [`FeeRate`]. +fn deserialize_fee_histogram<'de, D>(d: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Error; + let raw = Vec::<(f64, Weight)>::deserialize(d)?; + raw.into_iter() + .map(|(sat_per_vb, vsize)| { + let sat_per_kwu = sat_per_vb * 250.0; + if !sat_per_kwu.is_finite() { + return Err(D::Error::custom("feerate overflow")); + } + Ok((FeeRate::from_sat_per_kwu(sat_per_kwu as u64), vsize)) + }) + .collect() +} diff --git a/vendor/esplora-client-0.13.0/src/async.rs b/vendor/esplora-client-0.13.0/src/async.rs new file mode 100644 index 0000000000..d444588743 --- /dev/null +++ b/vendor/esplora-client-0.13.0/src/async.rs @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! # Asynchronous Esplora Client +//! +//! This module implements [`AsyncClient`], an asynchronous HTTP client for +//! interacting with an [Esplora] server by way of [`bitreq`]. +//! +//! Use this client from async applications and libraries. Each method returns a +//! future that sends the request, waits for the response, and decodes the body +//! into the requested type. +//! +//! The client is configured through [`Builder`], including the +//! base URL, proxy, socket timeout, custom headers, retry count, and maximum +//! number of cached connections. Retry sleeping is abstracted through +//! [`Sleeper`], so runtimes other than Tokio can provide their own sleep +//! implementation. +//! +//! # Example +//! +//! ```rust,ignore +//! # use esplora_client::{Builder, r#async::AsyncClient}; +//! # async fn example() -> Result<(), esplora_client::Error> { +//! +//! let client = Builder::new("https://mempool.space/api").build_async()?; +//! let height = client.get_height().await?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md + +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::marker::PhantomData; +use std::str::FromStr; +use std::time::Duration; + +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, Amount, Block, BlockHash, FeeRate, MerkleBlock, Script, Transaction, Txid}; + +use bitreq::{Client, Method, Proxy, Request, RequestExt, Response}; + +use crate::{ + duration_to_timeout_secs, is_retryable, is_success, sat_per_vbyte_to_feerate, AddressStats, + BlockInfo, BlockStatus, Builder, Error, EsploraTx, HeaderProvider, MempoolRecentTx, + MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, TxStatus, Utxo, + BASE_BACKOFF_MILLIS, +}; + +#[allow(deprecated)] +use crate::BlockSummary; + +// FIXME: (@oleonardolima) there's no `Debug` implementation for `bitreq::Client`. +/// An async client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. The client stores +/// the server base URL and request configuration, then exposes convenience +/// methods for the transaction, block, address, scripthash, fee-estimate, and +/// mempool endpoints. +/// +/// The generic parameter `S` determines the asynchronous runtime used for +/// sleeping between retries. Defaults to the Tokio-backed [`DefaultSleeper`]. +/// +/// # Retries +/// +/// Failed requests are automatically retried up to `max_retries` times +/// (configured via [`Builder`]) with exponential backoff, but only for +/// retryable HTTP status codes. See [`crate::RETRYABLE_ERROR_CODES`] for the +/// full list. +#[derive(Clone)] +pub struct AsyncClient { + /// The URL of the Esplora server. + url: String, + /// The URL of the proxy host. + /// + /// NOTE: The proxy is ignored when targeting `wasm32`. + proxy: Option, + /// Per-request socket timeout. + timeout: Option, + /// HTTP headers to set on every request made to the Esplora server. + headers: HashMap, + header_provider: Option, + /// Maximum number of retry attempts for retryable responses. + max_retries: usize, + /// The inner [`bitreq::Client`] HTTP client to cache connections. + client: Client, + /// Marker for the sleeper implementation. + marker: PhantomData, +} + +impl AsyncClient { + /// Build an [`AsyncClient`] from a [`Builder`]. + /// + /// Configures the underlying [`bitreq::Client`] with + /// proxy, timeout, and headers specified in the [`Builder`]. + /// No network request is made until a client method is awaited. + /// + /// # Errors + /// + /// Returns an [`Error`] if the HTTP client fails to build, + /// or if any of the provided header names or values are invalid. + pub fn from_builder(builder: Builder) -> Result { + Ok(AsyncClient { + url: builder.base_url, + proxy: builder.proxy, + timeout: builder.timeout, + headers: builder.headers, + header_provider: None, + max_retries: builder.max_retries, + client: Client::new(builder.max_connections), + marker: PhantomData, + }) + } + + /// Build an [`AsyncClient`] that obtains additional headers for each request. + pub fn from_builder_with_header_provider( + builder: Builder, header_provider: HeaderProvider, + ) -> Result { + let mut client = Self::from_builder(builder)?; + client.header_provider = Some(header_provider); + Ok(client) + } + + /// Return the base URL of the Esplora server this client connects to. + /// + /// The returned value is the exact string provided to [`Builder::new`]. + pub fn url(&self) -> &str { + &self.url + } + + /// Return the underlying [`bitreq::Client`]. + /// + /// This can be useful for callers that need access to shared connection + /// state managed by the HTTP client. + pub fn client(&self) -> &Client { + &self.client + } + + /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + /// + /// Configures the request with the proxy, timeout, and headers set on + /// this client. Used internally by all other request helper methods. + pub(crate) async fn build_request(&self, method: Method, path: &str) -> Result { + let mut request = Request::new(method, format!("{}{}", self.url, path)); + + #[cfg(not(target_arch = "wasm32"))] + if let Some(proxy) = &self.proxy { + request = request.with_proxy(Proxy::new_http(proxy)?); + } + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = self.timeout { + request = request.with_timeout(duration_to_timeout_secs(timeout)); + } + + let mut headers = self.headers.clone(); + if let Some(header_provider) = &self.header_provider { + headers.extend(header_provider().await.map_err(Error::HeaderProvider)?); + } + if !headers.is_empty() { + request = request.with_headers(&headers); + } + + Ok(request) + } + + /// Sends a GET request to `url`, retrying on retryable status codes + /// with exponential backoff until [`AsyncClient::max_retries`] is reached. + async fn get_with_retry(&self, path: &str) -> Result { + let mut delay = BASE_BACKOFF_MILLIS; + let mut attempts = 0; + + loop { + let request = self.build_request(Method::Get, path).await?; + match request.send_async_with_client(&self.client).await? { + response if attempts < self.max_retries && is_retryable(&response) => { + S::sleep(delay).await; + attempts += 1; + delay *= 2; + }, + response => return Ok(response), + } + } + } + + /// 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. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or deserialization fails. + async fn get_response(&self, path: &str) -> Result { + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(deserialize::(response.as_bytes())?) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// 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)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// 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. + async fn get_response_json( + &self, path: &str, + ) -> Result { + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + response.json::().map_err(Error::BitReq) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// Delegates to [`Self::get_response_json`]. See its documentation for details. + async fn get_opt_response_json( + &self, url: &str, + ) -> Result, Error> { + match self.get_response_json(url).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + 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. + async fn get_response_hex(&self, path: &str) -> Result { + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + let hex_str = response.as_str()?; + Ok(deserialize(&Vec::from_hex(hex_str)?)?) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// 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)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + 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. + async fn get_response_text(&self, path: &str) -> Result { + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(response.as_str()?.to_string()) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// 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)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP POST request to `path` with `body`. + /// + /// Configures query parameters, if any, and returns the raw response after + /// checking the HTTP status code. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// response's [`serde_json`] deserialization. + async fn post_request_bytes>>( + &self, path: &str, body: T, query_params: Option>, + ) -> Result { + let mut request: bitreq::Request = + self.build_request(Method::Post, path).await?.with_body(body); + + for (key, value) in query_params.unwrap_or_default() { + request = request.with_param(key, value); + } + + let response = request.send_async_with_client(&self.client).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(response) + } + + /// 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 raw [`Transaction`] given its [`Txid`]. + /// + /// Returns an [`Error::TransactionNotFound`] if the transaction is not found. + /// Prefer [`Self::get_tx`] if you want to handle the not-found case explicitly. + pub async fn get_tx_no_opt(&self, txid: &Txid) -> Result { + match self.get_tx(txid).await { + Ok(Some(tx)) => Ok(tx), + Ok(None) => Err(Error::TransactionNotFound(*txid)), + Err(e) => Err(e), + } + } + + /// 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, index: usize, + ) -> Result, Error> { + match self.get_opt_response_text(&format!("/block/{block_hash}/txid/{index}")).await? { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), + } + } + + /// 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 an [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], returns the Esplora-specific [`EsploraTx`] + /// type, which includes additional metadata such as confirmation status, + /// fee, and weight. Returns `None` if the transaction is not found. + pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}")).await + } + + /// Get the spend status of 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 [`BlockHeader`] of a [`Block`] given its [`BlockHash`]. + pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { + self.get_response_hex(&format!("/block/{block_hash}/header")).await + } + + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. + /// + /// Returns a [`BlockStatus`] indicating whether this [`Block`] is part of + /// the best chain, its height, and the [`BlockHash`] of the next [`Block`], + /// if any. + pub async fn get_block_status(&self, block_hash: &BlockHash) -> Result { + self.get_response_json(&format!("/block/{block_hash}/status")).await + } + + /// Get the full [`Block`] with the given [`BlockHash`]. + /// + /// Returns `None` if the [`Block`] is not found. + pub async fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { + self.get_opt_response(&format!("/block/{block_hash}/raw")).await + } + + /// Get a Merkle inclusion proof for a [`Transaction`] 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`] 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 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, index: u64, + ) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")).await + } + + /// 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.as_str()?).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 [`bitcoin::Wtxid`]. + /// + /// Optionally, `maxfeerate` and `maxburnamount` 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 params = HashSet::<(&str, String)>::new(); + + // Esplora expects `maxfeerate` in sats/vB. + if let Some(maxfeerate) = maxfeerate { + params.insert(("maxfeerate", maxfeerate.to_sat_per_vb_ceil().to_string())); + } + // Esplora expects `maxburnamount` in BTC. + if let Some(maxburnamount) = maxburnamount { + params.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(params), + ) + .await?; + + let result = response.json::()?; + + Ok(result) + } + + /// Get the block height of the current blockchain tip. + pub async fn get_height(&self) -> Result { + self.get_response_text("/blocks/tip/height") + .await + .map(|height| u32::from_str(&height).map_err(Error::Parsing))? + } + + /// Get the [`BlockHash`] of the current blockchain tip. + 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))? + } + + /// Get the [`BlockHash`] of a [`Block`] given its height. + pub async fn get_block_hash(&self, block_height: u32) -> Result { + self.get_response_text(&format!("/block-height/{block_height}")) + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? + } + + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool transaction + /// summaries for the given address, including funded and spent output + /// counts and their total values. + pub async fn get_address_stats(&self, address: &Address) -> Result { + let path = format!("/address/{address}"); + self.get_response_json(&path).await + } + + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing transaction summaries for the + /// SHA256 hash of the given [`Script`]. + pub async fn get_scripthash_stats(&self, script: &Script) -> Result { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}"); + self.get_response_json(&path).await + } + + /// Get 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 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"), + }; + + self.get_response_json(&path).await + } + + /// 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 confirmed transaction history for a [`Script`] hash, sorted newest first. + /// + /// Returns 25 transactions per page. To paginate, pass the [`Txid`] of the + /// last transaction seen in the previous response as `last_seen`. + pub async fn get_scripthash_txs( + &self, script: &Script, 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).await + } + + /// 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"); + + self.get_response_json(&path).await + } + + /// Get global statistics about the mempool. + /// + /// Returns a [`MempoolStats`] containing the transaction count, total + /// virtual size, total fees, and fee rate histogram. + pub async fn get_mempool_stats(&self) -> Result { + self.get_response_json("/mempool").await + } + + /// Get 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 + } + + /// Get the full list of [`Txid`]s in the mempool. + /// + /// The order of the [`Txid`]s is arbitrary. + pub async fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids").await + } + + /// 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); + + Ok(estimates) + } + + /// Get a [`BlockInfo`] summary for the [`Block`] with the given [`BlockHash`]. + /// + /// [`BlockInfo`] includes metadata such as the height, timestamp, + /// [`Transaction`] count, size, and [`Weight`](bitcoin::Weight). + /// + /// This method does not return the full [`Block`]. + pub async fn get_block_info(&self, blockhash: &BlockHash) -> Result { + let path = format!("/block/{blockhash}"); + + self.get_response_json(&path).await + } + + /// Get all [`Txid`]s of [`Transaction`]s included in the [`Block`] with the + /// given [`BlockHash`]. + pub async fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { + let path = format!("/block/{blockhash}/txids"); + + self.get_response_json(&path).await + } + + /// Get up to 25 [`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, 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 [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + #[allow(deprecated)] + #[deprecated(since = "0.13.0", 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 [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// + /// # 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 blocks: Vec = self.get_response_json(&path).await?; + if blocks.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(blocks) + } + + /// 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 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"); + + self.get_response_json(&path).await + } +} + +/// A trait for abstracting over async sleep implementations. +/// +/// [`AsyncClient`] uses this trait to wait between retry attempts without +/// committing the client type to a specific async runtime. +/// +/// The only provided implementation is [`DefaultSleeper`], which is backed by Tokio. +/// Custom implementations can be provided to support other runtimes. +pub trait Sleeper: 'static { + /// The [`Future`](std::future::Future) type returned by [`Sleeper::sleep`]. + type Sleep: std::future::Future; + /// Return a [`Future`](std::future::Future) that completes after `duration`. + fn sleep(dur: Duration) -> Self::Sleep; +} + +/// The default [`Sleeper`] implementation, backed by [`tokio::time::sleep`]. +/// +/// This type is available when the `tokio` feature is enabled or while running +/// tests. +#[derive(Debug, Clone, Copy)] +pub struct DefaultSleeper; + +#[cfg(any(test, feature = "tokio"))] +impl Sleeper for DefaultSleeper { + type Sleep = tokio::time::Sleep; + + fn sleep(dur: std::time::Duration) -> Self::Sleep { + tokio::time::sleep(dur) + } +} diff --git a/vendor/esplora-client-0.13.0/src/blocking.rs b/vendor/esplora-client-0.13.0/src/blocking.rs new file mode 100644 index 0000000000..6992403b32 --- /dev/null +++ b/vendor/esplora-client-0.13.0/src/blocking.rs @@ -0,0 +1,682 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! # Blocking Esplora Client +//! +//! This module implements [`BlockingClient`], a synchronous HTTP client for +//! interacting with an [Esplora] server by way of [`bitreq`]. +//! +//! Use this client from synchronous applications, command-line tools, tests, +//! or code paths where blocking the current thread is acceptable. Each method +//! sends the request immediately and returns only after the response body has +//! been read and decoded. +//! +//! The client is configured through [`Builder`], including the +//! base URL, proxy, socket timeout, custom headers, and retry count. +//! +//! # Example +//! +//! ```rust,no_run +//! # fn example() -> Result<(), esplora_client::Error> { +//! +//! let client = esplora_client::Builder::new("https://mempool.space/api").build_blocking(); +//! let height = client.get_height()?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md + +use std::collections::{HashMap, HashSet}; +use std::convert::TryFrom; +use std::str::FromStr; +use std::thread; +use std::time::Duration; + +use bitcoin::consensus::encode::serialize_hex; +use bitreq::{Method, Proxy, Request, Response}; + +use bitcoin::block::Header as BlockHeader; +use bitcoin::consensus::{deserialize, serialize, Decodable}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::{DisplayHex, FromHex}; +use bitcoin::{Address, Amount, Block, BlockHash, FeeRate, MerkleBlock, Script, Transaction, Txid}; + +use crate::{ + duration_to_timeout_secs, is_retryable, is_success, sat_per_vbyte_to_feerate, AddressStats, + BlockInfo, BlockStatus, Builder, Error, EsploraTx, MempoolRecentTx, MempoolStats, MerkleProof, + OutputStatus, ScriptHashStats, SubmitPackageResult, TxStatus, Utxo, BASE_BACKOFF_MILLIS, +}; + +#[allow(deprecated)] +use crate::BlockSummary; + +/// A synchronous client for interacting with an Esplora API server. +/// +/// Use [`Builder`] to construct an instance of this client. The client stores +/// the server base URL and request configuration, then exposes convenience +/// methods for the transaction, block, address, scripthash, fee-estimate, and +/// mempool endpoints. +/// +/// Each method blocks the current thread until the HTTP request completes and +/// the response is parsed into the requested type. +/// +/// # Retries +/// +/// Failed requests are automatically retried up to `max_retries` times +/// (configured via [`Builder`]) with exponential backoff, but only for +/// retryable HTTP status codes. See [`crate::RETRYABLE_ERROR_CODES`] for the +/// full list. +#[derive(Debug, Clone)] +pub struct BlockingClient { + /// The URL of the Esplora server. + url: String, + /// The URL of the proxy host. + /// + /// NOTE: The proxy is ignored when targeting `wasm32`. + pub proxy: Option, + /// Per-request socket timeout. + pub timeout: Option, + /// HTTP headers to set on every request made to the Esplora server. + pub headers: HashMap, + /// Maximum number of retry attempts for retryable responses. + pub max_retries: usize, +} + +impl BlockingClient { + /// Build a [`BlockingClient`] from a [`Builder`]. + /// + /// This consumes the builder configuration and stores it on the client. + /// No network request is made until a client method is called. + pub fn from_builder(builder: Builder) -> Self { + Self { + url: builder.base_url, + proxy: builder.proxy, + timeout: builder.timeout, + headers: builder.headers, + max_retries: builder.max_retries, + } + } + + /// Return the base URL of the Esplora server this client connects to. + /// + /// The returned value is the exact string provided to [`Builder::new`]. + pub fn url(&self) -> &str { + &self.url + } + + /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + /// + /// Configures the request with the proxy, timeout, and headers set on + /// this client. Used internally by all other request helper methods. + pub(crate) fn build_request(&self, method: Method, path: &str) -> Result { + let mut request = Request::new(method, format!("{}{}", self.url, path)); + + if let Some(proxy) = &self.proxy { + request = request.with_proxy(Proxy::new_http(proxy)?); + } + + if let Some(timeout) = self.timeout { + request = request.with_timeout(duration_to_timeout_secs(timeout)); + } + + if !self.headers.is_empty() { + request = request.with_headers(&self.headers); + } + + Ok(request) + } + + /// Make an HTTP POST request to `path` with `body`. + /// + /// Configures query parameters, if any, and returns the raw response after + /// checking the HTTP status code. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// response's [`serde_json`] deserialization. + pub fn post_request>>( + &self, path: &str, body: T, query_params: Option>, + ) -> Result { + let mut request = self.build_request(Method::Post, path)?.with_body(body); + + for (key, value) in query_params.unwrap_or_default() { + request = request.with_param(key, value); + } + + let response = request.send()?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(response) + } + + /// 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.build_request(Method::Get, url)?.send()? { + resp if attempts < self.max_retries && is_retryable(&resp) => { + thread::sleep(delay); + 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`]. + /// + /// Use this for endpoints that return raw binary Bitcoin data. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or deserialization fails. + fn get_response(&self, path: &str) -> Result { + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(deserialize::(response.as_bytes())?) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// Delegates to [`Self::get_response`]. See its documentation for details. + fn get_opt_response(&self, path: &str) -> Result, Error> { + match self.get_response(path) { + Ok(response) => Ok(Some(response)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + 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 { + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + let hex_str = response.as_str()?; + deserialize(&Vec::from_hex(hex_str)?).map_err(Error::BitcoinEncoding) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// Delegates to [`Self::get_response_hex`]. See its documentation for details. + fn get_opt_response_hex(&self, path: &str) -> Result, Error> { + match self.get_response_hex(path) { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// 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)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + response.json::().map_err(Error::BitReq) + } + + /// 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_response_json(path) { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + 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_text(&self, path: &str) -> Result { + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(response.as_str()?.to_string()) + } + + /// Makes a GET request to `path`, returning `None` on a 404 response. + /// + /// Delegates to [`Self::get_response_text`]. See its documentation for details. + fn get_opt_response_text(&self, path: &str) -> Result, Error> { + match self.get_response_text(path) { + Ok(s) => Ok(Some(s)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// 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 raw [`Transaction`] given its [`Txid`]. + /// + /// Returns an [`Error::TransactionNotFound`] if the transaction is not found. + /// Prefer [`Self::get_tx`] if you want to handle the not-found case explicitly. + pub fn get_tx_no_opt(&self, txid: &Txid) -> Result { + match self.get_tx(txid) { + Ok(Some(tx)) => Ok(tx), + Ok(None) => Err(Error::TransactionNotFound(*txid)), + Err(e) => Err(e), + } + } + + /// 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> { + match self.get_opt_response_text(&format!("/block/{block_hash}/txid/{index}"))? { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), + } + } + + /// 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 an [`EsploraTx`] given its [`Txid`]. + /// + /// Unlike [`Self::get_tx`], returns the Esplora-specific [`EsploraTx`] + /// type, which includes additional metadata such as confirmation status, + /// fee, and weight. Returns `None` if the transaction is not found. + pub fn get_tx_info(&self, txid: &Txid) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}")) + } + + /// Get the spend status of 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 [`BlockHeader`] of a [`Block`] given its [`BlockHash`]. + pub fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { + self.get_response_hex(&format!("/block/{block_hash}/header")) + } + + /// Get the [`BlockStatus`] of a [`Block`] given its [`BlockHash`]. + /// + /// Returns a [`BlockStatus`] indicating whether this [`Block`] is part of + /// the best chain, its height, and the [`BlockHash`] of the next [`Block`], + /// if any. + pub fn get_block_status(&self, block_hash: &BlockHash) -> Result { + self.get_response_json(&format!("/block/{block_hash}/status")) + } + + /// Get the full [`Block`] with the given [`BlockHash`]. + /// + /// Returns `None` if the [`Block`] is not found. + pub fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { + self.get_opt_response(&format!("/block/{block_hash}/raw")) + } + + /// Get a Merkle inclusion proof for a [`Transaction`] 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}")) + } + + /// Broadcast a [`Transaction`] to the Esplora server. + /// + /// The transaction is serialized and sent as a hex-encoded string. + /// Returns the [`Txid`] of the broadcasted transaction. + /// + /// # Errors + /// + /// Returns an [`Error`] if the request fails or the server rejects the transaction. + pub fn broadcast(&self, transaction: &Transaction) -> Result { + let body = serialize::(transaction).to_lower_hex_string(); + + let response = self.post_request("/tx", body, None)?; + let txid = Txid::from_str(response.as_str()?).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 [`bitcoin::Wtxid`]. + /// + /// Optionally, `maxfeerate` and `maxburnamount` 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, + ) -> Result { + let serialized_txs = transactions.iter().map(|tx| serialize_hex(&tx)).collect::>(); + + let mut params = HashSet::<(&str, String)>::new(); + + // Esplora expects `maxfeerate` in sats/vB. + if let Some(maxfeerate) = maxfeerate { + params.insert(("maxfeerate", maxfeerate.to_sat_per_vb_ceil().to_string())); + } + // Esplora expects `maxburnamount` in BTC. + if let Some(maxburnamount) = maxburnamount { + params.insert(("maxburnamount", maxburnamount.to_btc().to_string())); + } + + let response = self.post_request( + "/txs/package", + serde_json::to_string(&serialized_txs).map_err(Error::SerdeJson)?, + Some(params), + )?; + + let result = response.json::()?; + + Ok(result) + } + + /// Get the block height of the current blockchain tip. + pub fn get_height(&self) -> Result { + self.get_response_text("/blocks/tip/height") + .map(|s| u32::from_str(s.as_str()).map_err(Error::Parsing))? + } + + /// Get the [`BlockHash`] of the current blockchain tip. + pub fn get_tip_hash(&self) -> Result { + self.get_response_text("/blocks/tip/hash") + .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? + } + + /// Get the [`BlockHash`] of a [`Block`] given its height. + pub fn get_block_hash(&self, block_height: u32) -> Result { + self.get_response_text(&format!("/block-height/{block_height}")) + .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? + } + + /// Get global statistics about the mempool. + /// + /// Returns a [`MempoolStats`] containing the transaction count, total + /// virtual size, total fees, and fee rate histogram. + pub fn get_mempool_stats(&self) -> Result { + self.get_response_json("/mempool") + } + + /// Get the last 10 [`MempoolRecentTx`]s to enter the mempool. + pub fn get_mempool_recent_txs(&self) -> Result, Error> { + self.get_response_json("/mempool/recent") + } + + /// Get the full list of [`Txid`]s 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") + } + + /// 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) + } + + /// Get statistics about an [`Address`]. + /// + /// Returns an [`AddressStats`] containing confirmed and mempool transaction + /// summaries for the given address, including funded and spent output + /// counts and their total values. + pub fn get_address_stats(&self, address: &Address) -> Result { + let path = format!("/address/{address}"); + self.get_response_json(&path) + } + + /// Get statistics about a [`Script`] hash's confirmed and mempool transactions. + /// + /// Returns a [`ScriptHashStats`] containing transaction summaries for the + /// SHA256 hash of the given [`Script`]. + pub fn get_scripthash_stats(&self, script: &Script) -> Result { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}"); + self.get_response_json(&path) + } + + /// Get 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, 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"), + }; + + self.get_response_json(&path) + } + + /// 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 confirmed transaction history for a [`Script`] hash, sorted newest first. + /// + /// Returns 25 transactions per page. To paginate, pass the [`Txid`] of the + /// last transaction seen in the previous response as `last_seen`. + pub fn get_scripthash_txs( + &self, script: &Script, 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) + } + + /// Get unconfirmed mempool [`EsploraTx`]s for a [`Script`] hash, sorted newest first. + pub fn get_mempool_scripthash_txs(&self, script: &Script) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash:x}/txs/mempool"); + + self.get_response_json(&path) + } + + /// Get a [`BlockInfo`] summary for the [`Block`] with the given [`BlockHash`]. + /// + /// [`BlockInfo`] includes metadata such as the height, timestamp, + /// [`Transaction`] count, size, and [`Weight`](bitcoin::Weight). + /// + /// This method does not return the full [`Block`]. + pub fn get_block_info(&self, blockhash: &BlockHash) -> Result { + let path = format!("/block/{blockhash}"); + + self.get_response_json(&path) + } + + /// Get all [`Txid`]s of [`Transaction`]s included in the [`Block`] with the + /// given [`BlockHash`]. + pub fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { + let path = format!("/block/{blockhash}/txids"); + + self.get_response_json(&path) + } + + /// Get up to 25 [`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 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}"), + }; + + self.get_response_json(&path) + } + + /// Get [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + #[allow(deprecated)] + #[deprecated(since = "0.13.0", 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 [`BlockInfo`] summaries for recent [`Block`]s. + /// + /// If `height` is `Some(h)`, returns blocks starting from height `h`. + /// If `height` is `None`, returns blocks starting from the current tip. + /// + /// The maximum number of summaries returned depends on the backend itself: + /// Esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. + /// + /// # 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 blocks: Vec = self.get_response_json(&path)?; + if blocks.is_empty() { + return Err(Error::InvalidResponse); + } + Ok(blocks) + } + + /// Get all confirmed [`Utxo`]s locked to the given [`Address`]. + pub fn get_address_utxos(&self, address: &Address) -> Result, Error> { + let path = format!("/address/{address}/utxo"); + + self.get_response_json(&path) + } + + /// Get all 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) + } +} diff --git a/vendor/esplora-client-0.13.0/src/lib.rs b/vendor/esplora-client-0.13.0/src/lib.rs new file mode 100644 index 0000000000..cb99e2ff1c --- /dev/null +++ b/vendor/esplora-client-0.13.0/src/lib.rs @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! # Esplora Client +//! +//! A client library for querying [Esplora] HTTP APIs from Rust. +//! +//! The crate exposes a shared set of Esplora response types in [`api`], plus +//! optional blocking and async clients built on [`bitreq`]. Both clients use the +//! same [`Builder`] configuration for the base URL, proxy, timeout, custom +//! headers, and retry policy. +//! +//! # Client Modes +//! +//! Enable the `blocking` feature to use `BlockingClient`, whose methods block +//! the current thread until each request completes. Enable the `async` feature +//! to use `AsyncClient`, whose methods return futures and require an async +//! runtime. The default async sleeper is backed by Tokio when the `tokio` +//! feature is enabled; custom runtimes can supply their own `Sleeper`. +//! +//! # Examples +//! +//! Create a blocking client: +//! +//! ```rust,ignore +//! use esplora_client::Builder; +//! +//! fn main() -> Result<(), esplora_client::Error> { +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let blocking_client = builder.build_blocking(); +//! let height = blocking_client.get_height()?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! Create an async client: +//! +//! ```rust,ignore +//! use esplora_client::Builder; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), esplora_client::Error> { +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let async_client = builder.build_async()?; +//! let height = async_client.get_height().await?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! # Retries +//! +//! Both clients retry responses with status codes listed in +//! [`RETRYABLE_ERROR_CODES`]. Retry attempts use exponential backoff starting +//! at 256 milliseconds and are controlled by [`Builder::max_retries`]. +//! +//! # Features +//! +//! By default the crate enables all features. To select only the pieces you +//! need, set `default-features = false` in `Cargo.toml` and list the desired +//! features explicitly: +//! +//! ```toml +//! esplora-client = { version = "*", default-features = false, features = ["blocking"] } +//! ``` +//! +//! * `blocking` enables [`bitreq`], the blocking client with proxy. +//! * `blocking-https` enables [`bitreq`], the blocking client with proxy and TLS (SSL) capabilities +//! using the default [`bitreq`] backend. +//! * `blocking-https-rustls` enables [`bitreq`], the blocking client with proxy and TLS (SSL) +//! capabilities using the `rustls` backend. +//! * `blocking-https-native` enables [`bitreq`], the blocking client with proxy and TLS (SSL) +//! capabilities using the platform's native TLS backend (likely OpenSSL). +//! * `blocking-https-rustls-probe` enables [`bitreq`], the blocking client with proxy and TLS (SSL) +//! capabilities using `rustls` and probed system roots. +//! * `async` enables [`bitreq`], the async client with proxy capabilities. +//! * `async-https` enables [`bitreq`], the async client with support for proxying and TLS (SSL) +//! using the default [`bitreq`] TLS backend. +//! * `async-https-native` enables [`bitreq`], the async client with support for proxying and TLS +//! (SSL) using the platform's native TLS backend (likely OpenSSL). +//! * `async-https-rustls` enables [`bitreq`], the async client with support for proxying and TLS +//! (SSL) using the `rustls` TLS backend. +//! * `async-https-rustls-probe` enables [`bitreq`], the async client with support for proxying and +//! TLS (SSL) using `rustls` and probed system roots. +//! * `tokio` enables the default async sleeper used by [`Builder::build_async`]. +//! +//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md +//! [`bitreq`]: https://docs.rs/bitreq +#![allow(clippy::result_large_err)] +#![warn(missing_docs)] +#![allow(deprecated)] + +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::num::TryFromIntError; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(feature = "async")] +pub use r#async::Sleeper; + +pub mod api; +#[cfg(feature = "async")] +pub mod r#async; +#[cfg(feature = "blocking")] +pub mod blocking; + +pub use api::*; +#[cfg(any(feature = "blocking", feature = "async"))] +use bitreq::Response; +#[cfg(feature = "blocking")] +pub use blocking::BlockingClient; +#[cfg(feature = "async")] +pub use r#async::AsyncClient; + +/// Future returned by a dynamic Esplora header provider. +#[cfg(feature = "async")] +pub type HeaderProviderFuture = + Pin, String>> + Send>>; + +/// Callback used to obtain HTTP headers for each Esplora request. +#[cfg(feature = "async")] +pub type HeaderProvider = Arc HeaderProviderFuture + Send + Sync>; + +/// HTTP response status codes for which a request may be retried. +pub const RETRYABLE_ERROR_CODES: [u16; 3] = [ + 429, // TOO_MANY_REQUESTS + 500, // INTERNAL_SERVER_ERROR + 503, // SERVICE_UNAVAILABLE +]; + +/// Base delay used by the exponential retry backoff. +#[cfg(any(feature = "blocking", feature = "async"))] +const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); + +/// Default maximum number of retry attempts per request. +const DEFAULT_MAX_RETRIES: usize = 6; + +/// Default maximum number of cached connections for the async client. +#[cfg(feature = "async")] +const DEFAULT_MAX_CONNECTIONS: usize = 10; + +/// Check if the [`Response`] status code is informational (100-199). +#[allow(unused)] +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_informational(response: &Response) -> bool { + (100..200).contains(&response.status_code) +} + +/// Check if the [`Response`] status code is successful (200-299). +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_success(response: &Response) -> bool { + (200..300).contains(&response.status_code) +} + +/// Check if the [`Response`] status code is a redirection (300-399). +#[allow(unused)] +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_redirection(response: &Response) -> bool { + (300..400).contains(&response.status_code) +} + +/// Check if the [`Response`] status code is a client error (400-499). +#[allow(unused)] +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_client_error(response: &Response) -> bool { + (400..500).contains(&response.status_code) +} + +/// Check if the [`Response`] status code is a server error (500-599). +#[allow(unused)] +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_server_error(response: &Response) -> bool { + (500..600).contains(&response.status_code) +} + +/// Check if the [`Response`] status code is retryable (429, 500, 503). +#[cfg(any(feature = "blocking", feature = "async"))] +fn is_retryable(response: &Response) -> bool { + RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16)) +} + +/// Convert a [`Duration`] to whole timeout seconds for `bitreq`. +#[cfg(any(feature = "blocking", feature = "async"))] +fn duration_to_timeout_secs(duration: Duration) -> u64 { + if duration.subsec_nanos() == 0 { + duration.as_secs() + } else { + duration.as_secs().saturating_add(1) + } +} + +/// Return the [`FeeRate`] for the given confirmation target in blocks. +/// +/// Selects the highest confirmation target from `estimates` that is at or +/// below `target_blocks`, and returns its [`FeeRate`]. Returns `None` if no +/// matching estimate is found. +pub fn convert_fee_rate(target_blocks: usize, estimates: HashMap) -> Option { + estimates + .into_iter() + .filter(|(k, _)| *k as usize <= target_blocks) + .max_by_key(|(k, _)| *k) + .map(|(_, feerate)| feerate) +} + +/// Convert a [`HashMap`] of fee estimates expressed as sat/vB ([`f64`]) into [`FeeRate`]s. +pub fn sat_per_vbyte_to_feerate(estimates: HashMap) -> HashMap { + estimates + .into_iter() + .map(|(k, v)| (k, FeeRate::from_sat_per_kwu((v * 250.0).round() as u64))) + .collect() +} + +/// Shared configuration for `BlockingClient` and `AsyncClient`. +/// +/// Start with [`Builder::new`] and then chain optional configuration methods +/// before calling `Builder::build_blocking`, `Builder::build_async`, or +/// `Builder::build_async_with_sleeper`. +/// +/// # Example +/// +/// ```no_run +/// use std::time::Duration; +/// +/// # #[cfg(feature = "blocking")] +/// # { +/// let client = esplora_client::Builder::new("https://mempool.space/testnet/api") +/// .timeout(Duration::from_secs(30)) +/// .max_retries(4) +/// .header("user-agent", "my-wallet/0.1") +/// .build_blocking(); +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct Builder { + /// The base URL of the Esplora server. + /// + /// This should include the API prefix expected by the server, for example + /// `https://mempool.space/api` or `https://blockstream.info/testnet/api`. + pub base_url: String, + /// Optional proxy URL used for 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 by target and enabled transport features. See [bitreq]'s + /// proxy documentation for the accepted schemes. + /// + /// The proxy is ignored when targeting `wasm32`. + pub proxy: Option, + /// Per-request socket timeout. + pub timeout: Option, + /// HTTP headers to set on every request made to the Esplora server. + pub headers: HashMap, + /// Maximum number of retry attempts for retryable HTTP responses. + pub max_retries: usize, + /// Maximum number of cached connections for the async client. + #[cfg(feature = "async")] + pub max_connections: usize, +} + +impl Builder { + /// Create a [`Builder`] for an Esplora server base URL. + /// + /// The URL is stored exactly as provided and request paths are appended to + /// it. Do not include a trailing slash unless your server expects one. + pub fn new(base_url: &str) -> Self { + Builder { + base_url: base_url.to_string(), + proxy: None, + timeout: None, + headers: HashMap::new(), + max_retries: DEFAULT_MAX_RETRIES, + #[cfg(feature = "async")] + max_connections: DEFAULT_MAX_CONNECTIONS, + } + } + + /// Set the proxy URL used for requests. + /// + /// The proxy is ignored when targeting `wasm32`. + pub fn proxy(mut self, proxy: &str) -> Self { + self.proxy = Some(proxy.to_string()); + self + } + + /// Set the per-request socket timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Add or replace an HTTP header sent with every request. + pub fn header(mut self, key: &str, value: &str) -> Self { + self.headers.insert(key.to_string(), value.to_string()); + self + } + + /// Set the maximum number of retry attempts for retryable responses. + /// + /// Only responses whose status code is listed in + /// [`RETRYABLE_ERROR_CODES`] are retried. + pub fn max_retries(mut self, count: usize) -> Self { + self.max_retries = count; + self + } + + /// Set the maximum number of cached connections in the async client. + #[cfg(feature = "async")] + pub fn max_connections(mut self, count: usize) -> Self { + self.max_connections = count; + self + } + + /// Build a [`BlockingClient`] from this configuration. + #[cfg(feature = "blocking")] + pub fn build_blocking(self) -> BlockingClient { + BlockingClient::from_builder(self) + } + + /// Build an [`AsyncClient`] from this configuration. + /// + /// This uses `DefaultSleeper`, which is backed by Tokio. + /// + /// # Errors + /// + /// Returns an [`Error`] if the async HTTP client cannot be constructed. + #[cfg(all(feature = "async", feature = "tokio"))] + pub fn build_async(self) -> Result { + AsyncClient::from_builder(self) + } + + /// Build an [`AsyncClient`] that fetches additional HTTP headers for each request. + #[cfg(all(feature = "async", feature = "tokio"))] + pub fn build_async_with_header_provider( + self, header_provider: HeaderProvider, + ) -> Result { + AsyncClient::from_builder_with_header_provider(self, header_provider) + } + + /// Build an [`AsyncClient`] with a user-defined [`Sleeper`]. + /// + /// Use this when integrating with an async runtime other than Tokio or when + /// tests need a custom sleep implementation. + /// + /// # Errors + /// + /// Returns an [`Error`] if the async HTTP client cannot be constructed. + #[cfg(feature = "async")] + pub fn build_async_with_sleeper(self) -> Result, Error> { + AsyncClient::from_builder(self) + } +} + +/// Errors that can occur while building clients, sending requests, or decoding responses. +#[derive(Debug)] +pub enum Error { + /// Error during a [`bitreq`] HTTP request. + #[cfg(any(feature = "blocking", feature = "async"))] + BitReq(bitreq::Error), + /// Error during JSON serialization or deserialization. + SerdeJson(serde_json::Error), + /// Non-successful HTTP response from the Esplora server. + HttpResponse { + /// The HTTP status code returned by the server. + status: u16, + /// The response body returned by the server. + message: String, + }, + /// Invalid integer returned by the server. + Parsing(std::num::ParseIntError), + /// Invalid status code, unable to convert to `u16`. + StatusCode(TryFromIntError), + /// Invalid Bitcoin consensus data returned by the server. + BitcoinEncoding(bitcoin::consensus::encode::Error), + /// Invalid fixed-size hex data returned by the server. + HexToArray(bitcoin::hex::HexToArrayError), + /// Invalid variable-length hex data returned by the server. + HexToBytes(bitcoin::hex::HexToBytesError), + /// Transaction not found. + TransactionNotFound(Txid), + /// Block header height not found. + HeaderHeightNotFound(u32), + /// Block header hash not found. + HeaderHashNotFound(BlockHash), + /// Invalid HTTP header name specified in [`Builder::header`]. + InvalidHttpHeaderName(String), + /// Invalid HTTP header value specified in [`Builder::header`]. + InvalidHttpHeaderValue(String), + /// The dynamic HTTP header provider failed. + #[cfg(feature = "async")] + HeaderProvider(String), + /// The server sent an invalid response. + InvalidResponse, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(any(feature = "blocking", feature = "async"))] + Error::BitReq(e) => write!(f, "Bitreq HTTP error: {e}"), + Error::SerdeJson(e) => write!(f, "JSON (de)serialization error: {e}"), + Error::HttpResponse { status, message } => { + write!(f, "HTTP error {status}: {message}") + }, + Error::Parsing(e) => write!(f, "Failed to parse invalid number: {e}"), + Error::StatusCode(e) => write!(f, "Invalid status code: {e}"), + Error::BitcoinEncoding(e) => write!(f, "Invalid Bitcoin data: {e}"), + Error::HexToArray(e) => write!(f, "Invalid hex to array conversion: {e}"), + Error::HexToBytes(e) => write!(f, "Invalid hex to bytes conversion: {e}"), + Error::TransactionNotFound(txid) => { + write!(f, "Transaction not found: {txid}") + }, + Error::HeaderHeightNotFound(height) => { + write!(f, "Block header at height {height} not found") + }, + Error::HeaderHashNotFound(hash) => { + write!(f, "Block header with hash {hash} not found") + }, + Error::InvalidHttpHeaderName(name) => { + write!(f, "Invalid HTTP header name: {name}") + }, + Error::InvalidHttpHeaderValue(value) => { + write!(f, "Invalid HTTP header value: {value}") + }, + #[cfg(feature = "async")] + Error::HeaderProvider(error) => write!(f, "HTTP header provider failed: {error}"), + Error::InvalidResponse => write!(f, "The server sent an invalid response"), + } + } +} + +impl std::error::Error for Error {} + +macro_rules! impl_error { + ( $from:ty, $to:ident ) => { + impl_error!($from, $to, Error); + }; + ( $from:ty, $to:ident, $impl_for:ty ) => { + impl std::convert::From<$from> for $impl_for { + fn from(err: $from) -> Self { + <$impl_for>::$to(err) + } + } + }; +} + +#[cfg(any(feature = "blocking", feature = "async"))] +impl_error!(::bitreq::Error, BitReq, Error); +impl_error!(serde_json::Error, SerdeJson, Error); +impl_error!(std::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);