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
10 changes: 7 additions & 3 deletions crates/bitcoind_rpc/tests/test_emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,11 @@ fn get_balance(
recv_chain.tip().block_id(),
Default::default(),
)
.balance(outpoints, |_, _| true, 0);
.balance(
outpoints.into_iter().map(|(_, op)| op),
|_| false,
|pos| pos.is_confirmed(),
);
Ok(balance)
}

Expand Down Expand Up @@ -395,7 +399,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> {
assert_eq!(
get_balance(&recv_chain, &recv_graph)?,
Balance {
confirmed: SEND_AMOUNT * ADDITIONAL_COUNT as u64,
settled: SEND_AMOUNT * ADDITIONAL_COUNT as u64,
..Balance::default()
},
"initial balance must be correct",
Expand All @@ -410,7 +414,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> {
get_balance(&recv_chain, &recv_graph)?,
Balance {
trusted_pending: SEND_AMOUNT * reorg_count as u64,
confirmed: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64,
settled: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64,
..Balance::default()
},
"reorg_count: {reorg_count}",
Expand Down
6 changes: 5 additions & 1 deletion crates/chain/benches/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) {
let op = graph.index.outpoints().clone();
let bal = chain
.canonical_view(graph.graph(), chain.tip().block_id(), Default::default())
.balance(op, |_, _| false, 1);
.balance(
op.into_iter().map(|(_, o)| o),
|_| false,
|pos| pos.is_confirmed(),
);
assert_eq!(bal.total(), AMOUNT * TX_CT as u64);
}

Expand Down
17 changes: 9 additions & 8 deletions crates/chain/src/balance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,32 @@ pub struct Balance {
pub trusted_pending: Amount,
/// Unconfirmed UTXOs received from an external wallet
pub untrusted_pending: Amount,
/// Confirmed and immediately spendable balance
pub confirmed: Amount,
/// Settled balance: outputs whose transaction we are confident will not be replaced (e.g.
/// confirmed deeply enough), as determined by the balance query's `is_settled` predicate.
pub settled: Amount,
}

impl Balance {
/// Get sum of trusted_pending and confirmed coins.
/// Get sum of trusted_pending and settled coins.
///
/// This is the balance you can spend right now that shouldn't get cancelled via another party
/// double spending it.
pub fn trusted_spendable(&self) -> Amount {
self.confirmed + self.trusted_pending
self.settled + self.trusted_pending
}

/// Get the whole balance visible to the wallet.
pub fn total(&self) -> Amount {
self.confirmed + self.trusted_pending + self.untrusted_pending + self.immature
self.settled + self.trusted_pending + self.untrusted_pending + self.immature
}
}

impl core::fmt::Display for Balance {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, confirmed: {} }}",
self.immature, self.trusted_pending, self.untrusted_pending, self.confirmed
"{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, settled: {} }}",
self.immature, self.trusted_pending, self.untrusted_pending, self.settled
)
}
}
Expand All @@ -47,7 +48,7 @@ impl core::ops::Add for Balance {
immature: self.immature + other.immature,
trusted_pending: self.trusted_pending + other.trusted_pending,
untrusted_pending: self.untrusted_pending + other.untrusted_pending,
confirmed: self.confirmed + other.confirmed,
settled: self.settled + other.settled,
}
}
}
251 changes: 178 additions & 73 deletions crates/chain/src/canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@
//! }
//! ```

use crate::collections::HashMap;
use crate::collections::{HashMap, HashSet};
use alloc::collections::BTreeSet;
use alloc::sync::Arc;
use alloc::vec::Vec;
use core::{fmt, ops::RangeBounds};

use bdk_core::BlockId;
use bitcoin::{
constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid,
};
use bitcoin::{constants::COINBASE_MATURITY, OutPoint, ScriptBuf, Transaction, TxOut, Txid};

use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph};

Expand Down Expand Up @@ -393,99 +392,205 @@ impl<A, P: Clone> Canonical<A, P> {
}
}

/// The spend-eligibility classification of a canonical output, produced by
/// [`CanonicalView::classify_outpoints`].
///
/// This is a *chain-level* classification: it captures only what the chain can determine
/// (settled-ness, maturity, and taint). It deliberately knows nothing about wallet policies such as
/// locked or reserved coins — callers layer their own categories on top.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Eligibility {
/// A settled coinbase output that has not yet matured. Not spendable.
Immature,
/// Settled: the output's transaction is considered unlikely to be replaced.
Settled,
/// Pending (not settled), and neither it nor any of its unsettled ancestors taints it.
TrustedPending,
/// Pending (not settled), and it or one of its unsettled ancestors taints it.
UntrustedPending,
}

