diff --git a/.github/workflows/code_coverage.yml b/.github/workflows/code_coverage.yml index bfdf61a6..9baf7428 100644 --- a/.github/workflows/code_coverage.yml +++ b/.github/workflows/code_coverage.yml @@ -24,7 +24,7 @@ jobs: - name: Update toolchain run: rustup update - name: Test - run: cargo test --features all-keys,compiler,esplora,compact_filters --no-default-features + run: cargo test --features all-keys,compiler,esplora,ureq,compact_filters --no-default-features - id: coverage name: Generate coverage diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 2de29924..35101517 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -16,14 +16,16 @@ jobs: - default - minimal - all-keys - - minimal,esplora + - minimal,esplora,ureq - key-value-db - electrum - compact_filters - - esplora,key-value-db,electrum + - esplora,ureq,key-value-db,electrum - compiler - rpc - verify + - async-interface + - async-interface,esplora,reqwest steps: - name: checkout uses: actions/checkout@v2 @@ -137,7 +139,8 @@ jobs: - name: Update toolchain run: rustup update - name: Check - run: cargo check --target wasm32-unknown-unknown --features esplora --no-default-features + run: cargo check --target wasm32-unknown-unknown --features esplora,reqwest --no-default-features + fmt: name: Rust fmt diff --git a/.github/workflows/nightly_docs.yml b/.github/workflows/nightly_docs.yml index 4a09db22..e6a49e2e 100644 --- a/.github/workflows/nightly_docs.yml +++ b/.github/workflows/nightly_docs.yml @@ -24,7 +24,7 @@ jobs: - name: Update toolchain run: rustup update - name: Build docs - run: cargo rustdoc --verbose --features=compiler,electrum,esplora,compact_filters,key-value-db,all-keys -- --cfg docsrs -Dwarnings + run: cargo rustdoc --verbose --features=compiler,electrum,esplora,ureq,compact_filters,key-value-db,all-keys -- --cfg docsrs -Dwarnings - name: Upload artifact uses: actions/upload-artifact@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d94f0526..db401649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Added `RpcBlockchain` in the `AnyBlockchain` struct to allow using Rpc backend where `AnyBlockchain` is used (eg `bdk-cli`) +- Removed hard dependency on `tokio`. ### Wallet @@ -14,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Blockchain -- Removed `stop_gap` from `Blockchain` trait and added it to only `ElectrumBlockchain` and `EsploraBlockchain` structs +- Removed `stop_gap` from `Blockchain` trait and added it to only `ElectrumBlockchain` and `EsploraBlockchain` structs. +- Added a `ureq` backend for use when not using feature `async-interface` or target WASM. `ureq` is a blocking HTTP client. ## [v0.9.0] - [v0.8.0] diff --git a/Cargo.toml b/Cargo.toml index 71235807..f8caafbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ readme = "README.md" license = "MIT OR Apache-2.0" [dependencies] -bdk-macros = "^0.4" +bdk-macros = { path = "macros"} # TODO: Change this to version number after next release. log = "^0.4" miniscript = "5.1" bitcoin = { version = "~0.26.2", features = ["use-serde", "base64"] } @@ -24,6 +24,7 @@ rand = "^0.7" sled = { version = "0.34", optional = true } electrum-client = { version = "0.7", optional = true } reqwest = { version = "0.11", optional = true, features = ["json"] } +ureq = { version = "2.1", default-features = false, features = ["json"], optional = true } futures = { version = "0.3", optional = true } async-trait = { version = "0.1", optional = true } rocksdb = { version = "0.14", optional = true } @@ -37,10 +38,6 @@ bitcoinconsensus = { version = "0.19.0-3", optional = true } # Needed by bdk_blockchain_tests macro bitcoincore-rpc = { version = "0.13", optional = true } -# Platform-specific dependencies -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { version = "1", features = ["rt"] } - [target.'cfg(target_arch = "wasm32")'.dependencies] async-trait = "0.1" js-sys = "0.3" @@ -51,21 +48,32 @@ minimal = [] compiler = ["miniscript/compiler"] verify = ["bitcoinconsensus"] default = ["key-value-db", "electrum"] -electrum = ["electrum-client"] -esplora = ["reqwest", "futures"] compact_filters = ["rocksdb", "socks", "lazy_static", "cc"] key-value-db = ["sled"] -async-interface = ["async-trait"] all-keys = ["keys-bip39"] keys-bip39 = ["tiny-bip39", "zeroize"] rpc = ["bitcoincore-rpc"] +# We currently provide mulitple implementations of `Blockchain`, all are +# blocking except for the `EsploraBlockchain` which can be either async or +# blocking, depending on the HTTP client in use. +# +# - Users wanting asynchronous HTTP calls should enable `async-interface` to get +# access to the asynchronous method implementations. Then, if Esplora is wanted, +# enable `esplora` AND `reqwest`. +# - Users wanting blocking HTTP calls can use any of the other blockchain +# implementations (`compact_filters`, `electrum`, or `esplora`). Users wanting to +# use Esplora should enable `esplora` AND `ureq`. +async-interface = ["async-trait"] +electrum = ["electrum-client"] +esplora = ["futures"] # Requires one of: `ureq` or `reqwest`. + # Debug/Test features test-blockchains = ["bitcoincore-rpc", "electrum-client"] test-electrum = ["electrum", "electrsd/electrs_0_8_10", "test-blockchains"] test-rpc = ["rpc", "electrsd/electrs_0_8_10", "test-blockchains"] -test-esplora = ["esplora", "electrsd/legacy", "electrsd/esplora_a33e97e1", "test-blockchains"] +test-esplora = ["esplora", "ureq", "electrsd/legacy", "electrsd/esplora_a33e97e1", "test-blockchains"] test-md-docs = ["electrum"] [dev-dependencies] @@ -88,6 +96,6 @@ required-features = ["compiler"] [workspace] members = ["macros"] [package.metadata.docs.rs] -features = ["compiler", "electrum", "esplora", "compact_filters", "rpc", "key-value-db", "all-keys", "verify"] +features = ["compiler", "electrum", "esplora", "ureq", "compact_filters", "rpc", "key-value-db", "all-keys", "verify"] # defines the configuration attribute `docsrs` rustdoc-args = ["--cfg", "docsrs"] diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 74eda5cf..3ba8741c 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -121,26 +121,3 @@ pub fn maybe_await(expr: TokenStream) -> TokenStream { quoted.into() } - -/// Awaits if target_arch is "wasm32", uses `tokio::Runtime::block_on()` otherwise -/// -/// Requires the `tokio` crate as a dependecy with `rt-core` or `rt-threaded` to build on non-wasm32 platforms. -#[proc_macro] -pub fn await_or_block(expr: TokenStream) -> TokenStream { - let expr: proc_macro2::TokenStream = expr.into(); - let quoted = quote! { - { - #[cfg(all(not(target_arch = "wasm32"), not(feature = "async-interface")))] - { - tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(#expr) - } - - #[cfg(any(target_arch = "wasm32", feature = "async-interface"))] - { - #expr.await - } - } - }; - - quoted.into() -} diff --git a/src/blockchain/any.rs b/src/blockchain/any.rs index 549f5153..8470959d 100644 --- a/src/blockchain/any.rs +++ b/src/blockchain/any.rs @@ -37,9 +37,9 @@ //! )?; //! # } //! -//! # #[cfg(feature = "esplora")] +//! # #[cfg(all(feature = "esplora", feature = "ureq"))] //! # { -//! let esplora_blockchain = EsploraBlockchain::new("...", None, 20); +//! let esplora_blockchain = EsploraBlockchain::new("...", 20); //! let wallet_esplora: Wallet = Wallet::new( //! "...", //! None, @@ -60,6 +60,8 @@ //! # use bdk::blockchain::*; //! # use bdk::database::MemoryDatabase; //! # use bdk::Wallet; +//! # #[cfg(all(feature = "esplora", feature = "ureq"))] +//! # { //! let config = serde_json::from_str("...")?; //! let blockchain = AnyBlockchain::from_config(&config)?; //! let wallet = Wallet::new( @@ -69,6 +71,7 @@ //! MemoryDatabase::default(), //! blockchain, //! )?; +//! # } //! # Ok::<(), bdk::Error>(()) //! ``` diff --git a/src/blockchain/esplora/mod.rs b/src/blockchain/esplora/mod.rs new file mode 100644 index 00000000..b3fe721e --- /dev/null +++ b/src/blockchain/esplora/mod.rs @@ -0,0 +1,124 @@ +//! Esplora +//! +//! This module defines a [`EsploraBlockchain`] struct that can query an Esplora +//! backend populate the wallet's [database](crate::database::Database) by: +//! +//! ## Example +//! +//! ```no_run +//! # use bdk::blockchain::esplora::EsploraBlockchain; +//! let blockchain = EsploraBlockchain::new("https://blockstream.info/testnet/api", 20); +//! # Ok::<(), bdk::Error>(()) +//! ``` +//! +//! Esplora blockchain can use either `ureq` or `reqwest` for the HTTP client +//! depending on your needs (blocking or async respectively). +//! +//! Please note, to configure the Esplora HTTP client correctly use one of: +//! Blocking: --features='esplora,ureq' +//! Async: --features='async-interface,esplora,reqwest' --no-default-features +use std::fmt; +use std::io; + +use serde::Deserialize; + +use bitcoin::consensus; +use bitcoin::{BlockHash, Txid}; + +#[cfg(all( + feature = "esplora", + feature = "reqwest", + any(feature = "async-interface", target_arch = "wasm32"), +))] +mod reqwest; + +#[cfg(all( + feature = "esplora", + feature = "reqwest", + any(feature = "async-interface", target_arch = "wasm32"), +))] +pub use self::reqwest::*; + +#[cfg(all( + feature = "esplora", + not(any( + feature = "async-interface", + feature = "reqwest", + target_arch = "wasm32" + )), +))] +mod ureq; + +#[cfg(all( + feature = "esplora", + not(any( + feature = "async-interface", + feature = "reqwest", + target_arch = "wasm32" + )), +))] +pub use self::ureq::*; + +/// Data type used when fetching transaction history from Esplora. +#[derive(Deserialize)] +pub struct EsploraGetHistory { + txid: Txid, + status: EsploraGetHistoryStatus, +} + +#[derive(Deserialize)] +struct EsploraGetHistoryStatus { + block_height: Option, +} + +/// Errors that can happen during a sync with [`EsploraBlockchain`] +#[derive(Debug)] +pub enum EsploraError { + /// Error during ureq HTTP request + #[cfg(feature = "ureq")] + Ureq(::ureq::Error), + /// Transport error during the ureq HTTP call + #[cfg(feature = "ureq")] + UreqTransport(::ureq::Transport), + /// Error during reqwest HTTP request + #[cfg(feature = "reqwest")] + Reqwest(::reqwest::Error), + /// HTTP response error + HttpResponse(u16), + /// IO error during ureq response read + Io(io::Error), + /// No header found in ureq response + NoHeader, + /// Invalid number returned + Parsing(std::num::ParseIntError), + /// Invalid Bitcoin data returned + BitcoinEncoding(bitcoin::consensus::encode::Error), + /// Invalid Hex data returned + Hex(bitcoin::hashes::hex::Error), + + /// Transaction not found + TransactionNotFound(Txid), + /// Header height not found + HeaderHeightNotFound(u32), + /// Header hash not found + HeaderHashNotFound(BlockHash), +} + +impl fmt::Display for EsploraError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl std::error::Error for EsploraError {} + +#[cfg(feature = "ureq")] +impl_error!(::ureq::Error, Ureq, EsploraError); +#[cfg(feature = "ureq")] +impl_error!(::ureq::Transport, UreqTransport, EsploraError); +#[cfg(feature = "reqwest")] +impl_error!(::reqwest::Error, Reqwest, EsploraError); +impl_error!(io::Error, Io, EsploraError); +impl_error!(std::num::ParseIntError, Parsing, EsploraError); +impl_error!(consensus::encode::Error, BitcoinEncoding, EsploraError); +impl_error!(bitcoin::hashes::hex::Error, Hex, EsploraError); diff --git a/src/blockchain/esplora.rs b/src/blockchain/esplora/reqwest.rs similarity index 68% rename from src/blockchain/esplora.rs rename to src/blockchain/esplora/reqwest.rs index c0a5fc56..6666792e 100644 --- a/src/blockchain/esplora.rs +++ b/src/blockchain/esplora/reqwest.rs @@ -9,41 +9,30 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Esplora -//! -//! This module defines a [`Blockchain`] struct that can query an Esplora backend -//! populate the wallet's [database](crate::database::Database) by -//! -//! ## Example -//! -//! ```no_run -//! # use bdk::blockchain::esplora::EsploraBlockchain; -//! let blockchain = EsploraBlockchain::new("https://blockstream.info/testnet/api", None, 20); -//! # Ok::<(), bdk::Error>(()) -//! ``` +//! Esplora by way of `reqwest` HTTP client. use std::collections::{HashMap, HashSet}; -use std::fmt; -use bitcoin::consensus::{self, deserialize, serialize}; +use bitcoin::consensus::{deserialize, serialize}; use bitcoin::hashes::hex::{FromHex, ToHex}; use bitcoin::hashes::{sha256, Hash}; -use bitcoin::{BlockHash, BlockHeader, Script, Transaction, Txid}; -use futures::stream::{self, FuturesOrdered, StreamExt, TryStreamExt}; +use bitcoin::{BlockHeader, Script, Transaction, Txid}; + #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{Client, StatusCode}; -use serde::Deserialize; +use futures::stream::{self, FuturesOrdered, StreamExt, TryStreamExt}; + +use ::reqwest::{Client, StatusCode}; + +use crate::blockchain::esplora::{EsploraError, EsploraGetHistory}; +use crate::blockchain::utils::{ElectrumLikeSync, ElsGetHistoryRes}; +use crate::blockchain::*; use crate::database::BatchDatabase; use crate::error::Error; use crate::wallet::utils::ChunksIterator; use crate::FeeRate; -use super::*; - -use self::utils::{ElectrumLikeSync, ElsGetHistoryRes}; - const DEFAULT_CONCURRENT_REQUESTS: u8 = 4; #[derive(Debug)] @@ -75,17 +64,23 @@ impl std::convert::From for EsploraBlockchain { } impl EsploraBlockchain { - /// Create a new instance of the client from a base URL - pub fn new(base_url: &str, concurrency: Option, stop_gap: usize) -> Self { + /// Create a new instance of the client from a base URL and `stop_gap`. + pub fn new(base_url: &str, stop_gap: usize) -> Self { EsploraBlockchain { url_client: UrlClient { url: base_url.to_string(), client: Client::new(), - concurrency: concurrency.unwrap_or(DEFAULT_CONCURRENT_REQUESTS), + concurrency: DEFAULT_CONCURRENT_REQUESTS, }, stop_gap, } } + + /// Set the concurrency to use when doing batch queries against the Esplora instance. + pub fn with_concurrency(mut self, concurrency: u8) -> Self { + self.url_client.concurrency = concurrency; + self + } } #[maybe_async] @@ -111,19 +106,19 @@ impl Blockchain for EsploraBlockchain { } fn get_tx(&self, txid: &Txid) -> Result, Error> { - Ok(await_or_block!(self.url_client._get_tx(txid))?) + Ok(self.url_client._get_tx(txid).await?) } fn broadcast(&self, tx: &Transaction) -> Result<(), Error> { - Ok(await_or_block!(self.url_client._broadcast(tx))?) + Ok(self.url_client._broadcast(tx).await?) } fn get_height(&self) -> Result { - Ok(await_or_block!(self.url_client._get_height())?) + Ok(self.url_client._get_height().await?) } fn estimate_fee(&self, target: usize) -> Result { - let estimates = await_or_block!(self.url_client._get_fee_estimates())?; + let estimates = self.url_client._get_fee_estimates().await?; let fee_val = estimates .into_iter() @@ -298,74 +293,51 @@ impl ElectrumLikeSync for UrlClient { &self, scripts: I, ) -> Result>, Error> { - let future = async { - let mut results = vec![]; - for chunk in ChunksIterator::new(scripts.into_iter(), self.concurrency as usize) { - let mut futs = FuturesOrdered::new(); - for script in chunk { - futs.push(self._script_get_history(&script)); - } - let partial_results: Vec> = futs.try_collect().await?; - results.extend(partial_results); + let mut results = vec![]; + for chunk in ChunksIterator::new(scripts.into_iter(), self.concurrency as usize) { + let mut futs = FuturesOrdered::new(); + for script in chunk { + futs.push(self._script_get_history(script)); } - Ok(stream::iter(results).collect().await) - }; - - await_or_block!(future) + let partial_results: Vec> = futs.try_collect().await?; + results.extend(partial_results); + } + Ok(stream::iter(results).collect().await) } fn els_batch_transaction_get<'s, I: IntoIterator>( &self, txids: I, ) -> Result, Error> { - let future = async { - let mut results = vec![]; - for chunk in ChunksIterator::new(txids.into_iter(), self.concurrency as usize) { - let mut futs = FuturesOrdered::new(); - for txid in chunk { - futs.push(self._get_tx_no_opt(&txid)); - } - let partial_results: Vec = futs.try_collect().await?; - results.extend(partial_results); + let mut results = vec![]; + for chunk in ChunksIterator::new(txids.into_iter(), self.concurrency as usize) { + let mut futs = FuturesOrdered::new(); + for txid in chunk { + futs.push(self._get_tx_no_opt(txid)); } - Ok(stream::iter(results).collect().await) - }; - - await_or_block!(future) + let partial_results: Vec = futs.try_collect().await?; + results.extend(partial_results); + } + Ok(stream::iter(results).collect().await) } fn els_batch_block_header>( &self, heights: I, ) -> Result, Error> { - let future = async { - let mut results = vec![]; - for chunk in ChunksIterator::new(heights.into_iter(), self.concurrency as usize) { - let mut futs = FuturesOrdered::new(); - for height in chunk { - futs.push(self._get_header(height)); - } - let partial_results: Vec = futs.try_collect().await?; - results.extend(partial_results); + let mut results = vec![]; + for chunk in ChunksIterator::new(heights.into_iter(), self.concurrency as usize) { + let mut futs = FuturesOrdered::new(); + for height in chunk { + futs.push(self._get_header(height)); } - Ok(stream::iter(results).collect().await) - }; - - await_or_block!(future) + let partial_results: Vec = futs.try_collect().await?; + results.extend(partial_results); + } + Ok(stream::iter(results).collect().await) } } -#[derive(Deserialize)] -struct EsploraGetHistoryStatus { - block_height: Option, -} - -#[derive(Deserialize)] -struct EsploraGetHistory { - txid: Txid, - status: EsploraGetHistoryStatus, -} - /// Configuration for an [`EsploraBlockchain`] #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq)] pub struct EsploraBlockchainConfig { @@ -375,7 +347,7 @@ pub struct EsploraBlockchainConfig { pub base_url: String, /// Number of parallel requests sent to the esplora service (default: 4) pub concurrency: Option, - /// Stop searching addresses for transactions after finding an unused gap of this length + /// Stop searching addresses for transactions after finding an unused gap of this length. pub stop_gap: usize, } @@ -383,47 +355,14 @@ impl ConfigurableBlockchain for EsploraBlockchain { type Config = EsploraBlockchainConfig; fn from_config(config: &Self::Config) -> Result { - Ok(EsploraBlockchain::new( - config.base_url.as_str(), - config.concurrency, - config.stop_gap, - )) + let mut blockchain = EsploraBlockchain::new(config.base_url.as_str(), config.stop_gap); + if let Some(concurrency) = config.concurrency { + blockchain.url_client.concurrency = concurrency; + }; + Ok(blockchain) } } -/// Errors that can happen during a sync with [`EsploraBlockchain`] -#[derive(Debug)] -pub enum EsploraError { - /// Error with the HTTP call - Reqwest(reqwest::Error), - /// Invalid number returned - Parsing(std::num::ParseIntError), - /// Invalid Bitcoin data returned - BitcoinEncoding(bitcoin::consensus::encode::Error), - /// Invalid Hex data returned - Hex(bitcoin::hashes::hex::Error), - - /// Transaction not found - TransactionNotFound(Txid), - /// Header height not found - HeaderHeightNotFound(u32), - /// Header hash not found - HeaderHashNotFound(BlockHash), -} - -impl fmt::Display for EsploraError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } -} - -impl std::error::Error for EsploraError {} - -impl_error!(reqwest::Error, Reqwest, EsploraError); -impl_error!(std::num::ParseIntError, Parsing, EsploraError); -impl_error!(consensus::encode::Error, BitcoinEncoding, EsploraError); -impl_error!(bitcoin::hashes::hex::Error, Hex, EsploraError); - #[cfg(test)] #[cfg(feature = "test-esplora")] crate::bdk_blockchain_tests! { diff --git a/src/blockchain/esplora/ureq.rs b/src/blockchain/esplora/ureq.rs new file mode 100644 index 00000000..0efe483c --- /dev/null +++ b/src/blockchain/esplora/ureq.rs @@ -0,0 +1,391 @@ +// Bitcoin Dev Kit +// Written in 2020 by Alekos Filini +// +// Copyright (c) 2020-2021 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 `ureq` HTTP client. + +use std::collections::{HashMap, HashSet}; +use std::io; +use std::io::Read; +use std::time::Duration; + +#[allow(unused_imports)] +use log::{debug, error, info, trace}; + +use ureq::{Agent, Response}; + +use bitcoin::consensus::{deserialize, serialize}; +use bitcoin::hashes::hex::{FromHex, ToHex}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::{BlockHeader, Script, Transaction, Txid}; + +use crate::blockchain::esplora::{EsploraError, EsploraGetHistory}; +use crate::blockchain::utils::{ElectrumLikeSync, ElsGetHistoryRes}; +use crate::blockchain::*; +use crate::database::BatchDatabase; +use crate::error::Error; +use crate::FeeRate; + +#[derive(Debug)] +struct UrlClient { + url: String, + agent: Agent, +} + +/// Structure that implements the logic to sync with Esplora +/// +/// ## Example +/// See the [`blockchain::esplora`](crate::blockchain::esplora) module for a usage example. +#[derive(Debug)] +pub struct EsploraBlockchain { + url_client: UrlClient, + stop_gap: usize, +} + +impl std::convert::From for EsploraBlockchain { + fn from(url_client: UrlClient) -> Self { + EsploraBlockchain { + url_client, + stop_gap: 20, + } + } +} + +impl EsploraBlockchain { + /// Create a new instance of the client from a base URL and `stop_gap`. + pub fn new(base_url: &str, stop_gap: usize) -> Self { + EsploraBlockchain { + url_client: UrlClient { + url: base_url.to_string(), + agent: Agent::new(), + }, + stop_gap, + } + } + + /// Set the inner `ureq` agent. + pub fn with_agent(mut self, agent: Agent) -> Self { + self.url_client.agent = agent; + self + } +} + +impl Blockchain for EsploraBlockchain { + fn get_capabilities(&self) -> HashSet { + vec![ + Capability::FullHistory, + Capability::GetAnyTx, + Capability::AccurateFees, + ] + .into_iter() + .collect() + } + + fn setup( + &self, + database: &mut D, + progress_update: P, + ) -> Result<(), Error> { + self.url_client + .electrum_like_setup(self.stop_gap, database, progress_update) + } + + fn get_tx(&self, txid: &Txid) -> Result, Error> { + Ok(self.url_client._get_tx(txid)?) + } + + fn broadcast(&self, tx: &Transaction) -> Result<(), Error> { + let _txid = self.url_client._broadcast(tx)?; + Ok(()) + } + + fn get_height(&self) -> Result { + Ok(self.url_client._get_height()?) + } + + fn estimate_fee(&self, target: usize) -> Result { + let estimates = self.url_client._get_fee_estimates()?; + + let fee_val = estimates + .into_iter() + .map(|(k, v)| Ok::<_, std::num::ParseIntError>((k.parse::()?, v))) + .collect::, _>>() + .map_err(|e| Error::Generic(e.to_string()))? + .into_iter() + .take_while(|(k, _)| k <= &target) + .map(|(_, v)| v) + .last() + .unwrap_or(1.0); + + Ok(FeeRate::from_sat_per_vb(fee_val as f32)) + } +} + +impl UrlClient { + fn script_to_scripthash(script: &Script) -> String { + sha256::Hash::hash(script.as_bytes()).into_inner().to_hex() + } + + fn _get_tx(&self, txid: &Txid) -> Result, EsploraError> { + let resp = self + .agent + .get(&format!("{}/tx/{}/raw", self.url, txid)) + .call(); + + match resp { + Ok(resp) => Ok(Some(deserialize(&into_bytes(resp)?)?)), + Err(ureq::Error::Status(code, _)) => { + if is_status_not_found(code) { + return Ok(None); + } + Err(EsploraError::HttpResponse(code)) + } + Err(e) => Err(EsploraError::Ureq(e)), + } + } + + fn _get_tx_no_opt(&self, txid: &Txid) -> Result { + match self._get_tx(txid) { + Ok(Some(tx)) => Ok(tx), + Ok(None) => Err(EsploraError::TransactionNotFound(*txid)), + Err(e) => Err(e), + } + } + + fn _get_header(&self, block_height: u32) -> Result { + let resp = self + .agent + .get(&format!("{}/block-height/{}", self.url, block_height)) + .call(); + + let bytes = match resp { + Ok(resp) => Ok(into_bytes(resp)?), + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + }?; + + let hash = std::str::from_utf8(&bytes) + .map_err(|_| EsploraError::HeaderHeightNotFound(block_height))?; + + let resp = self + .agent + .get(&format!("{}/block/{}/header", self.url, hash)) + .call(); + + match resp { + Ok(resp) => Ok(deserialize(&Vec::from_hex(&resp.into_string()?)?)?), + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + } + } + + fn _broadcast(&self, transaction: &Transaction) -> Result<(), EsploraError> { + let resp = self + .agent + .post(&format!("{}/tx", self.url)) + .send_string(&serialize(transaction).to_hex()); + + match resp { + Ok(_) => Ok(()), // We do not return the txid? + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + } + } + + fn _get_height(&self) -> Result { + let resp = self + .agent + .get(&format!("{}/blocks/tip/height", self.url)) + .call(); + + match resp { + Ok(resp) => Ok(resp.into_string()?.parse()?), + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + } + } + + fn _script_get_history(&self, script: &Script) -> Result, EsploraError> { + let mut result = Vec::new(); + let scripthash = Self::script_to_scripthash(script); + + // Add the unconfirmed transactions first + + let resp = self + .agent + .get(&format!( + "{}/scripthash/{}/txs/mempool", + self.url, scripthash + )) + .call(); + + let v = match resp { + Ok(resp) => { + let v: Vec = resp.into_json()?; + Ok(v) + } + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + }?; + + result.extend(v.into_iter().map(|x| ElsGetHistoryRes { + tx_hash: x.txid, + height: x.status.block_height.unwrap_or(0) as i32, + })); + + debug!( + "Found {} mempool txs for {} - {:?}", + result.len(), + scripthash, + script + ); + + // Then go through all the pages of confirmed transactions + let mut last_txid = String::new(); + loop { + let resp = self + .agent + .get(&format!( + "{}/scripthash/{}/txs/chain/{}", + self.url, scripthash, last_txid + )) + .call(); + + let v = match resp { + Ok(resp) => { + let v: Vec = resp.into_json()?; + Ok(v) + } + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + }?; + + let len = v.len(); + if let Some(elem) = v.last() { + last_txid = elem.txid.to_hex(); + } + + debug!("... adding {} confirmed transactions", len); + + result.extend(v.into_iter().map(|x| ElsGetHistoryRes { + tx_hash: x.txid, + height: x.status.block_height.unwrap_or(0) as i32, + })); + + if len < 25 { + break; + } + } + + Ok(result) + } + + fn _get_fee_estimates(&self) -> Result, EsploraError> { + let resp = self + .agent + .get(&format!("{}/fee-estimates", self.url,)) + .call(); + + let map = match resp { + Ok(resp) => { + let map: HashMap = resp.into_json()?; + Ok(map) + } + Err(ureq::Error::Status(code, _)) => Err(EsploraError::HttpResponse(code)), + Err(e) => Err(EsploraError::Ureq(e)), + }?; + + Ok(map) + } +} + +fn is_status_not_found(status: u16) -> bool { + status == 404 +} + +fn into_bytes(resp: Response) -> Result, io::Error> { + const BYTES_LIMIT: usize = 10 * 1_024 * 1_024; + + let mut buf: Vec = vec![]; + resp.into_reader() + .take((BYTES_LIMIT + 1) as u64) + .read_to_end(&mut buf)?; + if buf.len() > BYTES_LIMIT { + return Err(io::Error::new( + io::ErrorKind::Other, + "response too big for into_bytes", + )); + } + + Ok(buf) +} + +impl ElectrumLikeSync for UrlClient { + fn els_batch_script_get_history<'s, I: IntoIterator>( + &self, + scripts: I, + ) -> Result>, Error> { + let mut results = vec![]; + for script in scripts.into_iter() { + let v = self._script_get_history(script)?; + results.push(v); + } + Ok(results) + } + + fn els_batch_transaction_get<'s, I: IntoIterator>( + &self, + txids: I, + ) -> Result, Error> { + let mut results = vec![]; + for txid in txids.into_iter() { + let tx = self._get_tx_no_opt(txid)?; + results.push(tx); + } + Ok(results) + } + + fn els_batch_block_header>( + &self, + heights: I, + ) -> Result, Error> { + let mut results = vec![]; + for height in heights.into_iter() { + let header = self._get_header(height)?; + results.push(header); + } + Ok(results) + } +} + +/// Configuration for an [`EsploraBlockchain`] +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq)] +pub struct EsploraBlockchainConfig { + /// Base URL of the esplora service eg. `https://blockstream.info/api/` + pub base_url: String, + /// Socket read timeout. + pub timeout_read: u64, + /// Socket write timeout. + pub timeout_write: u64, + /// Stop searching addresses for transactions after finding an unused gap of this length. + pub stop_gap: usize, +} + +impl ConfigurableBlockchain for EsploraBlockchain { + type Config = EsploraBlockchainConfig; + + fn from_config(config: &Self::Config) -> Result { + let agent: Agent = ureq::AgentBuilder::new() + .timeout_read(Duration::from_secs(config.timeout_read)) + .timeout_write(Duration::from_secs(config.timeout_write)) + .build(); + Ok(EsploraBlockchain::new(config.base_url.as_str(), config.stop_gap).with_agent(agent)) + } +} diff --git a/src/error.rs b/src/error.rs index 540f7419..73bf7119 100644 --- a/src/error.rs +++ b/src/error.rs @@ -130,7 +130,7 @@ pub enum Error { Electrum(electrum_client::Error), #[cfg(feature = "esplora")] /// Esplora client error - Esplora(crate::blockchain::esplora::EsploraError), + Esplora(Box), #[cfg(feature = "compact_filters")] /// Compact filters client error) CompactFilters(crate::blockchain::compact_filters::CompactFiltersError), @@ -190,8 +190,6 @@ impl_error!(bitcoin::util::psbt::PsbtParseError, PsbtParse); #[cfg(feature = "electrum")] impl_error!(electrum_client::Error, Electrum); -#[cfg(feature = "esplora")] -impl_error!(crate::blockchain::esplora::EsploraError, Esplora); #[cfg(feature = "key-value-db")] impl_error!(sled::Error, Sled); #[cfg(feature = "rpc")] @@ -216,3 +214,10 @@ impl From for Error { } } } + +#[cfg(feature = "esplora")] +impl From for Error { + fn from(other: crate::blockchain::esplora::EsploraError) -> Self { + Error::Esplora(Box::new(other)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 09779fd1..0d0c3e9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -205,11 +205,27 @@ extern crate serde; #[macro_use] extern crate serde_json; +#[cfg(all(feature = "reqwest", feature = "ureq"))] +compile_error!("Features reqwest and ureq are mutually exclusive and cannot be enabled together"); + #[cfg(all(feature = "async-interface", feature = "electrum"))] compile_error!( "Features async-interface and electrum are mutually exclusive and cannot be enabled together" ); +#[cfg(all(feature = "async-interface", feature = "ureq"))] +compile_error!( + "Features async-interface and ureq are mutually exclusive and cannot be enabled together" +); + +#[cfg(all(feature = "async-interface", feature = "compact_filters"))] +compile_error!( + "Features async-interface and compact_filters are mutually exclusive and cannot be enabled together" +); + +#[cfg(all(feature = "esplora", not(feature = "ureq"), not(feature = "reqwest")))] +compile_error!("Feature missing: esplora requires either ureq or reqwest to be enabled"); + #[cfg(feature = "keys-bip39")] extern crate bip39; @@ -228,9 +244,6 @@ pub extern crate bitcoincore_rpc; #[cfg(feature = "electrum")] pub extern crate electrum_client; -#[cfg(feature = "esplora")] -pub extern crate reqwest; - #[cfg(feature = "key-value-db")] pub extern crate sled;