diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1cdd1cc7..7bd6d159 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1114,14 +1114,107 @@ impl Wallet { /// Return the balance, separated into available, trusted-pending, untrusted-pending, and /// immature values. + /// + /// A pending UTXO is `trusted_pending` if every unconfirmed ancestor transaction + /// was created by this wallet. + /// Otherwise it is `untrusted_pending`. pub fn balance(&self) -> Balance { - self.tx_graph.graph().balance( + let graph = self.tx_graph.graph(); + let chain_tip = self.chain.tip().block_id(); + + // TODO: simplify with CanonicalView::txout() once released on bdk_chain + 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 { .. } => { + // TODO: Check with min_confirmation + 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 mut txids: Vec = vec![txout.outpoint.txid]; + let mut visited_tx: HashSet = HashSet::new(); + let mut trusted: bool = true; + + 'trust_check: while let Some(current_txid) = txids.pop() { + if !visited_tx.contains(¤t_txid) { + let Some((tx, _)) = canonical_txs.get(¤t_txid) else { + trusted = false; + break; + }; + + for input in tx.input.iter() { + if !&input.previous_output.is_null() { + let Some((parent_tx, parent_chain_pos)) = + canonical_txs.get(&input.previous_output.txid) + else { + trusted = false; + break 'trust_check; + }; + // TODO: Check with min_confirmation + if !parent_chain_pos.is_confirmed() { + let vout = input.previous_output.vout as usize; + 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 { + txids.push(input.previous_output.txid); + } else { + trusted = false; + break 'trust_check; + } + } + } + } + visited_tx.insert(current_txid); + } + } + if trusted { + trusted_pending += txout.txout.value; + } else { + untrusted_pending += txout.txout.value; + } + } + } + } + + Balance { + immature, + trusted_pending, + untrusted_pending, + confirmed, + } } /// Add an external signer diff --git a/tests/wallet.rs b/tests/wallet.rs index 268c66f8..dd375054 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); +}