Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): add async and blocking clients to submit txs package #114

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = { version = "1.0", default-features = false }
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
52 changes: 50 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
//!
//! 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;
pub use bitcoin::{
transaction, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness,
};

use bitcoin::{FeeRate, Weight, Wtxid};
use serde::Deserialize;

#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -123,6 +124,53 @@ pub struct AddressTxsSummary {
pub tx_count: u32,
}

#[derive(Deserialize, Debug)]
pub struct SubmitPackageResult {
/// The transaction package result message. "success" indicates all transactions were accepted
/// into or are already in the mempool.
pub package_msg: String,
Copy link
Contributor

@tnull tnull Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if the variants here are finite? Do we see a chance to parse this into an enum?

/// Transaction results keyed by [`Wtxid`].
#[serde(rename = "tx-results")]
pub tx_results: HashMap<Wtxid, TxResult>,
/// List of txids of replaced transactions.
#[serde(rename = "replaced-transactions")]
pub replaced_transactions: Option<Vec<Txid>>,
}

#[derive(Deserialize, Debug)]
pub struct TxResult {
/// The transaction id.
pub txid: Txid,
/// The [`Wtxid`] of a different transaction with the same [`Txid`] but different witness found
/// in the mempool.
///
/// If set, this means the submitted transaction was ignored.
#[serde(rename = "other-wtxid")]
pub other_wtxid: Option<Wtxid>,
/// Sigops-adjusted virtual transaction size.
pub vsize: Option<u32>,
/// Transaction fees.
pub fees: Option<MempoolFeesSubmitPackage>,
/// The transaction error string, if it was rejected by the mempool
pub error: Option<String>,
}

#[derive(Deserialize, Debug)]
pub struct MempoolFeesSubmitPackage {
/// Transaction fee.
pub base: Amount,
/// The effective feerate.
///
/// Will be `None` if the transaction was already in the mempool. For example, the package
/// feerate and/or feerate with modified fees from the `prioritisetransaction` JSON-RPC method.
#[serde(rename = "effective-feerate")]
pub effective_feerate: Option<FeeRate>,
/// If [`Self::effective_fee_rate`] is provided, this holds the [`Wtxid`]s of the transactions
/// whose fees and vsizes are included in effective-feerate.
#[serde(rename = "effective-includes")]
pub effective_includes: Option<Vec<Wtxid>>,
}

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 submit_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 submit_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