diff --git a/client/src/client_sync/v28.rs b/client/src/client_sync/v28/mod.rs similarity index 95% rename from client/src/client_sync/v28.rs rename to client/src/client_sync/v28/mod.rs index dab6ff7..7128bb3 100644 --- a/client/src/client_sync/v28.rs +++ b/client/src/client_sync/v28/mod.rs @@ -4,6 +4,8 @@ //! //! We ignore option arguments unless they effect the shape of the returned JSON data. +mod raw_transactions; + use bitcoin::address::{Address, NetworkChecked}; use bitcoin::{Amount, Block, BlockHash, Txid}; @@ -30,6 +32,7 @@ crate::impl_client_check_expected_server_version!({ [280000] }); // == Rawtransactions == crate::impl_client_v17__sendrawtransaction!(); +crate::impl_client_v28__submitpackage!(); // == Wallet == crate::impl_client_v17__createwallet!(); diff --git a/client/src/client_sync/v28/raw_transactions.rs b/client/src/client_sync/v28/raw_transactions.rs new file mode 100644 index 0000000..db2ca00 --- /dev/null +++ b/client/src/client_sync/v28/raw_transactions.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Macros for implementing JSON-RPC methods on a client. +//! +//! Specifically this is methods found under the `== Rawtransactions ==` section of the +//! API docs of `bitcoind v28.0`. +//! +//! All macros require `Client` to be in scope. +//! +//! See or use the `define_jsonrpc_minreq_client!` macro to define a `Client`. + +/// Implements bitcoind JSON-RPC API method `submitpackage` +#[macro_export] +macro_rules! impl_client_v28__submitpackage { + () => { + impl Client { + /// Submit a package of transactions to local node. + /// + /// The package will be validated according to consensus and mempool policy rules. If any transaction passes, it will be accepted to mempool. + /// + /// ## Arguments: + /// 1. `package`: An array of raw transactions. + /// The package must solely consist of a child and its parents. None of the parents may depend on each other. + /// The package must be topologically sorted, with the child being the last element in the array. + /// 2. `maxfeerate`: Reject transactions whose fee rate is higher than the specified value. + /// Fee rates larger than 1BTC/kvB are rejected. + /// Set to 0 to accept any fee rate. + /// If unset, will default to 0.10 BTC/kvb. + /// 3. `maxburnamount` If set, reject transactions with provably unspendable outputs (e.g. 'datacarrier' outputs that use the OP_RETURN opcode) greater than the specified value. + /// If burning funds through unspendable outputs is desired, increase this value. + /// This check is based on heuristics and does not guarantee spendability of outputs. + pub fn submit_package( + &self, + package: &[bitcoin::Transaction], + max_fee_rate: Option, + max_burn_amount: Option, + ) -> Result { + let package_txs = package + .into_iter() + .map(|tx| bitcoin::consensus::encode::serialize_hex(tx)) + .collect::>(); + let max_fee_rate_btc_kvb = + max_fee_rate.map(|r| r.to_sat_per_vb_floor() as f64 / 100_000.0); + let max_burn_amount_btc = max_burn_amount.map(|a| a.to_btc()); + self.call( + "submitpackage", + &[package_txs.into(), max_fee_rate_btc_kvb.into(), max_burn_amount_btc.into()], + ) + } + } + }; +} diff --git a/integration_test/src/lib.rs b/integration_test/src/lib.rs index 7fca3a9..9a19fe9 100644 --- a/integration_test/src/lib.rs +++ b/integration_test/src/lib.rs @@ -3,6 +3,7 @@ pub mod v17; pub mod v19; pub mod v22; +pub mod v28; /// Requires `RPC_PORT` to be in scope. use bitcoind::BitcoinD; diff --git a/integration_test/src/v28/mod.rs b/integration_test/src/v28/mod.rs new file mode 100644 index 0000000..52143a7 --- /dev/null +++ b/integration_test/src/v28/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Macros for implementing test methods on a JSON-RPC client for `bitcoind v28.0`. + +pub mod raw_transactions; diff --git a/integration_test/src/v28/raw_transactions.rs b/integration_test/src/v28/raw_transactions.rs new file mode 100644 index 0000000..b0af281 --- /dev/null +++ b/integration_test/src/v28/raw_transactions.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Macros for implementing test methods on a JSON-RPC client. +//! +//! Specifically this is methods found under the `== Rawtransactions ==` section of the +//! API docs of `bitcoind v28.0`. + +/// Requires `Client` to be in scope +#[macro_export] +macro_rules! impl_test_v28__submitpackage { + () => { + #[test] + fn submitpackage() { + //let bitcoind = $crate::bitcoind_no_wallet(); + + let bitcoind = $crate::bitcoind_with_default_wallet(); + + // Submitting the empty package should simply fail. + assert!(bitcoind.client.submit_package(&[], None, None).is_err()); + + // Premine to get some funds + let address = bitcoind.client.new_address().expect("failed to get new address"); + let json = + bitcoind.client.generate_to_address(101, &address).expect("generatetoaddress"); + json.into_model().unwrap(); + + // Send to ourselves, mine, send again to generate two transactions. + let (tx_0, tx_1) = { + let new_address = bitcoind.client.new_address().expect("failed to get new address"); + let txid = bitcoind + .client + .send_to_address(&new_address, bitcoin::Amount::from_sat(1000000)) + .unwrap() + .into_model() + .unwrap() + .txid; + + let _ = + bitcoind.client.generate_to_address(1, &address).expect("generatetoaddress"); + + let best_block_hash = bitcoind.client.best_block_hash().unwrap(); + let best_block = bitcoind.client.get_block(best_block_hash).unwrap(); + let tx_0 = best_block.txdata[1].clone(); + + let new_address = bitcoind.client.new_address().expect("failed to get new address"); + let txid = bitcoind + .client + .send_to_address(&new_address, bitcoin::Amount::from_sat(1000000)) + .unwrap() + .into_model() + .unwrap() + .txid; + + let _ = + bitcoind.client.generate_to_address(1, &address).expect("generatetoaddress"); + + let best_block_hash = bitcoind.client.best_block_hash().unwrap(); + let best_block = bitcoind.client.get_block(best_block_hash).unwrap(); + let tx_1 = best_block.txdata[1].clone(); + (tx_0, tx_1) + }; + + // The call for submitting this package should succeed, but yield an 'already known' + // error for all transactions. + let res = bitcoind + .client + .submit_package(&[tx_0, tx_1], None, None) + .expect("failed to submit package") + .into_model() + .expect("failed to submit package"); + for (_, tx_result) in &res.tx_results { + assert!(tx_result.error.is_some()); + } + assert!(res.replaced_transactions.is_empty()); + } + }; +} diff --git a/integration_test/tests/v28_api.rs b/integration_test/tests/v28_api.rs index 40a9f75..5bbfc0a 100644 --- a/integration_test/tests/v28_api.rs +++ b/integration_test/tests/v28_api.rs @@ -40,6 +40,7 @@ mod raw_transactions { use super::*; impl_test_v17__sendrawtransaction!(); + impl_test_v28__submitpackage!(); } // == Wallet == diff --git a/json/src/model/mod.rs b/json/src/model/mod.rs index adb90cc..f3fc086 100644 --- a/json/src/model/mod.rs +++ b/json/src/model/mod.rs @@ -34,7 +34,10 @@ pub use self::{ }, generating::{Generate, GenerateToAddress}, network::{GetNetworkInfo, GetNetworkInfoAddress, GetNetworkInfoNetwork}, - raw_transactions::SendRawTransaction, + raw_transactions::{ + SendRawTransaction, SubmitPackage, SubmitPackageError, SubmitPackageTxResult, + SubmitPackageTxResultFees, + }, wallet::{ CreateWallet, GetBalance, GetBalances, GetBalancesMine, GetBalancesWatchOnly, GetNewAddress, GetTransaction, GetTransactionDetail, GetTransactionDetailCategory, diff --git a/json/src/model/raw_transactions.rs b/json/src/model/raw_transactions.rs index a236ce1..8137a29 100644 --- a/json/src/model/raw_transactions.rs +++ b/json/src/model/raw_transactions.rs @@ -5,9 +5,68 @@ //! These structs model the types returned by the JSON-RPC API but have concrete types //! and are not specific to a specific version of Bitcoin Core. -use bitcoin::Txid; -use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use bitcoin::amount::ParseAmountError; +use bitcoin::hex::HexToArrayError; +use bitcoin::{Amount, FeeRate, Txid, Wtxid}; /// Models the result of JSON-RPC method `sendrawtransaction`. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq)] pub struct SendRawTransaction(pub Txid); + +#[derive(Clone, Debug, PartialEq)] +pub struct SubmitPackage { + /// 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`]. + pub tx_results: HashMap, + /// List of txids of replaced transactions. + pub replaced_transactions: Vec, +} + +/// Models the per-transaction result included in the JSON-RPC method `submitpackage`. +#[derive(Clone, Debug, PartialEq)] +pub struct SubmitPackageTxResult { + /// 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. + 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, +} + +/// Models the fees included in the per-transaction result of the JSON-RPC method `submitpackage`. +#[derive(Clone, Debug, PartialEq)] +pub struct SubmitPackageTxResultFees { + /// Transaction fee. + pub base_fee: 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. + pub effective_feerate: Option, + /// If [`Self::effective_feerate`] is provided, this holds the [`Wtxid`]s of the transactions + /// whose fees and vsizes are included in effective-feerate. + pub effective_includes: Vec, +} + +/// Error when converting a `SubmitPackageTxResultFees` type into the model type. +#[derive(Debug)] +pub enum SubmitPackageError { + /// Conversion of a `Txid` failed. + Txid(HexToArrayError), + /// Conversion of a `Wtxid` failed. + Wtxid(HexToArrayError), + /// Conversion of the `base_fee` field failed. + BaseFee(ParseAmountError), + /// Conversion of the `vsize` field failed. + Vsize, +} diff --git a/json/src/v28/mod.rs b/json/src/v28/mod.rs index 10101a6..9505164 100644 --- a/json/src/v28/mod.rs +++ b/json/src/v28/mod.rs @@ -90,7 +90,7 @@ //! - [ ] `joinpsbts ["psbt",...]` //! - [ ] `sendrawtransaction "hexstring" ( maxfeerate maxburnamount )` //! - [ ] `signrawtransactionwithkey "hexstring" ["privatekey",...] ( [{"txid":"hex","vout":n,"scriptPubKey":"hex","redeemScript":"hex","witnessScript":"hex","amount":amount},...] "sighashtype" )` -//! - [ ] `submitpackage ["rawtx",...] ( maxfeerate maxburnamount )` +//! - [x] `submitpackage ["rawtx",...] ( maxfeerate maxburnamount )` //! - [ ] `testmempoolaccept ["rawtx",...] ( maxfeerate )` //! - [ ] `utxoupdatepsbt "psbt" ( ["",{"desc":"str","range":n or [n,n]},...] )` //! @@ -182,12 +182,15 @@ mod blockchain; mod network; +mod raw_transactions; #[doc(inline)] pub use self::blockchain::GetBlockchainInfo; #[doc(inline)] pub use self::network::GetNetworkInfo; #[doc(inline)] +pub use self::raw_transactions::{SubmitPackage, SubmitPackageTxResult, SubmitPackageTxResultFees}; +#[doc(inline)] pub use crate::{ v17::{ GenerateToAddress, GetBalance, GetBestBlockHash, GetBlockVerbosityOne, diff --git a/json/src/v28/raw_transactions.rs b/json/src/v28/raw_transactions.rs new file mode 100644 index 0000000..7a804a1 --- /dev/null +++ b/json/src/v28/raw_transactions.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! The JSON-RPC API for Bitcoin Core v28.0 - raw transactions. +//! +//! Types for methods found under the `== Rawtransactions ==` section of the API docs. + +use std::collections::HashMap; +use std::str::FromStr; + +use bitcoin::{Amount, FeeRate, Txid, Wtxid}; +use serde::{Deserialize, Serialize}; + +use crate::model::{self, SubmitPackageError}; + +/// Result of JSON-RPC method `submitpackage`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SubmitPackage { + /// 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: Vec, +} + +impl SubmitPackage { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model(self) -> Result { + let mut tx_results = HashMap::with_capacity(self.tx_results.len()); + for (k, v) in self.tx_results { + let wtxid = Wtxid::from_str(&k).map_err(model::SubmitPackageError::Wtxid)?; + let result = v.into_model()?; + tx_results.insert(wtxid, result); + } + + let mut replaced_transactions = Vec::with_capacity(self.replaced_transactions.len()); + for t in self.replaced_transactions { + let txid = Txid::from_str(&t).map_err(model::SubmitPackageError::Txid)?; + replaced_transactions.push(txid); + } + + Ok(model::SubmitPackage { + package_msg: self.package_msg, + tx_results, + replaced_transactions, + }) + } +} + +/// Models the per-transaction result included in the JSON-RPC method `submitpackage`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SubmitPackageTxResult { + /// 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, +} + +impl SubmitPackageTxResult { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model(self) -> Result { + let other_wtxid = if let Some(other_wtxid) = self.other_wtxid { + Some(Wtxid::from_str(&other_wtxid).map_err(SubmitPackageError::Wtxid)?) + } else { + None + }; + + let vsize = if let Some(vsize) = self.vsize { + Some(vsize.try_into().map_err(|_| SubmitPackageError::Vsize)?) + } else { + None + }; + let fees = if let Some(fees) = self.fees { Some(fees.into_model()?) } else { None }; + Ok(model::SubmitPackageTxResult { + txid: self.txid, + other_wtxid, + vsize, + fees, + error: self.error, + }) + } +} + +/// Models the fees included in the per-transaction result of the JSON-RPC method `submitpackage`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SubmitPackageTxResultFees { + /// Transaction fee. + #[serde(rename = "base")] + pub base_fee: f64, + /// 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_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: Vec, +} + +impl SubmitPackageTxResultFees { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model(self) -> Result { + let mut effective_includes = Vec::with_capacity(self.effective_includes.len()); + for include in self.effective_includes { + let wtxid = Wtxid::from_str(&include).map_err(SubmitPackageError::Wtxid)?; + effective_includes.push(wtxid); + } + + let effective_feerate = self.effective_feerate.map(|rate_btc_kvb| { + // Bitcoin Core gives us a feerate in BTC/KvB. + // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. + let fee_rate_sat_per_kwu = (rate_btc_kvb * 25_000_000.0).round() as u64; + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) + }); + + Ok(model::SubmitPackageTxResultFees { + base_fee: Amount::from_btc(self.base_fee) + .map_err(model::SubmitPackageError::BaseFee)?, + effective_feerate, + effective_includes, + }) + } +}