Skip to content

Commit a5322c6

Browse files
committed
feat(client): add async and blocking clients to submit txs package
1 parent 8f49c84 commit a5322c6

File tree

4 files changed

+169
-15
lines changed

4 files changed

+169
-15
lines changed

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ path = "src/lib.rs"
1818

1919
[dependencies]
2020
serde = { version = "1.0", features = ["derive"] }
21+
serde_json = { version = "1.0", default-features = false }
2122
bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false }
2223
hex = { version = "0.2", package = "hex-conservative" }
2324
log = "^0.4"
@@ -28,7 +29,6 @@ reqwest = { version = "0.11", features = ["json"], default-features = false, op
2829
tokio = { version = "1", features = ["time"], optional = true }
2930

3031
[dev-dependencies]
31-
serde_json = "1.0"
3232
tokio = { version = "1.20.1", features = ["full"] }
3333
electrsd = { version = "0.28.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_25_0"] }
3434
lazy_static = "1.4.0"

src/api.rs

+50-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
//!
33
//! See: <https://github.com/Blockstream/esplora/blob/master/API.md>
44
5+
use std::collections::HashMap;
6+
57
pub use bitcoin::consensus::{deserialize, serialize};
68
pub use bitcoin::hex::FromHex;
7-
use bitcoin::Weight;
89
pub use bitcoin::{
910
transaction, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness,
1011
};
11-
12+
use bitcoin::{FeeRate, Weight, Wtxid};
1213
use serde::Deserialize;
1314

1415
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -123,6 +124,53 @@ pub struct AddressTxsSummary {
123124
pub tx_count: u32,
124125
}
125126

