Skip to content
Open
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
2 changes: 1 addition & 1 deletion crates/bitcoind_rpc/tests/test_emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ fn get_balance(
let outpoints = recv_graph.index.outpoints().clone();
let balance = recv_graph
.canonical_view(recv_chain, chain_tip, CanonicalizationParams::default())
.balance(outpoints, |_, _| true, 1);
.balance(outpoints, |_, _| true, 1, None);
Ok(balance)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/chain/benches/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) {
let op = graph.index.outpoints().clone();
let bal = graph
.canonical_view(chain, chain_tip, CanonicalizationParams::default())
.balance(op, |_, _| false, 1);
.balance(op, |_, _| false, 1, None);
assert_eq!(bal.total(), AMOUNT * TX_CT as u64);
}

Expand Down
3 changes: 2 additions & 1 deletion crates/chain/src/balance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use bitcoin::Amount;
#[derive(Debug, PartialEq, Eq, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Balance {
/// All coinbase outputs not yet matured
/// All outputs not yet matured (coinbase with < 100 confirmations,
/// or time-locked transactions whose locktime exceeds the chain tip's MTP)
pub immature: Amount,
/// Unconfirmed UTXOs generated by a wallet tx
pub trusted_pending: Amount,
Expand Down
9 changes: 6 additions & 3 deletions crates/chain/src/canonical_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ impl<A: Anchor> CanonicalView<A> {
txout: txout.clone(),
spent_by,
is_on_coinbase: tx.is_coinbase(),
lock_time: tx.lock_time,
})
}

Expand Down Expand Up @@ -362,18 +363,20 @@ impl<A: Anchor> CanonicalView<A> {
/// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
/// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap();
/// # let indexer = KeychainTxOutIndex::<&str>::default();
/// // Calculate balance with 6 confirmations, trusting all outputs
/// // Calculate balance with 6 confirmations, trusting all outputs, and no MTP
/// let balance = view.balance(
/// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)),
/// |_keychain, _script| true, // Trust all outputs
/// 6, // Require 6 confirmations
/// None, // No MTP provided
/// );
/// ```
pub fn balance<'v, O: Clone + 'v>(
&'v self,
outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
mut trust_predicate: impl FnMut(&O, &FullTxOut<A>) -> bool,
min_confirmations: u32,
mtp: Option<u32>,
) -> Balance {
let mut immature = Amount::ZERO;
let mut trusted_pending = Amount::ZERO;
Expand All @@ -398,9 +401,9 @@ impl<A: Anchor> CanonicalView<A> {
} else {
untrusted_pending += txout.txout.value;
}
} else if txout.is_confirmed_and_spendable(self.tip.height) {
} else if txout.is_confirmed_and_spendable_at_mtp(self.tip.height, mtp) {
confirmed += txout.txout.value;
} else if !txout.is_mature(self.tip.height) {
} else if !txout.is_mature_at_mtp(self.tip.height, mtp) {
immature += txout.txout.value;
}
}
Expand Down
63 changes: 62 additions & 1 deletion crates/chain/src/chain_data.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use bitcoin::{constants::COINBASE_MATURITY, OutPoint, TxOut, Txid};
use bitcoin::{constants::COINBASE_MATURITY, locktime::absolute::LockTime, OutPoint, TxOut, Txid};

use crate::Anchor;

Expand Down Expand Up @@ -174,6 +174,8 @@ pub struct FullTxOut<A> {
pub spent_by: Option<(ChainPosition<A>, Txid)>,
/// Whether this output is on a coinbase transaction.
pub is_on_coinbase: bool,
/// The lock_time of the transaction containing this output.
pub lock_time: LockTime,
}

impl<A: Ord> Ord for FullTxOut<A> {
Expand Down Expand Up @@ -253,6 +255,65 @@ impl<A: Anchor> FullTxOut<A> {

true
}

/// Whether the `txout` is considered mature, taking MTP into account.
///
/// In addition to coinbase maturity (100 confirmations), this also checks
/// time-based locktime maturity. A transaction with a time-based locktime
/// (>= 500_000_000) is considered immature if the MTP at the chain tip
/// hasn't reached the locktime value.
///
/// If `mtp` is `None` (missing block timestamps), time-locked transactions
/// are conservatively treated as immature.
pub fn is_mature_at_mtp(&self, tip: u32, mtp: Option<u32>) -> bool {
// Check coinbase maturity first
if !self.is_mature(tip) {
return false;
}

// Check time-based locktime maturity
match self.lock_time {
LockTime::Seconds(time) => {
match mtp {
Some(mtp_val) => mtp_val >= time.to_consensus_u32(),
// Missing MTP = worst case = treat as immature
None => false,
}
}
// Height-based locktimes or no locktime: already satisfied if confirmed
_ => true,
}
}

/// Whether the utxo is/was/will be spendable with chain `tip` and `mtp`.
///
/// Like `is_confirmed_and_spendable`, but uses `is_mature_at_mtp` instead of `is_mature`.
pub fn is_confirmed_and_spendable_at_mtp(&self, tip: u32, mtp: Option<u32>) -> bool {
if !self.is_mature_at_mtp(tip, mtp) {
return false;
}

let conf_height = match self.chain_position.confirmation_height_upper_bound() {
Some(height) => height,
None => return false,
};
if conf_height > tip {
return false;
}

// if the spending tx is confirmed within tip height, the txout is no longer spendable
if let Some(spend_height) = self
.spent_by
.as_ref()
.and_then(|(pos, _)| pos.confirmation_height_upper_bound())
{
if spend_height <= tip {
return false;
}
}

true
}
}

