Skip to content

Commit 8c01a67

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 71bf53d commit 8c01a67

File tree

2 files changed

+301
-18
lines changed

2 files changed

+301
-18
lines changed

crates/wallet/src/wallet/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,7 +1545,9 @@ impl Wallet {
15451545
///
15461546
/// Returns an error if the transaction is already confirmed or doesn't explicitly signal
15471547
/// *replace by fee* (RBF). If the transaction can be fee bumped then it returns a [`TxBuilder`]
1548-
/// pre-populated with the inputs and outputs of the original transaction.
1548+
/// pre-populated with the inputs and outputs of the original transaction. If you just
1549+
/// want to build a transaction that conflicts with the tx of the given `txid`, consider
1550+
/// using [`TxBuilder::replace_tx`].
15491551
///
15501552
/// ## Example
15511553
///
@@ -2525,7 +2527,7 @@ macro_rules! floating_rate {
25252527
/// Macro for getting a wallet for use in a doctest
25262528
macro_rules! doctest_wallet {
25272529
() => {{
2528-
use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
2530+
use $crate::bitcoin::{absolute, transaction, Amount, BlockHash, Transaction, TxOut, Network, hashes::Hash};
25292531
use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph};
25302532
use $crate::{Update, KeychainKind, Wallet};
25312533
use $crate::test_utils::*;

crates/wallet/src/wallet/tx_builder.rs

Lines changed: 297 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -274,27 +274,36 @@ 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+
let outputs = self
278+
.wallet
279+
.list_output()
280+
.map(|out| (out.outpoint, out))
281+
.collect::<HashMap<_, _>>();
277282
let utxo_batch = outpoints
278283
.iter()
279284
.map(|outpoint| {
280-
self.wallet
281-
.get_utxo(*outpoint)
282-
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))
283-
.map(|output| {
284-
(
285-
*outpoint,
286-
WeightedUtxo {
287-
satisfaction_weight: self
288-
.wallet
289-
.public_descriptor(output.keychain)
290-
.max_weight_to_satisfy()
291-
.unwrap(),
292-
utxo: Utxo::Local(output),
293-
},
294-
)
295-
})
285+
let output = outputs
286+
.get(outpoint)
287+
.cloned()
288+
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))?;
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(*outpoint));
292+
}
293+
Ok((
294+
*outpoint,
295+
WeightedUtxo {
296+
satisfaction_weight: self
297+
.wallet
298+
.public_descriptor(output.keychain)
299+
.max_weight_to_satisfy()
300+
.unwrap(),
301+
utxo: Utxo::Local(output),
302+
},
303+
))
296304
})
297305
.collect::<Result<HashMap<OutPoint, WeightedUtxo>, AddUtxoError>>()?;
306+
298307
self.params.utxos.extend(utxo_batch);
299308

300309
Ok(self)
@@ -308,6 +317,122 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
308317
self.add_utxos(&[outpoint])
309318
}
310319

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

849+
/// Error returned by [`TxBuilder::replace_tx`].
850+
#[derive(Debug)]
851+
pub enum ReplaceTxError {
852+
/// Transaction was not found in tx graph
853+
MissingTransaction,
854+
/// Transaction can't be replaced by this wallet
855+
NonReplaceable,
856+
/// Transaction is already confirmed
857+
TransactionConfirmed,
858+
}
859+
860+
impl fmt::Display for ReplaceTxError {
861+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
862+
match self {
863+
Self::MissingTransaction => write!(f, "transaction not found in tx graph"),
864+
Self::NonReplaceable => write!(f, "no replaceable input found"),
865+
Self::TransactionConfirmed => write!(f, "cannot replace a confirmed tx"),
866+
}
867+
}
868+
}
869+
870+
#[cfg(feature = "std")]
871+
impl std::error::Error for ReplaceTxError {}
872+
724873
#[derive(Debug)]
725874
/// Error returned from [`TxBuilder::add_foreign_utxo`].
726875
pub enum AddForeignUtxoError {
@@ -857,6 +1006,7 @@ mod test {
8571006
};
8581007
}
8591008