127+
#[derive(Deserialize, Debug)]
128+
pub struct SubmitPackageResult {
129+
/// The transaction package result message. "success" indicates all transactions were accepted
130+
/// into or are already in the mempool.
131+
pub package_msg: String,
132+
/// Transaction results keyed by [`Wtxid`].
133+
#[serde(rename = "tx-results")]
134+
pub tx_results: HashMap<Wtxid, TxResult>,
135+
/// List of txids of replaced transactions.
136+
#[serde(rename = "replaced-transactions")]
137+
pub replaced_transactions: Option<Vec<Txid>>,
138+
}
139+
140+
#[derive(Deserialize, Debug)]
141+
pub struct TxResult {
142+
/// The transaction id.
143+
pub txid: Txid,
144+
/// The [`Wtxid`] of a different transaction with the same [`Txid`] but different witness found
145+
/// in the mempool.
146+
///
147+
/// If set, this means the submitted transaction was ignored.
148+
#[serde(rename = "other-wtxid")]
149+
pub other_wtxid: Option<Wtxid>,
150+
/// Sigops-adjusted virtual transaction size.
151+
pub vsize: Option<u32>,
152+
/// Transaction fees.
153+
pub fees: Option<MempoolFeesSubmitPackage>,
154+
/// The transaction error string, if it was rejected by the mempool
155+
pub error: Option<String>,
156+
}
157+
158+
#[derive(Deserialize, Debug)]
159+
pub struct MempoolFeesSubmitPackage {
160+
/// Transaction fee.
161+
pub base: Amount,
162+
/// The effective feerate.
163+
///
164+
/// Will be `None` if the transaction was already in the mempool. For example, the package
165+
/// feerate and/or feerate with modified fees from the `prioritisetransaction` JSON-RPC method.
166+
#[serde(rename = "effective-feerate")]
167+
pub effective_feerate: Option<FeeRate>,
168+
/// If [`Self::effective_fee_rate`] is provided, this holds the [`Wtxid`]s of the transactions
169+
/// whose fees and vsizes are included in effective-feerate.
170+
#[serde(rename = "effective-includes")]
171+
pub effective_includes: Option<Vec<Wtxid>>,
172+
}
173+
126174
impl Tx {
127175
pub fn to_tx(&self) -> Transaction {
128176
Transaction {

src/async.rs

+52-2
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ use reqwest::{header, Client, Response};
3030

3131
use crate::api::AddressStats;
3232
use crate::{
33-
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus,
34-
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
33+
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, SubmitPackageResult, Tx,
34+
TxStatus, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
3535
};
3636

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

366+
/// Broadcast a package of [`Transaction`] to Esplora
367+
///
368+
/// if `maxfeerate` is provided, any transaction whose
369+
/// fee is higher will be rejected
370+
///
371+
/// if `maxburnamount` is provided, any transaction
372+
/// with higher provably unspendable outputs amount
373+
/// will be rejected
374+
pub async fn submit_package(
375+
&self,
376+
transactions: &[Transaction],
377+
maxfeerate: Option<f64>,
378+
maxburnamount: Option<f64>,
379+
) -> Result<SubmitPackageResult, Error> {
380+
let url = format!("{}/txs/package", self.url);
381+
382+
let serialized_txs = transactions
383+
.iter()
384+
.map(|tx| serialize(&tx).to_lower_hex_string())
385+
.collect::<Vec<_>>();
386+
387+
let mut request = self.client.post(url).body(
388+
serde_json::to_string(&serialized_txs)
389+
.unwrap()
390+
.as_bytes()
391+
.to_vec(),
392+
);
393+
394+
if let Some(maxfeerate) = maxfeerate {
395+
request = request.query(&[("maxfeerate", maxfeerate.to_string())])
396+
}
397+
398+
if let Some(maxburnamount) = maxburnamount {
399+
request = request.query(&[("maxburnamount", maxburnamount.to_string())])
400+
}
401+
402+
let response = request.send().await?;
403+
if !response.status().is_success() {
404+
return Err(Error::HttpResponse {
405+
status: response.status().as_u16(),
406+
message: response.text().await?,
407+
});
408+
}
409+
410+
response
411+
.json::<SubmitPackageResult>()
412+
.await
413+
.map_err(Error::Reqwest)
414+
}
415+
366416
/// Get the current height of the blockchain tip
367417
pub async fn get_height(&self) -> Result<u32, Error> {
368418
self.get_response_text("/blocks/tip/height")

src/blocking.rs

+66-10
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ use bitcoin::{
3131

3232
use crate::api::AddressStats;
3333
use crate::{
34-
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus,
35-
BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
34+
BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, SubmitPackageResult, Tx,
35+
TxStatus, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES,
3636
};
3737

3838
#[derive(Debug, Clone)]
@@ -88,6 +88,24 @@ impl BlockingClient {
8888
Ok(request)
8989
}
9090

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+
91109
fn get_opt_response<T: Decodable>(&self, path: &str) -> Result<Option<T>, Error> {
92110
match self.get_with_retry(path) {
93111
Ok(resp) if is_status_not_found(resp.status_code) => Ok(None),
@@ -268,20 +286,58 @@ impl BlockingClient {
268286

269287
/// Broadcast a [`Transaction`] to Esplora
270288
pub fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> {
271-
let mut request = minreq::post(format!("{}/tx", self.url)).with_body(
289+
let request = self.post_request(
290+
"tx",
272291
serialize(transaction)
273292
.to_lower_hex_string()
274293
.as_bytes()
275294
.to_vec(),
276-
);
295+
)?;
277296

278-
if let Some(proxy) = &self.proxy {
279-
let proxy = Proxy::new(proxy.as_str())?;
280-
request = request.with_proxy(proxy);
297+
match request.send() {
298+
Ok(resp) if !is_status_ok(resp.status_code) => {
299+
let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?;
300+
let message = resp.as_str().unwrap_or_default().to_string();
301+
Err(Error::HttpResponse { status, message })
302+
}
303+
Ok(_resp) => Ok(()),
304+
Err(e) => Err(Error::Minreq(e)),
281305
}
306+
}
282307

283-
if let Some(timeout) = &self.timeout {
284-
request = request.with_timeout(*timeout);
308+
/// Broadcast a package of [`Transaction`] to Esplora
309+
///
310+
/// if `maxfeerate` is provided, any transaction whose
311+
/// fee is higher will be rejected
312+
///
313+
/// if `maxburnamount` is provided, any transaction
314+
/// with higher provably unspendable outputs amount
315+
/// will be rejected
316+
pub fn submit_package(
317+
&self,
318+
transactions: &[Transaction],
319+
maxfeerate: Option<f64>,
320+
maxburnamount: Option<f64>,
321+
) -> Result<SubmitPackageResult, Error> {
322+
let serialized_txs = transactions
323+
.iter()
324+
.map(|tx| serialize(&tx).to_lower_hex_string())
325+
.collect::<Vec<_>>();
326+
327+
let mut request = self.post_request(
328+
"txs/package",
329+
serde_json::to_string(&serialized_txs)
330+
.unwrap()
331+
.as_bytes()
332+
.to_vec(),
333+
)?;
334+
335+
if let Some(maxfeerate) = maxfeerate {
336+
request = request.with_param("maxfeerate", maxfeerate.to_string())
337+
}
338+
339+
if let Some(maxburnamount) = maxburnamount {
340+
request = request.with_param("maxburnamount", maxburnamount.to_string())
285341
}
286342

287343
match request.send() {
@@ -290,7 +346,7 @@ impl BlockingClient {
290346
let message = resp.as_str().unwrap_or_default().to_string();
291347
Err(Error::HttpResponse { status, message })
292348
}
293-
Ok(_resp) => Ok(()),
349+
Ok(resp) => Ok(resp.json::<SubmitPackageResult>().map_err(Error::Minreq)?),
294350
Err(e) => Err(Error::Minreq(e)),
295351
}
296352
}

0 commit comments

Comments
 (0)