Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip(feat): add new AsyncAnonymizedClient using arti-hyper
Browse files Browse the repository at this point in the history
- feat: add new async client, `AsyncAnonymizedClient`, that uses `arti-hyper`,
  and `arti-client` to connect and do requests over the Tor network.
- feat+test: add all methods and tests for `get_tx_..`, `Transaction`
  related endpoints.
oleonardolima committed Dec 21, 2023
1 parent ef1925e commit 86103c8
Showing 3 changed files with 306 additions and 7 deletions.
19 changes: 16 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -17,15 +17,24 @@ path = "src/lib.rs"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
bitcoin = { version = "0.30.0", features = ["serde", "std"], default-features = false }
# Temporary dependency on internals until the rust-bitcoin devs release the hex-conservative crate.
bitcoin-internals = { version = "0.1.0", features = ["alloc"] }
log = "^0.4"
ureq = { version = "2.5.0", features = ["json"], optional = true }
ureq = { version = "2.5.0", optional = true, features = ["json"]}
reqwest = { version = "0.11", optional = true, default-features = false, features = ["json"] }
hyper = { version = "0.14", optional = true, features = ["http1", "client", "runtime"], default-features = false }
arti-client = { version = "0.12.0", optional = true }
tor-rtcompat = { version = "0.9.6", optional = true, features = ["tokio"]}
tls-api = { version = "0.9.0", optional = true }
tls-api-native-tls = { version = "0.9.0", optional = true }
arti-hyper = { version = "0.12.0", optional = true, features = ["default"] }

[target.'cfg(target_vendor="apple")'.dependencies]
tls-api-openssl = { version = "0.9.0", optional = true }

[dev-dependencies]
serde_json = "1.0"
tokio = { version = "1.20.1", features = ["full"] }
electrsd = { version = "0.24.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_22_0"] }
electrum-client = "0.16.0"
@@ -36,10 +45,14 @@ zip = "=0.6.3"
base64ct = "<1.6.0"

[features]
default = ["blocking", "async", "async-https"]
default = ["blocking", "async", "async-https", "async-arti-hyper"]
blocking = ["ureq", "ureq/socks-proxy"]
async = ["reqwest", "reqwest/socks"]
async-https = ["async", "reqwest/default-tls"]
async-https-native = ["async", "reqwest/native-tls"]
async-https-rustls = ["async", "reqwest/rustls-tls"]
async-https-rustls-manual-roots = ["async", "reqwest/rustls-tls-manual-roots"]
# TODO: (@leonardo) Should I rename it to async-anonymized ?
async-arti-hyper = ["hyper", "arti-client", "tor-rtcompat", "tls-api", "tls-api-native-tls", "tls-api-openssl", "arti-hyper"]
async-arti-hyper-native = ["async-arti-hyper", "arti-hyper/native-tls"]
async-arti-hyper-rustls = ["async-arti-hyper", "arti-hyper/rustls"]
158 changes: 157 additions & 1 deletion src/async.rs
Original file line number Diff line number Diff line change
@@ -9,11 +9,14 @@
// You may not use this file except in accordance with one or both of these
// licenses.

//! Esplora by way of `reqwest` HTTP client.
//! Esplora by way of `reqwest`, and `arti-hyper` HTTP client.
use std::collections::HashMap;
use std::str::FromStr;

use arti_client::{TorClient, TorClientConfig};

use arti_hyper::ArtiHttpConnector;
use bitcoin::consensus::{deserialize, serialize};
use bitcoin::hashes::hex::FromHex;
use bitcoin::hashes::{sha256, Hash};
@@ -22,10 +25,17 @@ use bitcoin::{
};
use bitcoin_internals::hex::display::DisplayHex;

use hyper::{Body, Uri};
#[allow(unused_imports)]
use log::{debug, error, info, trace};

use reqwest::{Client, StatusCode};
use tls_api::{TlsConnector as TlsConnectorTrait, TlsConnectorBuilder};
#[cfg(not(target_vendor = "apple"))]
use tls_api_native_tls::TlsConnector;
#[cfg(target_vendor = "apple")]
use tls_api_openssl::TlsConnector;
use tor_rtcompat::PreferredRuntime;

use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus};

@@ -429,3 +439,149 @@ impl AsyncClient {
&self.client
}
}

