Skip to content

Commit d9f3868

Browse files
acidbunny21oleonardolima
authored andcommitted
feat(client)!: add new submit_package API
- adds new `submit_package` API to `AsyncClient` and `BlockingClient`, it has been added to esplora by `Blockstream/electrs#159`. - adds new `SubmitPackageResult`, `TxResult` and `MempoolFeesSubmitPackage` API structures. - changes the `post_request_hex` method to `post_request_bytes`, now handling `query_params` and having `Response` as return type. BREAKING CHANGE: changes the `broadcast` method to return the `txid` for broadcasted transaction, on both `AsyncClient` and `BlockingClient`.
1 parent 2f364e2 commit d9f3868

File tree

4 files changed

+215
-32
lines changed

4 files changed

+215
-32
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ path = "src/lib.rs"
2121

2222
[dependencies]
2323
serde = { version = "1.0", features = ["derive"] }
24+
serde_json = { version = "1.0", default-features = false }
2425
bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false }
2526
hex = { version = "0.2", package = "hex-conservative" }
2627
log = "^0.4"
@@ -31,7 +32,6 @@ reqwest = { version = "0.12", features = ["json"], default-features = false, op
3132
tokio = { version = "1", features = ["time"], optional = true }
3233

3334
[dev-dependencies]
34-
serde_json = "1.0"
3535
tokio = { version = "1.20.1", features = ["full"] }
3636
electrsd = { version = "0.33.0", features = ["legacy", "esplora_a33e97e1", "corepc-node_28_0"] }
3737
lazy_static = "1.4.0"

src/api.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
1616
use bitcoin::hash_types;
1717
use serde::Deserialize;
18+
use std::collections::HashMap;
1819

1920
pub use bitcoin::consensus::{deserialize, serialize};
2021
use bitcoin::hash_types::TxMerkleNode;
2122
pub use bitcoin::hex::FromHex;
2223
pub use bitcoin::{
23-
absolute, block, transaction, Address, Amount, Block, BlockHash, CompactTarget, OutPoint,
24-
Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness,
24+
absolute, block, transaction, Address, Amount, Block, BlockHash, CompactTarget, FeeRate,
25+
OutPoint, Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness,
26+
Wtxid,
2527
};
2628

2729
/// Information about a previous output.
@@ -311,6 +313,61 @@ pub struct MempoolRecentTx {
311313
pub value: u64,
312314
}
313315

316+
/// The result for a broadcasted package of [`Transaction`]s.
317+
#[derive(Deserialize, Debug)]
318+
pub struct SubmitPackageResult {
319+
/// The transaction package result message. "success" indicates all transactions were accepted
320+
/// into or are already in the mempool.
321+
pub package_msg: String,
322+
/// Transaction results keyed by [`Wtxid`].
323+
#[serde(rename = "tx-results")]
324+
pub tx_results: HashMap<Wtxid, TxResult>,
325+
/// List of txids of replaced transactions.
326+
#[serde(rename = "replaced-transactions")]
327+
pub replaced_transactions: Option<Vec<Txid>>,
328+
}
329+
330+
/// The result [`Transaction`] for a broadcasted package of [`Transaction`]s.
331+
#[derive(Deserialize, Debug)]
332+
pub struct TxResult {
333+
/// The transaction id.
334+
pub txid: Txid,
335+
/// The [`Wtxid`] of a different transaction with the same [`Txid`] but different witness found
336+
/// in the mempool.
337+
///
338+
/// If set, this means the submitted transaction was ignored.
339+
#[serde(rename = "other-wtxid")]
340+
pub other_wtxid: Option<Wtxid>,
341+
/// Sigops-adjusted virtual transaction size.
342+
pub vsize: Option<u32>,
343+
/// Transaction fees.
344+
pub fees: Option<MempoolFeesSubmitPackage>,
345+
/// The transaction error string, if it was rejected by the mempool
346+
pub error: Option<String>,
347+
}
348+
349+
/// The mempool fees for a resulting [`Transaction`] broadcasted by a package of [`Transaction`]s.
350+
#[derive(Deserialize, Debug)]
351+
pub struct MempoolFeesSubmitPackage {
352+
/// Transaction fee.
353+
#[serde(with = "bitcoin::amount::serde::as_btc")]
354+
pub base: Amount,
355+
/// The effective feerate.
356+
///
357+
/// Will be `None` if the transaction was already in the mempool. For example, the package
358+
/// feerate and/or feerate with modified fees from the `prioritisetransaction` JSON-RPC method.
359+
#[serde(
360+
rename = "effective-feerate",
361+
default,
362+
deserialize_with = "deserialize_feerate"
363+
)]
364+
pub effective_feerate: Option<FeeRate>,
365+
/// If [`Self::effective_feerate`] is provided, this holds the [`Wtxid`]s of the transactions
366+
/// whose fees and vsizes are included in effective-feerate.
367+
#[serde(rename = "effective-includes")]
368+
pub effective_includes: Option<Vec<Wtxid>>,
369+
}
370+
314371
impl Tx {
315372
/// Convert a transaction from the format returned by Esplora into a `rust-bitcoin`
316373
/// [`Transaction`].
@@ -392,3 +449,20 @@ where
392449
.collect::<Result<Vec<Vec<u8>>, _>>()
393450
.map_err(serde::de::Error::custom)
394451
}
452+
453+
fn deserialize_feerate<'de, D>(d: D) -> Result<Option<FeeRate>, D::Error>
454+
where
455+
D: serde::de::Deserializer<'de>,
456+
{
457+
use serde::de::Error;
458+
459+
let btc_per_kvb = match Option::<f64>::deserialize(d)? {
460+
Some(v) => v,
461+
None => return Ok(None),
462+
};
463+
let sat_per_kwu = btc_per_kvb * 25_000_000.0;
464+
if sat_per_kwu.is_infinite() {
465+
return Err(D::Error::custom("feerate overflow"));
466+
}
467+
Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64)))
468+
}

