Skip to content

Commit e69d7e0

Browse files
acidbunny21oleonardolima
authored andcommitted
feat(client): add async and blocking clients to submit txs package
1 parent 9305e41 commit e69d7e0

File tree

4 files changed

+211
-32
lines changed

4 files changed

+211
-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: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
55
use bitcoin::hash_types;
66
use serde::Deserialize;
7+
use std::collections::HashMap;
78

89
pub use bitcoin::consensus::{deserialize, serialize};
910
pub use bitcoin::hex::FromHex;
1011
pub use bitcoin::{
11-
absolute, block, transaction, Amount, Block, BlockHash, CompactTarget, OutPoint, Script,
12-
ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness,
12+
absolute, block, transaction, Amount, Block, BlockHash, CompactTarget, FeeRate, OutPoint,
13+
Script, ScriptBuf, ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness, Wtxid,
1314
};
1415

1516
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -246,6 +247,58 @@ pub struct MempoolRecentTx {
246247
pub value: u64,
247248
}
248249

250+
#[derive(Deserialize, Debug)]
251+
pub struct SubmitPackageResult {
252+
/// The transaction package result message. "success" indicates all transactions were accepted
253+
/// into or are already in the mempool.
254+
pub package_msg: String,
255+
/// Transaction results keyed by [`Wtxid`].
256+
#[serde(rename = "tx-results")]
257+
pub tx_results: HashMap<Wtxid, TxResult>,
258+
/// List of txids of replaced transactions.
259+
#[serde(rename = "replaced-transactions")]
260+
pub replaced_transactions: Option<Vec<Txid>>,
261+
}
262+
263+
#[derive(Deserialize, Debug)]
264+
pub struct TxResult {
265+
/// The transaction id.
266+
pub txid: Txid,
267+
/// The [`Wtxid`] of a different transaction with the same [`Txid`] but different witness found
268+
/// in the mempool.
269+
///
270+
/// If set, this means the submitted transaction was ignored.
271+
#[serde(rename = "other-wtxid")]
272+
pub other_wtxid: Option<Wtxid>,
273+
/// Sigops-adjusted virtual transaction size.
274+
pub vsize: Option<u32>,
275+
/// Transaction fees.
276+
pub fees: Option<MempoolFeesSubmitPackage>,
277+
/// The transaction error string, if it was rejected by the mempool
278+
pub error: Option<String>,
279+
}
280+
281+
#[derive(Deserialize, Debug)]
282+
pub struct MempoolFeesSubmitPackage {
283+
/// Transaction fee.
284+
#[serde(with = "bitcoin::amount::serde::as_btc")]
285+
pub base: Amount,
286+
/// The effective feerate.
287+
///
288+
/// Will be `None` if the transaction was already in the mempool. For example, the package
289+
/// feerate and/or feerate with modified fees from the `prioritisetransaction` JSON-RPC method.
290+
#[serde(
291+
rename = "effective-feerate",
292+
default,
293+
deserialize_with = "deserialize_feerate"
294+
)]
295+
pub effective_feerate: Option<FeeRate>,
296+
/// If [`Self::effective_fee_rate`] is provided, this holds the [`Wtxid`]s of the transactions
297+
/// whose fees and vsizes are included in effective-feerate.
298+
#[serde(rename = "effective-includes")]
299+
pub effective_includes: Option<Vec<Wtxid>>,
300+
}
301+
249302
impl Tx {
250303
pub fn to_tx(&self) -> Transaction {
251304
Transaction {
@@ -321,3 +374,20 @@ where
321374
.collect::<Result<Vec<Vec<u8>>, _>>()
322375
.map_err(serde::de::Error::custom)
323376
}
377+
378+
fn deserialize_feerate<'de, D>(d: D) -> Result<Option<FeeRate>, D::Error>
379+
where
380+
D: serde::de::Deserializer<'de>,
381+
{
382+
use serde::de::Error;
383+
384+
let btc_per_kvb = match Option::<f64>::deserialize(d)? {
385+
Some(v) => v,
386+
None => return Ok(None),
387+
};
388+
let sat_per_kwu = btc_per_kvb * 25_000_000.0;
389+
if sat_per_kwu.is_infinite() {
390+
return Err(D::Error::custom("feerate overflow"));
391+
}
392+
Ok(Some(FeeRate::from_sat_per_kwu(sat_per_kwu as u64)))
393+
}

src/async.rs

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,26 @@
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

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

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

27-
use reqwest::{header, Client, Response};
28+
use reqwest::{header, Body, Client, Response};
2829

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

3536
#[derive(Debug, Clone)]
@@ -247,21 +248,27 @@ impl<S: Sleeper> AsyncClient<S> {
247248
}
248249
}
249250

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

264-
let response = self.client.post(url).body(body).send().await?;
271+
let response = request.send().await?;
265272

266273
if !response.status().is_success() {
267274
return Err(Error::HttpResponse {
@@ -270,7 +277,7 @@ impl<S: Sleeper> AsyncClient<S> {
270277
});
271278
}
272279

273-
Ok(())
280+
Ok(response)
274281
}
275282

276283
/// Get a [`Transaction`] option given its [`Txid`]
@@ -363,8 +370,49 @@ impl<S: Sleeper> AsyncClient<S> {
363370
}
364371

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

370418
/// 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
#[derive(Debug, Clone)]
@@ -86,6 +87,24 @@ impl BlockingClient {
8687
Ok(request)
8788
}
8889

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

272291
/// Broadcast a [`Transaction`] to Esplora
273-
pub fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
274-
let mut request = minreq::post(format!("{}/tx", self.url)).with_body(
292+
pub fn broadcast(&self, transaction: &Transaction) -> Result<Txid, Error> {
293+
let request = self.post_request(
294+
"tx",
275295
serialize(transaction)
276296
.to_lower_hex_string()
277297
.as_bytes()
278298
.to_vec(),
279-
);
299+
)?;
280300

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

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

290351
match request.send() {
@@ -293,7 +354,7 @@ impl BlockingClient {
293354
let message = resp.as_str().unwrap_or_default().to_string();
294355
Err(Error::HttpResponse { status, message })
295356
}
296-
Ok(_resp) => Ok(()),
357+
Ok(resp) => Ok(resp.json::<SubmitPackageResult>().map_err(Error::Minreq)?),
297358
Err(e) => Err(Error::Minreq(e)),
298359
}
299360
}

0 commit comments

Comments
 (0)