Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/bitcoind_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions examples/electrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions examples/esplora_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions examples/esplora_blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/replace_by_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)...");

Expand Down
148 changes: 137 additions & 11 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, Trust,
};
use bitcoin::{
absolute,
Expand Down Expand Up @@ -111,6 +111,38 @@ pub use params::*;
pub use persisted::*;
pub use utils::{IsDust, TxDetails};

/// Wallet balance with a `locked` category for confirmed outputs that are not yet spendable because of an unmet descriptor timelock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WalletBalance {
/// All coinbase outputs not yet matured.
pub immature: Amount,
/// Unconfirmed UTXOs generated by a wallet tx.
pub trusted_pending: Amount,
/// Unconfirmed UTXOs received from an external wallet.
pub untrusted_pending: Amount,
/// Confirmed and immediately spendable balance.
pub confirmed: Amount,
/// Confirmed outputs not yet spendable because a descriptor timelock hasn't matured.
pub locked: Amount,
}

impl WalletBalance {
/// Sum of `trusted_pending` and `confirmed`: what can be spent right now without a third party
/// being able to cancel it. Excludes `locked`, which is confirmed but not spendable yet.
pub fn trusted_spendable(&self) -> Amount {
self.confirmed + self.trusted_pending
}

/// The whole balance visible to the wallet, including `locked`.
pub fn total(&self) -> Amount {
self.confirmed
+ self.trusted_pending
+ self.untrusted_pending
+ self.immature
+ self.locked
}
}

/// Alias [`FullTxOut`] with associated keychain and derivation index.
#[allow(unused)]
type IndexedTxOut = ((KeychainKind, u32), FullTxOut<ConfirmationBlockTime>);
Expand Down Expand Up @@ -1151,16 +1183,110 @@ 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) -> WalletBalance {
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<ChainPosition<ConfirmationBlockTime>>| {
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<ConfirmationBlockTime>| {
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 = WalletBalance::default();
for (txout, eligibility) in view.classify_outpoints(
index.outpoints().iter().map(|(_, op)| *op),
does_taint,
is_settled,
) {
let value = txout.txout.value;
match eligibility {
Eligibility::Immature => balance.immature += value,
Eligibility::Unsettled(Trust::Trusted) => balance.trusted_pending += value,
Eligibility::Unsettled(Trust::Untrusted | Trust::Unknown) => {
balance.untrusted_pending += value
}
// Confirmed, but a descriptor timelock hasn't matured yet
Eligibility::Settled if !self.timelock_satisfied(&txout, tip_height) => {
balance.locked += value
}
Eligibility::Settled => balance.confirmed += value,
}
}
balance
}

/// Whether the descriptor timelock guarding `txout` is satisfied at `tip_height`.
///
/// Returns `true` when the output has no timelock or it has already matured.
///
/// Time-based timelocks are not yet evaluated against the median-time-past (see
/// bitcoindevkit/bdk_wallet#183).
fn timelock_satisfied(
&self,
txout: &CanonicalTxOut<ChainPosition<ConfirmationBlockTime>>,
tip_height: u32,
) -> bool {
let Some((keychain, _index)) = self.tx_graph.index.index_of_spk(&txout.txout.script_pubkey)
else {
return true;
};

let signers = self.get_signers(keychain);
let Ok(Some(policy)) =
self.public_descriptor(keychain)
.extract_policy(&signers, BuildSatisfaction::None, &self.secp)
else {
return true;
};
let condition = policy.get_condition(&Default::default()).unwrap_or_default();

// CLTV
if let Some(lock_time) = condition.timelock {
if tip_height < lock_time.to_consensus_u32() {
return false;
}
}
// CSV
if let Some(csv) = condition.csv.and_then(|seq| seq.to_relative_lock_time()) {
let conf_height = txout
.pos
.confirmation_height_upper_bound()
.unwrap_or(tip_height);
if tip_height < conf_height.saturating_add(csv.to_consensus_u32()) {
return false;
}
}
true
}

/// Add an external signer
Expand Down
2 changes: 1 addition & 1 deletion tests/create_psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading