From 15beb0b5d6584b9ff3f4f07a373671cc5ff70795 Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 27 May 2026 15:01:58 +0200 Subject: [PATCH 1/2] fix(wallet): classify unconfirmed UTXOs as trusted or untrusted using walk_ancestors --- src/wallet/mod.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 4 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1cdd1cc7b..1f9374b8f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1115,13 +1115,154 @@ impl Wallet { /// Return the balance, separated into available, trusted-pending, untrusted-pending, and /// immature values. pub fn balance(&self) -> Balance { - self.tx_graph.graph().balance( + let graph = self.tx_graph.graph(); + + // TODO: Use min_confirmation to use tip - min_confirmations as new tip. + let chain_tip = self.chain.tip().block_id(); + + // TODO: simplify once CanonicalView is available in a published bdk_chain release + let canonical_txs: HashMap, ChainPosition)> = + graph + .list_canonical_txs(&self.chain, chain_tip, CanonicalizationParams::default()) + .map(|ctx| { + ( + ctx.tx_node.txid, + (ctx.tx_node.tx.clone(), ctx.chain_position), + ) + }) + .collect(); + + let mut immature = Amount::ZERO; + let mut trusted_pending = Amount::ZERO; + let mut untrusted_pending = Amount::ZERO; + let mut confirmed = Amount::ZERO; + + for (_spk_i, txout) in graph.filter_chain_unspents( &self.chain, - self.chain.tip().block_id(), + chain_tip, CanonicalizationParams::default(), self.tx_graph.index.outpoints().iter().cloned(), - |&(k, _), _| k == KeychainKind::Internal, - ) + ) { + match &txout.chain_position { + ChainPosition::Confirmed { .. } => { + if txout.is_confirmed_and_spendable(chain_tip.height) { + confirmed += txout.txout.value; + } else if !txout.is_mature(chain_tip.height) { + immature += txout.txout.value; + } + } + + ChainPosition::Unconfirmed { .. } => { + let Some((root_tx, _)) = canonical_txs.get(&txout.outpoint.txid) else { + untrusted_pending += txout.txout.value; + continue; + }; + + let mut trusted = true; + + 'root: for input in root_tx.input.iter() { + if input.previous_output.is_null() { + continue; + } + + let vout = input.previous_output.vout as usize; + + match canonical_txs.get(&input.previous_output.txid) { + // Check first if it is confirmed, in case it is it can be marked as + // trusted + Some((_, pos)) if pos.is_confirmed() => { + continue; + } + Some((parent_tx, _)) => { + let is_ours = parent_tx + .output + .get(vout) + .map(|o| { + self.tx_graph + .index + .index_of_spk(o.script_pubkey.clone()) + .is_some() + }) + .unwrap_or(false); + + if !is_ours { + trusted = false; + break 'root; + } + } + None => { + trusted = false; + break 'root; + } + } + } + + if trusted { + graph + .walk_ancestors(root_tx.clone(), |_, ancestor_tx| -> Option<()> { + let ancestor_txid = ancestor_tx.compute_txid(); + match canonical_txs.get(&ancestor_txid) { + None => { + trusted = false; + return None; + } + Some((_, pos)) if pos.is_confirmed() => return None, + _ => {} + } + for input in ancestor_tx.input.iter() { + if input.previous_output.is_null() { + continue; + } + + let vout = input.previous_output.vout as usize; + + match canonical_txs.get(&input.previous_output.txid) { + Some((_, pos)) if pos.is_confirmed() => { + continue; + } + Some((ancestor_tx, _)) => { + let is_ours = ancestor_tx + .output + .get(vout) + .map(|o| { + self.tx_graph + .index + .index_of_spk(o.script_pubkey.clone()) + .is_some() + }) + .unwrap_or(false); + + if !is_ours { + trusted = false; + break; + } + } + None => { + trusted = false; + break; + } + } + } + Some(()) + }) + .run_until_finished(); + } + + if trusted { + trusted_pending += txout.txout.value; + } else { + untrusted_pending += txout.txout.value; + } + } + } + } + + Balance { + immature, + trusted_pending, + untrusted_pending, + confirmed, + } } /// Add an external signer From 1d6985e508a5e31e366fd7a0ca7e9dd9faea962c Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 27 May 2026 15:02:17 +0200 Subject: [PATCH 2/2] test(wallet): add balance categorization tests for trusted/untrusted pending --- tests/wallet.rs | 257 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/tests/wallet.rs b/tests/wallet.rs index 268c66f8a..dd375054d 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -3007,3 +3007,260 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() { "UTXOs should be ordered with required first, then selected" ); } + +#[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!(balance.trusted_pending > Amount::ZERO); + assert_eq!(balance.untrusted_pending, Amount::ZERO); +} + +#[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!(balance.untrusted_pending > Amount::ZERO); + assert_eq!(balance.trusted_pending, Amount::ZERO); +} + +#[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!(balance.trusted_pending > Amount::ZERO); + assert_eq!(balance.untrusted_pending, Amount::ZERO); +} + +#[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!(balance.untrusted_pending > Amount::ZERO); + assert_eq!(balance.trusted_pending, Amount::ZERO); +} + +#[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!(balance.trusted_pending > Amount::ZERO); + assert!(balance.untrusted_pending > Amount::ZERO); +} + +#[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); +}