Skip to content

Commit 0362998

Browse files
committed
feat(wallet): add Wallet::insert_txout function and updated docs for fee functions
added - Wallet::insert_txout function to allow inserting foreign TxOuts - test to verify error when trying to calculate fee with missing foreign utxo - test to calculate fee with inserted foreign utxo updated - docs for Wallet::calculate_fee, Wallet::calculate_fee_rate, and TxGraph::calculate_fee with note about missing foreign utxos
1 parent d443fe7 commit 0362998

File tree

5 files changed

+206
-61
lines changed

5 files changed

+206
-61
lines changed

crates/bdk/src/wallet/mod.rs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,16 +431,40 @@ impl<D> Wallet<D> {
431431
.next()
432432
}
433433

434+
/// Inserts the given foreign `TxOut` at `OutPoint` into the wallet's transaction graph. Any
435+
/// inserted foreign TxOuts are not persisted until [`Self::commit`] is called.
436+
///
437+
/// Only insert TxOuts you trust the values for!
438+
pub fn insert_txout(&mut self, outpoint: OutPoint, txout: TxOut)
439+
where
440+
D: PersistBackend<ChangeSet>,
441+
{
442+
let additions = self.indexed_graph.insert_txout(outpoint, &txout);
443+
self.persist.stage(ChangeSet::from(additions));
444+
}
445+
434446
/// Calculates the fee of a given transaction. Returns 0 if `tx` is a coinbase transaction.
435447
///
448+
/// To calculate the fee for a [`Transaction`] that depends on foreign [`TxOut`] values you must
449+
/// first manually insert the foreign TxOuts into the tx graph using the [`insert_txout`] function.
450+
/// Only insert TxOuts you trust the values for!
451+
///
436452
/// Note `tx` does not have to be in the graph for this to work.
453+
///
454+
/// [`insert_txout`]: Self::insert_txout
437455
pub fn calculate_fee(&self, tx: &Transaction) -> Result<u64, CalculateFeeError> {
438456
self.indexed_graph.graph().calculate_fee(tx)
439457
}
440458

