commands: add rbfpsbt command

This commit is contained in:
jp1ac4 2023-11-06 20:21:36 +00:00
parent d5f3167404
commit 5391bfe04c
No known key found for this signature in database
GPG Key ID: A7ACD32423568D7B
6 changed files with 742 additions and 3 deletions

View File

@ -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

View File

@ -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(

View File

@ -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()
}

View File

@ -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" => {

View File

@ -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())
}

View File

@ -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"]
)
)