diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index fe1af12..ddf5fb3 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -27,6 +27,9 @@ jobs: - async-https-native - async-https-rustls - async-https-rustls-manual-roots + - async-arti-hyper + - async-arti-hyper-native + - async-arti-hyper-rustls steps: - uses: actions/checkout@v3 - name: Generate cache key @@ -52,10 +55,11 @@ jobs: if: matrix.rust.version == '1.63.0' run: | cargo update -p home --precise 0.5.5 + cargo update -p tor-error --precise 0.4.1 - name: Build run: cargo build --features ${{ matrix.features }} --no-default-features - name: Clippy if: ${{ matrix.rust.clippy }} run: cargo clippy --all-targets --features ${{ matrix.features }} --no-default-features -- -D warnings - name: Test - run: cargo test --features ${{ matrix.features }} --no-default-features + run: cargo test --features ${{ matrix.features }} --no-default-features -- --include-ignored diff --git a/Cargo.toml b/Cargo.toml index 3b40dab..beb34a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,25 +18,39 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } bitcoin = { version = "0.30.0", features = ["serde", "std"], default-features = false } # Temporary dependency on internals until the rust-bitcoin devs release the hex-conservative crate. bitcoin-internals = { version = "0.1.0", features = ["alloc"] } log = "^0.4" -ureq = { version = "2.5.0", features = ["json"], optional = true } +ureq = { version = "2.5.0", optional = true, features = ["json"]} reqwest = { version = "0.11", optional = true, default-features = false, features = ["json"] } +hyper = { version = "0.14", optional = true, features = ["http1", "client", "runtime"], default-features = false } +arti-hyper = { version = "0.8.3", optional = true, features = ["default"] } +arti-client = { version = "0.8.3", optional = true } +tor-rtcompat = { version = "0.8.2", optional = true, features = ["tokio"]} +tls-api = { version = "0.9.0", optional = true } +tls-api-native-tls = { version = "0.9.0", optional = true } +ahash = { version = "=0.8.6" } # ahash 0.8.7 version don't work with our MSRV on aarch64, check: https://github.com/tkaitchuck/aHash/issues/195 + +[target.'cfg(target_vendor="apple")'.dependencies] +tls-api-openssl = { version = "0.9.0", optional = true } [dev-dependencies] -serde_json = "1.0" tokio = { version = "1.20.1", features = ["full"] } electrsd = { version = "0.24.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_22_0"] } electrum-client = "0.16.0" lazy_static = "1.4.0" [features] -default = ["blocking", "async", "async-https"] +default = ["blocking", "async", "async-https", "async-arti-hyper"] blocking = ["ureq", "ureq/socks-proxy"] async = ["reqwest", "reqwest/socks"] 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"] +# TODO: (@leonardo) Should I rename it to async-anonymized ? +async-arti-hyper = ["hyper", "arti-client", "tor-rtcompat", "tls-api", "tls-api-native-tls", "tls-api-openssl", "arti-hyper"] +async-arti-hyper-native = ["async-arti-hyper", "arti-hyper/native-tls"] +async-arti-hyper-rustls = ["async-arti-hyper", "arti-hyper/rustls"] diff --git a/src/async.rs b/src/async.rs index fcdf23e..9a16d70 100644 --- a/src/async.rs +++ b/src/async.rs @@ -9,7 +9,7 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Esplora by way of `reqwest` HTTP client. +//! Esplora by way of `reqwest`, and `arti-hyper` HTTP client. use std::collections::HashMap; use std::str::FromStr; @@ -25,16 +25,36 @@ use bitcoin_internals::hex::display::DisplayHex; #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{Client, StatusCode}; +#[cfg(feature = "async")] +use reqwest::Client; + +#[cfg(feature = "async-arti-hyper")] +use { + arti_client::{TorClient, TorClientConfig}, + arti_hyper::ArtiHttpConnector, + hyper::service::Service, + hyper::{Body, Request, Response, Uri}, + tls_api::{TlsConnector as TlsConnectorTrait, TlsConnectorBuilder}, + tor_rtcompat::PreferredRuntime, +}; + +#[cfg(feature = "async-arti-hyper")] +#[cfg(not(target_vendor = "apple"))] +use tls_api_native_tls::TlsConnector; +#[cfg(feature = "async-arti-hyper")] +#[cfg(target_vendor = "apple")] +use tls_api_openssl::TlsConnector; use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; +#[cfg(feature = "async")] #[derive(Debug, Clone)] pub struct AsyncClient { url: String, client: Client, } +#[cfg(feature = "async")] impl AsyncClient { /// build an async client from a builder pub fn from_builder(builder: Builder) -> Result { @@ -66,7 +86,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -101,7 +121,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -187,7 +207,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -209,7 +229,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -231,7 +251,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -258,7 +278,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Ok(None); } @@ -335,7 +355,7 @@ impl AsyncClient { .send() .await?; - if let StatusCode::NOT_FOUND = resp.status() { + if let reqwest::StatusCode::NOT_FOUND = resp.status() { return Err(Error::HeaderHeightNotFound(block_height)); } @@ -429,3 +449,448 @@ impl AsyncClient { &self.client } } + +#[cfg(feature = "async-arti-hyper")] +#[derive(Debug, Clone)] +pub struct AsyncAnonymizedClient { + url: String, + client: hyper::Client>, +} + +#[cfg(feature = "async-arti-hyper")] +impl AsyncAnonymizedClient { + /// build an async [`TorClient`] with default Tor configuration + async fn create_tor_client() -> Result, arti_client::Error> { + let config = TorClientConfig::default(); + TorClient::create_bootstrapped(config).await + } + + /// build an [`AsyncAnonymizedClient`] from a [`Builder`] + pub async fn from_builder(builder: Builder) -> Result { + let tor_client = Self::create_tor_client().await?.isolated_client(); + + let tls_conn: TlsConnector = TlsConnector::builder() + .map_err(|_| Error::TlsConnector)? + .build() + .map_err(|_| Error::TlsConnector)?; + + let connector = ArtiHttpConnector::new(tor_client, tls_conn); + + // TODO: (@leonardo) how to handle/pass the timeout option ? + let client = hyper::Client::builder().build::<_, Body>(connector); + Ok(Self::from_client(builder.base_url, client)) + } + + /// build an async client from the base url and [`Client`] + pub fn from_client( + url: String, + client: hyper::Client>, + ) -> Self { + AsyncAnonymizedClient { url, client } + } + + /// Get a [`Option`] given its [`Txid`] + pub async fn get_tx(&self, txid: &Txid) -> Result, Error> { + let path = format!("{}/tx/{}/raw", self.url, txid); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + Ok(Some(deserialize(&bytes)?)) + } + } + + /// 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> { + let path = format!("{}/block/{}/txid/{}", self.url, block_hash, index); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let text = Self::text(resp).await?; + let txid = Txid::from_str(&text)?; + Ok(Some(txid)) + } + } + + /// Get the status of a [`Transaction`] given its [`Txid`]. + pub async fn get_tx_status(&self, txid: &Txid) -> Result { + let path = format!("{}/tx/{}/status", self.url, txid); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + let tx_status = + serde_json::from_slice::(&bytes).map_err(|_| Error::ResponseDecoding)?; + Ok(tx_status) + } + } + + /// Get a [`BlockHeader`] given a particular block hash. + pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { + let path = format!("{}/block/{}/header", self.url, block_hash); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let text = Self::text(resp).await?; + let block_header = deserialize(&Vec::from_hex(&text)?)?; + Ok(block_header) + } + } + + /// Get the [`BlockStatus`] given a particular [`BlockHash`]. + pub async fn get_block_status(&self, block_hash: &BlockHash) -> Result { + let path = &format!("{}/block/{}/status", self.url, block_hash); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + + let block_status = serde_json::from_slice::(&bytes) + .map_err(|_| Error::ResponseDecoding)?; + Ok(block_status) + } + } + + /// Get a [`Block`] given a particular [`BlockHash`]. + pub async fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { + let path = format!("{}/block/{}/raw", self.url, block_hash); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + Ok(Some(deserialize(&bytes)?)) + } + } + + /// Get a merkle inclusion proof for a [`Transaction`] with the given [`Txid`]. + pub async fn get_merkle_proof(&self, tx_hash: &Txid) -> Result, Error> { + let path = format!("{}/tx/{}/merkle-proof", self.url, tx_hash); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + let merkle_proof = serde_json::from_slice::(&bytes) + .map_err(|_| Error::ResponseDecoding)?; + Ok(Some(merkle_proof)) + } + } + + /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the given [`Txid`]. + pub async fn get_merkle_block(&self, tx_hash: &Txid) -> Result, Error> { + let path = format!("{}/tx/{}/merkleblock-proof", self.url, tx_hash); + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let text = Self::text(resp).await?; + let merkle_block = deserialize(&Vec::from_hex(&text)?)?; + Ok(Some(merkle_block)) + } + } + + /// 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> { + let path = &format!("{}/tx/{}/outspend/{}", self.url, txid, index); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Ok(None); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + + let output_status = serde_json::from_slice::(&bytes) + .map_err(|_| Error::ResponseDecoding)?; + Ok(Some(output_status)) + } + } + + // /// Broadcast a [`Transaction`] to Esplora + pub async fn broadcast(&mut self, transaction: &Transaction) -> Result<(), Error> { + let path = &format!("{}/tx", self.url); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + + let body = Body::from(serialize(transaction).to_lower_hex_string()); + let req = Request::post(uri) + .body(body) + .map_err(|_| Error::InvalidBody)?; + + let resp = self.client.call(req).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + Ok(()) + } + } + + /// Get the current height of the blockchain tip + pub async fn get_height(&self) -> Result { + let path = &format!("{}/blocks/tip/height", self.url); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + + let block_height = + serde_json::from_slice::(&bytes).map_err(|_| Error::ResponseDecoding)?; + Ok(block_height) + } + } + + /// Get the [`BlockHash`] of the current blockchain tip. + pub async fn get_tip_hash(&self) -> Result { + let path = &format!("{}/blocks/tip/hash", self.url); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let text = Self::text(resp).await?; + let block_hash = BlockHash::from_str(&text).map_err(|_| Error::ResponseDecoding)?; + Ok(block_hash) + } + } + + /// Get the [`BlockHash`] of a specific block height + pub async fn get_block_hash(&self, block_height: u32) -> Result { + let path = &format!("{}/block-height/{}", self.url, block_height); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if let hyper::StatusCode::NOT_FOUND = resp.status() { + return Err(Error::HeaderHeightNotFound(block_height)); + } + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let text = Self::text(resp).await?; + let block_hash = BlockHash::from_str(&text).map_err(|_| Error::ResponseDecoding)?; + Ok(block_hash) + } + } + + /// Get an 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> { + let path = &format!("{}/fee-estimates", self.url); + let uri = Uri::from_str(path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + let fee_estimates = serde_json::from_slice::>(&bytes) + .map_err(|_| Error::ResponseDecoding)?; + Ok(fee_estimates) + } + } + + /// Get confirmed 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/{:x}/txs/chain/{}", + self.url, script_hash, last_seen + ), + None => format!("{}/scripthash/{:x}/txs", self.url, script_hash), + }; + + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + let txs = + serde_json::from_slice::>(&bytes).map_err(|_| Error::ResponseDecoding)?; + Ok(txs) + } + } + + /// Gets some recent block summaries starting at the tip or at `height` if provided. + /// + /// The maximum number of summaries returned depends on the backend itself: esplora returns `10` + /// while [mempool.space](https://mempool.space/docs/api) returns `15`. + pub async fn get_blocks(&self, height: Option) -> Result, Error> { + let path = match height { + Some(height) => format!("{}/blocks/{}", self.url, height), + None => format!("{}/blocks", self.url), + }; + let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?; + + let resp = self.client.get(uri).await?; + + if resp.status().is_server_error() || resp.status().is_client_error() { + Err(Error::HttpResponse { + status: resp.status().as_u16(), + message: Self::text(resp).await?, + }) + } else { + let body = resp.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + let blocks = serde_json::from_slice::>(&bytes) + .map_err(|_| Error::ResponseDecoding)?; + Ok(blocks) + } + } + + /// Get the underlying base URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Get the underlying [`hyper::Client`]. + pub fn client(&self) -> &hyper::Client> { + &self.client + } + + /// Get the given [`Response`] as [`String`]. + async fn text(response: Response) -> Result { + let body = response.into_body(); + let bytes = hyper::body::to_bytes(body).await?; + + match std::str::from_utf8(&bytes) { + Ok(text) => Ok(text.to_string()), + Err(_) => Err(Error::ResponseDecoding), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index a5fc313..926e994 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,9 @@ //! async Esplora client to query Esplora's backend. //! //! The library provides the possibility to build a blocking -//! client using [`ureq`] and an async client using [`reqwest`]. -//! The library supports communicating to Esplora via a proxy +//! client using [`ureq`], an async client using [`reqwest`], +//! and an anonymized async client using [`arti-hyper`]. +//! The library supports communicating to Esplora via a Tor, proxy, //! and also using TLS (SSL) for secure communication. //! //! @@ -35,6 +36,20 @@ //! # } //! ``` //! +//! // FIXME: (@leonardo) fix this documentation +//! Here is an example of how to create an anonymized asynchronous client. +//! +//! ```no_run +//! # #[cfg(feature = "async-arti-hyper")] +//! # { +//! use esplora_client::Builder; +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let async_client = builder.build_async_anonymized(); +//! # Ok::<(), esplora_client::Error>(()); +//! # } +//! ``` +//! +//! //! ## Features //! //! By default the library enables all features. To specify @@ -54,6 +69,12 @@ //! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for //! proxying and TLS (SSL) using the `rustls` TLS backend without using its the default root //! certificates. +//! * `async-arti-hyper` enables [`arti_hyper`], the async anonymized client support for TLS (SSL) over Tor, +//! using the default [`arti_hyper`] TLS backend. +//! * `async-arti-hyper-native` enables [`arti_hyper`], the async anonymized client support for TLS (SSL) over Tor, +//! using the platform's native TLS backend (likely OpenSSL). +//! * `async-arti-hyper-rustls` enables [`arti_hyper`], the async anonymized client support for TLS (SSL) over Tor, +//! using the `rustls` TLS backend without using its the default root certificates. //! //! @@ -67,7 +88,7 @@ use bitcoin::consensus; pub mod api; -#[cfg(feature = "async")] +#[cfg(any(feature = "async", feature = "async-arti-hyper"))] pub mod r#async; #[cfg(feature = "blocking")] pub mod blocking; @@ -75,6 +96,8 @@ pub mod blocking; pub use api::*; #[cfg(feature = "blocking")] pub use blocking::BlockingClient; +#[cfg(feature = "async-arti-hyper")] +pub use r#async::AsyncAnonymizedClient; #[cfg(feature = "async")] pub use r#async::AsyncClient; @@ -109,7 +132,7 @@ pub struct Builder { /// the `socks` feature enabled. /// /// The proxy is ignored when targeting `wasm32`. - pub proxy: Option, + pub proxy: Option, // TODO: (@leonardo) should this be available for `async-arti-hyper` /// Socket timeout. pub timeout: Option, } @@ -147,6 +170,12 @@ impl Builder { pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } + + // build an asynchronous anonymized, over Tor, client from builder + #[cfg(feature = "async-arti-hyper")] + pub async fn build_async_anonymized(self) -> Result { + AsyncAnonymizedClient::from_builder(self).await + } } /// Errors that can happen during a sync with `Esplora` @@ -161,6 +190,24 @@ pub enum Error { /// Error during reqwest HTTP request #[cfg(feature = "async")] Reqwest(::reqwest::Error), + /// Error during hyper HTTP request + #[cfg(feature = "async-arti-hyper")] + Hyper(::hyper::Error), + /// Error during hyper HTTP request + #[cfg(feature = "async-arti-hyper")] + InvalidUri, + /// Error during hyper HTTP request body creation + #[cfg(feature = "async-arti-hyper")] + InvalidBody, + /// Error during Tor client creation + #[cfg(feature = "async-arti-hyper")] + ArtiClient(::arti_client::Error), + /// Error during [`TlsConnector`] building + #[cfg(feature = "async-arti-hyper")] + TlsConnector, + /// Error during response decoding + #[cfg(feature = "async-arti-hyper")] + ResponseDecoding, /// HTTP response error HttpResponse { status: u16, message: String }, /// IO error during ureq response read @@ -173,7 +220,6 @@ pub enum Error { BitcoinEncoding(bitcoin::consensus::encode::Error), /// Invalid Hex data returned Hex(bitcoin::hashes::hex::Error), - /// Transaction not found TransactionNotFound(Txid), /// Header height not found @@ -206,6 +252,10 @@ impl std::error::Error for Error {} impl_error!(::ureq::Transport, UreqTransport, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); +#[cfg(feature = "async-arti-hyper")] +impl_error!(::hyper::Error, Hyper, Error); +#[cfg(feature = "async-arti-hyper")] +impl_error!(::arti_client::Error, ArtiClient, Error); impl_error!(io::Error, Io, Error); impl_error!(std::num::ParseIntError, Parsing, Error); impl_error!(consensus::encode::Error, BitcoinEncoding, Error); @@ -214,13 +264,14 @@ impl_error!(bitcoin::hashes::hex::Error, Hex, Error); #[cfg(test)] mod test { use super::*; + #[allow(unused_imports)] + use bitcoin::hashes::Hash; use electrsd::{bitcoind, bitcoind::BitcoinD, ElectrsD}; use lazy_static::lazy_static; use std::env; use tokio::sync::Mutex; #[cfg(all(feature = "blocking", feature = "async"))] use { - bitcoin::hashes::Hash, bitcoin::Amount, electrsd::{ bitcoind::bitcoincore_rpc::json::AddressType, bitcoind::bitcoincore_rpc::RpcApi, @@ -278,6 +329,20 @@ mod test { (blocking_client, async_client) } + #[cfg(feature = "async-arti-hyper")] + async fn setup_anonymized_client() -> AsyncAnonymizedClient { + const ESPLORA_URL: &str = "https://mempool.space/api"; + // const ESPLORA_URL: &str = "https://blockstream.info/testnet/api"; + // const ESPLORA_URL: &str = "http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/api"; + + let builder_async_anonymized = Builder::new(ESPLORA_URL); + + builder_async_anonymized + .build_async_anonymized() + .await + .unwrap() + } + #[cfg(all(feature = "blocking", feature = "async"))] fn generate_blocks_and_wait(num: usize) { let cur_height = BITCOIND.client.get_block_count().unwrap(); @@ -405,6 +470,20 @@ mod test { assert_eq!(tx, tx_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_tx() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_tx = genesis_block.coinbase().unwrap().to_owned(); + + let tx_async_anonymized = client.get_tx(&coinbase_tx.txid()).await.unwrap().unwrap(); + assert_eq!(coinbase_tx, tx_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_no_opt() { @@ -436,6 +515,20 @@ mod test { assert_eq!(tx_no_opt, tx_no_opt_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_tx_no_opt() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_tx = genesis_block.coinbase().unwrap().to_owned(); + + let tx_async_anonymized = client.get_tx_no_opt(&coinbase_tx.txid()).await.unwrap(); + assert_eq!(coinbase_tx, tx_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_status() { @@ -478,6 +571,28 @@ mod test { assert!(tx_status.block_time.is_none()); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_tx_status() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_txid = genesis_block.coinbase().unwrap().txid(); + + let tx_status_async_anonymized = client.get_tx_status(&coinbase_txid).await.unwrap(); + assert!(tx_status_async_anonymized.confirmed); + + // Bogus txid returns a TxStatus with false, None, None, None + let txid = Txid::hash(b"ayyyy lmao"); + let tx_status_async_anonymized = client.get_tx_status(&txid).await.unwrap(); + assert!(!tx_status_async_anonymized.confirmed); + assert!(tx_status_async_anonymized.block_height.is_none()); + assert!(tx_status_async_anonymized.block_hash.is_none()); + assert!(tx_status_async_anonymized.block_time.is_none()); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_header_by_hash() { @@ -490,6 +605,24 @@ mod test { assert_eq!(block_header, block_header_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + + async fn test_anonymized_get_header_by_hash() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let block_header_async_anonymized = client + .get_header_by_hash(&genesis_block.block_hash()) + .await + .unwrap(); + + assert_eq!(genesis_block.header, block_header_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_block_status() { @@ -510,6 +643,36 @@ mod test { assert_eq!(expected, block_status_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_block_status() { + use std::str::FromStr; + + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let genesis_block_status = BlockStatus { + in_best_chain: true, + height: Some(0), + // https://mempool.space/block/00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048 + next_best: Some( + BlockHash::from_str( + "00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048", + ) + .unwrap(), + ), + }; + + let block_status_async_anonymized = client + .get_block_status(&genesis_block.block_hash()) + .await + .unwrap(); + + assert_eq!(genesis_block_status, block_status_async_anonymized) + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_non_existing_block_status() { @@ -550,6 +713,24 @@ mod test { assert_eq!(expected, block_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_block_by_hash() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let block_status_async_anonymized = client + .get_block_by_hash(&genesis_block.block_hash()) + .await + .unwrap() + .unwrap(); + + assert_eq!(genesis_block, block_status_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_that_errors_are_propagated() { @@ -633,6 +814,26 @@ mod test { assert!(merkle_proof.pos > 0); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_merkle_proof() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let coinbase_txid = genesis_block.coinbase().unwrap().txid(); + let merkle_proof = client + .get_merkle_proof(&coinbase_txid) + .await + .unwrap() + .unwrap(); + + assert!(merkle_proof.pos == 0); + assert!(merkle_proof.block_height == 0); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_merkle_block() { @@ -674,6 +875,34 @@ mod test { assert!(indexes[0] > 0); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_merkle_block() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let coinbase_txid = genesis_block.coinbase().unwrap().txid(); + + let merkle_block = client + .get_merkle_block(&coinbase_txid) + .await + .unwrap() + .unwrap(); + + let mut matches = vec![coinbase_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() { @@ -713,6 +942,28 @@ mod test { assert_eq!(output_status, output_status_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_output_status() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_txid = genesis_block.coinbase().unwrap().txid(); + + let tx_status_async_anonymized = client + .get_output_status(&coinbase_txid, 1) + .await + .unwrap() + .unwrap(); + + assert!(!tx_status_async_anonymized.spent); + assert!(tx_status_async_anonymized.txid.is_none()); + assert!(tx_status_async_anonymized.vin.is_none()); + assert!(tx_status_async_anonymized.status.is_none()); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_height() { @@ -723,6 +974,15 @@ mod test { assert_eq!(block_height, block_height_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_height() { + let client = setup_anonymized_client().await; + let block_height = client.get_height().await.unwrap(); + assert!(block_height > 0); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tip_hash() { @@ -732,6 +992,13 @@ mod test { assert_eq!(tip_hash, tip_hash_async); } + // #[cfg(feature = "async-arti-hyper")] + // #[tokio::test] + // #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + // async fn test_anonymized_get_tip_hash() { + // unimplemented!() + // } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_block_hash() { @@ -745,6 +1012,19 @@ mod test { assert_eq!(block_hash, block_hash_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_block_hash() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let block_hash = client.get_block_hash(0).await.unwrap(); + assert_eq!(block_hash, genesis_block.block_hash()); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_txid_at_block_index() { @@ -764,6 +1044,27 @@ mod test { assert_eq!(txid_at_block_index, txid_at_block_index_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_txid_at_block_index() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let genesis_block_hash = genesis_block.block_hash(); + let coinbase_txid = genesis_block.coinbase().unwrap().txid(); + + let txid_at_block_index_async_anonymized = client + .get_txid_at_block_index(&genesis_block_hash, 0) + .await + .unwrap() + .unwrap(); + + assert_eq!(coinbase_txid, txid_at_block_index_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_fee_estimates() { @@ -773,6 +1074,13 @@ mod test { assert_eq!(fee_estimates.len(), fee_estimates_async.len()); } + // #[cfg(feature = "async-arti-hyper")] + // #[tokio::test] + // #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + // async fn test_anonymized_get_fee_estimates() { + // todo!() + // } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_scripthash_txs() { @@ -822,20 +1130,47 @@ mod test { assert_eq!(scripthash_txs_txids, scripthash_txs_txids_async); } + #[cfg(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_scripthash_txs() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let coinbase_tx = genesis_block.coinbase().unwrap(); + + let script = &coinbase_tx.output[0].script_pubkey; + let scripthash_txs_txids: Vec = client + .scripthash_txs(script, None) + .await + .unwrap() + .iter() + .map(|tx| tx.txid) + .collect(); + + assert!(scripthash_txs_txids.contains(&coinbase_tx.txid())) + } + #[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(); + 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(); @@ -846,8 +1181,35 @@ mod test { 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(feature = "async-arti-hyper")] + #[tokio::test] + #[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_blocks() { + let client = setup_anonymized_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let blocks = client.get_blocks(Some(1)).await.unwrap(); + + assert!(blocks.len() == 2); + + assert_eq!( + blocks[0].id.to_string(), + "00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048" + ); + assert_eq!( + blocks[0].previousblockhash.unwrap(), + genesis_block.block_hash() + ); + + assert_eq!(blocks[1].id, genesis_block.block_hash()); + assert_eq!(blocks[1].previousblockhash, None); + } }