diff --git a/Cargo.toml b/Cargo.toml index e51f692..6754b51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" diff --git a/src/api.rs b/src/api.rs index 296835c..f60e77a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,13 +2,14 @@ //! //! See: +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)] @@ -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, + /// Transaction results keyed by [`Wtxid`]. + #[serde(rename = "tx-results")] + pub tx_results: HashMap, + /// List of txids of replaced transactions. + #[serde(rename = "replaced-transactions")] + pub replaced_transactions: Option>, +} + +#[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, + /// Sigops-adjusted virtual transaction size. + pub vsize: Option, + /// Transaction fees. + pub fees: Option, + /// The transaction error string, if it was rejected by the mempool + pub error: Option, +} + +#[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, + /// 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>, +} + impl Tx { pub fn to_tx(&self) -> Transaction { Transaction { diff --git a/src/async.rs b/src/async.rs index b72988b..94fc8c8 100644 --- a/src/async.rs +++ b/src/async.rs @@ -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)] @@ -363,6 +363,56 @@ impl AsyncClient { 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, + maxburnamount: Option, + ) -> Result { + let url = format!("{}/txs/package", self.url); + + let serialized_txs = transactions + .iter() + .map(|tx| serialize(&tx).to_lower_hex_string()) + .collect::>(); + + 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::() + .await + .map_err(Error::Reqwest) + } + /// Get the current height of the blockchain tip pub async fn get_height(&self) -> Result { self.get_response_text("/blocks/tip/height") diff --git a/src/blocking.rs b/src/blocking.rs index fe6be6c..deb0e68 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -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)] @@ -88,6 +88,24 @@ impl BlockingClient { Ok(request) } + fn post_request(&self, path: &str, body: T) -> Result + where + T: Into>, + { + 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(&self, path: &str) -> Result, Error> { match self.get_with_retry(path) { Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), @@ -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, + maxburnamount: Option, + ) -> Result { + let serialized_txs = transactions + .iter() + .map(|tx| serialize(&tx).to_lower_hex_string()) + .collect::>(); + + 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() { @@ -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::().map_err(Error::Minreq)?), Err(e) => Err(Error::Minreq(e)), } }