#[derive(Debug, Clone)]
pub struct AsyncAnonymizedClient {
url: String,
client: hyper::Client<ArtiHttpConnector<PreferredRuntime, TlsConnector>>,
}

impl AsyncAnonymizedClient {
/// build an async [`TorClient`] with default Tor configuration
async fn create_tor_client() -> Result<TorClient<PreferredRuntime>, arti_client::Error> {
let config = TorClientConfig::default();
TorClient::create_bootstrapped(config).await
}

/// build an [`AsyncAnonymizedClient`] from a [`Builder`]
pub async fn from_builder(builder: Builder) -> Result<Self, Error> {
let tor_client = Self::create_tor_client().await?.isolated_client();

let tls_conn: TlsConnector = TlsConnector::builder()
.map_err(|_| Error::TlsConnector)?
.build()
.map_err(|_| Error::TlsConnector)?;

let connector = ArtiHttpConnector::new(tor_client, tls_conn);

// TODO: (@leonardo) how to handle/pass the timeout option ?
let client = hyper::Client::builder().build::<_, Body>(connector);
Ok(Self::from_client(builder.base_url, client))
}

/// build an async client from the base url and [`Client`]
pub fn from_client(
url: String,
client: hyper::Client<ArtiHttpConnector<PreferredRuntime, TlsConnector>>,
) -> Self {
AsyncAnonymizedClient { url, client }
}

/// Get a [`Option<Transaction>`] given its [`Txid`]
pub async fn get_tx(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
let path = format!("{}/tx/{}/raw", self.url, txid);
let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?;

let resp = self.client.get(uri).await?;

if let 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: {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
std::str::from_utf8(&bytes)
.map_err(|_| Error::ResponseDecoding)?
.to_string()
},
})
} else {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
Ok(Some(deserialize(&bytes)?))
}
}

/// Get a [`Transaction`] given its [`Txid`].
pub async fn get_tx_no_opt(&self, txid: &Txid) -> Result<Transaction, Error> {
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<Option<Txid>, Error> {
let path = format!("{}/block/{}/txid/{}", self.url, block_hash, index);
let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?;

let resp = self.client.get(uri).await?;

if let 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: {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
std::str::from_utf8(&bytes)
.map_err(|_| Error::ResponseDecoding)?
.to_string()
},
})
} else {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
Ok(Some(deserialize(&bytes)?))
}
}

/// Get the status of a [`Transaction`] given its [`Txid`].
pub async fn get_tx_status(&self, txid: &Txid) -> Result<TxStatus, Error> {
let path = format!("{}/tx/{}/status", self.url, txid);
let uri = Uri::from_str(&path).map_err(|_| Error::InvalidUri)?;

let resp = self.client.get(uri).await?;

if resp.status().is_server_error() || resp.status().is_client_error() {
Err(Error::HttpResponse {
status: resp.status().as_u16(),
message: {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
std::str::from_utf8(&bytes)
.map_err(|_| Error::ResponseDecoding)?
.to_string()
},
})
} else {
let body = resp.into_body();
let bytes = hyper::body::to_bytes(body).await?;
let tx_status =
serde_json::from_slice::<TxStatus>(&bytes).map_err(|_| Error::ResponseDecoding)?;
Ok(tx_status)
}
}

/// Get the underlying base URL.
pub fn url(&self) -> &str {
&self.url
}

