Skip to content
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 @@ -21,6 +21,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 @@ -31,7 +32,6 @@ reqwest = { version = "0.12", 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.33.0", features = ["legacy", "esplora_a33e97e1", "corepc-node_28_0"] }
lazy_static = "1.4.0"
Expand Down
78 changes: 76 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@

use bitcoin::hash_types;
use serde::Deserialize;
use std::collections::HashMap;

pub use bitcoin::consensus::{deserialize, serialize};
use bitcoin::hash_types::TxMerkleNode;
pub use bitcoin::hex::FromHex;
pub use bitcoin::{
absolute, block, transaction, Address, Amount, Block, BlockHash, CompactTarget, OutPoint,
Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness,
absolute, block, transaction, Address, Amount, Block, BlockHash, CompactTarget, FeeRate,
OutPoint, Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness,
Wtxid,
};

/// Information about a previous output.
Expand Down Expand Up @@ -311,6 +313,61 @@ pub struct MempoolRecentTx {
pub value: u64,
}

/// The result for a broadcasted package of [`Transaction`]s.
#[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?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed, that'd be best.

Copy link
Author

Choose a reason for hiding this comment

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

i agree that would be best, but as I can see here, there is no enum defined for that field. I'd be happy to update that part as soon as it is upgraded to one there

/// 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>>,
}

/// The result [`Transaction`] for a broadcasted package of [`Transaction`]s.
#[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>,
}

/// The mempool fees for a resulting [`Transaction`] broadcasted by a package of [`Transaction`]s.
#[derive(Deserialize, Debug)]
pub struct MempoolFeesSubmitPackage {
/// Transaction fee.
#[serde(with = "bitcoin::amount::serde::as_btc")]
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",
default,
deserialize_with = "deserialize_feerate"
)]
pub effective_feerate: Option<FeeRate>,
/// If [`Self::effective_feerate`] 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 {
/// Convert a transaction from the format returned by Esplora into a `rust-bitcoin`
/// [`Transaction`].
Expand Down Expand Up @@ -392,3 +449,20 @@ where
.collect::<Result<Vec<Vec<u8>>, _>>()
.map_err(serde::de::Error::custom)
}

fn deserialize_feerate<'de, D>(d: D) -> Result<Option<FeeRate>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde::de::Error;

let btc_per_kvb = match Option::<f64>::deserialize(d)? {
Some(v) => v,
None => return Ok(None),
};
let sat_per_kwu = btc_per_kvb * 25_000_000.0;
if sat_per_kwu.is_infinite() {
return Err(D::Error::custom("feerate overflow"));
}
Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64)))
}
85 changes: 67 additions & 18 deletions src/async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,27 @@

//! Esplora by way of `reqwest` HTTP client.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::marker::PhantomData;
use std::str::FromStr;
use std::time::Duration;

use bitcoin::block::Header as BlockHeader;
use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable};
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::consensus::{deserialize, serialize, Decodable};
use bitcoin::hashes::{sha256, Hash};
use bitcoin::hex::{DisplayHex, FromHex};
use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid};

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

use reqwest::{header, Client, Response};
use reqwest::{header, Body, Client, Response};

use crate::{
AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx,
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo,
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus,
Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
};

/// An async client for interacting with an Esplora API server.
Expand Down Expand Up @@ -249,21 +250,27 @@ impl<S: Sleeper> AsyncClient<S> {
}
}

/// 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`].
/// Make an HTTP POST request to given URL, converting any `T` that
/// implement [`Into<Body>`] and setting query parameters, if any.
///
/// # Errors
///
/// This function will return an error either from the HTTP client, or the
/// [`bitcoin::consensus::Encodable`] serialization.
async fn post_request_hex<T: Encodable>(&self, path: &str, body: T) -> Result<(), Error> {
let url = format!("{}{}", self.url, path);
let body = serialize::<T>(&body).to_lower_hex_string();
/// response's [`serde_json`] deserialization.
async fn post_request_bytes<T: Into<Body>>(
&self,
path: &str,
body: T,
query_params: Option<HashSet<(&str, String)>>,
) -> Result<Response, Error> {
let url: String = format!("{}{}", self.url, path);
let mut request = self.client.post(url).body(body);

for param in query_params.unwrap_or_default() {
request = request.query(&param);
}

let response = self.client.post(url).body(body).send().await?;
let response = request.send().await?;

if !response.status().is_success() {
return Err(Error::HttpResponse {
Expand All @@ -272,7 +279,7 @@ impl<S: Sleeper> AsyncClient<S> {
});
}

Ok(())
Ok(response)
}

/// Get a [`Transaction`] option given its [`Txid`]
Expand Down Expand Up @@ -364,9 +371,51 @@ impl<S: Sleeper> AsyncClient<S> {
.await
}

/// Broadcast a [`Transaction`] to Esplora
/// Broadcast a [`Transaction`] to Esplora.
pub async fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
self.post_request_hex("/tx", transaction).await
let body = serialize::<Transaction>(transaction).to_lower_hex_string();
match self.post_request_bytes("/tx", body, None).await {
Ok(_resp) => Ok(()),
Err(e) => Err(e),
}
}

/// Broadcast a package of [`Transaction`]s 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 mut queryparams = HashSet::<(&str, String)>::new();
if let Some(maxfeerate) = maxfeerate {
queryparams.insert(("maxfeerate", maxfeerate.to_string()));
}
if let Some(maxburnamount) = maxburnamount {
queryparams.insert(("maxburnamount", maxburnamount.to_string()));
}

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

let response = self
.post_request_bytes(
"/txs/package",
serde_json::to_string(&serialized_txs).unwrap(),
Some(queryparams),
)
.await?;

Ok(response.json::<SubmitPackageResult>().await?)
}

/// Get the current height of the blockchain tip
Expand Down
79 changes: 68 additions & 11 deletions src/blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use std::convert::TryFrom;
use std::str::FromStr;
use std::thread;

use bitcoin::consensus::encode::serialize_hex;
#[allow(unused_imports)]
use log::{debug, error, info, trace};

Expand All @@ -29,8 +30,8 @@ use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}

use crate::{
AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx,
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo,
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus,
Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
};

/// A blocking client for interacting with an Esplora API server.
Expand Down Expand Up @@ -87,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 @@ -270,22 +289,60 @@ impl BlockingClient {
self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}"))
}

/// Broadcast a [`Transaction`] to Esplora
/// 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`]s 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_hex(&tx))
.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 @@ -294,7 +351,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
Loading