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/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 2ac7e07..46ac073 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -1,4 +1,4 @@ -name: Rust +name: CI on: push: @@ -11,13 +11,12 @@ env: jobs: build-test: - + name: Build & Test runs-on: ubuntu-latest strategy: matrix: rust: - version: stable # STABLE - clippy: true - version: 1.63.0 # MSRV features: - default @@ -32,10 +31,11 @@ jobs: - async-https-rustls - async-https-rustls-manual-roots steps: - - uses: actions/checkout@v3 + - name: Checkout + 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: | @@ -43,16 +43,15 @@ 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 + - 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" @@ -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 diff --git a/Cargo.toml b/Cargo.toml index f26c04c..a216c11 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" @@ -19,26 +19,64 @@ 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 } + +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/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 bf72a48..d6effac 100644 --- a/src/async.rs +++ b/src/async.rs @@ -11,10 +11,11 @@ //! Esplora by way of `reqwest` HTTP client. +use core::str; 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,18 +25,22 @@ 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}; +#[cfg(feature = "async")] #[derive(Debug, Clone)] pub struct AsyncClient { + /// The URL of the Esplora Server. url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, } +#[cfg(feature = "async")] 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,33 +69,211 @@ 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 } } - /// 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 request = self.client.get(url); + let response = request.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 { @@ -100,344 +283,136 @@ 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, 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`]. + /// 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`]. + /// 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 index. + /// 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 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, /// 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, 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). + /// 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 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), - 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. 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/blocking.rs b/src/blocking.rs index 0448572..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, @@ -199,7 +200,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 +235,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 +304,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 +327,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..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,33 +36,66 @@ //! # } //! ``` //! +//! // 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 //! 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. +//! * `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. //! //! @@ -71,12 +105,12 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; -use bitcoin::consensus; - 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; @@ -85,11 +119,14 @@ 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. /// -/// 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() @@ -100,21 +137,24 @@ pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option://:@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`. pub proxy: 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, } @@ -147,20 +187,26 @@ 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) } + + // 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 sync with `Esplora` +/// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { /// Error during `minreq` HTTP request @@ -169,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 @@ -183,9 +253,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), @@ -217,21 +287,28 @@ 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!(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); #[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, @@ -300,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(); @@ -432,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() { @@ -463,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() { @@ -505,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() { @@ -571,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() { @@ -591,11 +757,41 @@ 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() { - // 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. @@ -631,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() { @@ -714,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() { @@ -755,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() { @@ -794,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() { @@ -804,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() { @@ -813,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() { @@ -826,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() { @@ -845,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() { @@ -854,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() { @@ -903,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(); @@ -927,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() {