From 3c5ad5fb7c523879998242769c7349f942e046e4 Mon Sep 17 00:00:00 2001 From: Dmenec Date: Fri, 24 Jul 2026 13:55:23 +0200 Subject: [PATCH 1/3] refactor(wallet): derive balance trust from output ancestry Refactor balance() to classify pending outputs by ancestry rather than keychain type. Outputs sent to external addresses now count as trusted if their unconfirmed ancestry belongs entirely to the wallet. --- src/wallet/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1f0ed24e..1594ea8d 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,50 @@ 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, + |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 From 5854d1959232d34269a5941af0f00b8856265038 Mon Sep 17 00:00:00 2001 From: Dmenec Date: Fri, 24 Jul 2026 13:58:22 +0200 Subject: [PATCH 2/3] test(wallet): add trusted/untrusted pending categorization tests Cover how balance() classifies unconfirmed outputs by ancestry: - Spending our own confirmed coin is trusted. - An input we do not own is untrusted. - Trust propagates down a chain that only spends our coins. - Paying our change keychain does not trust a foreign input. - Spending a foreign output is untrusted even next to a trusted change output. - Spending an owned-but-untrusted output stays untrusted. --- tests/wallet.rs | 263 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) 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); +} From 56c1299a75789e976a8ef6767815ba1d82b911ad Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 5 Aug 2026 14:32:11 +0200 Subject: [PATCH 3/3] feat(wallet)!: thread min_confirmations through Wallet::balance --- examples/bitcoind_rpc.rs | 4 +- examples/electrum.rs | 6 +- examples/esplora_async.rs | 6 +- examples/esplora_blocking.rs | 6 +- examples/replace_by_fee.rs | 2 +- src/wallet/mod.rs | 26 +++++--- tests/create_psbt.rs | 2 +- tests/psbt.rs | 2 +- tests/wallet.rs | 118 +++++++++++++++++++++++++++++++---- 9 files changed, 138 insertions(+), 34 deletions(-) diff --git a/examples/bitcoind_rpc.rs b/examples/bitcoind_rpc.rs index f0bbd729..09c9d5d7 100644 --- a/examples/bitcoind_rpc.rs +++ b/examples/bitcoind_rpc.rs @@ -119,7 +119,7 @@ fn main() -> anyhow::Result<()> { let address = wallet.reveal_next_address(KeychainKind::External).address; println!("Wallet address: {address}"); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance before syncing: {}", balance.total()); let wallet_tip = wallet.latest_checkpoint(); @@ -186,7 +186,7 @@ fn main() -> anyhow::Result<()> { } } let wallet_tip_end = wallet.latest_checkpoint(); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!( "Synced {} blocks in {}s", blocks_received, diff --git a/examples/electrum.rs b/examples/electrum.rs index 8ccbe130..44e4b7af 100644 --- a/examples/electrum.rs +++ b/examples/electrum.rs @@ -41,7 +41,7 @@ fn main() -> Result<(), anyhow::Error> { wallet.persist(&mut db)?; println!("Generated Address: {address}"); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance before syncing: {}", balance.total()); println!("Performing Full Sync..."); @@ -70,7 +70,7 @@ fn main() -> Result<(), anyhow::Error> { wallet.apply_update(update)?; wallet.persist(&mut db)?; - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance after full sync: {}", balance.total()); println!( "Wallet has {} transactions and {} utxos after full sync", @@ -166,7 +166,7 @@ fn main() -> Result<(), anyhow::Error> { } wallet.persist(&mut db)?; - let balance_after_sync = wallet.balance(); + let balance_after_sync = wallet.balance(1); println!("Wallet balance after sync: {}", balance_after_sync.total()); println!( "Wallet has {} transactions and {} utxos after partial sync", diff --git a/examples/esplora_async.rs b/examples/esplora_async.rs index 6e19069c..0028ae33 100644 --- a/examples/esplora_async.rs +++ b/examples/esplora_async.rs @@ -38,7 +38,7 @@ async fn main() -> Result<(), anyhow::Error> { wallet.persist(&mut db)?; println!("Next unused address: ({}) {address}", address.index); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance before syncing: {}", balance.total()); println!("Full Sync..."); @@ -64,7 +64,7 @@ async fn main() -> Result<(), anyhow::Error> { wallet.persist(&mut db)?; println!(); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance after full sync: {}", balance.total()); println!( "Wallet has {} transactions and {} utxos after full sync", @@ -175,7 +175,7 @@ async fn main() -> Result<(), anyhow::Error> { wallet.persist(&mut db)?; - let balance_after_sync = wallet.balance(); + let balance_after_sync = wallet.balance(1); println!("Wallet balance after sync: {}", balance_after_sync.total()); println!( "Wallet has {} transactions and {} utxos after partial sync", diff --git a/examples/esplora_blocking.rs b/examples/esplora_blocking.rs index 3131bec0..b1759e11 100644 --- a/examples/esplora_blocking.rs +++ b/examples/esplora_blocking.rs @@ -41,7 +41,7 @@ fn main() -> Result<(), anyhow::Error> { address.index, address.address ); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance before syncing: {}", balance.total()); println!("Full Sync..."); @@ -64,7 +64,7 @@ fn main() -> Result<(), anyhow::Error> { wallet.persist(&mut db)?; println!(); - let balance = wallet.balance(); + let balance = wallet.balance(1); println!("Wallet balance after syncing: {}", balance.total()); if balance.total() < SEND_AMOUNT { @@ -156,7 +156,7 @@ fn main() -> Result<(), anyhow::Error> { } wallet.persist(&mut db)?; - let balance_after_sync = wallet.balance(); + let balance_after_sync = wallet.balance(1); println!("Wallet balance after sync: {}", balance_after_sync.total()); println!( "Wallet has {} transactions and {} utxos", diff --git a/examples/replace_by_fee.rs b/examples/replace_by_fee.rs index f69cfd39..ff124a30 100644 --- a/examples/replace_by_fee.rs +++ b/examples/replace_by_fee.rs @@ -40,7 +40,7 @@ fn main() -> anyhow::Result<()> { println!( "Wallet funded with {}\n", - wallet.balance().total().display_dynamic() + wallet.balance(1).total().display_dynamic() ); println!("Creating first sweep transaction (tx1)..."); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1594ea8d..0b3f9bb8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1151,22 +1151,24 @@ impl Wallet { txs } - /// Return the balance, separated into available, trusted-pending, untrusted-pending, and - /// immature values. + /// 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 { + /// # Arguments + /// + /// * `min_confirmations` - How many confirmations an output needs to count as settled. `0` and `1` behave identically. It defines the `is_settled` predicate that bdk_chain's `classify_outpoints` uses to draw the confirmed/pending boundary. + /// + // NOTE: depends on `CanonicalView` (bitcoindevkit/bdk#2246), not yet in a published `bdk_chain` release. + pub fn balance(&self, min_confirmations: u32) -> Balance { let graph = self.tx_graph.graph(); let index = &self.tx_graph.index; let chain_tip = self.chain.tip().block_id(); + let tip_height = chain_tip.height; - // 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. + // 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; @@ -1178,6 +1180,12 @@ impl Wallet { }) }; + let min_confirmations = min_confirmations.max(1); + let is_settled = move |pos: &ChainPosition| { + pos.confirmation_height_upper_bound() + .is_some_and(|h| tip_height - h + 1 >= min_confirmations) + }; + let view = self .chain .canonical_view(graph, chain_tip, CanonicalParams::default()); @@ -1186,7 +1194,7 @@ impl Wallet { for (txout, eligibility) in view.classify_outpoints( index.outpoints().iter().map(|(_, op)| *op), does_taint, - |pos| pos.is_confirmed(), + is_settled, ) { let bucket = match eligibility { Eligibility::Settled => &mut balance.confirmed, diff --git a/tests/create_psbt.rs b/tests/create_psbt.rs index 1b9c0f12..ff0cfe87 100644 --- a/tests/create_psbt.rs +++ b/tests/create_psbt.rs @@ -889,7 +889,7 @@ fn test_create_psbt_utxo_filter() { ); } assert_eq!(wallet.list_unspent().count(), 4); - assert_eq!(wallet.balance().total().to_sat(), 2100); + assert_eq!(wallet.balance(1).total().to_sat(), 2100); let mut params = PsbtParams::default(); params.fee_rate(FeeRate::ZERO); diff --git a/tests/psbt.rs b/tests/psbt.rs index f848473c..002c7976 100644 --- a/tests/psbt.rs +++ b/tests/psbt.rs @@ -174,7 +174,7 @@ fn test_psbt_multiple_internalkey_signers() { let change_desc = "tr(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; let (mut wallet, _) = get_funded_wallet(&desc, change_desc); - let to_spend = wallet.balance().total(); + let to_spend = wallet.balance(1).total(); let send_to = wallet.peek_address(KeychainKind::External, 0); let mut builder = wallet.build_tx(); builder.drain_to(send_to.script_pubkey()).drain_wallet(); diff --git a/tests/wallet.rs b/tests/wallet.rs index 7b6cec08..fed7becb 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -78,7 +78,7 @@ fn test_get_funded_wallet_balance() { // The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000 // to a foreign address and one returning 50_000 back to the wallet as change. The remaining // 1000 sats are the transaction fee. - assert_eq!(wallet.balance().confirmed, Amount::from_sat(50_000)); + assert_eq!(wallet.balance(1).confirmed, Amount::from_sat(50_000)); } #[test] @@ -2836,7 +2836,7 @@ fn test_spend_coinbase() { let not_yet_mature_time = confirmation_height + COINBASE_MATURITY - 2; let maturity_time = confirmation_height + COINBASE_MATURITY - 1; - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!( balance, Balance { @@ -2888,7 +2888,7 @@ fn test_spend_coinbase() { hash: BlockHash::all_zeros(), }, ); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!( balance, Balance { @@ -3068,7 +3068,7 @@ fn test_keychains_with_overlapping_spks() { let non_wildcard_keychain = "wpkh(tprv8ZgxMBicQKsPdDArR4xSAECuVxeX1jwwSXR4ApKbkYgZiziDc4LdBy2WvJeGDfUSE4UT4hHhbgEwbdq8ajjUHiKDegkwrNU6V55CxcxonVN/1)"; let (mut wallet, _) = get_funded_wallet(wildcard_keychain, non_wildcard_keychain); - assert_eq!(wallet.balance().confirmed, Amount::from_sat(50000)); + assert_eq!(wallet.balance(1).confirmed, Amount::from_sat(50000)); let addr = wallet .reveal_addresses_to(KeychainKind::External, 1) @@ -3083,7 +3083,7 @@ fn test_keychains_with_overlapping_spks() { confirmation_time: 0, }; let _outpoint = receive_output_to_address(&mut wallet, addr, Amount::from_sat(8000), anchor); - assert_eq!(wallet.balance().confirmed, Amount::from_sat(58000)); + assert_eq!(wallet.balance(1).confirmed, Amount::from_sat(58000)); } #[test] @@ -3354,7 +3354,7 @@ fn test_trusted_pending_balance_from_owned_outpoints() { insert_tx(&mut wallet, tx.clone()); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!(balance.trusted_pending, Amount::from_sat(500)); assert_eq!(balance.untrusted_pending, Amount::ZERO); @@ -3389,7 +3389,7 @@ fn test_untrusted_pending_balance_from_external_inputs() { insert_tx(&mut wallet, tx.clone()); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!(balance.untrusted_pending, Amount::from_sat(500)); assert_eq!(balance.trusted_pending, Amount::ZERO); @@ -3439,7 +3439,7 @@ fn test_trusted_pending_transitive_chain() { }; insert_tx(&mut wallet, tx_b); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!(balance.trusted_pending, Amount::from_sat(500)); assert_eq!(balance.untrusted_pending, Amount::ZERO); @@ -3474,7 +3474,7 @@ fn test_pay_to_internal_from_not_trusted() { insert_tx(&mut wallet, tx); - let balance = wallet.balance(); + let balance = wallet.balance(1); // 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)); @@ -3535,7 +3535,7 @@ fn test_trusted_pending_does_not_propagate_through_foreign_outputs() { }; insert_tx(&mut wallet, tx_b); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!(balance.trusted_pending, Amount::from_sat(24_000)); assert_eq!(balance.untrusted_pending, Amount::from_sat(20_000)); @@ -3589,8 +3589,104 @@ fn test_spending_untrusted_is_untrusted() { insert_tx(&mut wallet, tx_spend); - let balance = wallet.balance(); + let balance = wallet.balance(1); assert_eq!(balance.untrusted_pending, Amount::from_sat(45_000)); assert_eq!(balance.trusted_pending, Amount::ZERO); } + +/// Raising `min_confirmations` above an owned output's depth demotes it from confirmed to trusted_pending. +#[test] +fn test_balance_min_confirmations_demotes_owned_to_trusted() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + + insert_checkpoint( + &mut wallet, + BlockId { + height: 2005, + hash: BlockHash::all_zeros(), + }, + ); + + // 6 confirmations, need 3. + assert_eq!(wallet.balance(3).confirmed, Amount::from_sat(50_000)); + // 6 confirmations, need 6. + assert_eq!(wallet.balance(6).confirmed, Amount::from_sat(50_000)); + // 6 confirmations, need 7. + let balance_7 = wallet.balance(7); + assert_eq!(balance_7.confirmed, Amount::ZERO); + assert_eq!(balance_7.trusted_pending, Amount::from_sat(50_000)); + assert_eq!(balance_7.total(), wallet.balance(6).total()); +} + +/// When `min_confirmations` pushes a confirmed output below the settled bar, it is re-classified by ancestry. (e.g. an output funded by a foreign input becomes untrusted_pending, not trusted) +#[test] +fn test_balance_min_confirmations_demotes_foreign_to_untrusted() { + 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 confirmation_block = BlockId { + height: 100, + hash: BlockHash::all_zeros(), + }; + insert_checkpoint(&mut wallet, confirmation_block); + + let tx = Transaction { + // Foreign input + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_raw_hash(Hash::all_zeros()), + 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 txid = tx.compute_txid(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = [( + ConfirmationBlockTime { + block_id: confirmation_block, + confirmation_time: 0, + }, + txid, + )] + .into(); + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .unwrap(); + + // Raise the tip to height 105, output has 6 confirmations. + insert_checkpoint( + &mut wallet, + BlockId { + height: 105, + hash: BlockHash::all_zeros(), + }, + ); + + // Settled even if foreign. + assert_eq!(wallet.balance(6).confirmed, Amount::from_sat(50_000)); + + // Below the bar, so is no longer settled. As it is foreign, should be untrusted_pending + let balance_7 = wallet.balance(7); + assert_eq!(balance_7.confirmed, Amount::ZERO); + assert_eq!(balance_7.untrusted_pending, Amount::from_sat(50_000)); + assert_eq!(balance_7.trusted_pending, Amount::ZERO); +}