1009+
use crate::test_utils::*;
8601010
use bitcoin::consensus::deserialize;
8611011
use bitcoin::hex::FromHex;
8621012
use bitcoin::TxOut;
@@ -1346,4 +1496,135 @@ mod test {
13461496
}
13471497
));
13481498
}
1499+
1500+
#[test]
1501+
fn replace_tx_allows_selecting_spent_outputs() {
1502+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1503+
let outpoint_1 = OutPoint::new(txid_0, 0);
1504+
1505+
// receive output 2
1506+
let outpoint_2 = receive_output_in_latest_block(&mut wallet, 49_000);
1507+
assert_eq!(wallet.list_unspent().count(), 2);
1508+
assert_eq!(wallet.balance().total().to_sat(), 99_000);
1509+
1510+
// create tx1: 2-in/1-out sending all to `recip`
1511+
let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap();
1512+
let mut builder = wallet.build_tx();
1513+
builder.add_recipient(recip.clone(), Amount::from_sat(98_800));
1514+
let psbt = builder.finish().unwrap();
1515+
let tx1 = psbt.unsigned_tx;
1516+
let txid1 = tx1.compute_txid();
1517+
insert_tx(&mut wallet, tx1);
1518+
assert!(wallet.list_unspent().next().is_none());
1519+
1520+
// now replace tx1 with a new transaction
1521+
let mut builder = wallet.build_tx();
1522+
builder.replace_tx(txid1).expect("should replace input");
1523+
let prev_feerate = builder.previous_fee().unwrap().1;
1524+
builder.add_recipient(recip, Amount::from_sat(98_500));
1525+
builder.fee_rate(FeeRate::from_sat_per_kwu(
1526+
prev_feerate.to_sat_per_kwu() + 250,
1527+
));
1528+
1529+
// Because outpoint 2 was spent in tx1, by default it won't be available for selection,
1530+
// but we can add it manually, with the caveat that the builder is in a bump-fee
1531+
// context.
1532+
builder.add_utxo(outpoint_2).expect("should add output");
1533+
let psbt = builder.finish().unwrap();
1534+
1535+
assert!(psbt
1536+
.unsigned_tx
1537+
.input
1538+
.iter()
1539+
.any(|txin| txin.previous_output == outpoint_1));
1540+
assert!(psbt
1541+
.unsigned_tx
1542+
.input
1543+
.iter()
1544+
.any(|txin| txin.previous_output == outpoint_2));
1545+
}
1546+
1547+
// Replacing a tx should mark the original txouts unspendable
1548+
#[test]
1549+
fn test_replace_tx_unspendable() {
1550+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1551+
let outpoint_0 = OutPoint::new(txid_0, 0);
1552+
let balance = wallet.balance().total();
1553+
let fee = Amount::from_sat(256);
1554+
1555+
let mut previous_output = outpoint_0;
1556+
1557+
// apply 3 unconfirmed txs to wallet
1558+
for i in 1..=3 {
1559+
let tx = Transaction {
1560+
input: vec![TxIn {
1561+
previous_output,
1562+
..Default::default()
1563+
}],
1564+
output: vec![TxOut {
1565+
script_pubkey: wallet
1566+
.reveal_next_address(KeychainKind::External)
1567+
.script_pubkey(),
1568+
value: balance - fee * i as u64,
1569+
}],
1570+
..new_tx(i)
1571+
};
1572+
1573+
let txid = tx.compute_txid();
1574+
insert_tx(&mut wallet, tx);
1575+
previous_output = OutPoint::new(txid, 0);
1576+
}
1577+
1578+
let unconfirmed_txs: Vec<_> = wallet
1579+
.transactions()
1580+
.filter(|c| !c.chain_position.is_confirmed())
1581+
.collect();
1582+
let txid_1 = unconfirmed_txs
1583+
.iter()
1584+
.find(|c| c.tx_node.input[0].previous_output == outpoint_0)
1585+
.map(|c| c.tx_node.txid)
1586+
.unwrap();
1587+
let unconfirmed_txids = unconfirmed_txs
1588+
.iter()
1589+
.map(|c| c.tx_node.txid)
1590+
.collect::<Vec<_>>();
1591+
assert_eq!(unconfirmed_txids.len(), 3);
1592+
1593+
// replace tx1
1594+
let mut builder = wallet.build_tx();
1595+
builder.replace_tx(txid_1).unwrap();
1596+
assert_eq!(builder.params.utxos.len(), 1);
1597+
assert!(builder.params.utxos.contains_key(&outpoint_0));
1598+
for txid in unconfirmed_txids {
1599+
assert!(builder.params.unspendable.contains(&OutPoint::new(txid, 0)));
1600+
}
1601+
}
1602+
1603+
#[test]
1604+
fn test_replace_tx_error() {
1605+
use bitcoin::hashes::Hash;
1606+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
1607+
1608+
// tx does not exist
1609+
let mut builder = wallet.build_tx();
1610+
let res = builder.replace_tx(Txid::all_zeros());
1611+
assert!(matches!(res, Err(ReplaceTxError::MissingTransaction)));
1612+
1613+
// tx confirmed
1614+
let mut builder = wallet.build_tx();
1615+
let res = builder.replace_tx(txid_0);
1616+
assert!(matches!(res, Err(ReplaceTxError::TransactionConfirmed)));
1617+
1618+
// can't replace a foreign tx
1619+
let tx = Transaction {
1620+
input: vec![TxIn::default()],
1621+
output: vec![TxOut::NULL],
1622+
..new_tx(0)
1623+
};
1624+
let txid = tx.compute_txid();
1625+
insert_tx(&mut wallet, tx);
1626+
let mut builder = wallet.build_tx();
1627+
let res = builder.replace_tx(txid);
1628+
assert!(matches!(res, Err(ReplaceTxError::NonReplaceable)));
1629+
}
13491630
}

0 commit comments

Comments
 (0)