/// Get the underlying [`hyper::Client`].
pub fn client(&self) -> &hyper::Client<ArtiHttpConnector<PreferredRuntime, TlsConnector>> {
&self.client
}
}
136 changes: 133 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -4,8 +4,9 @@
//! async Esplora client to query Esplora's backend.
//!
//! The library provides the possibility to build a blocking
//! client using [`ureq`] and an async client using [`reqwest`].
//! The library supports communicating to Esplora via a proxy
//! client using [`ureq`], an async client using [`reqwest`],
//! and an anonymized async client using [`arti-hyper`].
//! The library supports communicating to Esplora via a Tor, proxy,
//! and also using TLS (SSL) for secure communication.
//!
//!
@@ -35,6 +36,20 @@
//! # }
//! ```
//!
//! // FIXME: (@leonardo) fix this documentation
//! Here is an example of how to create an anonymized asynchronous client.
//!
//! ```no_run
//! # #[cfg(feature = "async-arti-hyper")]
//! # {
//! use esplora_client::Builder;
//! let builder = Builder::new("https://blockstream.info/testnet/api");
//! let async_client = builder.build_async_anonymized();
//! # Ok::<(), esplora_client::Error>(());
//! # }
//! ```
//!
//!
//! ## Features
//!
//! By default the library enables all features. To specify
@@ -54,6 +69,12 @@
//! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for
//! proxying and TLS (SSL) using the `rustls` TLS backend without using its the default root
//! certificates.
//! * `async-arti-hyper` enables [`arti-hyper`], the async anonymized client support for TLS (SSL) over Tor,
//! using the default [`arti-hyper`] TLS backend.
//! * `async-arti-hyper-native` enables [`arti-hyper`], the async anonymized client support for TLS (SSL) over Tor,
//! using the platform's native TLS backend (likely OpenSSL).
//! * `async-arti-hyper-rustls` enables [`arti-hyper`], the async anonymized client support for TLS (SSL) over Tor,
//! using the `rustls` TLS backend without using its the default root certificates.
//!
//!
@@ -75,6 +96,8 @@ pub mod blocking;
pub use api::*;
#[cfg(feature = "blocking")]
pub use blocking::BlockingClient;
#[cfg(feature = "async-arti-hyper")]
pub use r#async::AsyncAnonymizedClient;
#[cfg(feature = "async")]
pub use r#async::AsyncClient;

@@ -109,7 +132,7 @@ pub struct Builder {
/// the `socks` feature enabled.
///
/// The proxy is ignored when targeting `wasm32`.
pub proxy: Option<String>,
pub proxy: Option<String>, // TODO: (@leonardo) should this be available for `async-arti-hyper`
/// Socket timeout.
pub timeout: Option<u64>,
}
@@ -147,6 +170,11 @@ impl Builder {
pub fn build_async(self) -> Result<AsyncClient, Error> {
AsyncClient::from_builder(self)
}

// build an asynchronous anonymized, over Tor, client from builder
pub async fn build_async_anonymized(self) -> Result<AsyncAnonymizedClient, Error> {
AsyncAnonymizedClient::from_builder(self).await
}
}

