Skip to content

Commit 7e6276e

Browse files
committed
feat(wallet): add method replace_tx for TxBuilder
- Add method `TxBuilder::previous_fee` for getting the previous feerate of the replaced tx
1 parent a5335a1 commit 7e6276e

File tree

3 files changed

+217
-21
lines changed

3 files changed

+217
-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
///
@@ -2570,7 +2587,7 @@ macro_rules! floating_rate {
25702587
/// Macro for getting a wallet for use in a doctest
25712588
macro_rules! doctest_wallet {
25722589
() => {{
2573-
use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
2590+
use $crate::bitcoin::{transaction, Amount, BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash};
25742591
use $crate::chain::{ConfirmationBlockTime, BlockId, TxGraph, tx_graph};
25752592
use $crate::{Update, KeychainKind, Wallet};
25762593
use $crate::test_utils::*;

crates/wallet/src/wallet/tx_builder.rs

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

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

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

804+
/// Error returned by [`TxBuilder::replace_tx`].
805+
#[derive(Debug)]
806+
pub enum ReplaceTxError {
807+
/// Unable to find a locally owned output
808+
MissingOutput(OutPoint),
809+
/// Transaction was not found in tx graph
810+
MissingTransaction,
811+
/// Transaction can't be replaced by this wallet
812+
NonReplaceable,
813+
/// Transaction is already confirmed
814+
TransactionConfirmed,
815+
}
816+
817+
impl fmt::Display for ReplaceTxError {
818+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
819+
match self {
820+
Self::MissingOutput(op) => {
821+
write!(f, "could not find wallet output for outpoint {}", op)
822+
}
823+
Self::MissingTransaction => write!(f, "transaction not found in tx graph"),
824+
Self::NonReplaceable => write!(f, "no replaceable input found"),
825+
Self::TransactionConfirmed => write!(f, "cannot replace a confirmed tx"),
826+
}
827+
}
828+
}
829+
830+
#[cfg(feature = "std")]
831+
impl std::error::Error for ReplaceTxError {}
832+
700833
#[derive(Debug)]
701834
/// Error returned from [`TxBuilder::add_foreign_utxo`].
702835
pub enum AddForeignUtxoError {

crates/wallet/tests/wallet.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4280,3 +4280,49 @@ fn test_wallet_transactions_relevant() {
42804280
assert!(full_tx_count_before < full_tx_count_after);
42814281
assert!(canonical_tx_count_before < canonical_tx_count_after);
42824282
}
4283+
4284+
#[test]
4285+
fn replace_tx_allows_selecting_spent_inputs() {
4286+
let (mut wallet, txid_0) = get_funded_wallet_wpkh();
4287+
let outpoint_1 = OutPoint::new(txid_0, 0);
4288+
4289+
// receive output 2
4290+
let outpoint_2 = receive_output_in_latest_block(&mut wallet, 49_000);
4291+
assert_eq!(wallet.list_unspent().count(), 2);
4292+
assert_eq!(wallet.balance().total().to_sat(), 99_000);
4293+
4294+
// create tx1: 2-in/1-out sending all to `recip`
4295+
let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa").unwrap();
4296+
let mut builder = wallet.build_tx();
4297+
builder.add_recipient(recip.clone(), Amount::from_sat(98_800));
4298+
let psbt = builder.finish().unwrap();
4299+
let tx1 = psbt.unsigned_tx;
4300+
let txid1 = tx1.compute_txid();
4301+
insert_tx(&mut wallet, tx1);
4302+
assert!(wallet.list_unspent().next().is_none());
4303+
4304+
// now replace tx1 with a new transaction
4305+
let mut builder = wallet.build_tx();
4306+
builder.replace_tx(txid1).expect("should replace input");
4307+
let prev_feerate = builder.previous_fee().unwrap();
4308+
builder.add_recipient(recip, Amount::from_sat(98_500));
4309+
builder.fee_rate(FeeRate::from_sat_per_kwu(
4310+
prev_feerate.to_sat_per_kwu() + 250,
4311+
));
4312+
4313+
// Because outpoint 2 was spent in tx1, by default it won't be available for selection,
4314+
// but we can add it manually, with the caveat that the builder is in a bump-fee
4315+
// context.
4316+
builder.add_utxo(outpoint_2).expect("should add output");
4317+
let psbt = builder.finish().unwrap();
4318+
4319+
let tx2 = psbt.unsigned_tx;
4320+
assert!(tx2
4321+
.input
4322+
.iter()
4323+
.any(|txin| txin.previous_output == outpoint_1));
4324+
assert!(tx2
4325+
.input
4326+
.iter()
4327+
.any(|txin| txin.previous_output == outpoint_2));
4328+
}

0 commit comments

Comments
 (0)