441-
/// Calculate the `FeeRate` for a given transaction.
459+
/// Calculate the [`FeeRate`] for a given transaction.
460+
///
461+
/// To calculate the fee rate for a [`Transaction`] that depends on foreign [`TxOut`] values you
462+
/// must first manually insert the foreign TxOuts into the tx graph using the [`insert_txout`] function.
463+
/// Only insert TxOuts you trust the values for!
442464
///
443465
/// Note `tx` does not have to be in the graph for this to work.
466+
///
467+
/// [`insert_txout`]: Self::insert_txout
444468
pub fn calculate_fee_rate(&self, tx: &Transaction) -> Result<FeeRate, CalculateFeeError> {
445469
self.calculate_fee(tx).map(|fee| {
446470
let weight = tx.weight();
@@ -451,14 +475,55 @@ impl<D> Wallet<D> {
451475
/// Computes total input value going from script pubkeys in the index (sent) and the total output
452476
/// value going to script pubkeys in the index (received) in `tx`. For the `sent` to be computed
453477
/// correctly, the output being spent must have already been scanned by the index. Calculating
454-
/// received just uses the transaction outputs directly, so it will be correct even if it has not
455-
/// been scanned.
478+
/// received just uses the [`Transaction`] outputs directly, so it will be correct even if it has
479+
/// not been scanned.
456480
pub fn sent_and_received(&self, tx: &Transaction) -> (u64, u64) {
457481
self.indexed_graph.index.sent_and_received(tx)
458482
}
459483

460-
/// Return a single `CanonicalTx` made and received by the wallet or `None` if it doesn't
461-
/// exist in the wallet
484+
/// Get a single transaction from the wallet as a [`CanonicalTx`] (if the transaction exists).
485+
///
486+
/// `CanonicalTx` contains the full transaction alongside meta-data such as:
487+
/// * Blocks that the transaction is [`Anchor`]ed in. These may or may not be blocks that exist
488+
/// in the best chain.
489+
/// * The [`ChainPosition`] of the transaction in the best chain - whether the transaction is
490+
/// confirmed or unconfirmed. If the transaction is confirmed, the anchor which proves the
491+
/// confirmation is provided. If the transaction is unconfirmed, the unix timestamp of when
492+
/// the transaction was last seen in the mempool is provided.
493+
///
494+
/// ```rust, no_run
495+
/// use bdk::{chain::ChainPosition, Wallet};
496+
/// use bdk_chain::Anchor;
497+
/// # let wallet: Wallet<()> = todo!();
498+
/// # let my_txid: bitcoin::Txid = todo!();
499+
///
500+
/// let canonical_tx = wallet.get_tx(my_txid).expect("panic if tx does not exist");
501+
///
502+
/// // get reference to full transaction
503+
/// println!("my tx: {:#?}", canonical_tx.tx_node.tx);
504+
///
505+
/// // list all transaction anchors
506+
/// for anchor in canonical_tx.tx_node.anchors {
507+
/// println!(
508+
/// "tx is anchored by block of hash {}",
509+
/// anchor.anchor_block().hash
510+
/// );
511+
/// }
512+
///
513+
/// // get confirmation status of transaction
514+
/// match canonical_tx.chain_position {
515+
/// ChainPosition::Confirmed(anchor) => println!(
516+
/// "tx is confirmed at height {}, we know this since {}:{} is in the best chain",
517+
/// anchor.confirmation_height, anchor.anchor_block.height, anchor.anchor_block.hash,
518+
/// ),
519+
/// ChainPosition::Unconfirmed(last_seen) => println!(
520+
/// "tx is last seen at {}, it is unconfirmed as it is not anchored in the best chain",
521+
/// last_seen,
522+
/// ),
523+
/// }
524+
/// ```
525+
///
526+
/// [`Anchor`]: bdk_chain::Anchor
462527
pub fn get_tx(
463528
&self,
464529
txid: Txid,

crates/bdk/tests/common.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ use bitcoin::hashes::Hash;
77
use bitcoin::{Address, BlockHash, Network, OutPoint, Transaction, TxIn, TxOut, Txid};
88
use std::str::FromStr;
99

10-
/// Return a fake wallet that appears to be funded for testing.
10+
// Return a fake wallet that appears to be funded for testing.
11+
//
12+
// The funded wallet containing a tx with a 76_000 sats input and two outputs, one spending 25_000
13+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
14+
// sats are the transaction fee.
1115
pub fn get_funded_wallet_with_change(
1216
descriptor: &str,
1317
change: Option<&str>,
@@ -95,6 +99,11 @@ pub fn get_funded_wallet_with_change(
9599
(wallet, tx1.txid())
96100
}
97101

102+
// Return a fake wallet that appears to be funded for testing.
103+
//
104+
// The funded wallet containing a tx with a 76_000 sats input and two outputs, one spending 25_000
105+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
106+
// sats are the transaction fee.
98107
pub fn get_funded_wallet(descriptor: &str) -> (Wallet, bitcoin::Txid) {
99108
get_funded_wallet_with_change(descriptor, None)
100109
}

crates/bdk/tests/wallet.rs

Lines changed: 110 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ use bdk::wallet::coin_selection::LargestFirstCoinSelection;
66
use bdk::wallet::AddressIndex::*;
77
use bdk::wallet::{AddressIndex, AddressInfo, Balance, Wallet};
88
use bdk::{Error, FeeRate, KeychainKind};
9-
use bdk_chain::tx_graph::CalculateFeeError;
109
use bdk_chain::COINBASE_MATURITY;
1110
use bdk_chain::{BlockId, ConfirmationTime};
1211
use bitcoin::hashes::Hash;
@@ -84,63 +83,60 @@ fn test_descriptor_checksum() {
8483
#[test]
8584
fn test_get_funded_wallet_balance() {
8685
let (wallet, _) = get_funded_wallet(get_test_wpkh());
87-
assert_eq!(wallet.get_balance().confirmed, 50000);
86+
87+
// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
88+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
89+
// sats are the transaction fee.
90+
assert_eq!(wallet.get_balance().confirmed, 50_000);
8891
}
8992

9093
#[test]
9194
fn test_get_funded_wallet_sent_and_received() {
92-
let (wallet, _) = get_funded_wallet(get_test_wpkh());
93-
assert_eq!(wallet.get_balance().confirmed, 50000);
95+
let (wallet, txid) = get_funded_wallet(get_test_wpkh());
96+
9497
let mut tx_amounts: Vec<(Txid, (u64, u64))> = wallet
9598
.transactions()
96-
.map(|ct| (ct.node.txid, wallet.sent_and_received(ct.node.tx)))
99+
.map(|ct| (ct.tx_node.txid, wallet.sent_and_received(ct.tx_node.tx)))
97100
.collect();
98101
tx_amounts.sort_by(|a1, a2| a1.0.cmp(&a2.0));
99102

100-
assert_eq!(tx_amounts.len(), 2);
101-
assert_matches!(tx_amounts.get(0), Some((_, (76_000, 50_000))))
103+
let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx;
104+
let (sent, received) = wallet.sent_and_received(tx);
105+
106+
// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
107+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
108+
// sats are the transaction fee.
109+
assert_eq!(sent, 76_000);
110+
assert_eq!(received, 50_000);
102111
}
103112

104113
#[test]
105114
fn test_get_funded_wallet_tx_fees() {
106-
let (wallet, _) = get_funded_wallet(get_test_wpkh());
107-
assert_eq!(wallet.get_balance().confirmed, 50000);
108-
let mut tx_fee_amounts: Vec<(Txid, Result<u64, CalculateFeeError>)> = wallet
109-
.transactions()
110-
.map(|ct| {
111-
let fee = wallet.calculate_fee(ct.node.tx);
112-
(ct.node.txid, fee)
113-
})
114-
.collect();
115-
tx_fee_amounts.sort_by(|a1, a2| a1.0.cmp(&a2.0));
115+
let (wallet, txid) = get_funded_wallet(get_test_wpkh());
116116

117-
assert_eq!(tx_fee_amounts.len(), 2);
118-
assert_matches!(
119-
tx_fee_amounts.get(1),
120-
Some((_, Err(CalculateFeeError::MissingTxOut(_))))
121-
);
122-
assert_matches!(tx_fee_amounts.get(0), Some((_, Ok(1000))))
117+
let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx;
118+
let tx_fee = wallet.calculate_fee(tx).expect("transaction fee");
119+
120+
// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
121+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
122+
// sats are the transaction fee.
123+
assert_eq!(tx_fee, 1000)
123124
}
124125

125126
#[test]
126127
fn test_get_funded_wallet_tx_fee_rate() {
127-
let (wallet, _) = get_funded_wallet(get_test_wpkh());
128-
assert_eq!(wallet.get_balance().confirmed, 50000);
129-
let mut tx_fee_rates: Vec<(Txid, Result<FeeRate, CalculateFeeError>)> = wallet
130-
.transactions()
131-
.map(|ct| {
132-
let fee_rate = wallet.calculate_fee_rate(ct.node.tx);
133-
(ct.node.txid, fee_rate)
134-
})
135-
.collect();
136-
tx_fee_rates.sort_by(|a1, a2| a1.0.cmp(&a2.0));
128+
let (wallet, txid) = get_funded_wallet(get_test_wpkh());
137129

138-
assert_eq!(tx_fee_rates.len(), 2);
139-
assert_matches!(
140-
tx_fee_rates.get(1),
141-
Some((_, Err(CalculateFeeError::MissingTxOut(_))))
142-
);
143-
assert_matches!(tx_fee_rates.get(0), Some((_, Ok(_))))
130+
let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx;
131+
let tx_fee_rate = wallet.calculate_fee_rate(tx).expect("transaction fee rate");
132+
133+
// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
134+
// to a foreign address and one returning 50_000 back to the wallet as change. The remaining 1000
135+
// sats are the transaction fee.
136+
137+
// tx weight = 452 bytes, as vbytes = (452+3)/4 = 113
138+
// fee rate (sats per vbyte) = fee / vbytes = 1000 / 113 = 8.8495575221 rounded to 8.849558
139+
assert_eq!(tx_fee_rate.as_sat_per_vb(), 8.849558);
144140
}
145141

146142
macro_rules! assert_fee_rate {
@@ -1098,6 +1094,77 @@ fn test_add_foreign_utxo() {
10981094
assert!(finished, "all the inputs should have been signed now");
10991095
}
11001096

1097+
#[test]
1098+
#[should_panic(
1099+
expected = "MissingTxOut([OutPoint { txid: 0x21d7fb1bceda00ab4069fc52d06baa13470803e9050edd16f5736e5d8c4925fd, vout: 0 }])"
1100+
)]
1101+
fn test_calculate_fee_with_missing_foreign_utxo() {
1102+
let (mut wallet1, _) = get_funded_wallet(get_test_wpkh());
1103+
let (wallet2, _) =
1104+
get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
1105+
1106+
let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX")
1107+
.unwrap()
1108+
.assume_checked();
1109+
let utxo = wallet2.list_unspent().next().expect("must take!");
1110+
#[allow(deprecated)]
1111+
let foreign_utxo_satisfaction = wallet2
1112+
.get_descriptor_for_keychain(KeychainKind::External)
1113+
.max_satisfaction_weight()
1114+
.unwrap();
1115+
1116+
let psbt_input = psbt::Input {
1117+
witness_utxo: Some(utxo.txout.clone()),
1118+
..Default::default()
1119+
};
1120+
1121+
let mut builder = wallet1.build_tx();
1122+
builder
1123+
.add_recipient(addr.script_pubkey(), 60_000)
1124+
.only_witness_utxo()
1125+
.add_foreign_utxo(utxo.outpoint, psbt_input, foreign_utxo_satisfaction)
1126+
.unwrap();
1127+
let psbt = builder.finish().unwrap();
1128+
let tx = psbt.extract_tx();
1129+
wallet1.calculate_fee(&tx).unwrap();
1130+
}
1131+
1132+
#[test]
1133+
fn test_calculate_fee_with_inserted_foreign_utxo() {
1134+
let (mut wallet1, _) = get_funded_wallet(get_test_wpkh());
1135+
let (wallet2, _) =
1136+
get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
1137+
1138+
let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX")
1139+
.unwrap()
1140+
.assume_checked();
1141+
let utxo = wallet2.list_unspent().next().expect("must take!");
1142+
#[allow(deprecated)]
1143+
let foreign_utxo_satisfaction = wallet2
1144+
.get_descriptor_for_keychain(KeychainKind::External)
1145+
.max_satisfaction_weight()
1146+
.unwrap();
1147+
1148+
let psbt_input = psbt::Input {
1149+
witness_utxo: Some(utxo.txout.clone()),
1150+
..Default::default()
1151+
};
1152+
1153+
let mut builder = wallet1.build_tx();
1154+
builder
1155+
.add_recipient(addr.script_pubkey(), 60_000)
1156+
.only_witness_utxo()
1157+
.add_foreign_utxo(utxo.outpoint, psbt_input, foreign_utxo_satisfaction)
1158+
.unwrap();
1159+
let psbt = builder.finish().unwrap();
1160+
let psbt_fee = psbt.fee_amount().expect("psbt fee");
1161+
let tx = psbt.extract_tx();
1162+
1163+
wallet1.insert_txout(utxo.outpoint, utxo.txout);
1164+
let wallet1_fee = wallet1.calculate_fee(&tx).expect("wallet fee");
1165+
assert_eq!(psbt_fee, wallet1_fee);
1166+
}
1167+
11011168
#[test]
11021169
#[should_panic(expected = "Generic(\"Foreign utxo missing witness_utxo or non_witness_utxo\")")]
11031170
fn test_add_foreign_utxo_invalid_psbt_input() {
@@ -1122,8 +1189,8 @@ fn test_add_foreign_utxo_where_outpoint_doesnt_match_psbt_input() {
11221189
get_funded_wallet("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)");
11231190

11241191
let utxo2 = wallet2.list_unspent().next().unwrap();
1125-
let tx1 = wallet1.get_tx(txid1).unwrap().node.tx.clone();
1126-
let tx2 = wallet2.get_tx(txid2).unwrap().node.tx.clone();
1192+
let tx1 = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone();
1193+
let tx2 = wallet2.get_tx(txid2).unwrap().tx_node.tx.clone();
11271194

11281195
#[allow(deprecated)]
11291196
let satisfaction_weight = wallet2
@@ -1212,7 +1279,7 @@ fn test_add_foreign_utxo_only_witness_utxo() {
12121279

12131280
{
12141281
let mut builder = builder.clone();
1215-
let tx2 = wallet2.get_tx(txid2).unwrap().node.tx;
1282+
let tx2 = wallet2.get_tx(txid2).unwrap().tx_node.tx;
12161283
let psbt_input = psbt::Input {
12171284
non_witness_utxo: Some(tx2.clone()),
12181285
..Default::default()
@@ -2842,7 +2909,7 @@ fn test_taproot_sign_using_non_witness_utxo() {
28422909
let mut psbt = builder.finish().unwrap();
28432910

28442911
psbt.inputs[0].witness_utxo = None;
2845-
psbt.inputs[0].non_witness_utxo = Some(wallet.get_tx(prev_txid).unwrap().node.tx.clone());
2912+
psbt.inputs[0].non_witness_utxo = Some(wallet.get_tx(prev_txid).unwrap().tx_node.tx.clone());
28462913
assert!(
28472914
psbt.inputs[0].non_witness_utxo.is_some(),
28482915
"Previous tx should be present in the database"

crates/chain/src/spk_txout_index.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ impl<I: Clone + Ord> SpkTxOutIndex<I> {
288288
/// Computes total input value going from script pubkeys in the index (sent) and the total output
289289
/// value going to script pubkeys in the index (received) in `tx`. For the `sent` to be computed
290290
/// correctly, the output being spent must have already been scanned by the index. Calculating
291-
/// received just uses the transaction outputs directly, so it will be correct even if it has not
292-
/// been scanned.
291+
/// received just uses the [`Transaction`] outputs directly, so it will be correct even if it has
292+
/// not been scanned.
293293
pub fn sent_and_received(&self, tx: &Transaction) -> (u64, u64) {
294294
let mut sent = 0;
295295
let mut received = 0;

crates/chain/src/tx_graph.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -245,33 +245,37 @@ impl<A> TxGraph<A> {
245245
}
246246

247247
/// Calculates the fee of a given transaction. Returns 0 if `tx` is a coinbase transaction.
248-
/// Returns `OK(_)` if we have all the `TxOut`s being spent by `tx` in the graph (either as
248+
/// Returns `OK(_)` if we have all the [`TxOut`]s being spent by `tx` in the graph (either as
249249
/// the full transactions or individual txouts).
250250
///
251+
/// To calculate the fee for a [`Transaction`] that depends on foreign [`TxOut`] values you must
252+
/// first manually insert the foreign TxOuts into the tx graph using the [`insert_txout`] function.
253+
/// Only insert TxOuts you trust the values for!
254+
///
251255
/// Note `tx` does not have to be in the graph for this to work.
256+
///
257+
/// [`insert_txout`]: Self::insert_txout
252258
pub fn calculate_fee(&self, tx: &Transaction) -> Result<u64, CalculateFeeError> {
253259
if tx.is_coin_base() {
254260
return Ok(0);
255261
}
256-
let inputs_sum = tx.input.iter().fold(
257-
(0_u64, Vec::new()),
262+
263+
let (inputs_sum, missing_outputs) = tx.input.iter().fold(
264+
(0_i64, Vec::new()),
258265
|(mut sum, mut missing_outpoints), txin| match self.get_txout(txin.previous_output) {
259266
None => {
260267
missing_outpoints.push(txin.previous_output);
261268
(sum, missing_outpoints)
262269
}
263270
Some(txout) => {
264-
sum += txout.value;
271+
sum += txout.value as i64;
265272
(sum, missing_outpoints)
266273
}
267274
},
268275
);
269-
270-
let inputs_sum = if inputs_sum.1.is_empty() {
271-
Ok(inputs_sum.0 as i64)
272-
} else {
273-
Err(CalculateFeeError::MissingTxOut(inputs_sum.1))
274-
}?;
276+
if !missing_outputs.is_empty() {
277+
return Err(CalculateFeeError::MissingTxOut(missing_outputs));
278+
}
275279

276280
let outputs_sum = tx
277281
.output

0 commit comments

Comments
 (0)