/// Errors that can happen during a sync with `Esplora`
@@ -161,6 +189,21 @@ pub enum Error {
/// Error during reqwest HTTP request
#[cfg(feature = "async")]
Reqwest(::reqwest::Error),
/// Error during hyper HTTP request
#[cfg(feature = "async-arti-hyper")]
Hyper(::hyper::Error),
/// Error during hyper HTTP request
#[cfg(feature = "async-arti-hyper")]
InvalidUri,
/// Error during Tor client creation
#[cfg(feature = "async-arti-hyper")]
ArtiClient(::arti_client::Error),
/// Error during [`TlsConnector`] building
#[cfg(feature = "async-arti-hyper")]
TlsConnector,
/// Error during response decoding
#[cfg(feature = "async-arti-hyper")]
ResponseDecoding,
/// HTTP response error
HttpResponse { status: u16, message: String },
/// IO error during ureq response read
@@ -206,6 +249,10 @@ impl std::error::Error for Error {}
impl_error!(::ureq::Transport, UreqTransport, Error);
#[cfg(feature = "async")]
impl_error!(::reqwest::Error, Reqwest, Error);
#[cfg(feature = "async-arti-hyper")]
impl_error!(::hyper::Error, Hyper, Error);
#[cfg(feature = "async-arti-hyper")]
impl_error!(::arti_client::Error, ArtiClient, Error);
impl_error!(io::Error, Io, Error);
impl_error!(std::num::ParseIntError, Parsing, Error);
impl_error!(consensus::encode::Error, BitcoinEncoding, Error);
@@ -278,6 +325,20 @@ mod test {
(blocking_client, async_client)
}

#[cfg(feature = "async-arti-hyper")]
async fn setup_anonymized_client() -> AsyncAnonymizedClient {
const ESPLORA_URL: &str = "https://mempool.space/api";
// const ESPLORA_URL: &str = "https://blockstream.info/testnet/api";
// const ESPLORA_URL: &str = "http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/api";

let builder_async_anonymized = Builder::new(ESPLORA_URL);

builder_async_anonymized
.build_async_anonymized()
.await
.unwrap()
}

#[cfg(all(feature = "blocking", feature = "async"))]
fn generate_blocks_and_wait(num: usize) {
let cur_height = BITCOIND.client.get_block_count().unwrap();
@@ -405,6 +466,20 @@ mod test {
assert_eq!(tx, tx_async);
}

#[cfg(feature = "async-arti-hyper")]
#[tokio::test]
#[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"]
async fn test_anonymized_get_tx() {
let client = setup_anonymized_client().await;

let network = bitcoin::Network::Bitcoin;
let genesis_block = bitcoin::blockdata::constants::genesis_block(network);
let coinbase_tx = genesis_block.coinbase().unwrap().to_owned();

let tx_async_anonymized = client.get_tx(&coinbase_tx.txid()).await.unwrap().unwrap();
assert_eq!(coinbase_tx, tx_async_anonymized);
}

#[cfg(all(feature = "blocking", feature = "async"))]
#[tokio::test]
async fn test_get_tx_no_opt() {
@@ -436,6 +511,20 @@ mod test {
assert_eq!(tx_no_opt, tx_no_opt_async);
}

#[cfg(feature = "async-arti-hyper")]
#[tokio::test]
#[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"]
async fn test_anonymized_get_tx_no_opt() {
let client = setup_anonymized_client().await;

let network = bitcoin::Network::Bitcoin;
let genesis_block = bitcoin::blockdata::constants::genesis_block(network);
let coinbase_tx = genesis_block.coinbase().unwrap().to_owned();

let tx_async_anonymized = client.get_tx_no_opt(&coinbase_tx.txid()).await.unwrap();
assert_eq!(coinbase_tx, tx_async_anonymized);
}

#[cfg(all(feature = "blocking", feature = "async"))]
#[tokio::test]
async fn test_get_tx_status() {
@@ -478,6 +567,29 @@ mod test {
assert!(tx_status.block_time.is_none());
}

#[cfg(feature = "async-arti-hyper")]
#[tokio::test]
#[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"]

async fn test_anonymized_get_tx_status() {
let client = setup_anonymized_client().await;

let network = bitcoin::Network::Bitcoin;
let genesis_block = bitcoin::blockdata::constants::genesis_block(network);
let coinbase_txid = genesis_block.coinbase().unwrap().txid();

let tx_status_async_anonymized = client.get_tx_status(&coinbase_txid).await.unwrap();
assert!(tx_status_async_anonymized.confirmed);

// Bogus txid returns a TxStatus with false, None, None, None
let txid = Txid::hash(b"ayyyy lmao");
let tx_status_async_anonymized = client.get_tx_status(&txid).await.unwrap();
assert!(!tx_status_async_anonymized.confirmed);
assert!(tx_status_async_anonymized.block_height.is_none());
assert!(tx_status_async_anonymized.block_hash.is_none());
assert!(tx_status_async_anonymized.block_time.is_none());
}

#[cfg(all(feature = "blocking", feature = "async"))]
#[tokio::test]
async fn test_get_header_by_hash() {
@@ -764,6 +876,24 @@ mod test {
assert_eq!(txid_at_block_index, txid_at_block_index_async);
}

#[cfg(feature = "async-arti-hyper")]
#[tokio::test]
#[ignore = "The `AsyncAnonymizedClient` tests are ignored as they rely on a remote server with available Esplora API"]
async fn test_anonymized_get_txid_at_block_index() {
let client = setup_anonymized_client().await;

let network = bitcoin::Network::Bitcoin;
let genesis_block = bitcoin::blockdata::constants::genesis_block(network);

let genesis_block_hash = genesis_block.block_hash();
let coinbase_txid = genesis_block.coinbase().unwrap().txid();

let txid_at_block_index_async_anonymized =
client.get_txid_at_block_index(&genesis_block_hash, 0).await.unwrap().unwrap();

assert_eq!(coinbase_txid, txid_at_block_index_async_anonymized);
}

#[cfg(all(feature = "blocking", feature = "async"))]
#[tokio::test]
async fn test_get_fee_estimates() {

0 comments on commit 86103c8

Please sign in to comment.