Skip to content

Commit e5b8d34

Browse files
committed
Implement freeing up reserved utxos and cancel_tx
1 parent e2cf34d commit e5b8d34

File tree

2 files changed

+277
-4
lines changed

2 files changed

+277
-4
lines changed

src/wallet/mod.rs

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ pub struct Wallet {
109109
stage: ChangeSet,
110110
network: Network,
111111
secp: SecpCtx,
112+
reserved_utxos: HashSet<OutPoint>,
112113
}
113114

114115
/// An update to [`Wallet`].
@@ -499,6 +500,7 @@ impl Wallet {
499500
indexed_graph,
500501
stage,
501502
secp,
503+
reserved_utxos: HashSet::new(),
502504
})
503505
}
504506

@@ -695,6 +697,7 @@ impl Wallet {
695697
stage,
696698
network,
697699
secp,
700+
reserved_utxos: HashSet::new(),
698701
}))
699702
}
700703

@@ -1650,6 +1653,9 @@ impl Wallet {
16501653

16511654
let psbt = self.complete_transaction(tx, coin_selection.selected, params)?;
16521655

1656+
// Reserve the UTXOs used in this transaction
1657+
self.reserve_utxos(&psbt.unsigned_tx);
1658+
16531659
// recording changes to the change keychain
16541660
if let (Excess::Change { .. }, Some((keychain, index))) = (excess, drain_index) {
16551661
if let Some((_, index_changeset)) =
@@ -2093,11 +2099,38 @@ impl Wallet {
20932099
.0
20942100
}
20952101

2096-
/// Informs the wallet that you no longer intend to broadcast a tx that was built from it.
2102+
/// Marks the UTXOs used in the given transaction as reserved.
2103+
///
2104+
/// Reserved UTXOs will be excluded from coin selection until they are unreserved.
2105+
/// This prevents the same UTXO from being used in multiple unsigned transactions.
2106+
fn reserve_utxos(&mut self, tx: &Transaction) {
2107+
for input in &tx.input {
2108+
self.reserved_utxos.insert(input.previous_output);
2109+
}
2110+
}
2111+
2112+
/// Frees up UTXOs that were reserved for the given transaction.
2113+
///
2114+
/// This should be called when a transaction is cancelled or when it's confirmed on-chain.
2115+
/// Returns the number of UTXOs that were unreserved.
2116+
pub fn unreserve_utxos(&mut self, tx: &Transaction) -> usize {
2117+
let mut count = 0;
2118+
for input in &tx.input {
2119+
if self.reserved_utxos.remove(&input.previous_output) {
2120+
count += 1;
2121+
}
2122+
}
2123+
count
2124+
}
2125+
2126+
/// Frees up change addresses that were reserved for the given transaction.
2127+
///
2128+
/// This is called internally by [`cancel_tx`] to unreserve change addresses.
2129+
/// Note: This will **not** unreserve addresses that have actually been used
2130+
/// by a transaction in the tracker. It only removes superficial markings.
20972131
///
2098-
/// This frees up the change address used when creating the tx for use in future transactions.
2099-
// TODO: Make this free up reserved utxos when that's implemented
2100-
pub fn cancel_tx(&mut self, tx: &Transaction) {
2132+
/// [`cancel_tx`]: Self::cancel_tx
2133+
fn unreserve_change_address(&mut self, tx: &Transaction) {
21012134
let txout_index = &mut self.indexed_graph.index;
21022135
for txout in &tx.output {
21032136
if let Some((keychain, index)) = txout_index.index_of_spk(txout.script_pubkey.clone()) {
@@ -2108,6 +2141,41 @@ impl Wallet {
21082141
}
21092142
}
21102143

2144+
/// Informs the wallet that you no longer intend to broadcast a tx that was built from it.
2145+
///
2146+
/// This frees up both the change addresses and the UTXOs (inputs) used when creating the tx
2147+
/// for use in future transactions. Call this method when you decide not to broadcast a
2148+
/// transaction that was previously built.
2149+
///
2150+
/// Returns the number of UTXOs that were unreserved.
2151+
///
2152+
/// # Example
2153+
///
2154+
/// ```no_run
2155+
/// # use bdk_wallet::*;
2156+
/// # use bitcoin::*;
2157+
/// # let mut wallet = doctest_wallet!();
2158+
/// # let to_address = Address::from_str("2N4eQYCbKUHCCTUjBJeHcJp9ok6J2GZsTDt")
2159+
/// # .unwrap()
2160+
/// # .assume_checked();
2161+
/// // Build a transaction
2162+
/// let mut psbt = {
2163+
/// let mut builder = wallet.build_tx();
2164+
/// builder.add_recipient(to_address.script_pubkey(), Amount::from_sat(50_000));
2165+
/// builder.finish()?
2166+
/// };
2167+
///
2168+
/// // Later, decide not to broadcast it
2169+
/// let tx = psbt.unsigned_tx.clone();
2170+
/// let unreserved_count = wallet.cancel_tx(&tx);
2171+
/// println!("Unreserved {} UTXOs", unreserved_count);
2172+
/// # Ok::<(), Box<dyn std::error::Error>>(())
2173+
/// ```
2174+
pub fn cancel_tx(&mut self, tx: &Transaction) -> usize {
2175+
self.unreserve_change_address(tx);
2176+
self.unreserve_utxos(tx)
2177+
}
2178+
21112179
fn get_descriptor_for_txout(&self, txout: &TxOut) -> Option<DerivedDescriptor> {
21122180
let &(keychain, child) = self
21132181
.indexed_graph
@@ -2158,6 +2226,8 @@ impl Wallet {
21582226
})
21592227
// only add to optional UTxOs those marked as spendable
21602228
.filter(|local_output| !params.unspendable.contains(&local_output.outpoint))
2229+
// exclude reserved UTXOs (those used in other unsigned transactions)
2230+
.filter(|local_output| !self.reserved_utxos.contains(&local_output.outpoint))
21612231
// if bumping fees only add to optional UTxOs those confirmed
21622232
.filter(|local_output| {
21632233
params.bumping_fee.is_none() || local_output.chain_position.is_confirmed()

tests/wallet.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use core::panic;
12
use std::str::FromStr;
23
use std::sync::Arc;
34

@@ -3026,3 +3027,205 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering() {
30263027
// Check vout is sorted by recipient insertion order
30273028
assert!(txouts == vec![400, 300, 500]);
30283029
}
3030+
3031+
#[test]
3032+
fn test_utxo_reservation_prevents_double_spend() {
3033+
let (mut wallet, _) = get_funded_wallet_wpkh();
3034+
3035+
receive_output_in_latest_block(&mut wallet, Amount::from_sat(30_000));
3036+
receive_output_in_latest_block(&mut wallet, Amount::from_sat(30_000));
3037+
3038+
let addr = wallet.reveal_next_address(KeychainKind::External).address;
3039+
3040+
let initial_utxos: Vec<_> = wallet.list_unspent().collect();
3041+
assert!(
3042+
initial_utxos.len() >= 2,
3043+
"Need at least 2 UTXOs for this test"
3044+
);
3045+
3046+
let mut builder1 = wallet.build_tx();
3047+
builder1.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3048+
let psbt1 = builder1.finish().expect("should create tx1");
3049+
let tx1 = &psbt1.unsigned_tx;
3050+
3051+
let tx1_inputs: Vec<OutPoint> = tx1
3052+
.input
3053+
.iter()
3054+
.map(|input| input.previous_output)
3055+
.collect();
3056+
3057+
let mut builder2 = wallet.build_tx();
3058+
builder2.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3059+
let psbt2 = builder2.finish().expect("should create tx2");
3060+
let tx2 = &psbt2.unsigned_tx;
3061+
3062+
let tx2_inputs: Vec<OutPoint> = tx2
3063+
.input
3064+
.iter()
3065+
.map(|input| input.previous_output)
3066+
.collect();
3067+
3068+
for tx1_input in &tx1_inputs {
3069+
assert!(
3070+
!tx2_inputs.contains(tx1_input),
3071+
"tx2 should not reuse UTXO {:?} from tx1",
3072+
tx1_input
3073+
);
3074+
}
3075+
}
3076+
3077+
#[test]
3078+
fn test_cancel_tx_unreserves_utxos() {
3079+
let (mut wallet, _) = get_funded_wallet_wpkh();
3080+
let addr = wallet.reveal_next_address(KeychainKind::External).address;
3081+
3082+
let mut builder1 = wallet.build_tx();
3083+
builder1.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3084+
let psbt1 = builder1.finish().expect("should create tx1");
3085+
let tx1 = psbt1.unsigned_tx.clone();
3086+
3087+
let tx1_inputs: Vec<OutPoint> = tx1
3088+
.input
3089+
.iter()
3090+
.map(|input| input.previous_output)
3091+
.collect();
3092+
3093+
wallet.cancel_tx(&tx1);
3094+
3095+
let mut builder2 = wallet.build_tx();
3096+
builder2.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3097+
let psbt2 = builder2.finish().expect("should create tx2");
3098+
let tx2 = &psbt2.unsigned_tx;
3099+
3100+
let tx2_inputs: Vec<OutPoint> = tx2
3101+
.input
3102+
.iter()
3103+
.map(|input| input.previous_output)
3104+
.collect();
3105+
3106+
// After cancellation, tx2 should be able to use at least some UTXOs from tx1
3107+
// (coin selection might choose different UTXOs, but they should be available)
3108+
let shared_inputs: Vec<&OutPoint> = tx1_inputs
3109+
.iter()
3110+
.filter(|input| tx2_inputs.contains(input))
3111+
.collect();
3112+
3113+
// We expect tx2 to reuse at least one UTXO from tx1 since they have the same amount
3114+
assert!(
3115+
!shared_inputs.is_empty(),
3116+
"After cancel_tx, tx2 should be able to reuse UTXOs from tx1"
3117+
);
3118+
}
3119+
3120+
#[test]
3121+
fn test_unreserve_utxos_returns_correct_count() {
3122+
let (mut wallet, _) = get_funded_wallet_wpkh();
3123+
let addr = wallet.reveal_next_address(KeychainKind::External).address;
3124+
3125+
let mut builder = wallet.build_tx();
3126+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3127+
let psbt = builder.finish().expect("should create tx");
3128+
let tx = psbt.unsigned_tx.clone();
3129+
3130+
let input_count = tx.input.len();
3131+
3132+
let unreserved_count = wallet.unreserve_utxos(&tx);
3133+
3134+
// Should unreserve exactly as many UTXOs as there are inputs
3135+
assert_eq!(
3136+
unreserved_count, input_count,
3137+
"Should unreserve {} UTXOs",
3138+
input_count
3139+
);
3140+
3141+
// Unreserving again should return 0 (UTXOs already unreserved)
3142+
let second_unreserve_count = wallet.unreserve_utxos(&tx);
3143+
assert_eq!(
3144+
second_unreserve_count, 0,
3145+
"Second unreserve should return 0"
3146+
);
3147+
}
3148+
3149+
#[test]
3150+
fn test_multiple_unsigned_transactions() {
3151+
let (mut wallet, _) = get_funded_wallet_wpkh();
3152+
3153+
receive_output_in_latest_block(&mut wallet, Amount::from_sat(30_000));
3154+
receive_output_in_latest_block(&mut wallet, Amount::from_sat(30_000));
3155+
receive_output_in_latest_block(&mut wallet, Amount::from_sat(30_000));
3156+
3157+
let addr = wallet.reveal_next_address(KeychainKind::External).address;
3158+
3159+
let initial_utxos: Vec<_> = wallet.list_unspent().collect();
3160+
assert!(
3161+
initial_utxos.len() >= 3,
3162+
"Need at least 3 UTXOs for this test"
3163+
);
3164+
3165+
let mut transactions = Vec::new();
3166+
for i in 0..3 {
3167+
let mut builder = wallet.build_tx();
3168+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(20_000 + i * 1_000));
3169+
let psbt = builder
3170+
.finish()
3171+
.unwrap_or_else(|_| panic!("should create tx{}", i + 1));
3172+
transactions.push(psbt.unsigned_tx.clone());
3173+
}
3174+
3175+
let mut all_inputs: Vec<OutPoint> = Vec::new();
3176+
for tx in &transactions {
3177+
for input in &tx.input {
3178+
all_inputs.push(input.previous_output);
3179+
}
3180+
}
3181+
3182+
// Verify no UTXO is used twice
3183+
let mut seen = std::collections::HashSet::new();
3184+
for input in &all_inputs {
3185+
assert!(
3186+
seen.insert(input),
3187+
"UTXO {:?} was used in multiple transactions",
3188+
input
3189+
);
3190+
}
3191+
3192+
// Cancel all transactions and verify we can build new ones
3193+
for tx in &transactions {
3194+
wallet.cancel_tx(tx);
3195+
}
3196+
3197+
// After cancelling all, we should be able to build a new transaction
3198+
let mut builder = wallet.build_tx();
3199+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000));
3200+
let result = builder.finish();
3201+
assert!(
3202+
result.is_ok(),
3203+
"Should be able to build tx after cancelling all previous ones"
3204+
);
3205+
}
3206+
3207+
#[test]
3208+
fn test_cancel_tx_unreserves_change_address() {
3209+
let (mut wallet, _) = get_funded_wallet_wpkh();
3210+
let addr = wallet.reveal_next_address(KeychainKind::External).address;
3211+
3212+
let mut builder = wallet.build_tx();
3213+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(10_000));
3214+
let psbt = builder.finish().expect("should create tx");
3215+
let tx = psbt.unsigned_tx.clone();
3216+
3217+
// Check if transaction has change output (more than 1 output)
3218+
let has_change = tx.output.len() > 1;
3219+
3220+
if has_change {
3221+
let change_index_before = wallet.next_derivation_index(KeychainKind::Internal);
3222+
wallet.cancel_tx(&tx);
3223+
3224+
let change_index_after = wallet.next_derivation_index(KeychainKind::Internal);
3225+
// After unreserving, we should be back to the same index
3226+
assert_eq!(
3227+
change_index_before, change_index_after,
3228+
"Change address should be unreserved"
3229+
);
3230+
}
3231+
}

0 commit comments

Comments
 (0)