diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1f0ed24e..8f97a618 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -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, @@ -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 + /// `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>| { + 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. + |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 diff --git a/tests/wallet.rs b/tests/wallet.rs index 0204cfd5..7b6cec08 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -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); +}