From 7cc49a207ba5bceb2d7cb4aaaa391443ae954767 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 23 Aug 2024 13:32:38 -0300 Subject: [PATCH 1/7] chore(deps): bump `actions/checkout` from v3 to v4 - rename the workflow from Rust to CI - add name to build-test step of Build & Test --- .github/workflows/cont_integration.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 2ac7e07..2ca23b9 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -1,4 +1,4 @@ -name: Rust +name: CI on: push: @@ -11,7 +11,7 @@ env: jobs: build-test: - + name: Build & Test runs-on: ubuntu-latest strategy: matrix: @@ -32,10 +32,10 @@ jobs: - async-https-rustls - async-https-rustls-manual-roots steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Generate cache key run: echo "${{ matrix.rust.version }} ${{ matrix.features }}" | tee .cache_key - - name: cache + - name: Rust Cache uses: actions/cache@v3 with: path: | @@ -52,7 +52,7 @@ jobs: run: rustup component add clippy - name: Update toolchain run: rustup update - - name: pin dependencies + - name: Pin dependencies for MSRV if: matrix.rust.version == '1.63.0' run: | cargo update -p zstd-sys --precise "2.0.8+zstd.1.5.5" From 79a69f6b02313decc39ce325b39c7653a4ea8c4c Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 23 Aug 2024 13:43:50 -0300 Subject: [PATCH 2/7] refactor(ci)!: add new `fmt` and `clippy` jobs - adds two new jobs for `fmt` and `clippy`. - use `dtolnay/rust-toolchain@v1` instead of `actions-rs/toolchain@v1` --- .github/workflows/cont_integration.yml | 55 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 2ca23b9..46ac073 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -17,7 +17,6 @@ jobs: matrix: rust: - version: stable # STABLE - clippy: true - version: 1.63.0 # MSRV features: - default @@ -32,7 +31,8 @@ jobs: - async-https-rustls - async-https-rustls-manual-roots steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - name: Generate cache key run: echo "${{ matrix.rust.version }} ${{ matrix.features }}" | tee .cache_key - name: Rust Cache @@ -43,13 +43,12 @@ jobs: ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('.cache_key') }}-${{ hashFiles('**/Cargo.toml','**/Cargo.lock') }} - - name: Set default toolchain - run: rustup default ${{ matrix.rust.version }} + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust.version }} - name: Set profile run: rustup set profile minimal - - name: Add clippy - if: ${{ matrix.rust.clippy }} - run: rustup component add clippy - name: Update toolchain run: rustup update - name: Pin dependencies for MSRV @@ -62,8 +61,44 @@ jobs: cargo update -p tokio --precise "1.38.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 -- --test-threads=1 + + fmt: + name: Rust Formatting + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - name: Check fmt + run: cargo fmt --all -- --config format_code_in_doc_comments=true,wrap_comments=true --check + + clippy: + name: Rust Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: clippy + - name: Rust Cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('.cache_key') }}-${{ hashFiles('**/Cargo.toml','**/Cargo.lock') }} + - name: Check clippy + uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features --all-targets -- -D warnings From f451a1840b124d176540e946b9cfdd6e1abdf240 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 23 Aug 2024 13:45:38 -0300 Subject: [PATCH 3/7] fix(fmt): apply suggested fixes from `rustfmt` --- src/async.rs | 26 +++++++++++++--------- src/blocking.rs | 26 +++++++++++++--------- src/lib.rs | 58 ++++++++++++++++++++++++++----------------------- 3 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/async.rs b/src/async.rs index bf72a48..62b1689 100644 --- a/src/async.rs +++ b/src/async.rs @@ -100,7 +100,8 @@ impl AsyncClient { } } - /// Get a [`Txid`] of a transaction given its index in a block with a given hash. + /// 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, @@ -222,7 +223,8 @@ impl AsyncClient { } } - /// Get a merkle inclusion proof for a [`Transaction`] with the given [`Txid`]. + /// 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 resp = self .client @@ -244,7 +246,8 @@ impl AsyncClient { } } - /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the given [`Txid`]. + /// 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 resp = self .client @@ -267,7 +270,8 @@ impl AsyncClient { } } - /// Get the spending status of an output given a [`Txid`] and the output index. + /// Get the spending status of an output given a [`Txid`] and the output + /// index. pub async fn get_output_status( &self, txid: &Txid, @@ -372,7 +376,8 @@ impl AsyncClient { /// 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. + /// More can be requested by specifying the last txid seen by the previous + /// query. pub async fn scripthash_txs( &self, script: &Script, @@ -399,8 +404,8 @@ impl AsyncClient { } } - /// Get an map where the key is the confirmation target (in number of blocks) - /// and the value is the estimated feerate (in sat/vB). + /// 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 resp = self .client @@ -418,10 +423,11 @@ impl AsyncClient { } } - /// Gets some recent block summaries starting at the tip or at `height` if provided. + /// 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`. + /// 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 url = match height { Some(height) => format!("{}/blocks/{}", self.url, height), diff --git a/src/blocking.rs b/src/blocking.rs index 0448572..22c95fd 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -199,7 +199,8 @@ impl BlockingClient { } } - /// Get a [`Txid`] of a transaction given its index in a block with a given hash. + /// 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, @@ -233,17 +234,20 @@ impl BlockingClient { self.get_opt_response(&format!("/block/{}/raw", block_hash)) } - /// Get a merkle inclusion proof for a [`Transaction`] with the given [`Txid`]. + /// 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/{}/merkle-proof", txid)) } - /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the given [`Txid`]. + /// 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/{}/merkleblock-proof", txid)) } - /// Get the spending status of an output given a [`Txid`] and the output index. + /// Get the spending status of an output given a [`Txid`] and the output + /// index. pub fn get_output_status( &self, txid: &Txid, @@ -299,15 +303,16 @@ impl BlockingClient { .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } - /// Get an map where the key is the confirmation target (in number of blocks) - /// and the value is the estimated feerate (in sat/vB). + /// 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 fn get_fee_estimates(&self) -> Result, Error> { self.get_response_json("/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. + /// More can be requested by specifying the last txid seen by the previous + /// query. pub fn scripthash_txs( &self, script: &Script, @@ -321,10 +326,11 @@ impl BlockingClient { self.get_response_json(&path) } - /// Gets some recent block summaries starting at the tip or at `height` if provided. + /// 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`. + /// The maximum number of summaries returned depends on the backend itself: + /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. pub fn get_blocks(&self, height: Option) -> Result, Error> { let path = match height { Some(height) => format!("/blocks/{}", height), diff --git a/src/lib.rs b/src/lib.rs index 9c2658f..cca3ab1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,29 +41,30 @@ //! 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"] }` +//! `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. +//! * `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 its the default root -//! certificates. -//! -//! +//! * `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 its the default root certificates. #![allow(clippy::result_large_err)] @@ -89,7 +90,8 @@ pub use r#async::AsyncClient; /// 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. +/// 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() @@ -103,11 +105,13 @@ pub struct Builder { 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:`. + /// 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 + /// 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`. @@ -594,8 +598,8 @@ mod test { #[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 + // 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. From bcb66e636ac22813c747c1ec024b82a3b09d2c3f Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 23 Aug 2024 13:51:13 -0300 Subject: [PATCH 4/7] chore(rust+clippy): bump `edition` to 2021, and add `.clippy.toml` - bumps the `edition` on `Cargo.toml` to `2021. - add `.clippy.toml` with `msrv=1.63.0` file. --- .clippy.toml | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .clippy.toml diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..69478ce --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +msrv="1.63.0" diff --git a/Cargo.toml b/Cargo.toml index f26c04c..1d90703 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "esplora-client" version = "0.9.0" -edition = "2018" +edition = "2021" authors = ["Alekos Filini "] license = "MIT" homepage = "https://github.com/bitcoindevkit/rust-esplora-client" From 68ecc31f06fd33d03f70a225e34618610ac9d9fc Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 21 Aug 2024 17:00:53 -0300 Subject: [PATCH 5/7] chore(docs): minor improvements on docstrings - apply some standard on `Cargo.toml` deps. - minor docstring improvements, and fix missing docstrings. --- Cargo.toml | 4 ++-- src/api.rs | 4 ++-- src/async.rs | 6 ++++-- src/blocking.rs | 3 ++- src/lib.rs | 17 ++++++++--------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1d90703..8963fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,10 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false } -hex = { package = "hex-conservative", version = "0.2" } +hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } -reqwest = { version = "0.11", optional = true, default-features = false, features = ["json"] } +reqwest = { version = "0.11", features = ["json"], default-features = false, optional = true } [dev-dependencies] serde_json = "1.0" diff --git a/src/api.rs b/src/api.rs index d4dfa1e..1d30bb6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ -//! structs from the esplora API +//! Structs from the Esplora API //! -//! see: +//! See: pub use bitcoin::consensus::{deserialize, serialize}; pub use bitcoin::hex::FromHex; diff --git a/src/async.rs b/src/async.rs index 62b1689..031d7e0 100644 --- a/src/async.rs +++ b/src/async.rs @@ -30,12 +30,14 @@ use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus #[derive(Debug, Clone)] pub struct AsyncClient { + /// The URL of the Esplora Server. url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, } impl AsyncClient { - /// build an async client from a builder + /// Build an async client from a builder pub fn from_builder(builder: Builder) -> Result { let mut client_builder = Client::builder(); @@ -64,7 +66,7 @@ impl AsyncClient { Ok(Self::from_client(builder.base_url, client_builder.build()?)) } - /// build an async client from the base url and [`Client`] + /// Build an async client from the base url and [`Client`] pub fn from_client(url: String, client: Client) -> Self { AsyncClient { url, client } } diff --git a/src/blocking.rs b/src/blocking.rs index 22c95fd..be1cf1e 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -31,6 +31,7 @@ use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus #[derive(Debug, Clone)] pub struct BlockingClient { + /// The URL of the Esplora server. url: String, /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, @@ -41,7 +42,7 @@ pub struct BlockingClient { } impl BlockingClient { - /// build a blocking client from a [`Builder`] + /// Build a blocking client from a [`Builder`] pub fn from_builder(builder: Builder) -> Self { Self { url: builder.base_url, diff --git a/src/lib.rs b/src/lib.rs index cca3ab1..ba5f8d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,8 +72,6 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; -use bitcoin::consensus; - pub mod api; #[cfg(feature = "async")] @@ -102,6 +100,7 @@ pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option, /// Socket timeout. pub timeout: Option, - /// HTTP headers to set on every request made to Esplora server + /// HTTP headers to set on every request made to Esplora server. pub headers: HashMap, } @@ -151,20 +150,20 @@ impl Builder { self } - /// build a blocking client from builder + /// 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 + // Build an asynchronous client from builder #[cfg(feature = "async")] pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } } -/// Errors that can happen during a sync with `Esplora` +/// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { /// Error during `minreq` HTTP request @@ -187,9 +186,9 @@ pub enum Error { HexToBytes(bitcoin::hex::HexToBytesError), /// Transaction not found TransactionNotFound(Txid), - /// Header height not found + /// Block Header height not found HeaderHeightNotFound(u32), - /// Header hash not found + /// Block Header hash not found HeaderHashNotFound(BlockHash), /// Invalid HTTP Header name specified InvalidHttpHeaderName(String), @@ -222,7 +221,7 @@ impl_error!(::minreq::Error, Minreq, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); impl_error!(std::num::ParseIntError, Parsing, Error); -impl_error!(consensus::encode::Error, BitcoinEncoding, Error); +impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error); impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error); From ac64e04e5205110841e6d413e0a27fc40ae51ab6 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 21 Aug 2024 18:45:01 -0300 Subject: [PATCH 6/7] refactor(async)!: add common GET and POST methods - remove duplicated HTTP client code for handling GET and POST requests. - adds a few new methods to `AsyncClient` implementation, the new methods are responsible to handle common HTTP requests and parsing. It was previously duplicated throughout the Esplora API implementation, but now it follows the same approach already implemented for blocking client (`BlockingClient`). --- src/async.rs | 507 ++++++++++++++++++++++++--------------------------- 1 file changed, 235 insertions(+), 272 deletions(-) diff --git a/src/async.rs b/src/async.rs index 031d7e0..dfdd131 100644 --- a/src/async.rs +++ b/src/async.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::str::FromStr; -use bitcoin::consensus::{deserialize, serialize}; +use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{ @@ -24,7 +24,7 @@ use bitcoin::{ #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{header, Client, StatusCode}; +use reqwest::{header, Client}; use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; @@ -71,28 +71,205 @@ impl AsyncClient { AsyncClient { url, client } } - /// Get a [`Transaction`] option given its [`Txid`] - pub async fn get_tx(&self, txid: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/raw", self.url, txid)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); + /// 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.client.get(url).send().await?; + + match response.status().is_success() { + true => Ok(deserialize::(&response.bytes().await?)?), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), } + } - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(deserialize(&resp.bytes().await?)?)) + /// 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.client.get(url).send().await?; + + match response.status().is_success() { + true => Ok(response.json::().await.map_err(Error::Reqwest)?), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), + } + } + + /// 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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), } } + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implement [`bitcoin::consensus::Decodable`] from Hex, [`Vec`]. + /// + /// It should be used when requesting Esplora endpoints that can be directly + /// deserialized to native `rust-bitcoin` types, which implements + /// [`bitcoin::consensus::Decodable`] from Hex, `Vec<&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.client.get(url).send().await?; + + match response.status().is_success() { + true => { + let hex_str = response.text().await?; + let hex_vec = Vec::from_hex(&hex_str)?; + Ok(deserialize(&hex_vec)?) + } + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), + } + } + + /// 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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + 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.client.get(url).send().await?; + + match response.status().is_success() { + true => Ok(response.text().await?), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: 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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } + + /// Make an HTTP POST request to given URL, serializing from any `T` that + /// implement [`bitcoin::consensus::Encodable`]. + /// + /// It should be used when requesting Esplora endpoints that expected a + /// native bitcoin type serialized with [`bitcoin::consensus::Encodable`]. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Encodable`] serialization. + async fn post_request_hex(&self, path: &str, body: T) -> Result<(), Error> { + let url = format!("{}{}", self.url, path); + let body = serialize::(&body).to_lower_hex_string(); + + let response = self.client.post(url).body(body).send().await?; + + match response.status().is_success() { + true => Ok(()), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), + } + } + + /// 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 { @@ -109,167 +286,55 @@ impl AsyncClient { block_hash: &BlockHash, index: usize, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/block/{}/txid/{}", self.url, block_hash, index)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - Ok(Some(Txid::from_str(&resp.text().await?)?)) + 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 { - let resp = self - .client - .get(&format!("{}/tx/{}/status", self.url, txid)) - .send() - .await?; - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + self.get_response_json(&format!("/tx/{txid}/status")).await } /// Get transaction info given it's [`Txid`]. pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}", self.url, txid)) - .send() - .await?; - if resp.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{txid}")).await } /// Get a [`BlockHeader`] given a particular block hash. pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { - let resp = self - .client - .get(&format!("{}/block/{}/header", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - let header = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(header) - } + 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 { - let resp = self - .client - .get(&format!("{}/block/{}/status", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + 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> { - let resp = self - .client - .get(&format!("{}/block/{}/raw", self.url, block_hash)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - Ok(Some(deserialize(&resp.bytes().await?)?)) - } + 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> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkle-proof", self.url, tx_hash)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + 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> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkleblock-proof", self.url, tx_hash)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - let merkle_block = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(Some(merkle_block)) - } + 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 @@ -279,101 +344,34 @@ impl AsyncClient { txid: &Txid, index: u64, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/outspend/{}", self.url, txid, index)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + 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 resp = self - .client - .post(&format!("{}/tx", self.url)) - .body(serialize(transaction).to_lower_hex_string()) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(()) - } + self.post_request_hex("/tx", transaction).await } /// Get the current height of the blockchain tip pub async fn get_height(&self) -> Result { - let resp = self - .client - .get(&format!("{}/blocks/tip/height", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.text().await?.parse()?) - } + 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 { - let resp = self - .client - .get(&format!("{}/blocks/tip/hash", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + 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 { - let resp = self - .client - .get(&format!("{}/block-height/{}", self.url, block_height)) - .send() - .await?; - - if let 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: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + self.get_response_text(&format!("/block-height/{block_height}")) + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } /// Get confirmed transaction history for the specified address/scripthash, @@ -386,43 +384,18 @@ impl AsyncClient { last_seen: Option, ) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); - let url = 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 path = match last_seen { + Some(last_seen) => format!("/scripthash/{:x}/txs/chain/{}", script_hash, last_seen), + None => format!("/scripthash/{:x}/txs", script_hash), }; - let resp = self.client.get(url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// 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 resp = self - .client - .get(&format!("{}/fee-estimates", self.url,)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json("/fee-estimates").await } /// Gets some recent block summaries starting at the tip or at `height` if @@ -431,21 +404,11 @@ impl AsyncClient { /// 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 url = match height { - Some(height) => format!("{}/blocks/{}", self.url, height), - None => format!("{}/blocks", self.url), + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), }; - - let resp = self.client.get(&url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// Get the underlying base URL. From 8f7fff796d2a74a58a8f06a0d1cdaa19f2569e18 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Sun, 1 Sep 2024 17:51:50 -0300 Subject: [PATCH 7/7] feat(tor): add new `AsyncTorClient` - feat(tor): add new async client, `AsyncTorClient`, which uses `arti-client` to establish Tor connections, and `hyper` as HTTP client over custom Tor anonymized data stream. - feat(tor): implements the common methods: `get_response`, `get_response_json`, `get_response_hex` and their `opt` versions too. --- Cargo.toml | 42 ++- examples/tor.rs | 28 ++ src/async.rs | 6 +- src/async_tor.rs | 649 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 398 ++++++++++++++++++++++++++++- 5 files changed, 1117 insertions(+), 6 deletions(-) create mode 100644 examples/tor.rs create mode 100644 src/async_tor.rs diff --git a/Cargo.toml b/Cargo.toml index 8963fdf..a216c11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,21 +24,59 @@ log = "^0.4" minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } reqwest = { version = "0.11", features = ["json"], default-features = false, optional = true } +arti-client = { version = "0.21.0", default-features = false, optional = true } +tor-rtcompat = { version = "0.21.0", default-features = false, optional = true } +hyper = { version = "1.4.1", features = ["client", "http1"], default-features = false, optional = true } +hyper-util = { version = "0.1.7", features = ["tokio"], default-features = false, optional = true } +tokio = { version = "1.38.1", optional = true } +http-body-util = { version = "0.1.2", optional = true} +http = { version = "1.1.0", optional = true } +serde_json = { version = "1.0.127", optional = true } +tokio-rustls = { version = "0.26.0", default-features = false, features = [ + "logging", + "tls12", + "ring", +], optional = true } +webpki-roots = { version = "0.26.3", optional = true } +rustls-pki-types = { version = "1.8.0", optional = true } + [dev-dependencies] -serde_json = "1.0" tokio = { version = "1.20.1", features = ["full"] } electrsd = { version = "0.28.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_25_0"] } lazy_static = "1.4.0" [features] -default = ["blocking", "async", "async-https"] +default = ["blocking", "async", "async-https", "async-tor"] blocking = ["minreq", "minreq/proxy"] blocking-https = ["blocking", "minreq/https"] blocking-https-rustls = ["blocking", "minreq/https-rustls"] blocking-https-native = ["blocking", "minreq/https-native"] blocking-https-bundled = ["blocking", "minreq/https-bundled"] + 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"] + +async-tor = [ + "dep:arti-client", + "arti-client/tokio", + "arti-client/onion-service-client", + "arti-client/native-tls", + + "dep:tor-rtcompat", + "tor-rtcompat/tokio", + + "dep:hyper", + "dep:hyper-util", + "dep:tokio", + "dep:http-body-util", + "dep:http", + "dep:serde_json", + "dep:tokio-rustls", + "dep:webpki-roots", + "dep:rustls-pki-types" + ] +async-tor-https-native = ["async-tor", "arti-client/native-tls"] +async-tor-https-rustls = ["async-tor", "arti-client/rustls"] diff --git a/examples/tor.rs b/examples/tor.rs new file mode 100644 index 0000000..b401480 --- /dev/null +++ b/examples/tor.rs @@ -0,0 +1,28 @@ +use bitcoin::{consensus::encode::deserialize_hex, Transaction}; +use esplora_client::{r#async_tor::AsyncTorClient, Builder}; + +extern crate esplora_client; + +// const MEMPOOL_SPACE_API: &str = "https://mempool.space/api"; +const MEMPOOL_SPACE_API: &str = "https://blockstream.info/api"; + +#[tokio::main] +async fn main() { + let builder = Builder::new(MEMPOOL_SPACE_API); + let esplora_client = AsyncTorClient::from_builder(builder).await.unwrap(); + + let raw_tx = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000"; + let tx: Transaction = deserialize_hex(raw_tx).unwrap(); + esplora_client.broadcast(&tx).await.unwrap(); + + print!( + "successfully broadcasted transaction, with txid: {:?}", + tx.compute_txid() + ); + + // let tx_id = + // Txid::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b").unwrap(); + // let tx = esplora_client.get_tx(&tx_id).await.unwrap().unwrap(); + + // println!("successfully fetched the transaction {:?}", tx); +} diff --git a/src/async.rs b/src/async.rs index dfdd131..d6effac 100644 --- a/src/async.rs +++ b/src/async.rs @@ -11,6 +11,7 @@ //! Esplora by way of `reqwest` HTTP client. +use core::str; use std::collections::HashMap; use std::str::FromStr; @@ -28,6 +29,7 @@ use reqwest::{header, Client}; use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; +#[cfg(feature = "async")] #[derive(Debug, Clone)] pub struct AsyncClient { /// The URL of the Esplora Server. @@ -36,6 +38,7 @@ pub struct AsyncClient { client: Client, } +#[cfg(feature = "async")] impl AsyncClient { /// Build an async client from a builder pub fn from_builder(builder: Builder) -> Result { @@ -84,7 +87,8 @@ impl AsyncClient { /// [`bitcoin::consensus::Decodable`] deserialization. async fn get_response(&self, path: &str) -> Result { let url = format!("{}{}", self.url, path); - let response = self.client.get(url).send().await?; + let request = self.client.get(url); + let response = request.send().await?; match response.status().is_success() { true => Ok(deserialize::(&response.bytes().await?)?), diff --git a/src/async_tor.rs b/src/async_tor.rs new file mode 100644 index 0000000..275be73 --- /dev/null +++ b/src/async_tor.rs @@ -0,0 +1,649 @@ +// Bitcoin Dev Kit +// Written in 2024 by BDK Developers +// +// Copyright (c) 2020-2024 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 `arti-client` over `hyper` HTTP client. + +use arti_client::{TorClient, TorClientConfig}; + +use bitcoin::block::Header as BlockHeader; +use bitcoin::hashes::{sha256, Hash}; +use hex::{DisplayHex, FromHex}; +use http::{HeaderName, HeaderValue, Uri}; +use http_body_util::{BodyExt, Empty, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::{Request, Response}; + +use core::str; +use std::collections::HashMap; +use std::str::FromStr; + +use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable}; +use bitcoin::{Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; + +#[allow(unused_imports)] +use log::{debug, error, info, trace}; + +use tor_rtcompat::PreferredRuntime; + +use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; + +#[cfg(feature = "async-tor")] +// #[derive(Debug, Clone)] +pub struct AsyncTorClient { + /// The URL of the Esplora Server. + url: String, + /// The inner [`arti_client::TorClient`] to make HTTP requests over Tor network. + client: TorClient, + /// Socket timeout. + pub timeout: Option, + /// HTTP headers to set on every request made to Esplora server. + pub headers: HashMap, +} + +#[cfg(feature = "async-tor")] +impl AsyncTorClient { + /// Build a [`TorClient`] with default [`TorClientConfig`]. + pub async fn create_tor_client() -> Result, arti_client::Error> { + let config = TorClientConfig::default(); + TorClient::create_bootstrapped(config).await + } + + /// Build an async client from a builder + pub async fn from_builder(builder: Builder) -> Result { + let tor_client = Self::create_tor_client().await?.isolated_client(); + + Ok(Self { + url: builder.base_url, + timeout: builder.timeout, + headers: builder.headers, + client: tor_client, + }) + } + + /// Get the underlying base URL. + pub fn url(&self) -> &str { + &self.url + } + + // async fn hyper_request() -> {todo!()} + // async fn hyper_bootstrap() -> {todo!()} + // async fn get_request() -> {todo!()} + // async fn post_request() -> {todo!()} + + async fn get_request(&self, uri: &Uri) -> Result>, Error> { + let mut request = Request::get(uri.path()) + .header(http::header::HOST, uri.host().ok_or(Error::InvalidUri)?) + .body(Empty::new())?; + + let headers = request.headers_mut(); + + if !self.headers.is_empty() { + for (key, val) in &self.headers { + let header_name: HeaderName = + HeaderName::from_str(key).map_err(|e| Error::Http(e.into()))?; + let header_value: HeaderValue = + HeaderValue::from_str(val).map_err(|e| Error::Http(e.into()))?; + headers.insert(header_name, header_value); + } + } + + Ok(request) + } + + async fn post_request(&self, uri: &Uri, body: Bytes) -> Result>, Error> { + let mut request = Request::post(uri.path()) + .header(http::header::HOST, uri.host().ok_or(Error::InvalidUri)?) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(Full::from(body))?; + + let headers = request.headers_mut(); + + if !self.headers.is_empty() { + for (key, val) in &self.headers { + let header_name: HeaderName = + HeaderName::from_str(key).map_err(|e| Error::Http(e.into()))?; + let header_value: HeaderValue = + HeaderValue::from_str(val).map_err(|e| Error::Http(e.into()))?; + headers.insert(header_name, header_value); + } + } + + Ok(request) + } + + async fn send_empty( + &self, + request: Request>, + ) -> Result, Error> { + let uri = hyper::Uri::from_str(&self.url).map_err(|e| Error::Http(e.into()))?; + let host = uri.host().ok_or(Error::InvalidUri)?.to_owned(); + + let is_tls = match uri.scheme_str() { + Some("https") => true, + Some("http") => false, + Some(_unexpected_scheme) => { + panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + None => { + panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }; + + let port = uri.port_u16().unwrap_or(match is_tls { + true => 443, + false => 80, + }); + + let data_stream = self + .client + .connect((host.clone(), port)) + .await + .map_err(Error::Arti)?; + + match is_tls { + false => { + let io = hyper_util::rt::TokioIo::new(data_stream); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + + tokio::task::spawn(async move { + if let Err(_e) = connection.await { + // panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }); + + Ok(sender.send_request(request).await?) + } + true => { + // FIXME: (@leonardo) It should have two branches: native-tls support (activated by feature+default) and the usage of rustls (as already implemented below) + + // let cx = tokio_native_tls::native_tls::TlsConnector::builder() + // .build() + // .unwrap(); + // let tls_connector = tokio_native_tls::TlsConnector::from(cx); + // let mut tls_stream = tls_connector + // .connect(host, anonymized_data_stream) + // .await + // .unwrap(); + + let webpki_roots = webpki_roots::TLS_SERVER_ROOTS.iter().cloned(); + let mut root_certs = tokio_rustls::rustls::RootCertStore::empty(); + root_certs.extend(webpki_roots); + + let tls_config = tokio_rustls::rustls::ClientConfig::builder() + .with_root_certificates(root_certs) + .with_no_client_auth(); + let tls_connector = + tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config)); + + let server_name = rustls_pki_types::ServerName::try_from(host.clone()).unwrap(); + + let tls_stream = tls_connector + .connect(server_name, data_stream) + .await + .unwrap(); + + let io = hyper_util::rt::TokioIo::new(tls_stream); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + + tokio::task::spawn(async move { + if let Err(_e) = connection.await { + // panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }); + + Ok(sender.send_request(request).await?) + } + } + } + + async fn send_full(&self, request: Request>) -> Result, Error> { + let uri = hyper::Uri::from_str(&self.url).map_err(|e| Error::Http(e.into()))?; + let host = uri.host().ok_or(Error::InvalidUri)?.to_owned(); + + let is_tls = match uri.scheme_str() { + Some("https") => true, + Some("http") => false, + Some(_unexpected_scheme) => { + panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + None => { + panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }; + + let port = uri.port_u16().unwrap_or(match is_tls { + true => 443, + false => 80, + }); + + let data_stream = self + .client + .connect((host.clone(), port)) + .await + .map_err(Error::Arti)?; + + match is_tls { + false => { + let io = hyper_util::rt::TokioIo::new(data_stream); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + + tokio::task::spawn(async move { + if let Err(_e) = connection.await { + // panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }); + + Ok(sender.send_request(request).await?) + } + true => { + // FIXME: (@leonardo) It should have two branches: native-tls support (activated by feature+default) and the usage of rustls (as already implemented below) + + // let cx = tokio_native_tls::native_tls::TlsConnector::builder() + // .build() + // .unwrap(); + // let tls_connector = tokio_native_tls::TlsConnector::from(cx); + // let mut tls_stream = tls_connector + // .connect(host, anonymized_data_stream) + // .await + // .unwrap(); + + let webpki_roots = webpki_roots::TLS_SERVER_ROOTS.iter().cloned(); + let mut root_certs = tokio_rustls::rustls::RootCertStore::empty(); + root_certs.extend(webpki_roots); + + let tls_config = tokio_rustls::rustls::ClientConfig::builder() + .with_root_certificates(root_certs) + .with_no_client_auth(); + let tls_connector = + tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config)); + + let server_name = rustls_pki_types::ServerName::try_from(host.clone()).unwrap(); + + let tls_stream = tls_connector + .connect(server_name, data_stream) + .await + .unwrap(); + + let io = hyper_util::rt::TokioIo::new(tls_stream); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + + tokio::task::spawn(async move { + if let Err(_e) = connection.await { + // panic!() // FIXME: (@leonardo) do not panic, return proper error! + } + }); + + Ok(sender.send_request(request).await?) + } + } + } + + /// Perform a raw HTTP POST request with the given URI `path` and body [`Bytes`]. + async fn post(&self, url: &str, body: Bytes) -> Result, Error> { + let uri = hyper::Uri::from_str(url).map_err(|e| Error::Http(e.into()))?; + let request = self.post_request(&uri, body).await?; + + let (parts, body) = self.send_full(request).await?.into_parts(); + let body = body.collect().await?.to_bytes(); + let response = Response::from_parts(parts, body); + + Ok(response) + } + + /// Perform a raw HTTP GET request with the given URI `path`. + async fn get(&self, url: &str) -> Result, Error> { + let uri = hyper::Uri::from_str(url).map_err(|e| Error::Http(e.into()))?; + let request = self.get_request(&uri).await?; + + let (parts, body) = self.send_empty(request).await?.into_parts(); + let body = body.collect().await?.to_bytes(); + let response = Response::from_parts(parts, body); + + Ok(response) + } + + async fn get_response(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.get(&url).await.unwrap(); + + match response.status().is_success() { + true => Ok(deserialize::(&response.into_body()).unwrap()), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: str::from_utf8(response.body()).unwrap().to_string(), + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncTorClient::get_response`] internally. + /// + /// See [`AsyncTorClient::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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + 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(&url).await.unwrap(); + + match response.status().is_success() { + true => { + let body = response.into_body(); + let json = serde_json::from_slice::(&body).unwrap(); + Ok(json) + } + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: str::from_utf8(response.body()).unwrap().to_string(), + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncTorClient::get_response_json`] internally. + /// + /// See [`AsyncTorClient::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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implement [`bitcoin::consensus::Decodable`] from Hex, [`Vec`]. + /// + /// It should be used when requesting Esplora endpoints that can be directly + /// deserialized to native `rust-bitcoin` types, which implements + /// [`bitcoin::consensus::Decodable`] from Hex, [`Vec<&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(&url).await?; + + match response.status().is_success() { + true => { + let hex_str = str::from_utf8(response.body()).unwrap().to_string(); + let hex_vec = Vec::from_hex(&hex_str)?; + Ok(deserialize(&hex_vec)?) + } + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: str::from_utf8(response.body()).unwrap().to_string(), + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncTorClient::get_response_hex`] internally. + /// + /// See [`AsyncTorClient::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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + 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(&url).await.unwrap(); + + match response.status().is_success() { + true => Ok(str::from_utf8(response.body()).unwrap().to_string()), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: str::from_utf8(response.body()).unwrap().to_string(), + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncTorClient::get_response_text`] internally. + /// + /// See [`AsyncTorClient::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, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } + + /// Make an HTTP POST request to given URL, serializing from any `T` that + /// implement [`bitcoin::consensus::Encodable`]. + /// + /// It should be used when requesting Esplora endpoints that expected a + /// native bitcoin type serialized with [`bitcoin::consensus::Encodable`]. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Encodable`] serialization. + async fn post_request_hex(&self, path: &str, body: T) -> Result<(), Error> { + let url = format!("{}{}", self.url, path); + let body = serialize::(&body).to_lower_hex_string(); + + let response = self.post(&url, Bytes::from(body)).await?; + + match response.status().is_success() { + true => Ok(()), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: str::from_utf8(response.body()).unwrap().to_string(), + }), + } + } + + /// 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 it's [`Txid`]. + pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { + self.get_opt_response_json(&format!("/tx/{txid}")).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> { + self.post_request_hex("/tx", transaction).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 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/{}", script_hash, last_seen), + None => format!("/scripthash/{:x}/txs", script_hash), + }; + + self.get_response_json(&path).await + } + + /// 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> { + self.get_response_json("/fee-estimates").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`. + pub async fn get_blocks(&self, height: Option) -> Result, Error> { + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), + }; + self.get_response_json(&path).await + } +} diff --git a/src/lib.rs b/src/lib.rs index ba5f8d1..709d749 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 [`minreq`] and an async client using [`reqwest`]. -//! The library supports communicating to Esplora via a proxy +//! client using [`minreq`] and 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,30 @@ //! # } //! ``` //! +//! // FIXME: (@leonardo) fix this documentation +//! Here is an example of how to create an anonymized (Tor) asynchronous client. +//! +//! ```no_run +//! # #[cfg(feature = "async-arti-tor")] +//! # { +//! use esplora_client::Builder; +//! let builder = Builder::new("https://blockstream.info/testnet/api"); +//! let async_tor_client = builder.build_async_tor(); +//! # Ok::<(), esplora_client::Error>(()); +//! # } +//! ``` +//! +//! ```no_run +//! # #[cfg(feature = "async-arti-tor")] +//! # { +//! use esplora_client::Builder; +//! let builder = Builder::new("http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/testnet/api"); +//! let async_hs_client = builder.build_async_tor(); +//! # Ok::<(), esplora_client::Error>(()); +//! # } +//! ``` +//! +//! //! ## Features //! //! By default the library enables all features. To specify @@ -65,6 +90,14 @@ //! * `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-tor` enables [`hyper`] and [`arti_client`], the async anonymized client support for TLS (SSL) over Tor, +//! using the default [`hyper`] and [`arti_client`] TLS backend. +//! * `async-arti-tor-native` enables [`hyper`] and [`arti_client`], the async anonymized client support for TLS (SSL) over Tor, +//! using the platform's native TLS backend (likely OpenSSL). +//! * `async-arti-tor-rustls` enables [`hyper`] and [`arti_client`], the async anonymized client support for TLS (SSL) over Tor, +//! using the `rustls` TLS backend without using its the default root certificates. +//! +//! #![allow(clippy::result_large_err)] @@ -76,6 +109,8 @@ pub mod api; #[cfg(feature = "async")] pub mod r#async; +#[cfg(feature = "async-tor")] +pub mod r#async_tor; #[cfg(feature = "blocking")] pub mod blocking; @@ -84,6 +119,8 @@ pub use api::*; pub use blocking::BlockingClient; #[cfg(feature = "async")] pub use r#async::AsyncClient; +#[cfg(feature = "async-tor")] +pub use r#async_tor::AsyncTorClient; /// Get a fee value in sats/vbytes from the estimates /// that matches the confirmation target set as parameter. @@ -161,6 +198,12 @@ impl Builder { pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } + + // Build an asynchronous Tor client from builder + #[cfg(feature = "async-tor")] + pub async fn build_async_tor(self) -> Result { + AsyncTorClient::from_builder(self).await + } } /// Errors that can happen during a request to `Esplora` servers. @@ -172,6 +215,30 @@ pub enum Error { /// Error during reqwest HTTP request #[cfg(feature = "async")] Reqwest(::reqwest::Error), + /// Error during `arti-client` connection establishment + #[cfg(feature = "async-tor")] + Arti(::arti_client::Error), + /// Error during hyper HTTP request + #[cfg(feature = "async-tor")] + Hyper(::hyper::Error), + /// Error during hyper HTTP connection + #[cfg(feature = "async-tor")] + Http(::http::Error), + /// Error during hyper HTTP connection + #[cfg(feature = "async-tor")] + InvalidUri, + // /// Error during hyper HTTP request body creation + // #[cfg(feature = "async-tor")] + // InvalidBody, + // /// Error during Tor client creation + // #[cfg(feature = "async-tor")] + // ArtiClient(::arti_client::Error), + // /// Error during [`TlsConnector`] building + // #[cfg(feature = "async-tor")] + // TlsConnector, + // /// Error during response decoding + // #[cfg(feature = "async-tor")] + // ResponseDecoding, /// HTTP response error HttpResponse { status: u16, message: String }, /// Invalid number returned @@ -220,6 +287,12 @@ impl std::error::Error for Error {} impl_error!(::minreq::Error, Minreq, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); +#[cfg(feature = "async-tor")] +impl_error!(::hyper::Error, Hyper, Error); +#[cfg(feature = "async-tor")] +impl_error!(::http::Error, Http, Error); +#[cfg(feature = "async-tor")] +impl_error!(::arti_client::Error, Arti, Error); impl_error!(std::num::ParseIntError, Parsing, Error); impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error); @@ -228,13 +301,14 @@ impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, 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, @@ -303,6 +377,20 @@ mod test { (blocking_client, async_client) } + #[cfg(feature = "async-tor")] + async fn setup_anonymized_tor_client() -> AsyncTorClient { + 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_tor = Builder::new(ESPLORA_URL); + + builder_async_anonymized_tor + .build_async_tor() + .await + .unwrap() + } + #[cfg(all(feature = "blocking", feature = "async"))] fn generate_blocks_and_wait(num: usize) { let cur_height = BITCOIND.client.get_block_count().unwrap(); @@ -435,6 +523,24 @@ mod test { assert_eq!(tx, tx_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_tx() { + let client = setup_anonymized_tor_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.compute_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() { @@ -466,6 +572,23 @@ mod test { assert_eq!(tx_no_opt, tx_no_opt_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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.compute_txid()) + .await + .unwrap(); + assert_eq!(coinbase_tx, tx_async_anonymized); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_status() { @@ -508,6 +631,28 @@ mod test { assert!(tx_status.block_time.is_none()); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_txid = genesis_block.coinbase().unwrap().compute_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_tx_info() { @@ -574,6 +719,24 @@ mod test { assert_eq!(block_header, block_header_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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() { @@ -594,6 +757,36 @@ mod test { assert_eq!(expected, block_status_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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() { @@ -634,6 +827,24 @@ mod test { assert_eq!(expected, block_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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() { @@ -717,6 +928,26 @@ mod test { assert!(merkle_proof.pos > 0); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let coinbase_txid = genesis_block.coinbase().unwrap().compute_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() { @@ -758,6 +989,34 @@ mod test { assert!(indexes[0] > 0); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + + let coinbase_txid = genesis_block.coinbase().unwrap().compute_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() { @@ -797,6 +1056,28 @@ mod test { assert_eq!(output_status, output_status_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_client().await; + + let network = bitcoin::Network::Bitcoin; + let genesis_block = bitcoin::blockdata::constants::genesis_block(network); + let coinbase_txid = genesis_block.coinbase().unwrap().compute_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() { @@ -807,6 +1088,15 @@ mod test { assert_eq!(block_height, block_height_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_height() { + let client = setup_anonymized_tor_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() { @@ -816,6 +1106,13 @@ mod test { assert_eq!(tip_hash, tip_hash_async); } + // #[cfg(feature = "async-tor")] + // #[tokio::test] + // #[ignore = "The `AsyncTorClient` 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() { @@ -829,6 +1126,19 @@ mod test { assert_eq!(block_hash, block_hash_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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() { @@ -848,6 +1158,27 @@ mod test { assert_eq!(txid_at_block_index, txid_at_block_index_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` 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_tor_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().compute_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() { @@ -857,6 +1188,13 @@ mod test { assert_eq!(fee_estimates.len(), fee_estimates_async.len()); } + // #[cfg(feature = "async-tor")] + // #[tokio::test] + // #[ignore = "The `AsyncTorClient` 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() { @@ -906,20 +1244,47 @@ mod test { assert_eq!(scripthash_txs_txids, scripthash_txs_txids_async); } + #[cfg(feature = "async-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_scripthash_txs() { + let client = setup_anonymized_tor_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.compute_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(); @@ -930,11 +1295,38 @@ 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-tor")] + #[tokio::test] + #[ignore = "The `AsyncTorClient` tests are ignored as they rely on a remote server with available Esplora API"] + async fn test_anonymized_get_blocks() { + let client = setup_anonymized_tor_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); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_with_http_header() {