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 24, 2023
1 parent 0f36f1a commit bb90d82
Show file tree
Hide file tree
Showing 5 changed files with 417 additions and 1 deletion.
19 changes: 19 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 @@ -249,6 +250,24 @@ This command does not return anything for now.
| Field | Type | Description |
| -------------- | --------- | ---------------------------------------------------- |

### `rbfpsbt`

TODO: Add comment that increasing the fee on a self-send may add other coins to the transaction.

#### Request

| Field | Type | Description |
| -------- | ------ | ---------------------------------------------------------------------------------------- |
| `txid` | string | Hex encoded txid of the Spend transaction to be replaced. |
| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. |
| `is_cancel` | bool | If `false`, the non-change outputs of the transaction to be replaced will be preserved. If `true`, non-change outputs will be removed and the only output of the replacement transaction will be change. |

#### Response

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

### `startrescan`

#### Request
Expand Down
216 changes: 215 additions & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
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 @@ -962,6 +985,197 @@ impl DaemonControl {
.map_err(CommandError::TxBroadcast)
}

/// Create PSBT to replace the given transaction.
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 change_index = prev_derivs
.iter()
.filter_map(|(_, amt, deriv)| {
if let Some((ind, true)) = &deriv {
Some((*ind, amt))
} else {
None
}
})
.max_by(|(ind_1, amt_1), (ind_2, amt_2)| amt_1.cmp(amt_2).then(ind_1.cmp(ind_2)))
.map(|(ind, _)| ind);
// 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, deriv)| match deriv {
Some((ind, true)) if *ind == change_index.expect("change_index is some here") => {
None
}
_ => Some((addr.clone(), *amt)),
})
.collect();

// 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);
}
// 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_rbf_spend(
&destinations,
&candidate_coins,
feerate_vb,
min_fee,
change_index,
) {
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 = {
// TODO: Make this a function (can be used also in `sanity_check`).
let witness_factor: u64 =
descriptors::WITNESS_FACTOR.try_into().expect("it must fit");
let max_sat_weight: u64 = self
.config
.main_descriptor
.max_sat_weight()
.try_into()
.expect("it must fit");
let num_inputs: u64 = rbf_psbt.psbt.inputs.len().try_into().expect("it must fit");
let rbf_weight =
rbf_psbt.psbt.unsigned_tx.weight().to_wu() + (max_sat_weight * num_inputs);
rbf_weight
.checked_add(witness_factor - 1)
.unwrap()
.checked_div(witness_factor)
.unwrap()
};
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
31 changes: 31 additions & 0 deletions src/jsonrpc/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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 @@ -331,6 +356,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 bb90d82

Please sign in to comment.