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 b2db2dc6..b85548e5 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. +pub mod raw_transactions; + use bitcoin::address::{Address, NetworkChecked}; use bitcoin::{Amount, Block, BlockHash, Txid}; @@ -33,6 +35,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 00000000..72ddb558 --- /dev/null +++ b/client/src/client_sync/v28/raw_transactions.rs @@ -0,0 +1,37 @@ +// 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 Bitcoin Core JSON-RPC API method `submitpackage` +#[macro_export] +macro_rules! impl_client_v28__submitpackage { + () => { + impl Client { + 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 4e6bfa0c..bfca4b34 100644 --- a/integration_test/src/lib.rs +++ b/integration_test/src/lib.rs @@ -47,6 +47,18 @@ pub trait NodeExt { /// Generates [`NBLOCKS`] to an address controlled by the loaded wallet. fn fund_wallet(&self); + + /// Mine a block. + /// + /// Should send mining reward to a new address for the loaded wallet. + fn mine_a_block(&self); + + /// Create a transaction and mine it. + /// + /// # Returns + /// + /// The receive address and the transaction. + fn create_mined_transaction(&self) -> (bitcoin::Address, bitcoin::Transaction); } impl NodeExt for Node { @@ -56,15 +68,35 @@ impl NodeExt for Node { if let Some(wallet) = wallet { conf.wallet = Some(wallet); } - + Node::with_conf(exe, &conf).expect("failed to create node") } fn fund_wallet(&self) { - // TODO: Consider returning the error. let address = self.client.new_address().expect("failed to get new address"); self.client.generate_to_address(NBLOCKS, &address).expect("failed to generate to address"); } + + fn create_mined_transaction(&self) -> (bitcoin::Address, bitcoin::Transaction) { + const MILLION_SATS: bitcoin::Amount = bitcoin::Amount::from_sat(1000000); + + let address = self.client.new_address().expect("failed to get new address"); + + let _ = self.client.send_to_address(&address, MILLION_SATS); + self.mine_a_block(); + + let best_block_hash = self.client.best_block_hash().expect("best_block_hash"); + let best_block = self.client.get_block(best_block_hash).expect("best_block"); + let tx = best_block.txdata[1].clone(); + + (address, tx) + } + + fn mine_a_block(&self) { + // TODO: Consider returning the error. + let address = self.client.new_address().expect("failed to get new address"); + self.client.generate_to_address(1, &address).expect("failed to generate to address"); + } } /// Return a temporary file path. diff --git a/integration_test/tests/raw_transactions.rs b/integration_test/tests/raw_transactions.rs index 41e65eb3..bce7c965 100644 --- a/integration_test/tests/raw_transactions.rs +++ b/integration_test/tests/raw_transactions.rs @@ -2,7 +2,6 @@ //! Tests for methods found under the `== Rawtransactions ==` section of the API docs. -#[cfg(feature = "TODO")] use integration_test::{Node, NodeExt as _}; #[test] @@ -11,3 +10,30 @@ fn send_raw_transaction() { let _node = Node::new_no_wallet(); todo!() } + +#[test] +#[cfg(feature = "v28")] +fn submitpackage() { + let node = Node::new_with_default_wallet(); + + // Submitting the empty package should simply fail. + assert!(node.client.submit_package(&[], None, None).is_err()); + + node.fund_wallet(); + + let (_, tx_0) = node.create_mined_transaction(); + let (_, tx_1) = node.create_mined_transaction(); + + // The call for submitting this package should succeed, but yield an 'already known' + // error for all transactions. + let res = node + .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/types/src/model/mod.rs b/types/src/model/mod.rs index 3271baa5..6b3e63e0 100644 --- a/types/src/model/mod.rs +++ b/types/src/model/mod.rs @@ -32,7 +32,9 @@ pub use self::{ }, generating::{Generate, GenerateToAddress}, network::{GetNetworkInfo, GetNetworkInfoAddress, GetNetworkInfoNetwork}, - raw_transactions::SendRawTransaction, + raw_transactions::{ + SendRawTransaction, SubmitPackage, SubmitPackageTxResult, SubmitPackageTxResultFees, + }, wallet::{ AddMultisigAddress, AddressInformation, AddressLabel, AddressPurpose, Bip125Replaceable, BumpFee, CreateWallet, DumpPrivKey, DumpWallet, GetAddressInfo, GetAddressInfoEmbedded, diff --git a/types/src/model/raw_transactions.rs b/types/src/model/raw_transactions.rs index a236ce12..c953f97c 100644 --- a/types/src/model/raw_transactions.rs +++ b/types/src/model/raw_transactions.rs @@ -5,9 +5,54 @@ //! 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 std::collections::BTreeMap; + +use bitcoin::{Amount, FeeRate, Txid, Wtxid}; use serde::{Deserialize, Serialize}; /// Models the result of JSON-RPC method `sendrawtransaction`. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct SendRawTransaction(pub Txid); + +/// Models the 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`]. + pub tx_results: BTreeMap, + /// 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, 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. + 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, Deserialize, Serialize)] +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_fee_rate: 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. + pub effective_includes: Vec, +} diff --git a/types/src/v28/mod.rs b/types/src/v28/mod.rs index f8e1e150..88d6fa1b 100644 --- a/types/src/v28/mod.rs +++ b/types/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, GetBlockCount, GetBlockVerbosityOne, diff --git a/types/src/v28/raw_transactions/error.rs b/types/src/v28/raw_transactions/error.rs new file mode 100644 index 00000000..5d97983b --- /dev/null +++ b/types/src/v28/raw_transactions/error.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: CC0-1.0 + +use core::fmt; + +use bitcoin::amount::ParseAmountError; +use bitcoin::hex::HexToArrayError; +use internals::write_err; + +use crate::NumericError; + +/// Error when converting a `SubmitPackage` type into the model type. +#[derive(Debug)] +pub enum SubmitPackageError { + /// Conversion of key from `tx_results` map failed. + TxResultKey(HexToArrayError), + /// Conversion of value from `tx_results` map failed. + TxResultValue(SubmitPackageTxResultError), + /// Conversion of a list item from `replaced_transactions` field failed. + ReplaceTransactions(HexToArrayError), +} + +impl fmt::Display for SubmitPackageError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use SubmitPackageError as E; + + match *self { + E::TxResultKey(ref e) => + write_err!(f, "conversion of key from `tx_results` map failed"; e), + E::TxResultValue(ref e) => + write_err!(f, "conversion of value from `tx_results` map failed"; e), + E::ReplaceTransactions(ref e) => + write_err!(f, "conversion of a list item from `replaced_transactions` field failed"; e), + } + } +} + +impl std::error::Error for SubmitPackageError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use SubmitPackageError as E; + + match *self { + E::TxResultKey(ref e) => Some(e), + E::TxResultValue(ref e) => Some(e), + E::ReplaceTransactions(ref e) => Some(e), + } + } +} + +/// Error when converting a `SubmitPackageTxResult` type into the model type. +#[derive(Debug)] +pub enum SubmitPackageTxResultError { + /// Conversion of numeric type to expected type failed. + Numeric(NumericError), + /// Conversion of the `txid` field failed. + Txid(HexToArrayError), + /// Conversion of the `other_wtxid` field failed. + OtherWtxid(HexToArrayError), + /// Conversion of the `fees` field failed. + Fees(SubmitPackageTxResultFeesError), +} + +impl fmt::Display for SubmitPackageTxResultError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use SubmitPackageTxResultError as E; + + match *self { + E::Numeric(ref e) => write_err!(f, "numeric"; e), + E::Txid(ref e) => write_err!(f, "conversion of the `txid` field failed"; e), + E::OtherWtxid(ref e) => + write_err!(f, "conversion of the `other_wtxid` field failed"; e), + E::Fees(ref e) => write_err!(f, "conversion of the `fees` field failed"; e), + } + } +} + +impl std::error::Error for SubmitPackageTxResultError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use SubmitPackageTxResultError as E; + + match *self { + E::Numeric(ref e) => Some(e), + E::Txid(ref e) => Some(e), + E::OtherWtxid(ref e) => Some(e), + E::Fees(ref e) => Some(e), + } + } +} + +impl From for SubmitPackageTxResultError { + fn from(e: NumericError) -> Self { Self::Numeric(e) } +} + +/// Error when converting a `SubmitPackageTxResultFees` type into the model type. +#[derive(Debug)] +pub enum SubmitPackageTxResultFeesError { + /// Conversion of the `base_fee` field failed. + BaseFee(ParseAmountError), + /// Conversion of the `effective_fee_rate` field failed. + EffectiveFeeRate(ParseAmountError), + /// Conversion of a list item from `effective_includes` field failed. + EffectiveIncludes(HexToArrayError), +} + +impl fmt::Display for SubmitPackageTxResultFeesError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use SubmitPackageTxResultFeesError as E; + + match *self { + E::BaseFee(ref e) => write_err!(f, "conversion of the `base_fee` field failed"; e), + E::EffectiveFeeRate(ref e) => + write_err!(f, "conversion of the `effective_fee_rate` field failed"; e), + E::EffectiveIncludes(ref e) => write_err!(f, "effective_includes"; e), + } + } +} + +impl std::error::Error for SubmitPackageTxResultFeesError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use SubmitPackageTxResultFeesError as E; + + match *self { + E::BaseFee(ref e) => Some(e), + E::EffectiveFeeRate(ref e) => Some(e), + E::EffectiveIncludes(ref e) => Some(e), + } + } +} diff --git a/types/src/v28/raw_transactions/into.rs b/types/src/v28/raw_transactions/into.rs new file mode 100644 index 00000000..eee80c08 --- /dev/null +++ b/types/src/v28/raw_transactions/into.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: CC0-1.0 + +use bitcoin::{Amount, Txid, Wtxid}; + +// TODO: Use explicit imports? +use super::*; + +impl SubmitPackage { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model(self) -> Result { + use SubmitPackageError as E; + + let mut tx_results = BTreeMap::new(); + for (k, v) in self.tx_results { + let wtxid = k.parse::().map_err(E::TxResultKey)?; + let result = v.into_model().map_err(E::TxResultValue)?; + tx_results.insert(wtxid, result); + } + + let replaced_transactions = self + .replaced_transactions + .iter() + .map(|tx| tx.parse::().map_err(E::ReplaceTransactions)) + .collect::, _>>()?; + + Ok(model::SubmitPackage { + package_msg: self.package_msg, + tx_results, + replaced_transactions, + }) + } +} + +impl SubmitPackageTxResult { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model(self) -> Result { + use SubmitPackageTxResultError as E; + + let txid = self.txid.parse::().map_err(E::Txid)?; + let other_wtxid = + self.other_wtxid.map(|s| s.parse::().map_err(E::OtherWtxid)).transpose()?; + let vsize = self.vsize.map(|vsize| crate::to_u32(vsize, "vsize")).transpose()?; + let fees = self.fees.map(|fees| fees.into_model().map_err(E::Fees)).transpose()?; + + Ok(model::SubmitPackageTxResult { txid, other_wtxid, vsize, fees, error: self.error }) + } +} + +impl SubmitPackageTxResultFees { + /// Converts version specific type to a version in-specific, more strongly typed type. + pub fn into_model( + self, + ) -> Result { + use SubmitPackageTxResultFeesError as E; + + let base_fee = Amount::from_btc(self.base_fee).map_err(E::BaseFee)?; + let effective_fee_rate = self + .effective_fee_rate + .map(|f| crate::btc_per_kb(f).map_err(E::EffectiveFeeRate)) + .transpose()? + .flatten(); + let effective_includes = self + .effective_includes + .iter() + .map(|s| s.parse::().map_err(E::EffectiveIncludes)) + .collect::, _>>()?; + + Ok(model::SubmitPackageTxResultFees { base_fee, effective_fee_rate, effective_includes }) + } +} diff --git a/types/src/v28/raw_transactions/mod.rs b/types/src/v28/raw_transactions/mod.rs new file mode 100644 index 00000000..fba217f0 --- /dev/null +++ b/types/src/v28/raw_transactions/mod.rs @@ -0,0 +1,83 @@ +// 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. + +mod error; +mod into; + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +// TODO: Remove wildcard, use explicit types. +pub use self::error::*; +use crate::model; + +/// Result of JSON-RPC method `submitpackage`. +/// +/// > submitpackage ["rawtx",...] ( maxfeerate maxburnamount ) +/// > +/// > Submit a package of raw transactions (serialized, hex-encoded) 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. +/// > This RPC is experimental and the interface may be unstable. Refer to doc/policy/packages.md for documentation on package policies. +/// > Warning: successful submission does not mean the transactions will propagate throughout the network. +/// > +/// > Arguments: +/// > 1. package (json array, required) 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. +/// > [ +/// > "rawtx", (string) +/// > ... +/// > ] +#[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: BTreeMap, + /// List of txids of replaced transactions. + #[serde(rename = "replaced-transactions")] + pub replaced_transactions: Vec, +} + +/// 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: String, + /// 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, +} + +/// 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_fee_rate: 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: Vec, +}