Add an `is_locked` predicate on `classify_outpoints`/`balance` so the caller marks outputs whose timelock has not matured yet. They are classified as `Immature`.
outpoints.into_iter().map(|(_, op)| op),
bdk_chain::taints_unowned(&recv_graph.index),
|pos| pos.is_confirmed(),
+ |_txout| false,
);
Ok(balance)
}
op.into_iter().map(|(_, o)| o),
bdk_chain::taints_unowned(&graph.index),
|pos| pos.is_confirmed(),
+ |_txout| false,
);
assert_eq!(bal.total(), AMOUNT * TX_CT as u64);
}
LocalChain::from_blocks(blocks).unwrap()
}
-// The two predicates `classify_outpoints` takes, faking what a wallet would pass:
-// `does_taint` = the tx pulls in coins that aren't ours, `is_settled` = the tx is confirmed.
+// The three predicates `classify_outpoints` takes, faking what a wallet would pass:
+// `does_taint` = the tx pulls in coins that aren't ours; `is_settled` = the tx is confirmed;
+// `is_locked` = the tx has timelocked outputs.
#[allow(clippy::type_complexity)]
-fn does_taint_and_is_settled<'a>(
+fn classify_outpoints_predicates<'a>(
view: &'a CanonicalView<ConfirmationBlockTime>,
owned: &ScriptBuf,
) -> (
impl FnMut(&bdk_chain::CanonicalTx<ChainPosition<ConfirmationBlockTime>>) -> bool + 'a,
impl Fn(&ChainPosition<ConfirmationBlockTime>) -> bool,
+ impl Fn(&bdk_chain::CanonicalTxOut<ChainPosition<ConfirmationBlockTime>>) -> bool,
) {
let owned = owned.clone();
let is_mine = move |spk: &Script| spk == owned.as_script();
};
let is_settled =
|pos: &ChainPosition<ConfirmationBlockTime>| matches!(pos, ChainPosition::Confirmed { .. });
- (does_taint, is_settled)
+
+ let is_locked =
+ |_txout: &bdk_chain::CanonicalTxOut<ChainPosition<ConfirmationBlockTime>>| false;
+
+ (does_taint, is_settled, is_locked)
}
// Per-UTXO memoized classification (`classify_outpoints`).
owned: &ScriptBuf,
) {
let outpoints = utxo_txids.iter().map(|&txid| OutPoint::new(txid, 0));
- let (does_taint, is_settled) = does_taint_and_is_settled(view, owned);
- for item in view.classify_outpoints(outpoints, does_taint, is_settled) {
+ let (does_taint, is_settled, is_locked) = classify_outpoints_predicates(view, owned);
+ for item in view.classify_outpoints(outpoints, does_taint, is_settled, is_locked) {
std::hint::black_box(item);
}
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Balance {
- /// All coinbase outputs not yet matured
+ /// Outputs not yet spendable by consensus (immature coinbase or unmet timelock), confirmed or
+ /// not.
pub immature: Amount,
/// Unconfirmed UTXOs generated by a wallet tx
pub trusted_pending: Amount,
/// [`classify_outpoints`](CanonicalView::classify_outpoints). Typically confirmed deeply
/// enough to be unlikely to be replaced, but the caller decides.
Settled,
- /// A coinbase output that has not yet matured and is not spendable.
+ /// An output not yet spendable by consensus: an immature coinbase output, or one the
+ /// `is_locked` predicate reports as timelocked. May be unconfirmed; its [`Trust`] is not
+ /// evaluated.
Immature,
/// An output not yet settled.
Unsettled(Trust),
/// This is the primitive behind [`balance`](Self::balance).
///
/// Callers that need richer handling (coin selection, coin control, or
- /// wallet-specific categories like "locked") can fold over this instead of `balance`.
+ /// wallet-specific categories) can fold over this instead of `balance`.
///
/// Outpoints that are already spent, or that aren't part of this canonical view, are skipped.
///
/// [`taints_unowned`] to classify everything foreign as untrusted.
/// * `is_settled` - Returns `true` for the [position](ChainPosition) of a transaction we
/// consider settled (unlikely to be replaced), for example one with enough confirmations.
+ /// * `is_locked` - Returns `true` for a txout whose timelock is not met yet, classifying it as
+ /// [`Immature`](Eligibility::Immature). For an output confirmed
+ /// [`transitively`](ChainPosition::Confirmed::transitively), the confirmation height is only
+ /// an upper bound, so a relative timelock may look unmet when it is already met.
pub fn classify_outpoints<'a>(
&'a self,
outpoints: impl IntoIterator<Item = OutPoint> + 'a,
mut does_taint: impl FnMut(&CanonicalTx<ChainPosition<A>>) -> bool + 'a,
is_settled: impl Fn(&ChainPosition<A>) -> bool + 'a,
+ is_locked: impl Fn(&CanonicalTxOut<ChainPosition<A>>) -> bool + 'a,
) -> impl Iterator<Item = (CanonicalTxOut<ChainPosition<A>>, Eligibility)> + 'a {
let tip = self.tip.height;
// Shared across outpoints so an ancestor reached by several of them is only walked once.
.filter_map(move |op| self.txout(op))
.filter(|txo| txo.spent_by.is_none())
.map(move |txout| {
- let eligibility = if !txout.is_mature(tip) {
+ let eligibility = if !txout.is_mature(tip) || is_locked(&txout) {
Eligibility::Immature
} else if is_settled(&txout.pos) {
Eligibility::Settled
/// pos.confirmation_height_upper_bound()
/// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6)
/// },
+ /// |_txout| false,
/// );
/// ```
pub fn balance(
outpoints: impl IntoIterator<Item = OutPoint>,
does_taint: impl FnMut(&CanonicalTx<ChainPosition<A>>) -> bool,
is_settled: impl Fn(&ChainPosition<A>) -> bool,
+ is_locked: impl Fn(&CanonicalTxOut<ChainPosition<A>>) -> bool,
) -> Balance {
- self.classify_outpoints(outpoints, does_taint, is_settled)
+ self.classify_outpoints(outpoints, does_taint, is_settled, is_locked)
.collect()
}
}
[outpoint],
|_tx| false, // leave trust to ancestry
settled(tip_height, 1),
+ |_txout| false,
);
assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000));
[outpoint],
|_tx| false, // leave trust to ancestry
settled(tip_height, 6),
+ |_txout| false,
);
assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000));
assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO);
[outpoint],
|_tx| false, // leave trust to ancestry
settled(tip_height, 7),
+ |_txout| false,
);
assert_eq!(balance_7_conf.confirmed, Amount::ZERO);
assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000));
[outpoint],
|_tx| true, // taint everything
settled(tip_height, 5),
+ |_txout| false,
);
// Should be untrusted pending (not enough confirmations and not trusted)
[outpoint],
|_tx| false, // leave trust to ancestry
settled(tip_height, 5),
+ |_txout| false,
);
assert_eq!(balance.trusted_pending, Amount::from_sat(25_000));
assert_eq!(balance.untrusted_pending, Amount::ZERO);
// tx0: 11 confirmations -> confirmed
// tx1: 6 confirmations -> confirmed
// tx2: 3 confirmations -> trusted pending
- let balance = canonical_view.balance(outpoints.clone(), |_tx| false, settled(tip_height, 5));
+ let balance = canonical_view.balance(
+ outpoints.clone(),
+ |_tx| false,
+ settled(tip_height, 5),
+ |_txout| false,
+ );
assert_eq!(
balance.confirmed,
// tx0: 11 confirmations -> confirmed
// tx1: 6 confirmations -> trusted pending
// tx2: 3 confirmations -> trusted pending
- let balance_high = canonical_view.balance(outpoints, |_tx| false, settled(tip_height, 10));
+ let balance_high = canonical_view.balance(
+ outpoints,
+ |_tx| false,
+ settled(tip_height, 10),
+ |_txout| false,
+ );
assert_eq!(
balance_high.confirmed,
.any(|txin| !owned.contains(&txin.previous_output))
},
|pos| pos.is_confirmed(),
+ |_txout| false,
);
assert_eq!(balance.confirmed, Amount::ZERO);
// An `is_settled` that claims everything is settled counts the (mature, non-coinbase)
// unconfirmed output as settled rather than dropping it, even when it is tainted.
- let balance = view.balance([OutPoint::new(txid, 0)], |_| true, |_| true);
+ let balance = view.balance([OutPoint::new(txid, 0)], |_| true, |_| true, |_txout| false);
assert_eq!(balance.confirmed, Amount::from_sat(50_000));
assert_eq!(balance.immature, Amount::ZERO);
assert_eq!(balance.trusted_pending, Amount::ZERO);
.any(|txin| !owned.contains(&txin.previous_output))
},
|pos| pos.is_confirmed(),
+ |_txout| false,
)
.map(|(txout, eligibility)| (txout.outpoint, eligibility))
.collect::<std::collections::HashMap<_, _>>();
];
let by_op = view
- .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed())
+ .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false)
.map(|(txout, eligibility)| (txout.outpoint, eligibility))
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
assert_eq!(by_op[&OutPoint::new(normal_txid, 0)], Eligibility::Settled);
// The balance buckets reflect the same classification.
- let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed());
+ let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false);
assert_eq!(balance.immature, Amount::from_sat(50_000));
assert_eq!(balance.confirmed, Amount::from_sat(30_000));
}
.any(|txin| !owned.contains(&txin.previous_output))
},
|pos| pos.is_confirmed(),
+ |_txout| false,
);
assert_eq!(balance.trusted_pending, Amount::ZERO);
assert_eq!(balance.untrusted_pending, Amount::from_sat(29_000 + 19_000));
],
|_| false,
|pos| pos.is_confirmed(),
+ |_txout| false,
)
.collect::<Vec<_>>();
// Require 3 confirmations to be settled.
let by_op = view
- .classify_outpoints([OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3))
+ .classify_outpoints(
+ [OutPoint::new(txid, 0)],
+ |_| false,
+ settled(tip_height, 3),
+ |_txout| false,
+ )
.map(|(txout, e)| (txout.outpoint, e))
.collect::<std::collections::HashMap<_, _>>();
let tip_height = view.tip().height;
let by_op = view
- .classify_outpoints([OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3))
+ .classify_outpoints(
+ [OutPoint::new(txid, 0)],
+ |_| false,
+ settled(tip_height, 3),
+ |_txout| false,
+ )
.map(|(txout, e)| (txout.outpoint, e))
.collect::<std::collections::HashMap<_, _>>();
[OutPoint::new(child2_txid, 0)],
|_| false,
|pos| pos.is_confirmed(),
+ |_txout| false,
)
.map(|(txout, e)| (txout.outpoint, e))
.collect::<std::collections::HashMap<_, _>>();
graph.index.outpoints().iter().map(|(_, op)| *op),
bdk_chain::taints_unowned(&graph.index),
|pos| pos.is_confirmed(),
+ |_txout| false,
);
let confirmed_txouts_txid = txouts
env.indexer.outpoints().iter().map(|(_, op)| *op),
bdk_chain::taints_unowned(&env.indexer),
|pos| pos.is_confirmed(),
+ |_txout| false,
);
assert_eq!(
balance, scenario.exp_balance,
outpoints.into_iter().map(|(_, op)| op),
bdk_chain::taints_unowned(&recv_graph.index),
|pos| pos.is_confirmed(),
+ |_txout| false,
);
Ok(balance)
}