Skip to content

Commit

Permalink
commands: add rbfpsbt command
Browse files Browse the repository at this point in the history
  • Loading branch information
jp1ac4 committed Nov 27, 2023
1 parent a9e42fe commit cb4d2a3
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 3 deletions.
24 changes: 24 additions & 0 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -257,6 +258,29 @@ This command does not return anything for now.
| Field | Type | Description |
| -------------- | --------- | ---------------------------------------------------- |

### `rbfpsbt`

Create PSBT to replace the given transaction using RBF.

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.

#### Request

| Field | Type | Description |
| -------- | ------ | ---------------------------------------------------------------------------------------- |
| `txid` | string | Hex encoded txid of the Spend transaction to be replaced. |
| `is_cancel` | bool | Whether to "cancel" the transaction by removing non-change outputs from 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, 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` | integer(optional) | Target feerate for the RBF transaction (in sat/vb). If not given, 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. |

#### Response

| Field | Type | Description |
| -------------- | --------- | ---------------------------------------------------- |
| `psbt` | string | PSBT of the spending transaction, encoded as base64. |

### `startrescan`

#### Request
Expand Down
236 changes: 234 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -79,6 +79,7 @@ pub enum CommandError {
/// Overflowing or unhardened derivation index.
InvalidDerivationIndex,
CoinSelectionError(InsufficientFunds),
RbfError(RbfErrorInfo),
}

impl fmt::Display for CommandError {
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -171,6 +173,27 @@ pub enum InsaneFeeInfo {
TooHighFeerate(u64),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RbfErrorInfo {
TxAlreadyConfirmed(bitcoin::Txid),
TxNotYetBroadcast(bitcoin::Txid),
TooLowFeerate(u64),
}

impl fmt::Display for RbfErrorInfo {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::TxAlreadyConfirmed(txid) => {
write!(f, "Transaction '{}' has already been confirmed.", txid)
}
Self::TxNotYetBroadcast(txid) => {
write!(f, "Transaction '{}' has not yet been broadcast.", txid)
}
Self::TooLowFeerate(r) => write!(f, "Feerate too low: {}.", r),
}
}
}

/// A candidate for coin selection when creating a transaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CandidateCoin {
Expand Down Expand Up @@ -1009,6 +1032,215 @@ impl DaemonControl {
.map_err(CommandError::TxBroadcast)
}

/// Create PSBT to replace the given transaction using RBF.
///
/// 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.
///
/// `is_cancel` indicates whether to "cancel" the transaction by removing non-change outputs from
/// 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, 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> {
// The transaction to be replaced must be one of ours and so we can assume it signals replaceability.
// The inputs in the replacement transaction will be either those coins used as inputs in the transaction
// to be replaced or "confirmed" coins. Therefore, we can assume the transaction to be replaced is
// the only one directly conflicting with the replacement transaction.
let mut db_conn = self.db.connection();

let prev_psbt = db_conn
.spend_tx(txid)
.ok_or(CommandError::UnknownSpend(*txid))?;
let prev_coins = {
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);
assert_eq!(prev_outpoints.len(), prev_coins.len());
prev_coins
};
if prev_coins
.iter()
.any(|(_, coin)| coin.spend_block.is_some())
{
return Err(CommandError::RbfError(RbfErrorInfo::TxAlreadyConfirmed(
*txid,
)));
}
let mempool_entry = self
.bitcoin
.mempool_entry(txid)
.ok_or(CommandError::RbfError(RbfErrorInfo::TxNotYetBroadcast(
*txid,
)))?;
let prev_feerate_vb = mempool_entry
.fees
.base
.checked_div(mempool_entry.vsize)
.ok_or(CommandError::InsaneFees(InsaneFeeInfo::InvalidFeerate))?;
// Check replacement transaction's feerate is greater than previous feerate (the only directly conflicting transaction).
// In case no feerate has been given, set it to prev_feerate_vb + 1.
let min_feerate_vb = prev_feerate_vb.to_sat().checked_add(1).unwrap();
let feerate_vb = feerate_vb.unwrap_or(min_feerate_vb);
if feerate_vb < min_feerate_vb && !is_cancel {
return Err(CommandError::RbfError(RbfErrorInfo::TooLowFeerate(
feerate_vb,
)));
}
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 mempool transaction");
(
address.clone(),
bitcoin::Amount::from_sat(txo.value),
db_conn.derivation_index_by_address(&address),
)
})
.collect();
// Set the change index we'll use for the replacement transaction, if any, 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);
// Use all previous outputs as destinations, except for the output corresponding to the change index we found above.
let destinations: HashMap<bitcoin::Address, bitcoin::Amount> = prev_derivs
.iter()
.filter_map(|(addr, amt, _)| {
if prev_change_address != Some(addr) {
Some((addr.clone(), *amt))
} else {
None
}
})
.collect();
// 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.cloned().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| {
// In case the user attempts RBF before the previous coins have been updated in the DB,
// they would be returned as confirmed and not spending.
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 feerate 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 rbf_vsize = 0_u64;
loop {
// Note that `rbf_vsize` strictly increases on each iteration as otherwise `min_fee`
// will ensure that the PSBT meets the required replacement fee and the loop will exit.
let min_fee = mempool_entry.fees.descendant.to_sat() + rbf_vsize;
println!("trying min_fee of {}", min_fee);
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);
}
};
rbf_vsize = unsigned_tx_max_vbytes(&rbf_psbt.psbt.unsigned_tx, max_sat_weight);

println!("replacement vsize estimated at {}", rbf_vsize);
if rbf_psbt
.psbt
.fee()
.expect("has already been sanity checked")
>= mempool_entry.fees.descendant + bitcoin::Amount::from_sat(rbf_vsize)
{
return Ok(rbf_psbt);
}
}
}

/// 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.
Expand Down
25 changes: 24 additions & 1 deletion src/commands/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
}
31 changes: 31 additions & 0 deletions src/jsonrpc/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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" => {
Expand Down
1 change: 1 addition & 0 deletions src/jsonrpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Loading

0 comments on commit cb4d2a3

Please sign in to comment.