#[cfg(test)]
Expand Down
9 changes: 7 additions & 2 deletions crates/chain/tests/test_canonical_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fn test_min_confirmations_parameter() {
[((), outpoint)],
|_, _| true, // trust all
1,
None,
);

assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000));
Expand All @@ -72,6 +73,7 @@ fn test_min_confirmations_parameter() {
[((), outpoint)],
|_, _| true, // trust all
6,
None,
);
assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000));
assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO);
Expand All @@ -81,6 +83,7 @@ fn test_min_confirmations_parameter() {
[((), outpoint)],
|_, _| true, // trust all
7,
None,
);
assert_eq!(balance_7_conf.confirmed, Amount::ZERO);
assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000));
Expand All @@ -90,6 +93,7 @@ fn test_min_confirmations_parameter() {
[((), outpoint)],
|_, _| true, // trust all
0,
None,
);
assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000));
assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO);
Expand Down Expand Up @@ -153,6 +157,7 @@ fn test_min_confirmations_with_untrusted_tx() {
[((), outpoint)],
|_, _| false, // don't trust
5,
None,
);

// Should be untrusted pending (not enough confirmations and not trusted)
Expand Down Expand Up @@ -273,7 +278,7 @@ fn test_min_confirmations_multiple_transactions() {
// tx0: 11 confirmations -> confirmed
// tx1: 6 confirmations -> confirmed
// tx2: 3 confirmations -> trusted pending
let balance = canonical_view.balance(outpoints.clone(), |_, _| true, 5);
let balance = canonical_view.balance(outpoints.clone(), |_, _| true, 5, None);

assert_eq!(
balance.confirmed,
Expand All @@ -289,7 +294,7 @@ fn test_min_confirmations_multiple_transactions() {
// tx0: 11 confirmations -> confirmed
// tx1: 6 confirmations -> trusted pending
// tx2: 3 confirmations -> trusted pending
let balance_high = canonical_view.balance(outpoints, |_, _| true, 10);
let balance_high = canonical_view.balance(outpoints, |_, _| true, 10, None);

assert_eq!(
balance_high.confirmed,
Expand Down
82 changes: 82 additions & 0 deletions crates/chain/tests/test_indexed_tx_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ fn test_list_owned_txouts() {
graph.index.outpoints().iter().cloned(),
|_, txout| trusted_spks.contains(&txout.txout.script_pubkey),
1,
None,
);

let confirmed_txouts_txid = txouts
Expand Down Expand Up @@ -887,3 +888,84 @@ fn test_get_chain_position() {
.into_iter()
.for_each(|t| run(&chain, &mut graph, t));
}

#[test]
fn test_balance_mtp_maturity() {
use bdk_chain::Balance;
use bitcoin::locktime::absolute::LockTime;

let blocks = [
(0, hash!("genesis")),
(1, hash!("b1")),
(2, hash!("b2")),
]
.into_iter()
.collect();
let chain = LocalChain::from_blocks(blocks).unwrap();

let mut tx_graph = TxGraph::default();

let tx_time_locked = Transaction {
lock_time: LockTime::from_time(500_000_100).expect("valid time"),
input: vec![TxIn {
previous_output: OutPoint::new(hash!("parent0"), 0),
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(10_000),
script_pubkey: ScriptBuf::new(),
}],
..new_tx(1)
};
let txid_time_locked = tx_time_locked.compute_txid();
let outpoint = OutPoint::new(txid_time_locked, 0);

let _ = tx_graph.insert_tx(tx_time_locked);
// Anchor it so it is "confirmed" based on height alone.
let _ = tx_graph.insert_anchor(
txid_time_locked,
ConfirmationBlockTime {
block_id: chain.get(1).unwrap().block_id(),
confirmation_time: 123456,
},
);

let canonical_view = tx_graph.canonical_view(
&chain,
chain.tip().block_id(),
CanonicalizationParams::default(),
);

// MTP is less than locktime, so it should be immature
let balance_immature = canonical_view.balance(
[((), outpoint)],
|_, _| true, // trust all
1,
Some(500_000_000), // MTP < 500_000_100
);

assert_eq!(balance_immature.immature, Amount::from_sat(10_000));
assert_eq!(balance_immature.confirmed, Amount::ZERO);

// MTP is greater than or equal to locktime, so it should be mature
let balance_mature = canonical_view.balance(
[((), outpoint)],
|_, _| true, // trust all
1,
Some(500_000_200), // MTP > 500_000_100
);

assert_eq!(balance_mature.immature, Amount::ZERO);
assert_eq!(balance_mature.confirmed, Amount::from_sat(10_000));

// No MTP provided: conservatively treated as immature
let balance_no_mtp = canonical_view.balance(
[((), outpoint)],
|_, _| true, // trust all
1,
None,
);

assert_eq!(balance_no_mtp.immature, Amount::from_sat(10_000));
assert_eq!(balance_no_mtp.confirmed, Amount::ZERO);
}
1 change: 1 addition & 0 deletions crates/chain/tests/test_tx_graph_conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,7 @@ fn test_tx_conflict_handling() {
.is_some()
},
0,
None,
);
assert_eq!(
balance, scenario.exp_balance,
Expand Down
2 changes: 1 addition & 1 deletion crates/electrum/tests/test_electrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ fn get_balance(
let outpoints = recv_graph.index.outpoints().clone();
let balance = recv_graph
.canonical_view(recv_chain, chain_tip, CanonicalizationParams::default())
.balance(outpoints, |_, _| true, 1);
.balance(outpoints, |_, _| true, 1, None);
Ok(balance)
}

Expand Down