Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use bdk_chain::{
},
tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate},
BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt,
FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge,
Eligibility, FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge,
};
use bitcoin::{
absolute,
Expand Down Expand Up @@ -1153,14 +1153,51 @@ impl Wallet {

/// Return the balance, separated into available, trusted-pending, untrusted-pending, and
/// immature values.
///
/// A pending output is trusted only when its entire unconfirmed ancestry spends coins we own.
/// If any unconfirmed ancestor pulls in a foreign or unknown output, the output is untrusted.
///
/// NOTE: depends on `CanonicalView` (bitcoindevkit/bdk#2246), not yet in a published

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this a comment (i.e., // NOTE:) so IDEs highlight it.

/// `bdk_chain` release.
pub fn balance(&self) -> Balance {
self.tx_graph.graph().balance(
&self.chain,
self.chain.tip().block_id(),
CanonicalizationParams::default(),
self.tx_graph.index.outpoints().iter().cloned(),
|&(k, _), _| k == KeychainKind::Internal,
)
let graph = self.tx_graph.graph();
let index = &self.tx_graph.index;
let chain_tip = self.chain.tip().block_id();

// A tx pulls in untrusted funds if any of its inputs spends an output we don't own
// (foreign spk, or unknown to our graph). Transitive taint through unconfirmed ancestry
// is handled by `classify_outpoints`, which calls this on every unsettled ancestor.
let does_taint = |ctx: &CanonicalTx<ChainPosition<ConfirmationBlockTime>>| {
ctx.tx.input.iter().any(|txin| {
let op = txin.previous_output;
!op.is_null()
&& graph
.get_txout(op)
.map(|txo| index.index_of_spk(txo.script_pubkey.clone()).is_none())
.unwrap_or(true)
})
};

let view = self
.chain
.canonical_view(graph, chain_tip, CanonicalParams::default());

let mut balance = Balance::default();
for (txout, eligibility) in view.classify_outpoints(
index.outpoints().iter().map(|(_, op)| *op),
does_taint,
// TODO: take `min_confirmations` and build `is_settled` from it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why min_confirmations isn't here yet?

|pos| pos.is_confirmed(),
) {
let bucket = match eligibility {
Eligibility::Settled => &mut balance.confirmed,
Eligibility::Immature => &mut balance.immature,
Eligibility::TrustedPending => &mut balance.trusted_pending,
Eligibility::UntrustedPending => &mut balance.untrusted_pending,
};
*bucket += txout.txout.value;
}
balance
}

/// Add an external signer
Expand Down
263 changes: 263 additions & 0 deletions tests/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3331,3 +3331,266 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() {
"UTXOs should be ordered with required first, then selected"
);
}

/// An unconfirmed output that spends our own confirmed coin is trusted-pending.
#[test]
fn test_trusted_pending_balance_from_owned_outpoints() {
let (mut wallet, txid) = get_funded_wallet_wpkh();
let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx.clone());

let balance = wallet.balance();

assert_eq!(balance.trusted_pending, Amount::from_sat(500));
assert_eq!(balance.untrusted_pending, Amount::ZERO);
}

/// An unconfirmed output funded by an input we do not own is untrusted-pending.
#[test]
fn test_untrusted_pending_balance_from_external_inputs() {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()
.expect("wallet");

let txid = Txid::from_raw_hash(Hash::all_zeros());

let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx.clone());

let balance = wallet.balance();

assert_eq!(balance.untrusted_pending, Amount::from_sat(500));
assert_eq!(balance.trusted_pending, Amount::ZERO);
}

/// Trust propagates down a chain of unconfirmed txs that only spend our own coins.
#[test]
fn test_trusted_pending_transitive_chain() {
let (mut wallet, txid) = get_funded_wallet_wpkh();

let tx_a = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

let tx_a_txid = tx_a.compute_txid();
insert_tx(&mut wallet, tx_a);

let tx_b = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: tx_a_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
insert_tx(&mut wallet, tx_b);

let balance = wallet.balance();

assert_eq!(balance.trusted_pending, Amount::from_sat(500));
assert_eq!(balance.untrusted_pending, Amount::ZERO);
}

/// Paying our change keychain does not grant trust when the input is foreign.
#[test]
fn test_pay_to_internal_from_not_trusted() {
let (mut wallet, _) = get_funded_wallet_wpkh();

// Build a tx whose input comes from an unknown (external) outpoint,
// but whose output goes to our change (internal) keychain address.
let external_txid = Txid::from_raw_hash(Hash::all_zeros());
let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: external_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx);

let balance = wallet.balance();

// The output is ours but the input is not owned, so it must be untrusted_pending.
assert_eq!(balance.untrusted_pending, Amount::from_sat(500));
assert_eq!(balance.trusted_pending, Amount::ZERO);
}

/// Untrusted even when a sibling change output of the same tx is trusted.
#[test]
fn test_trusted_pending_does_not_propagate_through_foreign_outputs() {
let (mut wallet, txid) = get_funded_wallet_wpkh();

let foreign_addr = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5")
.expect("valid address")
.require_network(Network::Regtest)
.unwrap();

let tx_a = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![
TxOut {
value: Amount::from_sat(25_000),
script_pubkey: foreign_addr.script_pubkey(),
},
TxOut {
value: Amount::from_sat(24_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
},
],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
let tx_a_txid = tx_a.compute_txid();
insert_tx(&mut wallet, tx_a);

let tx_b = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: tx_a_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(20_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
insert_tx(&mut wallet, tx_b);

let balance = wallet.balance();

assert_eq!(balance.trusted_pending, Amount::from_sat(24_000));
assert_eq!(balance.untrusted_pending, Amount::from_sat(20_000));
}

/// Spending an owned-but-untrusted unconfirmed output stays untrusted.
#[test]
fn test_spending_untrusted_is_untrusted() {
let (mut wallet, _) = get_funded_wallet_wpkh();

let external_tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: Txid::all_zeros(), // not owned by wallet
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(50_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

let external_txid = external_tx.compute_txid();
insert_tx(&mut wallet, external_tx);

let tx_spend = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: external_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(45_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx_spend);

let balance = wallet.balance();

assert_eq!(balance.untrusted_pending, Amount::from_sat(45_000));
assert_eq!(balance.trusted_pending, Amount::ZERO);
}