Skip to content

Commit ab4c3dc

Browse files
committed
feat(wallet): add method replace_tx for TxBuilder
- Add method `TxBuilder::previous_fee` for getting the previous fee / feerate of the replaced tx.
1 parent a41db82 commit ab4c3dc

File tree

2 files changed

+308
-22
lines changed

2 files changed

+308
-22
lines changed

crates/wallet/src/wallet/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,7 +1537,9 @@ impl Wallet {
15371537
///
15381538
/// Returns an error if the transaction is already confirmed or doesn't explicitly signal
15391539
/// *replace by fee* (RBF). If the transaction can be fee bumped then it returns a [`TxBuilder`]
1540-
/// pre-populated with the inputs and outputs of the original transaction.
1540+
/// pre-populated with the inputs and outputs of the original transaction. If you just
1541+
/// want to build a transaction that conflicts with the tx of the given `txid`, consider
1542+
/// using [`TxBuilder::replace_tx`].
15411543
///
15421544
/// ## Example
15431545
///
@@ -2571,7 +2573,7 @@ macro_rules! floating_rate {
25712573
/// Macro for getting a wallet for use in a doctest
25722574
macro_rules! doctest_wallet {
25732575
() => {{
2574-
use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
2576+
use $crate::bitcoin::{absolute, transaction, Amount, BlockHash, Transaction, TxOut, Network, hashes::Hash};
25752577
use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph};
25762578
use $crate::{Update, KeychainKind, Wallet};
25772579
use $crate::test_utils::*;

crates/wallet/src/wallet/tx_builder.rs

Lines changed: 304 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ use rand_core::RngCore;
5252
use super::coin_selection::CoinSelectionAlgorithm;
5353
use super::utils::shuffle_slice;
5454
use super::{CreateTxError, Wallet};
55-
use crate::collections::{BTreeMap, HashSet};
55+
use crate::collections::{BTreeMap, HashMap, HashSet};
5656
use crate::{KeychainKind, LocalOutput, Utxo, WeightedUtxo};
5757

5858
/// A transaction builder
@@ -274,25 +274,35 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
274274
/// These have priority over the "unspendable" utxos, meaning that if a utxo is present both in
275275
/// the "utxos" and the "unspendable" list, it will be spent.
276276
pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> Result<&mut Self, AddUtxoError> {
277-
{
278-
let wallet = &mut self.wallet;
279-
let utxos = outpoints
280-
.iter()
281-
.map(|outpoint| {
282-
wallet
283-
.get_utxo(*outpoint)
284-
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))
285-
})
286-
.collect::<Result<Vec<_>, _>>()?;
287-
288-
for utxo in utxos {
289-
let descriptor = wallet.public_descriptor(utxo.keychain);
290-
let satisfaction_weight = descriptor.max_weight_to_satisfy().unwrap();
291-
self.params.utxos.push(WeightedUtxo {
292-
satisfaction_weight,
293-
utxo: Utxo::Local(utxo),
294-
});
295-
}
277+
let outputs = self
278+
.wallet
279+
.list_output()
280+
.map(|out| (out.outpoint, out))
281+
.collect::<HashMap<_, _>>();
282+
let utxos = outpoints
283+
.iter()
284+
.map(|op| {
285+
let output = outputs
286+
.get(op)
287+
.cloned()
288+
.ok_or(AddUtxoError::UnknownUtxo(*op))?;
289+
// the output should be unspent unless we're doing a RBF
290+
if self.params.bumping_fee.is_none() && output.is_spent {
291+
return Err(AddUtxoError::UnknownUtxo(*op));
292+
}
293+
Ok(output)
294+
})
295+
.collect::<Result<Vec<_>, _>>()?;
296+
297+
for utxo in utxos {
298+
let descriptor = self.wallet.public_descriptor(utxo.keychain);
299+
let satisfaction_weight = descriptor
300+
.max_weight_to_satisfy()
301+
.expect("descriptor should be satisfiable");
302+
self.params.utxos.push(WeightedUtxo {
303+
satisfaction_weight,
304+
utxo: Utxo::Local(utxo),
305+
});
296306
}
297307

298308
Ok(self)
@@ -306,6 +316,122 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
306316
self.add_utxos(&[outpoint])
307317
}
308318

319+
/// Replace an unconfirmed transaction.
320+
///
321+
/// This method attempts to create a replacement for the transaction with `txid` by
322+
/// looking for the largest input that is owned by this wallet and adding it to the
323+
/// list of UTXOs to spend.
324+
///
325+
/// # Note
326+
///
327+
/// Aside from reusing one of the inputs, the method makes no assumptions about the
328+
/// structure of the replacement, so if you need to reuse the original recipient(s)
329+
/// and/or change address, you should add them manually before [`finish`] is called.
330+
///
331+
/// # Example
332+
///
333+
/// Create a replacement for an unconfirmed wallet transaction
334+
///
335+
/// ```rust,no_run
336+
/// # let mut wallet = bdk_wallet::doctest_wallet!();
337+
/// let wallet_txs = wallet.transactions().collect::<Vec<_>>();
338+
/// let tx = wallet_txs.first().expect("must have wallet tx");
339+
///
340+
/// if !tx.chain_position.is_confirmed() {
341+
/// let txid = tx.tx_node.txid;
342+
/// let mut builder = wallet.build_tx();
343+
/// builder.replace_tx(txid).expect("should replace");
344+
///
345+
/// // Continue building tx...
346+
///
347+
/// let psbt = builder.finish()?;
348+
/// }
349+
/// # Ok::<_, anyhow::Error>(())
350+
/// ```
351+
///
352+
/// # Errors
353+
///
354+
/// - If the original transaction is not found in the tx graph
355+
/// - If the orginal transaction is confirmed
356+
/// - If none of the inputs are owned by this wallet
357+
///
358+
/// [`finish`]: TxBuilder::finish
359+
pub fn replace_tx(&mut self, txid: Txid) -> Result<&mut Self, ReplaceTxError> {
360+
let tx = self
361+
.wallet
362+
.indexed_graph
363+
.graph()
364+
.get_tx(txid)
365+
.ok_or(ReplaceTxError::MissingTransaction)?;
366+
if self
367+
.wallet
368+
.transactions()
369+
.find(|c| c.tx_node.txid == txid)
370+
.map(|c| c.chain_position.is_confirmed())
371+
.unwrap_or(false)
372+
{
373+
return Err(ReplaceTxError::TransactionConfirmed);
374+
}
375+
let outpoint = tx
376+
.input
377+
.iter()
378+
.filter_map(|txin| {
379+
let prev_tx = self
380+
.wallet
381+
.indexed_graph
382+
.graph()
383+
.get_tx(txin.previous_output.txid)?;
384+
let txout = &prev_tx.output[txin.previous_output.vout as usize];
385+
if self.wallet.is_mine(txout.script_pubkey.clone()) {
386+
Some((txin.previous_output, txout.value))
387+
} else {
388+
None
389+
}
390+
})
391+
.max_by_key(|(_, value)| *value)
392+
.map(|(op, _)| op)
393+
.ok_or(ReplaceTxError::NonReplaceable)?;
394+
395+
// add previous fee
396+
let absolute = self.wallet.calculate_fee(&tx).unwrap_or_default();
397+
let rate = absolute / tx.weight();
398+
self.params.bumping_fee = Some(PreviousFee { absolute, rate });
399+
400+
self.add_utxo(outpoint).expect("we must have the utxo");
401+
402+
// do not allow spending outputs of the replaced tx including descendants
403+
core::iter::once((txid, tx))
404+
.chain(
405+
self.wallet
406+
.tx_graph()
407+
.walk_descendants(txid, |_, descendant_txid| {
408+
Some((
409+
descendant_txid,
410+
self.wallet.tx_graph().get_tx(descendant_txid)?,
411+
))
412+
}),
413+
)
414+
.for_each(|(txid, tx)| {
415+
self.params
416+
.unspendable
417+
.extend((0..tx.output.len()).map(|vout| OutPoint::new(txid, vout as u32)));
418+
});
419+
420+
Ok(self)
421+
}
422+
423+
/// Get the previous fee and feerate, i.e. the fee of the tx being fee-bumped, if any.
424+
///
425+
/// This method may be used in combination with either [`build_fee_bump`] or [`replace_tx`]
426+
/// and is useful for deciding what fee to attach to a transaction for the purpose of
427+
/// "replace-by-fee" (RBF).
428+
///
429+
/// [`build_fee_bump`]: Wallet::build_fee_bump
430+
/// [`replace_tx`]: Self::replace_tx
431+
pub fn previous_fee(&self) -> Option<(Amount, FeeRate)> {
432+
self.params.bumping_fee.map(|p| (p.absolute, p.rate))
433+
}
434+
309435
/// Add a foreign UTXO i.e. a UTXO not owned by this wallet.
310436
///
311437
/// At a minimum to add a foreign UTXO we need:
@@ -697,6 +823,30 @@ impl fmt::Display for AddUtxoError {
697823
#[cfg(feature = "std")]
698824
impl std::error::Error for AddUtxoError {}
699825

826+
/// Error returned by [`TxBuilder::replace_tx`].
827+
#[derive(Debug)]
828+
pub enum ReplaceTxError {
829+
/// Transaction was not found in tx graph
830+
MissingTransaction,
831+
/// Transaction can't be replaced by this wallet
832+
NonReplaceable,
833+
/// Transaction is already confirmed
834+
TransactionConfirmed,
835+
}
836+
837+
impl fmt::Display for ReplaceTxError {
838+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
839+
match self {
840+
Self::MissingTransaction => write!(f, "transaction not found in tx graph"),
841+
Self::NonReplaceable => write!(f, "no replaceable input found"),
842+
Self::TransactionConfirmed => write!(f, "cannot replace a confirmed tx"),
843+
}
844+
}
845+
}
846+
847+
#[cfg(feature = "std")]
848+
impl std::error::Error for ReplaceTxError {}
849+
700850
#[derive(Debug)]
701851
/// Error returned from [`TxBuilder::add_foreign_utxo`].
702852
pub enum AddForeignUtxoError {
@@ -833,6 +983,7 @@ mod test {
833983
};
834984
}
835985

986+
use crate::test_utils::*;
836987
use bitcoin::consensus::deserialize;
837988
use bitcoin::hex::FromHex;
838989
use bitcoin::TxOut;
@@ -1098,4 +1249,137 @@ mod test {
10981249
builder.fee_rate(FeeRate::from_sat_per_kwu(feerate + 250));
10991250
let _ = builder.finish().unwrap();
11001251
}
1252+
#[test]
1253+
fn replace_tx_allows_selecting_spent_outputs() {
1254+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1255+
let outpoint_1 = OutPoint::new(txid_0, 0);
1256+
1257+
// receive output 2
1258+
let outpoint_2 = receive_output_in_latest_block(&mut wallet, 49_000);
1259+
assert_eq!(wallet.list_unspent().count(), 2);
1260+
assert_eq!(wallet.balance().total().to_sat(), 99_000);
1261+
1262+
// create tx1: 2-in/1-out sending all to `recip`
1263+
let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap();
1264+
let mut builder = wallet.build_tx();
1265+
builder.add_recipient(recip.clone(), Amount::from_sat(98_800));
1266+
let psbt = builder.finish().unwrap();
1267+
let tx1 = psbt.unsigned_tx;
1268+
let txid1 = tx1.compute_txid();
1269+
insert_tx(&mut wallet, tx1);
1270+
assert!(wallet.list_unspent().next().is_none());
1271+
1272+
// now replace tx1 with a new transaction
1273+
let mut builder = wallet.build_tx();
1274+
builder.replace_tx(txid1).expect("should replace input");
1275+
let prev_feerate = builder.previous_fee().unwrap().1;
1276+
builder.add_recipient(recip, Amount::from_sat(98_500));
1277+
builder.fee_rate(FeeRate::from_sat_per_kwu(
1278+
prev_feerate.to_sat_per_kwu() + 250,
1279+
));
1280+
1281+
// Because outpoint 2 was spent in tx1, by default it won't be available for selection,
1282+
// but we can add it manually, with the caveat that the builder is in a bump-fee
1283+
// context.
1284+
builder.add_utxo(outpoint_2).expect("should add output");
1285+
let psbt = builder.finish().unwrap();
1286+
1287+
assert!(psbt
1288+
.unsigned_tx
1289+
.input
1290+
.iter()
1291+
.any(|txin| txin.previous_output == outpoint_1));
1292+
assert!(psbt
1293+
.unsigned_tx
1294+
.input
1295+
.iter()
1296+
.any(|txin| txin.previous_output == outpoint_2));
1297+
}
1298+
1299+
#[test]
1300+
fn test_replace_tx_unspendable_with_descendants() {
1301+
use crate::KeychainKind::External;
1302+
1303+
// Replacing a tx should mark the original txouts unspendable
1304+
1305+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1306+
let outpoint_0 = OutPoint::new(txid_0, 0);
1307+
let balance = wallet.balance().total();
1308+
let fee = Amount::from_sat(256);
1309+
1310+
let mut previous_output = outpoint_0;
1311+
1312+
// apply 3 unconfirmed txs to wallet
1313+
for i in 1..=3 {
1314+
let tx = Transaction {
1315+
input: vec![TxIn {
1316+
previous_output,
1317+
..Default::default()
1318+
}],
1319+
output: vec![TxOut {
1320+
script_pubkey: wallet.reveal_next_address(External).script_pubkey(),
1321+
value: balance - fee * i as u64,
1322+
}],
1323+
..new_tx(i)
1324+
};
1325+
1326+
let txid = tx.compute_txid();
1327+
insert_tx(&mut wallet, tx);
1328+
previous_output = OutPoint::new(txid, 0);
1329+
}
1330+
1331+
let unconfirmed_txs: Vec<_> = wallet
1332+
.transactions()
1333+
.filter(|c| !c.chain_position.is_confirmed())
1334+
.collect();
1335+
let txid_1 = unconfirmed_txs
1336+
.iter()
1337+
.find(|c| c.tx_node.input[0].previous_output == outpoint_0)
1338+
.map(|c| c.tx_node.txid)
1339+
.unwrap();
1340+
let unconfirmed_txids = unconfirmed_txs
1341+
.iter()
1342+
.map(|c| c.tx_node.txid)
1343+
.collect::<Vec<_>>();
1344+
assert_eq!(unconfirmed_txids.len(), 3);
1345+
1346+
// replace tx1
1347+
let mut builder = wallet.build_tx();
1348+
builder.replace_tx(txid_1).unwrap();
1349+
assert_eq!(
1350+
builder.params.utxos.first().unwrap().utxo.outpoint(),
1351+
outpoint_0
1352+
);
1353+
for txid in unconfirmed_txids {
1354+
assert!(builder.params.unspendable.contains(&OutPoint::new(txid, 0)));
1355+
}
1356+
}
1357+
1358+
#[test]
1359+
fn test_replace_tx_error() {
1360+
use bitcoin::hashes::Hash;
1361+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1362+
1363+
// tx does not exist
1364+
let mut builder = wallet.build_tx();
1365+
let res = builder.replace_tx(Txid::all_zeros());
1366+
assert!(matches!(res, Err(ReplaceTxError::MissingTransaction)));
1367+
1368+
// tx confirmed
1369+
let mut builder = wallet.build_tx();
1370+
let res = builder.replace_tx(txid_0);
1371+
assert!(matches!(res, Err(ReplaceTxError::TransactionConfirmed)));
1372+
1373+
// can't replace a foreign tx
1374+
let tx = Transaction {
1375+
input: vec![TxIn::default()],
1376+
output: vec![TxOut::NULL],
1377+
..new_tx(0)
1378+
};
1379+
let txid = tx.compute_txid();
1380+
insert_tx(&mut wallet, tx);
1381+
let mut builder = wallet.build_tx();
1382+
let res = builder.replace_tx(txid);
1383+
assert!(matches!(res, Err(ReplaceTxError::NonReplaceable)));
1384+
}
11011385
}

0 commit comments

Comments
 (0)