src/async.rs

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,27 @@
1111

1212
//! Esplora by way of `reqwest` HTTP client.
1313
14-
use std::collections::HashMap;
14+
use std::collections::{HashMap, HashSet};
1515
use std::marker::PhantomData;
1616
use std::str::FromStr;
1717
use std::time::Duration;
1818

1919
use bitcoin::block::Header as BlockHeader;
20-
use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable};
20+
use bitcoin::consensus::encode::serialize_hex;
21+
use bitcoin::consensus::{deserialize, serialize, Decodable};
2122
use bitcoin::hashes::{sha256, Hash};
2223
use bitcoin::hex::{DisplayHex, FromHex};
2324
use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid};
2425

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

28-
use reqwest::{header, Client, Response};
29+
use reqwest::{header, Body, Client, Response};
2930

3031
use crate::{
3132
AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx,
32-
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo,
33-
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
33+
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus,
34+
Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
3435
};
3536

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

252-
/// Make an HTTP POST request to given URL, serializing from any `T` that
253-
/// implement [`bitcoin::consensus::Encodable`].
254-
///
255-
/// It should be used when requesting Esplora endpoints that expected a
256-
/// native bitcoin type serialized with [`bitcoin::consensus::Encodable`].
253+
/// Make an HTTP POST request to given URL, converting any `T` that
254+
/// implement [`Into<Body>`] and setting query parameters, if any.
257255
///
258256
/// # Errors
259257
///
260258
/// This function will return an error either from the HTTP client, or the
261-
/// [`bitcoin::consensus::Encodable`] serialization.
262-
async fn post_request_hex<T: Encodable>(&self, path: &str, body: T) -> Result<(), Error> {
263-
let url = format!("{}{}", self.url, path);
264-
let body = serialize::<T>(&body).to_lower_hex_string();
259+
/// response's [`serde_json`] deserialization.
260+
async fn post_request_bytes<T: Into<Body>>(
261+
&self,
262+
path: &str,
263+
body: T,
264+
query_params: Option<HashSet<(&str, String)>>,
265+
) -> Result<Response, Error> {
266+
let url: String = format!("{}{}", self.url, path);
267+
let mut request = self.client.post(url).body(body);
268+
269+
for param in query_params.unwrap_or_default() {
270+
request = request.query(&param);
271+
}
265272

266-
let response = self.client.post(url).body(body).send().await?;
273+
let response = request.send().await?;
267274

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

275-
Ok(())
282+
Ok(response)
276283
}
277284

278285
/// Get a [`Transaction`] option given its [`Txid`]
@@ -365,8 +372,49 @@ impl<S: Sleeper> AsyncClient<S> {
365372
}
366373

367374
/// Broadcast a [`Transaction`] to Esplora
368-
pub async fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
369-
self.post_request_hex("/tx", transaction).await
375+
pub async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, Error> {
376+
let body = serialize::<Transaction>(transaction).to_lower_hex_string();
377+
let response = self.post_request_bytes("/tx", body, None).await?;
378+
let txid = Txid::from_str(&response.text().await?).map_err(|_| Error::InvalidResponse)?;
379+
Ok(txid)
380+
}
381+
382+
/// Broadcast a package of [`Transaction`] to Esplora
383+
///
384+
/// If `maxfeerate` is provided, any transaction whose
385+
/// fee is higher will be rejected
386+
///
387+
/// If `maxburnamount` is provided, any transaction
388+
/// with higher provably unspendable outputs amount
389+
/// will be rejected.
390+
pub async fn submit_package(
391+
&self,
392+
transactions: &[Transaction],
393+
maxfeerate: Option<f64>,
394+
maxburnamount: Option<f64>,
395+
) -> Result<SubmitPackageResult, Error> {
396+
let mut queryparams = HashSet::<(&str, String)>::new();
397+
if let Some(maxfeerate) = maxfeerate {
398+
queryparams.insert(("maxfeerate", maxfeerate.to_string()));
399+
}
400+
if let Some(maxburnamount) = maxburnamount {
401+
queryparams.insert(("maxburnamount", maxburnamount.to_string()));
402+
}
403+
404+
let serialized_txs = transactions
405+
.iter()
406+
.map(|tx| serialize_hex(&tx))
407+
.collect::<Vec<_>>();
408+
409+
let response = self
410+
.post_request_bytes(
411+
"/txs/package",
412+
serde_json::to_string(&serialized_txs).unwrap(),
413+
Some(queryparams),
414+
)
415+
.await?;
416+
417+
Ok(response.json::<SubmitPackageResult>().await?)
370418
}
371419

372420
/// Get the current height of the blockchain tip

src/blocking.rs

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use std::convert::TryFrom;
1616
use std::str::FromStr;
1717
use std::thread;
1818

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

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

3031
use crate::{
3132
AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx,
32-
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo,
33-
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
33+
MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus,
34+
Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
3435
};
3536

3637
/// A blocking client for interacting with an Esplora API server.
@@ -87,6 +88,24 @@ impl BlockingClient {
8788
Ok(request)
8889
}
8990

91+
fn post_request<T>(&self, path: &str, body: T) -> Result<Request, Error>
92+
where
93+
T: Into<Vec<u8>>,
94+
{
95+
let mut request = minreq::post(format!("{}{}", self.url, path)).with_body(body);
96+
97+
if let Some(proxy) = &self.proxy {
98+
let proxy = Proxy::new(proxy.as_str())?;
99+
request = request.with_proxy(proxy);
100+
}
101+
102+
if let Some(timeout) = &self.timeout {
103+
request = request.with_timeout(*timeout);
104+
}
105+
106+
Ok(request)
107+
}
108+
90109
fn get_opt_response<T: Decodable>(&self, path: &str) -> Result<Option<T>, Error> {
91110
match self.get_with_retry(path) {
92111
Ok(resp) if is_status_not_found(resp.status_code) => Ok(None),
@@ -271,21 +290,63 @@ impl BlockingClient {
271290
}
272291

273292
/// Broadcast a [`Transaction`] to Esplora
274-
pub fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
275-
let mut request = minreq::post(format!("{}/tx", self.url)).with_body(
293+
pub fn broadcast(&self, transaction: &Transaction) -> Result<Txid, Error> {
294+
let request = self.post_request(
295+
"/tx",
276296
serialize(transaction)
277297
.to_lower_hex_string()
278298
.as_bytes()
279299
.to_vec(),
280-
);
300+
)?;
281301

282-
if let Some(proxy) = &self.proxy {
283-
let proxy = Proxy::new(proxy.as_str())?;
284-
request = request.with_proxy(proxy);
302+
match request.send() {
303+
Ok(resp) if !is_status_ok(resp.status_code) => {
304+
let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?;
305+
let message = resp.as_str().unwrap_or_default().to_string();
306+
Err(Error::HttpResponse { status, message })
307+
}
308+
Ok(resp) => {
309+
let txid =
310+
Txid::from_str(resp.as_str().unwrap_or_default()).map_err(Error::HexToArray)?;
311+
Ok(txid)
312+
}
313+
Err(e) => Err(Error::Minreq(e)),
285314
}
315+
}
286316

287-
if let Some(timeout) = &self.timeout {
288-
request = request.with_timeout(*timeout);
317+
/// Broadcast a package of [`Transaction`] to Esplora
318+
///
319+
/// If `maxfeerate` is provided, any transaction whose
320+
/// fee is higher will be rejected
321+
///
322+
/// If `maxburnamount` is provided, any transaction
323+
/// with higher provably unspendable outputs amount
324+
/// will be rejected.
325+
pub fn submit_package(
326+
&self,
327+
transactions: &[Transaction],
328+
maxfeerate: Option<f64>,
329+
maxburnamount: Option<f64>,
330+
) -> Result<SubmitPackageResult, Error> {
331+
let serialized_txs = transactions
332+
.iter()
333+
.map(|tx| serialize_hex(&tx))
334+
.collect::<Vec<_>>();
335+
336+
let mut request = self.post_request(
337+
"/txs/package",
338+
serde_json::to_string(&serialized_txs)
339+
.unwrap()
340+
.as_bytes()
341+
.to_vec(),
342+
)?;
343+
344+
if let Some(maxfeerate) = maxfeerate {
345+
request = request.with_param("maxfeerate", maxfeerate.to_string())
346+
}
347+
348+
if let Some(maxburnamount) = maxburnamount {
349+
request = request.with_param("maxburnamount", maxburnamount.to_string())
289350
}
290351

291352
match request.send() {
@@ -294,7 +355,7 @@ impl BlockingClient {
294355
let message = resp.as_str().unwrap_or_default().to_string();
295356
Err(Error::HttpResponse { status, message })
296357
}
297-
Ok(_resp) => Ok(()),
358+
Ok(resp) => Ok(resp.json::<SubmitPackageResult>().map_err(Error::Minreq)?),
298359
Err(e) => Err(Error::Minreq(e)),
299360
}
300361
}

0 commit comments

Comments
 (0)