impl<A: Anchor> CanonicalView<A> {
/// Calculate the total balance of the given outpoints.
/// Classify each of the given `outpoints` by its [spend eligibility](Eligibility).
///
/// This method computes a detailed balance breakdown for a set of outpoints, categorizing
/// outputs as confirmed, pending (trusted/untrusted), or immature based on their chain
/// position and the provided trust predicate.
/// This is the primitive behind [`balance`](Self::balance) and is the building block for coin
/// selection / coin control: it yields each unspent output paired with a chain-level
/// [`Eligibility`], leaving aggregation (and any wallet-specific categories like "locked") to
/// the caller. Spent outpoints, and outpoints not in this canonical view, are skipped.
///
/// # Arguments
///
/// * `outpoints` - Iterator of `(identifier, outpoint)` pairs to calculate balance for
/// * `trust_predicate` - Function that returns `true` for trusted scripts. Trusted outputs
/// count toward `trusted_pending` balance, while untrusted ones count toward
/// `untrusted_pending`
/// * `min_confirmations` - Minimum confirmations required for an output to be considered
/// confirmed. Outputs with fewer confirmations are treated as pending.
/// * `outpoints` - Iterator of outpoints to classify.
/// * `does_taint` - Function that returns `true` for transactions that should *taint* their
/// descendants. A pending output is [`UntrustedPending`](Eligibility::UntrustedPending) if
/// its transaction, or any of its unsettled ancestors, taints; otherwise
/// [`TrustedPending`](Eligibility::TrustedPending). See [Taint](#taint) below.
/// * `is_settled` - Function that returns `true` for the [position](ChainPosition) of an output
/// whose transaction is considered settled — unlikely to be replaced (e.g. confirmed deeply
/// enough). Settled outputs are [`Settled`](Eligibility::Settled) or
/// [`Immature`](Eligibility::Immature); the rest are pending. `is_settled` is the sole
/// authority on this boundary — a settled output is never tainted. See [Settled](#settled)
/// below.
///
/// # Minimum Confirmations
/// # Settled
///
/// The `min_confirmations` parameter controls when outputs are considered confirmed. A
/// `min_confirmations` value of `0` is equivalent to `1` (require at least 1 confirmation).
/// `is_settled` controls the boundary between settled and pending outputs — i.e. whether we are
/// confident a transaction will not be replaced. Typically it checks that an output has at
/// least some number of confirmations, for example:
///
/// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or
/// untrusted based on the trust predicate).
/// ```
/// # use bdk_chain::ChainPosition;
/// # use bdk_core::BlockId;
/// # let tip_height: u32 = 100;
/// # let min_confirmations: u32 = 6;
/// let is_settled = |pos: &ChainPosition<BlockId>| {
/// pos.confirmation_height_upper_bound()
/// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= min_confirmations)
/// };
/// # let _ = is_settled;
/// ```
///
/// # Taint
///
/// `does_taint` decides whether a pending output is trusted. The canonical *unsettled* ancestry
/// of each pending output is walked (stopping at settled transactions); if `does_taint` returns
/// `true` for the output's own transaction or any walked ancestor, the output is
/// [`UntrustedPending`](Eligibility::UntrustedPending), otherwise
/// [`TrustedPending`](Eligibility::TrustedPending).
///
/// A common use is to taint transactions that spend outputs the wallet does not own while
/// unconfirmed (i.e. unconfirmed coins received from, or chained on top of, a third party).
/// Returning `false` for every transaction treats all pending outputs as trusted; returning
/// `true` treats them all as untrusted.
///
/// # Example
///
/// ```
/// # use bdk_chain::{CanonicalParams, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
/// # use bdk_chain::{CanonicalParams, ChainPosition, Eligibility, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
/// # use bdk_core::BlockId;
/// # use bitcoin::hashes::Hash;
/// # let tx_graph = TxGraph::<BlockId>::default();
/// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
/// # let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), CanonicalParams::default());
/// # let indexer = KeychainTxOutIndex::<&str>::default();
/// // Coin control: prefer settled coins, fall back to trusted-pending; never spend the rest.
/// let mut candidates = vec![];
/// for (txout, eligibility) in view.classify_outpoints(
/// indexer.outpoints().iter().map(|(_, op)| *op),
/// |_tx| false, // Never taint
/// |pos: &ChainPosition<_>| pos.confirmation_height_upper_bound().is_some(),
/// ) {
/// match eligibility {
/// Eligibility::Settled | Eligibility::TrustedPending => candidates.push(txout.outpoint),
/// Eligibility::Immature | Eligibility::UntrustedPending => {}
/// }
/// }
/// ```
pub fn classify_outpoints(
&self,
outpoints: impl IntoIterator<Item = OutPoint>,
mut does_taint: impl FnMut(CanonicalTx<ChainPosition<A>>) -> bool,
is_settled: impl Fn(&ChainPosition<A>) -> bool,
) -> impl Iterator<Item = (CanonicalTxOut<ChainPosition<A>>, Eligibility)> {
let utxos = outpoints
.into_iter()
.filter_map(|op| self.txout(op))
.filter(|txo| txo.spent_by.is_none())
.collect::<Vec<_>>();

// The set of transaction ids of pending outputs that are tainted by themselves or an
// unsettled ancestor.
let tainted = {
// Pending outputs seed the walk; settled ones cannot be tainted.
let seeds = utxos
.iter()
.filter(|txo| !is_settled(&txo.pos))
.map(|txo| txo.outpoint.txid)
.collect::<HashSet<Txid>>();

let mut tainted = HashSet::<Txid>::new();
// Walk each pending output together with its unsettled ancestry (deduped across seeds,
// stopping at settled transactions). Each transaction carries the set of descendants it
// reaches; when an unsettled transaction taints, all of them are tainted. The settled
// boundary is yielded but never taints (a settled transaction cannot be replaced).
for (descendants, c_tx) in self.ancestors_inclusive::<BTreeSet<Txid>, _, _>(
seeds.iter().copied(),
|c_tx| core::iter::once(c_tx.txid).collect(),
|c_tx| !is_settled(&c_tx.pos),
) {
if !is_settled(&c_tx.pos) && does_taint(c_tx) {
tainted.extend(descendants);
}
}
tainted
};

let tip = self.tip.height;
utxos.into_iter().map(move |txout| {
let eligibility = if is_settled(&txout.pos) {
// Settled outputs are settled unless they are an immature coinbase. We rely on
// `is_settled` alone (not on a confirmation height), so a caller is free to treat
// unconfirmed outputs as settled.
if txout.is_mature(tip) {
Eligibility::Settled
} else {
Eligibility::Immature
}
} else if tainted.contains(&txout.outpoint.txid) {
Eligibility::UntrustedPending
} else {
Eligibility::TrustedPending
};
(txout, eligibility)
})
}

/// Calculate the total [`Balance`] of the given `outpoints`.
///
/// This is a convenience fold over [`classify_outpoints`](Self::classify_outpoints): each
/// output's value is added to the [`Balance`] bucket matching its [`Eligibility`]. See
/// [`classify_outpoints`](Self::classify_outpoints) for the meaning of `does_taint` and
/// `is_settled`, and for richer per-output handling (e.g. coin control).
///
/// # Example
///
/// ```
/// # use bdk_chain::{CanonicalParams, ChainPosition, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
/// # use bdk_core::BlockId;
/// # use bitcoin::hashes::Hash;
/// # let tx_graph = TxGraph::<BlockId>::default();
/// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
/// # let chain_tip = chain.tip().block_id();
/// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default());
/// # let indexer = KeychainTxOutIndex::<&str>::default();
/// // Calculate balance with 6 confirmations, trusting all outputs
/// let tip_height = view.tip().height;
/// // Calculate balance requiring 6 confirmations, tainting nothing (all pending trusted)
/// let balance = view.balance(
/// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)),
/// |_keychain, _script| true, // Trust all outputs
/// 6, // Require 6 confirmations
/// indexer.outpoints().iter().map(|(_, op)| *op),
/// |_tx| false, // Never taint
/// |pos: &ChainPosition<_>| {
/// pos.confirmation_height_upper_bound()
/// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6)
/// },
/// );
/// ```
pub fn balance<'v, O: Clone + 'v>(
&'v self,
outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
mut trust_predicate: impl FnMut(&O, &CanonicalTxOut<ChainPosition<A>>) -> bool,
min_confirmations: u32,
pub fn balance(
&self,
outpoints: impl IntoIterator<Item = OutPoint>,
does_taint: impl FnMut(CanonicalTx<ChainPosition<A>>) -> bool,
is_settled: impl Fn(&ChainPosition<A>) -> bool,
) -> Balance {
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 self.filter_unspent_outpoints(outpoints) {
match &txout.pos {
ChainPosition::Confirmed { anchor, .. } => {
let confirmation_height = anchor.confirmation_height_upper_bound();
let confirmations = self
.tip
.height
.saturating_sub(confirmation_height)
.saturating_add(1);
let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically

if confirmations < min_confirmations {
// Not enough confirmations, treat as trusted/untrusted pending
if trust_predicate(&spk_i, &txout) {
trusted_pending += txout.txout.value;
} else {
untrusted_pending += txout.txout.value;
}
} else if txout.is_confirmed_and_spendable(self.tip.height) {
confirmed += txout.txout.value;
} else if !txout.is_mature(self.tip.height) {
immature += txout.txout.value;
}
}
ChainPosition::Unconfirmed { .. } => {
if trust_predicate(&spk_i, &txout) {
trusted_pending += txout.txout.value;
} else {
untrusted_pending += txout.txout.value;
}
}
}
}

Balance {
immature,
trusted_pending,
untrusted_pending,
confirmed,
let mut balance = Balance::default();
for (txout, eligibility) in self.classify_outpoints(outpoints, does_taint, is_settled) {
let bucket = match eligibility {
Eligibility::Immature => &mut balance.immature,
Eligibility::Settled => &mut balance.settled,
Eligibility::TrustedPending => &mut balance.trusted_pending,
Eligibility::UntrustedPending => &mut balance.untrusted_pending,
};
*bucket += txout.txout.value;
}
balance
}
}

Expand Down
Loading