diff --git a/Cargo.toml b/Cargo.toml index 1d90703..8963fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,10 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false } -hex = { package = "hex-conservative", version = "0.2" } +hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } -reqwest = { version = "0.11", optional = true, default-features = false, features = ["json"] } +reqwest = { version = "0.11", features = ["json"], default-features = false, optional = true } [dev-dependencies] serde_json = "1.0" diff --git a/src/api.rs b/src/api.rs index d4dfa1e..1d30bb6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ -//! structs from the esplora API +//! Structs from the Esplora API //! -//! see: +//! See: pub use bitcoin::consensus::{deserialize, serialize}; pub use bitcoin::hex::FromHex; diff --git a/src/async.rs b/src/async.rs index 62b1689..ac0a761 100644 --- a/src/async.rs +++ b/src/async.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::str::FromStr; -use bitcoin::consensus::{deserialize, serialize}; +use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{ @@ -24,18 +24,20 @@ 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}; #[derive(Debug, Clone)] pub struct AsyncClient { + /// The URL of the Esplora Server. url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, } impl AsyncClient { - /// build an async client from a builder + /// Build an async client from a builder pub fn from_builder(builder: Builder) -> Result { let mut client_builder = Client::builder(); @@ -64,31 +66,198 @@ 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 response = self.client.get(url).send().await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(deserialize::(&response.bytes().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: 404, .. }) => Ok(None), + 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?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); } + + response.json::().await.map_err(Error::Reqwest) + } + + /// 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: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implements [`bitcoin::consensus::Decodable`]. + /// + /// It should be used when requesting Esplora endpoints that are expected + /// to return a hex string decodable to native `rust-bitcoin` types which + /// implement [`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_hex(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.client.get(url).send().await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + let hex_str = response.text().await?; + Ok(deserialize(&Vec::from_hex(&hex_str)?)?) + } + + /// 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: 404, .. }) => Ok(None), + 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?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(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: 404, .. }) => Ok(None), + 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?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(()) + } + + /// 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`]. @@ -107,167 +276,55 @@ impl AsyncClient { block_hash: &BlockHash, index: usize, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/block/{}/txid/{}", self.url, block_hash, index)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(Txid::from_str(&resp.text().await?)?)) + match self + .get_opt_response_text(&format!("/block/{block_hash}/txid/{index}")) + .await? + { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), } } /// Get the status of a [`Transaction`] given its [`Txid`]. pub async fn get_tx_status(&self, txid: &Txid) -> Result { - let resp = self - .client - .get(&format!("{}/tx/{}/status", self.url, txid)) - .send() - .await?; - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + self.get_response_json(&format!("/tx/{txid}/status")).await } /// Get transaction info given it's [`Txid`]. pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}", self.url, txid)) - .send() - .await?; - if resp.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{txid}")).await } /// Get a [`BlockHeader`] given a particular block hash. pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { - let resp = self - .client - .get(&format!("{}/block/{}/header", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - let header = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(header) - } + self.get_response_hex(&format!("/block/{block_hash}/header")) + .await } /// Get the [`BlockStatus`] given a particular [`BlockHash`]. pub async fn get_block_status(&self, block_hash: &BlockHash) -> Result { - let resp = self - .client - .get(&format!("{}/block/{}/status", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + self.get_response_json(&format!("/block/{block_hash}/status")) + .await } /// Get a [`Block`] given a particular [`BlockHash`]. pub async fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/block/{}/raw", self.url, block_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(deserialize(&resp.bytes().await?)?)) - } + self.get_opt_response(&format!("/block/{block_hash}/raw")) + .await } /// Get a merkle inclusion proof for a [`Transaction`] with the given /// [`Txid`]. pub async fn get_merkle_proof(&self, tx_hash: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkle-proof", self.url, tx_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{tx_hash}/merkle-proof")) + .await } /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the /// given [`Txid`]. pub async fn get_merkle_block(&self, tx_hash: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkleblock-proof", self.url, tx_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - let merkle_block = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(Some(merkle_block)) - } + self.get_opt_response_hex(&format!("/tx/{tx_hash}/merkleblock-proof")) + .await } /// Get the spending status of an output given a [`Txid`] and the output @@ -277,101 +334,34 @@ impl AsyncClient { txid: &Txid, index: u64, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/outspend/{}", self.url, txid, index)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")) + .await } /// Broadcast a [`Transaction`] to Esplora pub async fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> { - let resp = self - .client - .post(&format!("{}/tx", self.url)) - .body(serialize(transaction).to_lower_hex_string()) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(()) - } + self.post_request_hex("/tx", transaction).await } /// Get the current height of the blockchain tip pub async fn get_height(&self) -> Result { - let resp = self - .client - .get(&format!("{}/blocks/tip/height", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.text().await?.parse()?) - } + self.get_response_text("/blocks/tip/height") + .await + .map(|height| u32::from_str(&height).map_err(Error::Parsing))? } /// Get the [`BlockHash`] of the current blockchain tip. pub async fn get_tip_hash(&self) -> Result { - let resp = self - .client - .get(&format!("{}/blocks/tip/hash", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + self.get_response_text("/blocks/tip/hash") + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } /// Get the [`BlockHash`] of a specific block height pub async fn get_block_hash(&self, block_height: u32) -> Result { - let resp = self - .client - .get(&format!("{}/block-height/{}", self.url, block_height)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Err(Error::HeaderHeightNotFound(block_height)); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + self.get_response_text(&format!("/block-height/{block_height}")) + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } /// Get confirmed transaction history for the specified address/scripthash, @@ -384,43 +374,18 @@ impl AsyncClient { last_seen: Option, ) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); - let url = match last_seen { - Some(last_seen) => format!( - "{}/scripthash/{:x}/txs/chain/{}", - self.url, script_hash, last_seen - ), - None => format!("{}/scripthash/{:x}/txs", self.url, script_hash), + let path = match last_seen { + Some(last_seen) => format!("/scripthash/{:x}/txs/chain/{}", script_hash, last_seen), + None => format!("/scripthash/{:x}/txs", script_hash), }; - let resp = self.client.get(url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/fee-estimates", self.url,)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json("/fee-estimates").await } /// Gets some recent block summaries starting at the tip or at `height` if @@ -429,21 +394,11 @@ impl AsyncClient { /// The maximum number of summaries returned depends on the backend itself: /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. pub async fn get_blocks(&self, height: Option) -> Result, Error> { - let url = match height { - Some(height) => format!("{}/blocks/{}", self.url, height), - None => format!("{}/blocks", self.url), + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), }; - - let resp = self.client.get(&url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// Get the underlying base URL. diff --git a/src/blocking.rs b/src/blocking.rs index 22c95fd..be1cf1e 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -31,6 +31,7 @@ use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus #[derive(Debug, Clone)] pub struct BlockingClient { + /// The URL of the Esplora server. url: String, /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, @@ -41,7 +42,7 @@ pub struct BlockingClient { } impl BlockingClient { - /// build a blocking client from a [`Builder`] + /// Build a blocking client from a [`Builder`] pub fn from_builder(builder: Builder) -> Self { Self { url: builder.base_url, diff --git a/src/lib.rs b/src/lib.rs index 7b7efc3..20722be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,8 +70,6 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; -use bitcoin::consensus; - pub mod api; #[cfg(feature = "async")] @@ -100,6 +98,7 @@ pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option, /// Socket timeout. pub timeout: Option, - /// HTTP headers to set on every request made to Esplora server + /// HTTP headers to set on every request made to Esplora server. pub headers: HashMap, } @@ -149,20 +148,20 @@ impl Builder { self } - /// build a blocking client from builder + /// Build a blocking client from builder #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient { BlockingClient::from_builder(self) } - // build an asynchronous client from builder + // Build an asynchronous client from builder #[cfg(feature = "async")] pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } } -/// Errors that can happen during a sync with `Esplora` +/// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { /// Error during `minreq` HTTP request @@ -185,9 +184,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), @@ -220,7 +219,7 @@ impl_error!(::minreq::Error, Minreq, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); impl_error!(std::num::ParseIntError, Parsing, Error); -impl_error!(consensus::encode::Error, BitcoinEncoding, Error); +impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error); impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error);