Skip to content

Commit affd516

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 6e160d4 commit affd516

File tree

2 files changed

+312
-21
lines changed

2 files changed

+312
-21
lines changed

crates/wallet/src/wallet/mod.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,21 @@ impl Wallet {
889889
.next()
890890
}
891891

892+
/// Get a local output if the txout referenced by `outpoint` exists on chain and can
893+
/// be found in the inner tx graph.
894+
fn get_output(&self, outpoint: OutPoint) -> Option<LocalOutput> {
895+
let ((keychain, index), _) = self.indexed_graph.index.txout(outpoint)?;
896+
self.indexed_graph
897+
.graph()
898+
.filter_chain_txouts(
899+
&self.chain,
900+
self.chain.tip().block_id(),
901+
core::iter::once(((), outpoint)),
902+
)
903+
.map(|(_, full_txo)| new_local_utxo(keychain, index, full_txo))
904+
.next()
905+
}
906+
892907
/// Inserts a [`TxOut`] at [`OutPoint`] into the wallet's transaction graph.
893908
///
894909
/// This is used for providing a previous output's value so that we can use [`calculate_fee`]
@@ -1537,7 +1552,9 @@ impl Wallet {
15371552
///
15381553
/// Returns an error if the transaction is already confirmed or doesn't explicitly signal
15391554
/// *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.
1555+
/// pre-populated with the inputs and outputs of the original transaction. If you just
1556+
/// want to build a transaction that conflicts with the tx of the given `txid`, consider
1557+
/// using [`TxBuilder::replace_tx`].
15411558
///
15421559
/// ## Example
15431560
///
@@ -2571,7 +2588,7 @@ macro_rules! floating_rate {
25712588
/// Macro for getting a wallet for use in a doctest
25722589
macro_rules! doctest_wallet {
25732590
() => {{
2574-
use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
2591+
use $crate::bitcoin::{absolute, transaction, Amount, BlockHash, Transaction, TxOut, Network, hashes::Hash};
25752592
use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph};
25762593
use $crate::{Update, KeychainKind, Wallet};
25772594
use $crate::test_utils::*;

crates/wallet/src/wallet/tx_builder.rs

Lines changed: 293 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -274,25 +274,30 @@ 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 utxos = outpoints
278+
.iter()
279+
.map(|outpoint| {
280+
self.wallet
281+
.get_utxo(*outpoint)
282+
.or_else(|| {
283+
// allow selecting a spent output if we're bumping fee
284+
self.params
285+
.bumping_fee
286+
.and_then(|_| self.wallet.get_output(*outpoint))
287+
})
288+
.ok_or(AddUtxoError::UnknownUtxo(*outpoint))
289+
})
290+
.collect::<Result<Vec<_>, _>>()?;
291+
292+
for utxo in utxos {
293+
let descriptor = self.wallet.public_descriptor(utxo.keychain);
294+
let satisfaction_weight = descriptor
295+
.max_weight_to_satisfy()
296+
.expect("descriptor should be satisfiable");
297+
self.params.utxos.push(WeightedUtxo {
298+
satisfaction_weight,
299+
utxo: Utxo::Local(utxo),
300+
});
296301
}
297302

298303
Ok(self)
@@ -306,6 +311,120 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
306311
self.add_utxos(&[outpoint])
307312
}
308313

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

819+
/// Error returned by [`TxBuilder::replace_tx`].
820+
#[derive(Debug)]
821+
pub enum ReplaceTxError {
822+
/// Transaction was not found in tx graph
823+
MissingTransaction,
824+
/// Transaction can't be replaced by this wallet
825+
NonReplaceable,
826+
/// Transaction is already confirmed
827+
TransactionConfirmed,
828+
}
829+
830+
impl fmt::Display for ReplaceTxError {
831+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
832+
match self {
833+
Self::MissingTransaction => write!(f, "transaction not found in tx graph"),
834+
Self::NonReplaceable => write!(f, "no replaceable input found"),
835+
Self::TransactionConfirmed => write!(f, "cannot replace a confirmed tx"),
836+
}
837+
}
838+
}
839+
840+
#[cfg(feature = "std")]
841+
impl std::error::Error for ReplaceTxError {}
842+
700843
#[derive(Debug)]
701844
/// Error returned from [`TxBuilder::add_foreign_utxo`].
702845
pub enum AddForeignUtxoError {
@@ -833,6 +976,7 @@ mod test {
833976
};
834977
}
835978

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

0 commit comments

Comments
 (0)