Skip to content

Commit

Permalink
feat(client): add async and blocking clients to submit txs package
Browse files Browse the repository at this point in the history
  • Loading branch information
acidbunny21 committed Jan 23, 2025
1 parent 8f49c84 commit 01fb9c9
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 13 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ path = "src/lib.rs"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false }
hex = { version = "0.2", package = "hex-conservative" }
log = "^0.4"
Expand All @@ -28,7 +29,6 @@ reqwest = { version = "0.11", features = ["json"], default-features = false, op
tokio = { version = "1", features = ["time"], 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"
Expand Down
30 changes: 30 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//!
//! See: <https://github.com/Blockstream/esplora/blob/master/API.md>
use std::collections::HashMap;

pub use bitcoin::consensus::{deserialize, serialize};
pub use bitcoin::hex::FromHex;
use bitcoin::Weight;
Expand Down Expand Up @@ -123,6 +125,34 @@ pub struct AddressTxsSummary {
pub tx_count: u32,
}

#[derive(Deserialize, Debug)]
pub struct SubmitPackageResult {
pub package_msg: String,
#[serde(rename = "tx-results")]
pub tx_results: HashMap<String, TxResult>,
#[serde(rename = "replaced-transactions")]
pub replaced_transactions: Option<Vec<String>>,
}

#[derive(Deserialize, Debug)]
pub struct TxResult {
pub txid: String,
#[serde(rename = "other-wtxid")]
pub other_wtxid: Option<String>,
pub vsize: Option<u32>,
pub fees: Option<MempoolFeesSubmitPackage>,
pub error: Option<String>,
}

#[derive(Deserialize, Debug)]
pub struct MempoolFeesSubmitPackage {
pub base: f64,
#[serde(rename = "effective-feerate")]
pub effective_feerate: Option<f64>,
#[serde(rename = "effective-includes")]
pub effective_includes: Option<Vec<String>>,
}

impl Tx {
pub fn to_tx(&self) -> Transaction {
Transaction {
Expand Down
54 changes: 52 additions & 2 deletions src/async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ use reqwest::{header, Client, Response};

use crate::api::AddressStats;
use crate::{
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus,
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, SubmitPackageResult, Tx,
TxStatus, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -363,6 +363,56 @@ impl<S: Sleeper> AsyncClient<S> {
self.post_request_hex("/tx", transaction).await
}

/// Broadcast a package of [`Transaction`] to Esplora
///
/// if `maxfeerate` is provided, any transaction whose
/// fee is higher will be rejected
///
/// if `maxburnamount` is provided, any transaction
/// with higher provably unspendable outputs amount
/// will be rejected
pub async fn broadcast_package(
&self,
transactions: &[Transaction],
maxfeerate: Option<f64>,
maxburnamount: Option<f64>,
) -> Result<SubmitPackageResult, Error> {
let url = format!("{}/txs/package", self.url);

let serialized_txs = transactions
.iter()
.map(|tx| serialize(&tx).to_lower_hex_string())
.collect::<Vec<_>>();

let mut request = self.client.post(url).body(
serde_json::to_string(&serialized_txs)
.unwrap()
.as_bytes()
.to_vec(),
);

if let Some(maxfeerate) = maxfeerate {
request = request.query(&[("maxfeerate", maxfeerate.to_string())])
}

if let Some(maxburnamount) = maxburnamount {
request = request.query(&[("maxburnamount", maxburnamount.to_string())])
}

let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::HttpResponse {
status: response.status().as_u16(),
message: response.text().await?,
});
}

response
.json::<SubmitPackageResult>()
.await
.map_err(Error::Reqwest)
}

/// Get the current height of the blockchain tip
pub async fn get_height(&self) -> Result<u32, Error> {
self.get_response_text("/blocks/tip/height")
Expand Down
76 changes: 66 additions & 10 deletions src/blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ use bitcoin::{

use crate::api::AddressStats;
use crate::{
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus,
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, SubmitPackageResult, Tx,
TxStatus, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -88,6 +88,24 @@ impl BlockingClient {
Ok(request)
}

fn post_request<T>(&self, path: &str, body: T) -> Result<Request, Error>
where
T: Into<Vec<u8>>,
{
let mut request = minreq::post(format!("{}/{}", self.url, path)).with_body(body);

if let Some(proxy) = &self.proxy {
let proxy = Proxy::new(proxy.as_str())?;
request = request.with_proxy(proxy);
}

if let Some(timeout) = &self.timeout {
request = request.with_timeout(*timeout);
}

Ok(request)
}

fn get_opt_response<T: Decodable>(&self, path: &str) -> Result<Option<T>, Error> {
match self.get_with_retry(path) {
Ok(resp) if is_status_not_found(resp.status_code) => Ok(None),
Expand Down Expand Up @@ -268,20 +286,58 @@ impl BlockingClient {

/// Broadcast a [`Transaction`] to Esplora
pub fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
let mut request = minreq::post(format!("{}/tx", self.url)).with_body(
let request = self.post_request(
"tx",
serialize(transaction)
.to_lower_hex_string()
.as_bytes()
.to_vec(),
);
)?;

if let Some(proxy) = &self.proxy {
let proxy = Proxy::new(proxy.as_str())?;
request = request.with_proxy(proxy);
match request.send() {
Ok(resp) if !is_status_ok(resp.status_code) => {
let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?;
let message = resp.as_str().unwrap_or_default().to_string();
Err(Error::HttpResponse { status, message })
}
Ok(_resp) => Ok(()),
Err(e) => Err(Error::Minreq(e)),
}
}

if let Some(timeout) = &self.timeout {
request = request.with_timeout(*timeout);
/// Broadcast a package of [`Transaction`] to Esplora
///
/// if `maxfeerate` is provided, any transaction whose
/// fee is higher will be rejected
///
/// if `maxburnamount` is provided, any transaction
/// with higher provably unspendable outputs amount
/// will be rejected
pub fn broadcast_package(
&self,
transactions: &[Transaction],
maxfeerate: Option<f64>,
maxburnamount: Option<f64>,
) -> Result<SubmitPackageResult, Error> {
let serialized_txs = transactions
.iter()
.map(|tx| serialize(&tx).to_lower_hex_string())
.collect::<Vec<_>>();

let mut request = self.post_request(
"txs/package",
serde_json::to_string(&serialized_txs)
.unwrap()
.as_bytes()
.to_vec(),
)?;

if let Some(maxfeerate) = maxfeerate {
request = request.with_param("maxfeerate", maxfeerate.to_string())
}

if let Some(maxburnamount) = maxburnamount {
request = request.with_param("maxburnamount", maxburnamount.to_string())
}

match request.send() {
Expand All @@ -290,7 +346,7 @@ impl BlockingClient {
let message = resp.as_str().unwrap_or_default().to_string();
Err(Error::HttpResponse { status, message })
}
Ok(_resp) => Ok(()),
Ok(resp) => Ok(resp.json::<SubmitPackageResult>().map_err(Error::Minreq)?),
Err(e) => Err(Error::Minreq(e)),
}
}
Expand Down

0 comments on commit 01fb9c9

Please sign in to comment.