Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wallet): add TxBuilder::replace_tx #1799

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions crates/wallet/src/wallet/mod.rs
Original file line number Diff line number Diff line change
@@ -889,6 +889,21 @@ impl Wallet {
.next()
}

/// Get a local output if the txout referenced by `outpoint` exists on chain and can
/// be found in the inner tx graph.
fn get_output(&self, outpoint: OutPoint) -> Option<LocalOutput> {
let ((keychain, index), _) = self.indexed_graph.index.txout(outpoint)?;
self.indexed_graph
.graph()
.filter_chain_txouts(
&self.chain,
self.chain.tip().block_id(),
core::iter::once(((), outpoint)),
)
.map(|(_, full_txo)| new_local_utxo(keychain, index, full_txo))
.next()
}

/// Inserts a [`TxOut`] at [`OutPoint`] into the wallet's transaction graph.
///
/// This is used for providing a previous output's value so that we can use [`calculate_fee`]
@@ -1537,7 +1552,9 @@ impl Wallet {
///
/// Returns an error if the transaction is already confirmed or doesn't explicitly signal
/// *replace by fee* (RBF). If the transaction can be fee bumped then it returns a [`TxBuilder`]
/// pre-populated with the inputs and outputs of the original transaction.
/// pre-populated with the inputs and outputs of the original transaction. If you just
/// want to build a transaction that conflicts with the tx of the given `txid`, consider
/// using [`TxBuilder::replace_tx`].
///
/// ## Example
///
@@ -2570,7 +2587,7 @@ macro_rules! floating_rate {
/// Macro for getting a wallet for use in a doctest
macro_rules! doctest_wallet {
() => {{
use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
use $crate::bitcoin::{transaction, Amount, BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph};
use $crate::{Update, KeychainKind, Wallet};
use $crate::test_utils::*;
171 changes: 152 additions & 19 deletions crates/wallet/src/wallet/tx_builder.rs
Original file line number Diff line number Diff line change
@@ -274,25 +274,29 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
/// These have priority over the "unspendable" utxos, meaning that if a utxo is present both in
/// the "utxos" and the "unspendable" list, it will be spent.
pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> Result<&mut Self, AddUtxoError> {
{
let wallet = &mut self.wallet;
let utxos = outpoints
.iter()
.map(|outpoint| {
wallet
.get_utxo(*outpoint)
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))
})
.collect::<Result<Vec<_>, _>>()?;

for utxo in utxos {
let descriptor = wallet.public_descriptor(utxo.keychain);
let satisfaction_weight = descriptor.max_weight_to_satisfy().unwrap();
self.params.utxos.push(WeightedUtxo {
satisfaction_weight,
utxo: Utxo::Local(utxo),
});
}
let wallet = &mut self.wallet;
let utxos = outpoints
.iter()
.map(|outpoint| {
wallet
.get_utxo(*outpoint)
.or_else(|| {
// allow selecting a spent output if we're bumping fee
self.params
.bumping_fee
.and_then(|_| wallet.get_output(*outpoint))
})
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))
})
.collect::<Result<Vec<_>, _>>()?;

for utxo in utxos {
let descriptor = wallet.public_descriptor(utxo.keychain);
let satisfaction_weight = descriptor.max_weight_to_satisfy().unwrap();
self.params.utxos.push(WeightedUtxo {
satisfaction_weight,
utxo: Utxo::Local(utxo),
});
}

Ok(self)
@@ -306,6 +310,106 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
self.add_utxos(&[outpoint])
}

/// Replace an unconfirmed transaction.
///
/// This method attempts to create a replacement for the transaction with `txid` by
/// looking for the largest input that is owned by this wallet and adding it to the
/// list of UTXOs to spend.
///
/// # Note
///
/// Aside from reusing one of the inputs, the method makes no assumptions about the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be wary of leaving this caveat up to documentation. The way I see it, one could either declare the recipient when calling replace_tx as a parameter or a new variant on the error path could be added for NoRecipient such that set_recipient must be called before replace_tx.

/// structure of the replacement, so if you need to reuse the original recipient(s)
/// and/or change address, you should add them manually before [`finish`] is called.
///
/// # Example
///
/// Create a replacement for an unconfirmed wallet transaction
///
/// ```rust,no_run
/// # let mut wallet = bdk_wallet::doctest_wallet!();
/// let wallet_txs = wallet.transactions().collect::<Vec<_>>();
/// let tx = wallet_txs.first().expect("must have wallet tx");
///
/// if !tx.chain_position.is_confirmed() {
/// let txid = tx.tx_node.txid;
/// let mut builder = wallet.build_tx();
/// builder.replace_tx(txid).expect("should replace");
///
/// // Continue building tx...
///
/// let psbt = builder.finish()?;
/// }
/// # Ok::<_, anyhow::Error>(())
/// ```
///
/// # Errors
///
/// - If the original transaction is not found in the tx graph
/// - If the orginal transaction is confirmed
/// - If none of the inputs are owned by this wallet
///
/// [`finish`]: TxBuilder::finish
pub fn replace_tx(&mut self, txid: Txid) -> Result<&mut Self, ReplaceTxError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably warn that we don't help keep the original tx's recipient.

let tx = self
.wallet
.indexed_graph
.graph()
.get_tx(txid)
.ok_or(ReplaceTxError::MissingTransaction)?;
if self
.wallet
.transactions()
.find(|c| c.tx_node.txid == txid)
.map(|c| c.chain_position.is_confirmed())
.unwrap_or(false)
{
return Err(ReplaceTxError::TransactionConfirmed);
}
let outpoint = tx
.input
.iter()
.filter_map(|txin| {
let prev_tx = self
.wallet
.indexed_graph
.graph()
.get_tx(txin.previous_output.txid)?;
let txout = &prev_tx.output[txin.previous_output.vout as usize];
if self.wallet.is_mine(txout.script_pubkey.clone()) {
Some((txin.previous_output, txout.value))
} else {
None
}
})
.max_by_key(|(_, value)| *value)
.map(|(op, _)| op)
.ok_or(ReplaceTxError::NonReplaceable)?;

// add previous fee
if let Ok(absolute) = self.wallet.calculate_fee(&tx) {
let rate = absolute / tx.weight();
let previous_fee = PreviousFee { absolute, rate };
self.params.bumping_fee = Some(previous_fee);
}
Comment on lines +389 to +394
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we still allow replacement if we can't determine the previous tx's fee/feerate?


self.add_utxo(outpoint).map_err(|e| match e {
AddUtxoError::UnknownUtxo(op) => ReplaceTxError::MissingOutput(op),
})?;

// do not try to spend the outputs of the tx being replaced
self.params
.unspendable
.extend((0..tx.output.len()).map(|vout| OutPoint::new(txid, vout as u32)));
Comment on lines +400 to +403
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also need to add the children outputs of the tx being replaced.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will fix.


Ok(self)
}

/// Get the previous feerate, i.e. the feerate of the tx being fee-bumped, if any.
pub fn previous_fee(&self) -> Option<FeeRate> {
self.params.bumping_fee.map(|p| p.rate)
}

/// Add a foreign UTXO i.e. a UTXO not owned by this wallet.
///
/// At a minimum to add a foreign UTXO we need:
@@ -697,6 +801,35 @@ impl fmt::Display for AddUtxoError {
#[cfg(feature = "std")]
impl std::error::Error for AddUtxoError {}

/// Error returned by [`TxBuilder::replace_tx`].
#[derive(Debug)]
pub enum ReplaceTxError {
/// Unable to find a locally owned output
MissingOutput(OutPoint),
/// Transaction was not found in tx graph
MissingTransaction,
/// Transaction can't be replaced by this wallet
NonReplaceable,
/// Transaction is already confirmed
TransactionConfirmed,
}

impl fmt::Display for ReplaceTxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingOutput(op) => {
write!(f, "could not find wallet output for outpoint {}", op)
}
Self::MissingTransaction => write!(f, "transaction not found in tx graph"),
Self::NonReplaceable => write!(f, "no replaceable input found"),
Self::TransactionConfirmed => write!(f, "cannot replace a confirmed tx"),
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for ReplaceTxError {}

#[derive(Debug)]
/// Error returned from [`TxBuilder::add_foreign_utxo`].
pub enum AddForeignUtxoError {
46 changes: 46 additions & 0 deletions crates/wallet/tests/wallet.rs
Original file line number Diff line number Diff line change
@@ -4280,3 +4280,49 @@ fn test_wallet_transactions_relevant() {
assert!(full_tx_count_before < full_tx_count_after);
assert!(canonical_tx_count_before < canonical_tx_count_after);
}

#[test]
fn replace_tx_allows_selecting_spent_inputs() {
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
let outpoint_1 = OutPoint::new(txid_0, 0);

// receive output 2
let outpoint_2 = receive_output_in_latest_block(&mut wallet, 49_000);
assert_eq!(wallet.list_unspent().count(), 2);
assert_eq!(wallet.balance().total().to_sat(), 99_000);

// create tx1: 2-in/1-out sending all to `recip`
let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap();
let mut builder = wallet.build_tx();
builder.add_recipient(recip.clone(), Amount::from_sat(98_800));
let psbt = builder.finish().unwrap();
let tx1 = psbt.unsigned_tx;
let txid1 = tx1.compute_txid();
insert_tx(&mut wallet, tx1);
assert!(wallet.list_unspent().next().is_none());

// now replace tx1 with a new transaction
let mut builder = wallet.build_tx();
builder.replace_tx(txid1).expect("should replace input");
let prev_feerate = builder.previous_fee().unwrap();
builder.add_recipient(recip, Amount::from_sat(98_500));
builder.fee_rate(FeeRate::from_sat_per_kwu(
prev_feerate.to_sat_per_kwu() + 250,
));

// Because outpoint 2 was spent in tx1, by default it won't be available for selection,
// but we can add it manually, with the caveat that the builder is in a bump-fee
// context.
builder.add_utxo(outpoint_2).expect("should add output");
let psbt = builder.finish().unwrap();

let tx2 = psbt.unsigned_tx;
assert!(tx2
.input
.iter()
.any(|txin| txin.previous_output == outpoint_1));
assert!(tx2
.input
.iter()
.any(|txin| txin.previous_output == outpoint_2));
}
Loading