diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index b75d474caa..bb54a8910e 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -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) } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index 5907c76a00..203792227b 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -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); } diff --git a/crates/chain/src/balance.rs b/crates/chain/src/balance.rs index 2d4dc9dbe3..618d49ffcf 100644 --- a/crates/chain/src/balance.rs +++ b/crates/chain/src/balance.rs @@ -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, diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 0191f45071..b754e05f47 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -236,6 +236,7 @@ impl CanonicalView { txout: txout.clone(), spent_by, is_on_coinbase: tx.is_coinbase(), + lock_time: tx.lock_time, }) } @@ -362,11 +363,12 @@ impl CanonicalView { /// # 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>( @@ -374,6 +376,7 @@ impl CanonicalView { outpoints: impl IntoIterator + 'v, mut trust_predicate: impl FnMut(&O, &FullTxOut) -> bool, min_confirmations: u32, + mtp: Option, ) -> Balance { let mut immature = Amount::ZERO; let mut trusted_pending = Amount::ZERO; @@ -398,9 +401,9 @@ impl CanonicalView { } 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; } } diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 43d41e2ed4..4f4fb0f5e9 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -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; @@ -174,6 +174,8 @@ pub struct FullTxOut { pub spent_by: Option<(ChainPosition, 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 Ord for FullTxOut { @@ -253,6 +255,65 @@ impl FullTxOut { 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) -> 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) -> 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)] diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 3c0d54381c..67a2f68277 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -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)); @@ -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); @@ -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)); @@ -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); @@ -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) @@ -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, @@ -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, diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 2a33f3b1c3..5b5a7d0b8a 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -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 @@ -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); +} diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 38f21365c3..c7970bc279 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -1038,6 +1038,7 @@ fn test_tx_conflict_handling() { .is_some() }, 0, + None, ); assert_eq!( balance, scenario.exp_balance, diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index ef94c62f3e..bd918b781f 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -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) }