commands: add rbfpsbt command
This commit is contained in:
parent
d5f3167404
commit
5391bfe04c
38
doc/API.md
38
doc/API.md
@ -17,6 +17,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions |
|
||||
| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction |
|
||||
| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it |
|
||||
| [`rbfpsbt`](#rbfpsbt) | Create a new RBF Spend transaction |
|
||||
| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date |
|
||||
| [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds |
|
||||
| [`listtransactions`](#listtransactions) | List of transactions with the given txids |
|
||||
@ -257,6 +258,43 @@ This command does not return anything for now.
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
|
||||
### `rbfpsbt`
|
||||
|
||||
Create PSBT to replace the given transaction, which must point to a PSBT in our database, using RBF.
|
||||
|
||||
This command can be used to either:
|
||||
- "cancel" the transaction: the replacement will include at least one input from the previous transaction and will have only
|
||||
a single output (change).
|
||||
- bump the fee: the replacement will include all inputs from the previous transaction and all non-change outputs
|
||||
will be kept the same, with only the change amount being modified as required.
|
||||
|
||||
In both cases, the replacement transaction may include additional confirmed coins as inputs if required
|
||||
in order to pay the higher fee (this applies also when replacing a self-send).
|
||||
|
||||
If the transaction includes a change output to one of our own change addresses,
|
||||
this same address will be used for change in the replacement transaction, if required.
|
||||
|
||||
If the transaction pays to more than one of our change addresses, then the one receiving the highest value
|
||||
will be used as a change address in the replacement and the others will be treated as non-change outputs
|
||||
(i.e. removed for cancel or otherwise kept the same).
|
||||
|
||||
If `feerate` is not passed to the command, the target feerate of the replacement will be set to the minimum value
|
||||
allowed in order to replace this transaction using RBF (see https://github.com/bitcoin/bitcoin/blob/master/doc/policy/mempool-replacements.md#current-replace-by-fee-policy for further details about this and other conditions that must be satisfied when using RBF).
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ----------------- | --------------------------------------------------------------- |
|
||||
| `txid` | string | Hex encoded txid of the Spend transaction to be replaced. |
|
||||
| `is_cancel` | bool | Whether to "cancel" the transaction or simply bump the fee. |
|
||||
| `feerate` | integer(optional) | Target feerate for the RBF transaction (in sat/vb). |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
| `psbt` | string | PSBT of the spending transaction, encoded as base64. |
|
||||
|
||||
### `startrescan`
|
||||
|
||||
#### Request
|
||||
|
||||
@ -15,7 +15,7 @@ pub use crate::database::{CoinStatus, LabelItem};
|
||||
use bdk_coin_select::InsufficientFunds;
|
||||
use utils::{
|
||||
deser_addr_assume_checked, deser_amount_from_sats, deser_fromstr, deser_hex,
|
||||
select_coins_for_spend, ser_amount, ser_hex, ser_to_string,
|
||||
select_coins_for_spend, ser_amount, ser_hex, ser_to_string, unsigned_tx_max_vbytes,
|
||||
};
|
||||
|
||||
use std::{
|
||||
@ -79,6 +79,7 @@ pub enum CommandError {
|
||||
/// Overflowing or unhardened derivation index.
|
||||
InvalidDerivationIndex,
|
||||
CoinSelectionError(InsufficientFunds),
|
||||
RbfError(RbfErrorInfo),
|
||||
}
|
||||
|
||||
impl fmt::Display for CommandError {
|
||||
@ -144,7 +145,8 @@ impl fmt::Display for CommandError {
|
||||
"No coin currently spendable through this timelocked recovery path."
|
||||
),
|
||||
Self::InvalidDerivationIndex => write!(f, "Unhardened or overflowing BIP32 derivation index."),
|
||||
Self::CoinSelectionError(e) => write!(f, "Coin selection error: '{}'", e),
|
||||
Self::CoinSelectionError(e) => write!(f, "Coin selection error: '{}'", e),
|
||||
Self::RbfError(e) => write!(f, "RBF error: '{}'.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -171,6 +173,29 @@ pub enum InsaneFeeInfo {
|
||||
TooHighFeerate(u64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RbfErrorInfo {
|
||||
MissingFeerate,
|
||||
SuperfluousFeerate,
|
||||
TooLowFeerate(u64),
|
||||
NotSignaling,
|
||||
}
|
||||
|
||||
impl fmt::Display for RbfErrorInfo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::MissingFeerate => {
|
||||
write!(f, "A feerate must be provided if not creating a cancel.")
|
||||
}
|
||||
Self::SuperfluousFeerate => {
|
||||
write!(f, "A feerate must not be provided if creating a cancel. We'll always use the smallest one which satisfies the RBF rules.")
|
||||
}
|
||||
Self::TooLowFeerate(r) => write!(f, "Feerate too low: {}.", r),
|
||||
Self::NotSignaling => write!(f, "Replacement candidate does not signal for RBF."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate for coin selection when creating a transaction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CandidateCoin {
|
||||
@ -816,6 +841,251 @@ impl DaemonControl {
|
||||
.map_err(CommandError::TxBroadcast)
|
||||
}
|
||||
|
||||
/// Create PSBT to replace the given transaction using RBF.
|
||||
///
|
||||
/// `txid` must point to a PSBT in our database.
|
||||
///
|
||||
/// `is_cancel` indicates whether to "cancel" the transaction by including only a single (change)
|
||||
/// output in the replacement or otherwise to keep the same (non-change) outputs and simply
|
||||
/// bump the fee.
|
||||
/// If `true`, the only output of the RBF transaction will be change and the inputs will include
|
||||
/// at least one of the inputs from the previous transaction. If `false`, all inputs from the previous
|
||||
/// transaction will be used in the replacement.
|
||||
/// In both cases:
|
||||
/// - if the previous transaction includes a change output to one of our own change addresses,
|
||||
/// this same address will be used for change in the RBF transaction, if required. If the previous
|
||||
/// transaction pays to more than one of our change addresses, then the one receiving the highest
|
||||
/// value will be used as a change address and the others will be treated as non-change outputs.
|
||||
/// - the RBF transaction may include additional confirmed coins as inputs if required
|
||||
/// in order to pay the higher fee (this applies also when replacing a self-send).
|
||||
///
|
||||
/// `feerate_vb` is the target feerate for the RBF transaction (in sat/vb). If `None`, it will be set
|
||||
/// to 1 sat/vb larger than the feerate of the previous transaction, which is the minimum value allowed
|
||||
/// when using RBF.
|
||||
pub fn rbf_psbt(
|
||||
&self,
|
||||
txid: &bitcoin::Txid,
|
||||
is_cancel: bool,
|
||||
feerate_vb: Option<u64>,
|
||||
) -> Result<CreateSpendResult, CommandError> {
|
||||
let mut db_conn = self.db.connection();
|
||||
|
||||
if is_cancel && feerate_vb.is_some() {
|
||||
return Err(CommandError::RbfError(RbfErrorInfo::SuperfluousFeerate));
|
||||
}
|
||||
|
||||
let prev_psbt = db_conn
|
||||
.spend_tx(txid)
|
||||
.ok_or(CommandError::UnknownSpend(*txid))?;
|
||||
if !prev_psbt.unsigned_tx.is_explicitly_rbf() {
|
||||
return Err(CommandError::RbfError(RbfErrorInfo::NotSignaling));
|
||||
}
|
||||
let prev_outpoints: Vec<bitcoin::OutPoint> = prev_psbt
|
||||
.unsigned_tx
|
||||
.input
|
||||
.iter()
|
||||
.map(|txin| txin.previous_output)
|
||||
.collect();
|
||||
let prev_coins = db_conn.coins_by_outpoints(&prev_outpoints);
|
||||
// Make sure all prev outpoints are coins in our DB.
|
||||
if let Some(op) = prev_outpoints
|
||||
.iter()
|
||||
.find(|op| !prev_coins.contains_key(op))
|
||||
{
|
||||
return Err(CommandError::UnknownOutpoint(*op));
|
||||
}
|
||||
if let Some(op) = prev_coins.iter().find_map(|(_, coin)| {
|
||||
if coin.spend_block.is_some() {
|
||||
Some(coin.outpoint)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
return Err(CommandError::AlreadySpent(op));
|
||||
}
|
||||
// Compute the minimal feerate and fee the replacement transaction must have to satisfy RBF
|
||||
// rules #3, #4 and #6 (see
|
||||
// https://github.com/bitcoin/bitcoin/blob/master/doc/policy/mempool-replacements.md). By
|
||||
// default (ie if the transaction we are replacing was dropped from the mempool) there is
|
||||
// no minimum absolute fee and the minimum feerate is 1, the minimum relay feerate.
|
||||
let (min_feerate_vb, descendant_fees) = self
|
||||
.bitcoin
|
||||
.mempool_spenders(&prev_outpoints)
|
||||
.into_iter()
|
||||
.fold(
|
||||
(1, bitcoin::Amount::from_sat(0)),
|
||||
|(min_feerate, descendant_fee), entry| {
|
||||
let entry_feerate = entry
|
||||
.fees
|
||||
.base
|
||||
.checked_div(entry.vsize)
|
||||
.expect("Can't have a null vsize or tx would be invalid")
|
||||
.to_sat()
|
||||
.checked_add(1)
|
||||
.expect("Can't overflow or tx would be invalid");
|
||||
(
|
||||
std::cmp::max(min_feerate, entry_feerate),
|
||||
descendant_fee + entry.fees.descendant,
|
||||
)
|
||||
},
|
||||
);
|
||||
// Check replacement transaction's target feerate, if set, is high enough,
|
||||
// and otherwise set it to the min feerate found above.
|
||||
let feerate_vb = if is_cancel {
|
||||
min_feerate_vb
|
||||
} else {
|
||||
feerate_vb.ok_or(CommandError::RbfError(RbfErrorInfo::MissingFeerate))?
|
||||
};
|
||||
if feerate_vb < min_feerate_vb {
|
||||
return Err(CommandError::RbfError(RbfErrorInfo::TooLowFeerate(
|
||||
feerate_vb,
|
||||
)));
|
||||
}
|
||||
// Get info about prev outputs to determine replacement outputs.
|
||||
let prev_derivs: Vec<_> = prev_psbt
|
||||
.unsigned_tx
|
||||
.output
|
||||
.iter()
|
||||
.map(|txo| {
|
||||
let address = bitcoin::Address::from_script(
|
||||
&txo.script_pubkey,
|
||||
self.config.bitcoin_config.network,
|
||||
)
|
||||
.expect("address already used in finalized transaction");
|
||||
(
|
||||
address.clone(),
|
||||
bitcoin::Amount::from_sat(txo.value),
|
||||
db_conn.derivation_index_by_address(&address),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Set the previous change address to that of the change output with the largest value
|
||||
// and then largest index.
|
||||
let prev_change_address = prev_derivs
|
||||
.iter()
|
||||
.filter_map(|(addr, amt, deriv)| {
|
||||
if let Some((ind, true)) = &deriv {
|
||||
Some((addr, amt, ind))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.max_by(|(_, amt_1, ind_1), (_, amt_2, ind_2)| amt_1.cmp(amt_2).then(ind_1.cmp(ind_2)))
|
||||
.map(|(addr, _, _)| addr)
|
||||
.cloned();
|
||||
// If not cancel, use all previous outputs as destinations, except for
|
||||
// the output corresponding to the change address we found above.
|
||||
// If cancel, the replacement will not have any destinations, only a change output.
|
||||
let destinations = if !is_cancel {
|
||||
prev_derivs
|
||||
.into_iter()
|
||||
.filter_map(|(addr, amt, _)| {
|
||||
if prev_change_address.as_ref() != Some(&addr) {
|
||||
Some((addr, amt))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// If there was no previous change address, we set the change address for the replacement
|
||||
// to our next change address. This way, we won't increment the change index with each attempt
|
||||
// at creating the replacement PSBT below.
|
||||
let change_address = prev_change_address.unwrap_or_else(|| {
|
||||
let index = db_conn.change_index();
|
||||
let desc = self
|
||||
.config
|
||||
.main_descriptor
|
||||
.change_descriptor()
|
||||
.derive(index, &self.secp);
|
||||
desc.address(self.config.bitcoin_config.network)
|
||||
});
|
||||
// If `!is_cancel`, we take the previous coins as mandatory candidates and add confirmed coins as optional.
|
||||
// Otherwise, we take the previous coins as optional candidates and let coin selection find the
|
||||
// best solution that includes at least one of these. If there are insufficient funds to create the replacement
|
||||
// transaction in this way, then we set candidates in the same way as for the `!is_cancel` case.
|
||||
let mut candidate_coins: Vec<CandidateCoin> = prev_coins
|
||||
.values()
|
||||
.map(|c| CandidateCoin {
|
||||
coin: *c,
|
||||
must_select: !is_cancel,
|
||||
})
|
||||
.collect();
|
||||
let confirmed_cands: Vec<CandidateCoin> = db_conn
|
||||
.coins(&[CoinStatus::Confirmed], &[])
|
||||
.into_values()
|
||||
.filter_map(|c| {
|
||||
// Make sure we don't have duplicate candidates in case any of the coins are not
|
||||
// currently set as spending in the DB (and are therefore still confirmed).
|
||||
if !prev_coins.contains_key(&c.outpoint) {
|
||||
Some(CandidateCoin {
|
||||
coin: c,
|
||||
must_select: false,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !is_cancel {
|
||||
candidate_coins.extend(&confirmed_cands);
|
||||
}
|
||||
let max_sat_weight: u64 = self
|
||||
.config
|
||||
.main_descriptor
|
||||
.max_sat_weight()
|
||||
.try_into()
|
||||
.expect("it must fit");
|
||||
// Try with increasing fee until fee paid by replacement transaction is high enough.
|
||||
// Replacement fee must be at least:
|
||||
// sum of fees paid by original transactions + incremental feerate * replacement size.
|
||||
// Loop will continue until either we find a suitable replacement or we have insufficient funds.
|
||||
let mut replacement_vsize = 0;
|
||||
for incremental_feerate in 0.. {
|
||||
let min_fee = descendant_fees.to_sat() + replacement_vsize * incremental_feerate;
|
||||
let rbf_psbt = match self.create_spend_internal(
|
||||
&destinations,
|
||||
&candidate_coins,
|
||||
feerate_vb,
|
||||
min_fee,
|
||||
Some(change_address.clone()),
|
||||
) {
|
||||
Ok(psbt) => psbt,
|
||||
// If we get a coin selection error due to insufficient funds and we want to cancel the
|
||||
// transaction, then set all previous coins as mandatory and add confirmed coins as
|
||||
// optional, unless we have already done this.
|
||||
Err(CommandError::CoinSelectionError(_))
|
||||
if is_cancel && candidate_coins.iter().all(|c| !c.must_select) =>
|
||||
{
|
||||
for cand in candidate_coins.iter_mut() {
|
||||
cand.must_select = true;
|
||||
}
|
||||
candidate_coins.extend(&confirmed_cands);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
replacement_vsize = unsigned_tx_max_vbytes(&rbf_psbt.psbt.unsigned_tx, max_sat_weight);
|
||||
|
||||
// Make sure it satisfies RBF rule 4.
|
||||
if rbf_psbt
|
||||
.psbt
|
||||
.fee()
|
||||
.expect("has already been sanity checked")
|
||||
>= descendant_fees + bitcoin::Amount::from_sat(replacement_vsize)
|
||||
{
|
||||
return Ok(rbf_psbt);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("We keep increasing the min fee until we run out of funds or satisfy rule 4.")
|
||||
}
|
||||
|
||||
/// Trigger a rescan of the block chain for transactions involving our main descriptor between
|
||||
/// the given date and the current tip.
|
||||
/// The date must be after the genesis block time and before the current tip blocktime.
|
||||
@ -1744,6 +2014,81 @@ mod tests {
|
||||
ms.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rbf_psbt() {
|
||||
let dummy_op_a = bitcoin::OutPoint::from_str(
|
||||
"3753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:0",
|
||||
)
|
||||
.unwrap();
|
||||
let mut dummy_bitcoind = DummyBitcoind::new();
|
||||
// Transaction spends outpoint a.
|
||||
let dummy_tx_a = bitcoin::Transaction {
|
||||
version: 2,
|
||||
lock_time: absolute::LockTime::Blocks(absolute::Height::ZERO),
|
||||
input: vec![bitcoin::TxIn {
|
||||
previous_output: dummy_op_a,
|
||||
sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME,
|
||||
..bitcoin::TxIn::default()
|
||||
}],
|
||||
output: vec![],
|
||||
};
|
||||
// PSBT corresponding to the above transaction.
|
||||
let dummy_psbt_a = Psbt {
|
||||
unsigned_tx: dummy_tx_a.clone(),
|
||||
version: 0,
|
||||
xpub: BTreeMap::new(),
|
||||
proprietary: BTreeMap::new(),
|
||||
unknown: BTreeMap::new(),
|
||||
inputs: vec![],
|
||||
outputs: vec![],
|
||||
};
|
||||
let dummy_txid_a = dummy_psbt_a.unsigned_tx.txid();
|
||||
dummy_bitcoind.txs.insert(dummy_txid_a, (dummy_tx_a, None));
|
||||
let ms = DummyLiana::new(dummy_bitcoind, DummyDatabase::new());
|
||||
let control = &ms.handle.control;
|
||||
let mut db_conn = control.db().lock().unwrap().connection();
|
||||
// The spend needs to be in DB before using RBF.
|
||||
assert_eq!(
|
||||
control.rbf_psbt(&dummy_txid_a, true, None),
|
||||
Err(CommandError::UnknownSpend(dummy_txid_a))
|
||||
);
|
||||
// Store the spend.
|
||||
db_conn.store_spend(&dummy_psbt_a);
|
||||
// Now add the coin to DB, but as spent.
|
||||
db_conn.new_unspent_coins(&[Coin {
|
||||
outpoint: dummy_op_a,
|
||||
is_immature: false,
|
||||
block_info: Some(BlockInfo {
|
||||
height: 174500,
|
||||
time: 174500,
|
||||
}),
|
||||
amount: bitcoin::Amount::from_sat(300_000),
|
||||
derivation_index: bip32::ChildNumber::from(11),
|
||||
is_change: false,
|
||||
spend_txid: Some(dummy_txid_a),
|
||||
spend_block: Some(BlockInfo {
|
||||
height: 184500,
|
||||
time: 184500,
|
||||
}),
|
||||
}]);
|
||||
// The coin is spent so we cannot RBF.
|
||||
assert_eq!(
|
||||
control.rbf_psbt(&dummy_txid_a, true, None),
|
||||
Err(CommandError::AlreadySpent(dummy_op_a))
|
||||
);
|
||||
db_conn.unspend_coins(&[dummy_op_a]);
|
||||
// Now remove the coin.
|
||||
db_conn.remove_coins(&[dummy_op_a]);
|
||||
assert_eq!(
|
||||
control.rbf_psbt(&dummy_txid_a, true, None),
|
||||
Err(CommandError::UnknownOutpoint(dummy_op_a))
|
||||
);
|
||||
// A target feerate not higher than the previous should return an error. This is tested in
|
||||
// the functional tests.
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_confirmed_transactions() {
|
||||
let outpoint = OutPoint::new(
|
||||
|
||||
@ -5,7 +5,7 @@ use bdk_coin_select::{
|
||||
use log::warn;
|
||||
use std::{convert::TryInto, str::FromStr};
|
||||
|
||||
use miniscript::bitcoin::{self, consensus, hashes::hex::FromHex};
|
||||
use miniscript::bitcoin::{self, consensus, constants::WITNESS_SCALE_FACTOR, hashes::hex::FromHex};
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
use crate::database::Coin;
|
||||
@ -244,3 +244,26 @@ pub fn select_coins_for_spend(
|
||||
change_amount,
|
||||
))
|
||||
}
|
||||
|
||||
/// An unsigned transaction's maximum possible size in vbytes after satisfaction.
|
||||
///
|
||||
/// This assumes all inputs are internal (or have the same `max_sat_weight` value).
|
||||
///
|
||||
/// `tx` is the unsigned transaction.
|
||||
///
|
||||
/// `max_sat_weight` is the maximum weight difference of an input in the
|
||||
/// transaction before and after satisfaction. Must be in weight units.
|
||||
pub fn unsigned_tx_max_vbytes(tx: &bitcoin::Transaction, max_sat_weight: u64) -> u64 {
|
||||
let witness_factor: u64 = WITNESS_SCALE_FACTOR.try_into().unwrap();
|
||||
let num_inputs: u64 = tx.input.len().try_into().unwrap();
|
||||
let tx_wu: u64 = tx
|
||||
.weight()
|
||||
.to_wu()
|
||||
.checked_add(max_sat_weight.checked_mul(num_inputs).unwrap())
|
||||
.unwrap();
|
||||
tx_wu
|
||||
.checked_add(witness_factor.checked_sub(1).unwrap())
|
||||
.unwrap()
|
||||
.checked_div(witness_factor)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@ -98,6 +98,31 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result<serde_json
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
fn rbf_psbt(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let txid = params
|
||||
.get(0, "txid")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?
|
||||
.as_str()
|
||||
.and_then(|s| bitcoin::Txid::from_str(s).ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?;
|
||||
let is_cancel: bool = params
|
||||
.get(1, "is_cancel")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'is_cancel' parameter."))?
|
||||
.as_bool()
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'is_cancel' parameter."))?;
|
||||
let feerate_vb: Option<u64> = if let Some(feerate) = params.get(2, "feerate") {
|
||||
Some(
|
||||
feerate
|
||||
.as_u64()
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let res = control.rbf_psbt(&txid, is_cancel, feerate_vb)?;
|
||||
Ok(serde_json::json!(&res))
|
||||
}
|
||||
|
||||
fn list_coins(control: &DaemonControl, params: Option<Params>) -> Result<serde_json::Value, Error> {
|
||||
let statuses_arg = params
|
||||
.as_ref()
|
||||
@ -342,6 +367,12 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?;
|
||||
delete_spend(control, params)?
|
||||
}
|
||||
"rbfpsbt" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params("Missing 'txid', 'feerate' and 'is_cancel' parameters.")
|
||||
})?;
|
||||
rbf_psbt(control, params)?
|
||||
}
|
||||
"getinfo" => serde_json::json!(&control.get_info()),
|
||||
"getnewaddress" => serde_json::json!(&control.get_new_address()),
|
||||
"listcoins" => {
|
||||
|
||||
@ -165,6 +165,7 @@ impl From<commands::CommandError> for Error {
|
||||
| commands::CommandError::InsaneRescanTimestamp(..)
|
||||
| commands::CommandError::AlreadyRescanning
|
||||
| commands::CommandError::InvalidDerivationIndex
|
||||
| commands::CommandError::RbfError(..)
|
||||
| commands::CommandError::RecoveryNotAvailable => {
|
||||
Error::new(ErrorCode::InvalidParams, e.to_string())
|
||||
}
|
||||
|
||||
@ -997,3 +997,304 @@ def test_labels(lianad, bitcoind):
|
||||
assert addr not in res
|
||||
assert sec_addr not in res
|
||||
assert res[random_address] == "this address is random"
|
||||
|
||||
|
||||
def test_rbfpsbt_bump_fee(lianad, bitcoind):
|
||||
"""Test the use of RBF to bump the fee of a transaction."""
|
||||
|
||||
# Get three coins.
|
||||
destinations = {
|
||||
lianad.rpc.getnewaddress()["address"]: 0.003,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.004,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.005,
|
||||
}
|
||||
txid = bitcoind.rpc.sendmany("", destinations)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 3)
|
||||
coins = lianad.rpc.listcoins(["confirmed"])["coins"]
|
||||
|
||||
# Create a spend that will later be replaced.
|
||||
first_outpoints = [c["outpoint"] for c in coins[:2]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 650_000,
|
||||
}
|
||||
first_res = lianad.rpc.createspend(destinations, first_outpoints, 1)
|
||||
first_psbt = PSBT.from_base64(first_res["psbt"])
|
||||
# The transaction has a change output.
|
||||
assert len(first_psbt.o) == len(first_psbt.tx.vout) == 2
|
||||
first_txid = first_psbt.tx.txid().hex()
|
||||
# We must provide a valid feerate.
|
||||
for bad_feerate in [-1, "foo", 18_446_744_073_709_551_616]:
|
||||
with pytest.raises(RpcError, match=f"Invalid 'feerate' parameter."):
|
||||
lianad.rpc.rbfpsbt(first_txid, False, bad_feerate)
|
||||
# We cannot RBF yet as first PSBT has not been saved.
|
||||
with pytest.raises(RpcError, match=f"Unknown spend transaction '{first_txid}'."):
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 1)
|
||||
# Now save the PSBT.
|
||||
lianad.rpc.updatespend(first_res["psbt"])
|
||||
# The RBF command succeeds even if transaction has not been signed.
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 2)
|
||||
# The RBF command also succeeds if transaction has been signed but not broadcast.
|
||||
first_psbt = lianad.signer.sign_psbt(first_psbt)
|
||||
lianad.rpc.updatespend(first_psbt.to_base64())
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 2)
|
||||
# Now broadcast the spend and wait for it to be detected.
|
||||
lianad.rpc.broadcastspend(first_txid)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == first_txid
|
||||
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# We can now use RBF, but the feerate must be higher than that of the first transaction.
|
||||
with pytest.raises(RpcError, match=f"Feerate too low: 1."):
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 1)
|
||||
# Using a higher feerate works.
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 2)
|
||||
# Let's use an even higher feerate.
|
||||
rbf_1_res = lianad.rpc.rbfpsbt(first_txid, False, 10)
|
||||
rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"])
|
||||
# The inputs are the same in both (no new inputs needed in the replacement).
|
||||
assert sorted(
|
||||
psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in first_psbt.i
|
||||
) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i)
|
||||
# Check non-change output is the same in both.
|
||||
assert first_psbt.tx.vout[0].nValue == rbf_1_psbt.tx.vout[0].nValue
|
||||
assert first_psbt.tx.vout[0].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey
|
||||
# Change address is the same but change amount will be lower in the replacement to pay higher fee.
|
||||
assert first_psbt.tx.vout[1].nValue > rbf_1_psbt.tx.vout[1].nValue
|
||||
assert first_psbt.tx.vout[1].scriptPubKey == rbf_1_psbt.tx.vout[1].scriptPubKey
|
||||
# Broadcast the replacement and wait for it to be detected.
|
||||
rbf_1_txid = sign_and_broadcast_psbt(lianad, rbf_1_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_1_txid
|
||||
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# If we try to RBF the first transaction again, it will use the first RBF's
|
||||
# feerate of 10 sat/vb to set the min feerate, instead of 1 sat/vb of first
|
||||
# transaction:
|
||||
with pytest.raises(RpcError, match=f"Feerate too low: 10."):
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 10)
|
||||
# Using 11 for feerate works.
|
||||
lianad.rpc.rbfpsbt(first_txid, False, 11)
|
||||
# Add a new transaction spending the change from the first RBF.
|
||||
desc_1_destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 500_000,
|
||||
}
|
||||
desc_1_outpoints = [f"{rbf_1_txid}:1", coins[2]["outpoint"]]
|
||||
wait_for(lambda: len(lianad.rpc.listcoins([], desc_1_outpoints)["coins"]) == 2)
|
||||
desc_1_res = lianad.rpc.createspend(desc_1_destinations, desc_1_outpoints, 1)
|
||||
desc_1_psbt = PSBT.from_base64(desc_1_res["psbt"])
|
||||
assert len(desc_1_psbt.tx.vout) == 2
|
||||
desc_1_txid = sign_and_broadcast_psbt(lianad, desc_1_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == desc_1_txid
|
||||
for c in lianad.rpc.listcoins([], desc_1_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# Add a new transaction spending the change from the first descendant.
|
||||
desc_2_destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 25_000,
|
||||
}
|
||||
desc_2_outpoints = [f"{desc_1_txid}:1"]
|
||||
wait_for(lambda: len(lianad.rpc.listcoins([], desc_2_outpoints)["coins"]) == 1)
|
||||
desc_2_res = lianad.rpc.createspend(desc_2_destinations, desc_2_outpoints, 1)
|
||||
desc_2_psbt = PSBT.from_base64(desc_2_res["psbt"])
|
||||
assert len(desc_2_psbt.tx.vout) == 2
|
||||
desc_2_txid = sign_and_broadcast_psbt(lianad, desc_2_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == desc_2_txid
|
||||
for c in lianad.rpc.listcoins([], desc_2_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# Now replace the first RBF, which will also remove its descendants.
|
||||
rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, 11)
|
||||
rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"])
|
||||
# The inputs are the same in both (no new inputs needed in the replacement).
|
||||
assert sorted(
|
||||
psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i
|
||||
) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_2_psbt.i)
|
||||
# Check non-change output is the same in both.
|
||||
assert rbf_1_psbt.tx.vout[0].nValue == rbf_2_psbt.tx.vout[0].nValue
|
||||
assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey
|
||||
# Change address is the same but change amount will be lower in the replacement to pay higher fee.
|
||||
assert rbf_1_psbt.tx.vout[1].nValue > rbf_2_psbt.tx.vout[1].nValue
|
||||
assert rbf_1_psbt.tx.vout[1].scriptPubKey == rbf_2_psbt.tx.vout[1].scriptPubKey
|
||||
|
||||
# Broadcast the replacement and wait for it to be detected.
|
||||
rbf_2_txid = sign_and_broadcast_psbt(lianad, rbf_2_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_2_txid
|
||||
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# The unconfirmed coins used in the descendant transactions have been removed so that
|
||||
# only one of the input coins remains, and its spend info has been wiped so that it is as before.
|
||||
assert lianad.rpc.listcoins([], desc_1_outpoints + desc_2_outpoints)["coins"] == [
|
||||
coins[2]
|
||||
]
|
||||
# Now confirm the replacement transaction.
|
||||
bitcoind.generate_block(1, wait_for_mempool=rbf_2_txid)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"]["txid"] == rbf_2_txid
|
||||
and c["spend_info"]["height"] is not None
|
||||
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_rbfpsbt_cancel(lianad, bitcoind):
|
||||
"""Test the use of RBF to cancel a transaction."""
|
||||
|
||||
# Get three coins.
|
||||
destinations = {
|
||||
lianad.rpc.getnewaddress()["address"]: 0.003,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.004,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.005,
|
||||
}
|
||||
txid = bitcoind.rpc.sendmany("", destinations)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 3)
|
||||
coins = lianad.rpc.listcoins(["confirmed"])["coins"]
|
||||
|
||||
# Create a spend that will later be replaced.
|
||||
first_outpoints = [c["outpoint"] for c in coins[:2]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 650_000,
|
||||
}
|
||||
first_res = lianad.rpc.createspend(destinations, first_outpoints, 1)
|
||||
first_psbt = PSBT.from_base64(first_res["psbt"])
|
||||
# The transaction has a change output.
|
||||
assert len(first_psbt.o) == len(first_psbt.tx.vout) == 2
|
||||
first_txid = first_psbt.tx.txid().hex()
|
||||
# Broadcast the spend and wait for it to be detected.
|
||||
first_txid = sign_and_broadcast_psbt(lianad, first_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == first_txid
|
||||
for c in lianad.rpc.listcoins([], first_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# We can use RBF and let the command choose the min possible feerate (1 larger than previous).
|
||||
rbf_1_res = lianad.rpc.rbfpsbt(first_txid, True)
|
||||
# But we can't set the feerate explicitly.
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match=re.escape(
|
||||
"A feerate must not be provided if creating a cancel."
|
||||
),
|
||||
):
|
||||
rbf_1_res = lianad.rpc.rbfpsbt(first_txid, True, 2)
|
||||
rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"])
|
||||
# Replacement only has a single input.
|
||||
assert len(rbf_1_psbt.i) == 1
|
||||
# This input is one of the two from the previous transaction.
|
||||
assert rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] in [
|
||||
psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i
|
||||
]
|
||||
# The replacement only has a change output.
|
||||
assert len(rbf_1_psbt.tx.vout) == 1
|
||||
# Change address is the same but change amount will be higher in the replacement as it is the only output.
|
||||
assert first_psbt.tx.vout[1].nValue < rbf_1_psbt.tx.vout[0].nValue
|
||||
assert first_psbt.tx.vout[1].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey
|
||||
# Broadcast the replacement and wait for it to be detected.
|
||||
rbf_1_txid = sign_and_broadcast_psbt(lianad, rbf_1_psbt)
|
||||
# The spend info of the coin used in the replacement will be updated.
|
||||
|
||||
rbf_1_outpoint = (
|
||||
f"{rbf_1_psbt.tx.vin[0].prevout.hash:064x}:{rbf_1_psbt.tx.vin[0].prevout.n}"
|
||||
)
|
||||
assert rbf_1_outpoint in first_outpoints
|
||||
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_1_txid
|
||||
for c in lianad.rpc.listcoins([], [rbf_1_outpoint])["coins"]
|
||||
)
|
||||
)
|
||||
# The other coin will have its spend info removed.
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is None
|
||||
for c in lianad.rpc.listcoins(
|
||||
[], [op for op in first_outpoints if op != rbf_1_outpoint]
|
||||
)["coins"]
|
||||
)
|
||||
)
|
||||
# Add a new transaction spending the only output (change) from the first RBF.
|
||||
desc_1_destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 500_000,
|
||||
}
|
||||
desc_1_outpoints = [f"{rbf_1_txid}:0", coins[2]["outpoint"]]
|
||||
wait_for(lambda: len(lianad.rpc.listcoins([], desc_1_outpoints)["coins"]) == 2)
|
||||
desc_1_res = lianad.rpc.createspend(desc_1_destinations, desc_1_outpoints, 1)
|
||||
desc_1_psbt = PSBT.from_base64(desc_1_res["psbt"])
|
||||
assert len(desc_1_psbt.tx.vout) == 2
|
||||
desc_1_txid = sign_and_broadcast_psbt(lianad, desc_1_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == desc_1_txid
|
||||
for c in lianad.rpc.listcoins([], desc_1_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# Add a new transaction spending the change from the first descendant.
|
||||
desc_2_destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 25_000,
|
||||
}
|
||||
desc_2_outpoints = [f"{desc_1_txid}:1"]
|
||||
wait_for(lambda: len(lianad.rpc.listcoins([], desc_2_outpoints)["coins"]) == 1)
|
||||
desc_2_res = lianad.rpc.createspend(desc_2_destinations, desc_2_outpoints, 1)
|
||||
desc_2_psbt = PSBT.from_base64(desc_2_res["psbt"])
|
||||
assert len(desc_2_psbt.tx.vout) == 2
|
||||
desc_2_txid = sign_and_broadcast_psbt(lianad, desc_2_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == desc_2_txid
|
||||
for c in lianad.rpc.listcoins([], desc_2_outpoints)["coins"]
|
||||
)
|
||||
)
|
||||
# Now cancel the first RBF, which will also remove its descendants.
|
||||
rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, True)
|
||||
rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"])
|
||||
#
|
||||
assert len(rbf_2_psbt.i) == 1
|
||||
assert (
|
||||
rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO]
|
||||
== rbf_2_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO]
|
||||
)
|
||||
# The inputs are the same in both (no new inputs needed in the replacement).
|
||||
|
||||
# Only a single output (change) in the replacement.
|
||||
assert len(rbf_2_psbt.tx.vout) == 1
|
||||
# Change address is the same but change amount will be lower in the replacement to pay higher fee.
|
||||
assert rbf_1_psbt.tx.vout[0].nValue > rbf_2_psbt.tx.vout[0].nValue
|
||||
assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey
|
||||
|
||||
# Broadcast the replacement and wait for it to be detected.
|
||||
rbf_2_txid = sign_and_broadcast_psbt(lianad, rbf_2_psbt)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_2_txid
|
||||
for c in lianad.rpc.listcoins([], [rbf_1_outpoint])["coins"]
|
||||
)
|
||||
)
|
||||
# The unconfirmed coins used in the descendant transactions have been removed so that
|
||||
# only one of the input coins remains, and its spend info has been wiped so that it is as before.
|
||||
assert lianad.rpc.listcoins([], desc_1_outpoints + desc_2_outpoints)["coins"] == [
|
||||
coins[2]
|
||||
]
|
||||
# Now confirm the replacement transaction.
|
||||
bitcoind.generate_block(1, wait_for_mempool=rbf_2_txid)
|
||||
wait_for(
|
||||
lambda: all(
|
||||
c["spend_info"]["txid"] == rbf_2_txid
|
||||
and c["spend_info"]["height"] is not None
|
||||
for c in lianad.rpc.listcoins([], [rbf_1_outpoint])["coins"]
|
||||
)
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user