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 1f0ed24e..0b3f9bb8 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, @@ -1151,16 +1151,60 @@ impl Wallet { txs } - /// Return the balance, separated into available, trusted-pending, untrusted-pending, and - /// immature values. - 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, - ) + /// 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. + /// + /// # 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. + 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 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()); + + let mut balance = Balance::default(); + for (txout, eligibility) in view.classify_outpoints( + index.outpoints().iter().map(|(_, op)| *op), + does_taint, + is_settled, + ) { + 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/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 0204cfd5..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] @@ -3331,3 +3331,362 @@ 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(1); + + 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(1); + + 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(1); + + 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(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)); + 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(1); + + 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(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); +}