diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 5609726..0c6a81c 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, PsbtParams, ScriptSource, SelectorParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence}; @@ -71,7 +71,7 @@ fn main() -> anyhow::Result<()> { let selection = wallet .all_candidates() .regroup(group_by_spk()) - .filter(filter_unspendable_now(tip_height, tip_time)) + .filter(filter_unspendable(tip_height, Some(tip_time))) .into_selection( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams::new( diff --git a/examples/common.rs b/examples/common.rs index 4423bf6..e639906 100644 --- a/examples/common.rs +++ b/examples/common.rs @@ -6,7 +6,9 @@ use bdk_chain::{ }; use bdk_coin_select::{ChangePolicy, DrainWeights}; use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; -use bdk_tx::{CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus, TxWithStatus}; +use bdk_tx::{ + CanonicalUnspents, ConfirmationStatus, Input, InputCandidates, RbfParams, TxWithStatus, +}; use bitcoin::{absolute, Address, Amount, BlockHash, OutPoint, Transaction, TxOut, Txid}; use miniscript::{ plan::{Assets, Plan}, @@ -71,17 +73,22 @@ impl Wallet { ) } - /// TODO: Add to chain sources. + /// Info for the block at the tip. + /// + /// Returns a tuple of: + /// - Tip's height. I.e. `tip.height` + /// - Tip's MTP. I.e. `MTP(tip.height)` pub fn tip_info( &self, client: &impl RpcApi, ) -> anyhow::Result<(absolute::Height, absolute::Time)> { - let tip = self.chain.tip().block_id(); - let tip_info = client.get_block_header_info(&tip.hash)?; - let tip_height = absolute::Height::from_consensus(tip.height)?; - let tip_time = - absolute::Time::from_consensus(tip_info.median_time.unwrap_or(tip_info.time) as _)?; - Ok((tip_height, tip_time)) + let tip_hash = self.chain.tip().block_id().hash; + let tip_info = client.get_block_header_info(&tip_hash)?; + let tip_height = absolute::Height::from_consensus(tip_info.height as u32)?; + let tip_mtp = absolute::Time::from_consensus( + tip_info.median_time.expect("must have median time") as _, + )?; + Ok((tip_height, tip_mtp)) } // TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add @@ -112,15 +119,16 @@ impl Wallet { } pub fn canonical_txs(&self) -> impl Iterator>> + '_ { - pub fn status_from_position(pos: ChainPosition) -> Option { + pub fn status_from_position( + pos: ChainPosition, + ) -> Option { match pos { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(TxStatus { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some(ConfirmationStatus { height: absolute::Height::from_consensus( anchor.confirmation_height_upper_bound(), ) .expect("must convert to height"), - time: absolute::Time::from_consensus(anchor.confirmation_time as _) - .expect("must convert from time"), + prev_mtp: None, // TODO: Use `CheckPoint::prev_mtp` }), bdk_chain::ChainPosition::Unconfirmed { .. } => None, } diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 66d36fa..125efc0 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,6 +1,6 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, FeeStrategy, Output, PsbtParams, ScriptSource, SelectorParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate, Sequence}; @@ -39,7 +39,7 @@ fn main() -> anyhow::Result<()> { println!("Received {txid}"); println!("Balance (pending): {}", wallet.balance()); - let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?; + let (tip_height, tip_mtp) = wallet.tip_info(env.rpc_client())?; let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); let recipient_addr = env @@ -51,7 +51,7 @@ fn main() -> anyhow::Result<()> { let selection = wallet .all_candidates() .regroup(group_by_spk()) - .filter(filter_unspendable_now(tip_height, tip_time)) + .filter(filter_unspendable(tip_height, Some(tip_mtp))) .into_selection( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams::new( diff --git a/src/canonical_unspents.rs b/src/canonical_unspents.rs index 3b05afa..5cc1eb7 100644 --- a/src/canonical_unspents.rs +++ b/src/canonical_unspents.rs @@ -6,17 +6,18 @@ use bitcoin::{psbt, OutPoint, Sequence, Transaction, TxOut, Txid}; use miniscript::{bitcoin, plan::Plan}; use crate::{ - collections::HashMap, input::CoinbaseMismatch, FromPsbtInputError, Input, RbfSet, TxStatus, + collections::HashMap, input::CoinbaseMismatch, ConfirmationStatus, FromPsbtInputError, Input, + RbfSet, }; /// Tx with confirmation status. -pub type TxWithStatus = (T, Option); +pub type TxWithStatus = (T, Option); /// Our canonical view of unspent outputs. #[derive(Debug, Clone)] pub struct CanonicalUnspents { txs: HashMap>, - statuses: HashMap, + statuses: HashMap, spends: HashMap, } diff --git a/src/input.rs b/src/input.rs index 8018303..b9dee4b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -10,25 +10,29 @@ use miniscript::bitcoin; use miniscript::bitcoin::{OutPoint, Transaction, TxOut}; use miniscript::plan::Plan; -/// Confirmation status of a tx data. +/// Confirmation status of tx data. #[derive(Debug, Clone, Copy)] -pub struct TxStatus { +pub struct ConfirmationStatus { /// Confirmation block height. pub height: absolute::Height, - /// Confirmation block median time past. + /// Previous block's MTP (median time past) value as per BIP-0068, if available. /// - /// TODO: Currently BDK cannot fetch MTP time. We can pretend that the latest block time is the - /// MTP time for now. - pub time: absolute::Time, + /// If this is `None` and the input has a relative time-based lock, timelock + /// checking methods ([`Input::is_time_timelocked`], [`Input::is_timelocked`], + /// [`Input::is_spendable`]) will return `None` to indicate the lock status + /// cannot be determined. + pub prev_mtp: Option, } -impl TxStatus { - /// From consensus `height` and `time`. - pub fn new(height: u32, time: u64) -> Result { +impl ConfirmationStatus { + /// From consensus `height` and `prev_mtp`. + /// + /// * `height` - Height of the block that the transaction is confirmed in. + /// * `prev_mtp` - The previous block's MTP value. I.e. MTP(`height` - 1). + pub fn new(height: u32, prev_mtp: Option) -> Result { Ok(Self { height: absolute::Height::from_consensus(height)?, - // TODO: handle `.try_into::()` - time: absolute::Time::from_consensus(time as _)?, + prev_mtp: prev_mtp.map(absolute::Time::from_consensus).transpose()?, }) } } @@ -191,7 +195,7 @@ pub struct Input { prev_txout: TxOut, prev_tx: Option>, plan: PlanOrPsbtInput, - status: Option, + status: Option, is_coinbase: bool, } @@ -206,7 +210,7 @@ impl Input { plan: Plan, prev_tx: T, output_index: usize, - status: Option, + status: Option, ) -> Result where T: Into>, @@ -228,7 +232,7 @@ impl Input { plan: Plan, prev_outpoint: OutPoint, prev_txout: TxOut, - status: Option, + status: Option, is_coinbase: bool, ) -> Self { Self { @@ -254,7 +258,7 @@ impl Input { sequence: Sequence, psbt_input: psbt::Input, satisfaction_weight: usize, - status: Option, + status: Option, is_coinbase: bool, ) -> Result { let outpoint = prev_outpoint; @@ -332,7 +336,7 @@ impl Input { } /// Confirmation status. - pub fn status(&self) -> Option { + pub fn status(&self) -> Option { self.status } @@ -341,17 +345,19 @@ impl Input { self.is_coinbase } - /// Whether prev output is an immature coinbase output and cannot be spent in the next block. + /// Whether prev output is an immature coinbase output. pub fn is_immature(&self, tip_height: absolute::Height) -> bool { if !self.is_coinbase { return false; } match self.status { Some(status) => { - let age = tip_height + let spending_height = tip_height .to_consensus_u32() - .saturating_sub(status.height.to_consensus_u32()); - age + 1 < COINBASE_MATURITY + .checked_add(1) + .expect("must not overflow"); + let age = spending_height.saturating_sub(status.height.to_consensus_u32()); + age < COINBASE_MATURITY } None => { debug_assert!(false, "coinbase should never be unconfirmed"); @@ -360,39 +366,98 @@ impl Input { } } - /// Whether the output is still locked by timelock constraints and cannot be spent in the - /// next block. - pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - if let Some(locktime) = self.plan.absolute_timelock() { - if !locktime.is_satisfied_by(tip_height, tip_time) { - return true; + /// Whether this is locked by a block-based timelock (absolute or relative). + pub fn is_block_timelocked(&self, tip_height: absolute::Height) -> bool { + let spending_height = tip_height + .to_consensus_u32() + .checked_add(1) + .expect("must not overflow"); + if let Some(absolute::LockTime::Blocks(lt_height)) = self.plan.absolute_timelock() { + // Bitcoin Core's `IsFinalTx` uses strict less-than: a tx is final (unlocked) when + // `nLockTime < blockHeight`. This means `nLockTime = 100` is first spendable in + // block 101, not block 100. We return "locked" when the inverse is true. + return lt_height.to_consensus_u32() >= spending_height; + } + + match (self.plan.relative_timelock(), self.status) { + (Some(relative::LockTime::Blocks(lt_height)), Some(conf_status)) => { + // BIP 68: relative lock is satisfied when `height_diff >= lock_value`. + // We return "locked" when `lock_value > height_diff`. + let height_diff = + spending_height.saturating_sub(conf_status.height.to_consensus_u32()); + lt_height.to_consensus_u32() > height_diff } + // A block-timelocked output that is unconfirmed must be locked. + (Some(relative::LockTime::Blocks(_)), None) => true, + // No relative block-timelock. + _ => false, } - if let Some(locktime) = self.plan.relative_timelock() { - // TODO: Make sure this logic is right. - let (relative_height, relative_time) = match self.status { - Some(status) => { - let relative_height = tip_height - .to_consensus_u32() - .saturating_sub(status.height.to_consensus_u32()); - let relative_time = tip_time - .to_consensus_u32() - .saturating_sub(status.time.to_consensus_u32()); - ( - relative::Height::from_height( - relative_height.try_into().unwrap_or(u16::MAX), - ), - relative::Time::from_seconds_floor(relative_time) - .unwrap_or(relative::Time::MAX), - ) - } - None => (relative::Height::ZERO, relative::Time::ZERO), - }; - if !locktime.is_satisfied_by(relative_height, relative_time) { - return true; + } + + /// Whether this is locked by a time-based timelock (absolute or relative). + /// + /// Returns `None` if [`ConfirmationStatus::prev_mtp`] is required but unavailable. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_time_timelocked(&self, tip_mtp: absolute::Time) -> Option { + if let Some(absolute::LockTime::Seconds(lt_time)) = self.plan.absolute_timelock() { + // Bitcoin Core's `IsFinalTx` (with BIP 113) uses strict less-than: a tx is final + // (unlocked) when `nLockTime < MTP`. This means `nLockTime = T` is first spendable + // when `MTP > T`, not when `MTP == T`. We return "locked" when the inverse is true. + return Some(lt_time.to_consensus_u32() >= tip_mtp.to_consensus_u32()); + } + + match (self.plan.relative_timelock(), self.status) { + (Some(relative::LockTime::Time(lt_time)), Some(conf_status)) => { + // BIP 68: relative time lock is satisfied when `time_diff >= lock_value * 512`. + // We return "locked" when `lock_value * 512 > time_diff`. + let time_diff = tip_mtp + .to_consensus_u32() + // If we are missing `prev_mtp`, we cannot determine whether the output is still + // locked. + .saturating_sub(conf_status.prev_mtp?.to_consensus_u32()); + Some(lt_time.value() as u32 * 512 > time_diff) + } + // A time-timelocked output that is unconfirmed must be locked. + (Some(relative::LockTime::Time(_)), None) => Some(true), + // No relative time-timelock. + _ => Some(false), + } + } + + /// Whether this is locked by any timelock constraint. + /// + /// Returns `None` if a time-based lock exists but `tip_mtp` is not provided or + /// [`ConfirmationStatus::prev_mtp`] is unavailable. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_timelocked( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + if self.is_block_timelocked(tip_height) { + return Some(true); + } + + let has_time_timelock = self + .plan + .absolute_timelock() + .is_some_and(|l| l.is_block_time()) + || self + .plan + .relative_timelock() + .is_some_and(|l| l.is_block_time()); + + if has_time_timelock { + if let Some(mtp) = tip_mtp { + return self.is_time_timelocked(mtp); } + return None; } - false + + // No timelock exists + Some(false) } /// Confirmations of this tx. @@ -404,9 +469,15 @@ impl Input { }) } - /// Whether this output can be spent now. - pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - !self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_time) + /// Whether this output can be spent at the given height and mtp time. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_spendable( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + Some(!self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_mtp)?) } /// Absolute timelock. @@ -482,23 +553,60 @@ impl InputGroup { self.0.push(input); } - /// Whether any contained inputs are immature. + /// Whether any contained input is immature. pub fn is_immature(&self, tip_height: absolute::Height) -> bool { self.0.iter().any(|input| input.is_immature(tip_height)) } - /// Whether any contained inputs are time locked. - pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + /// Whether any contained input is locked by a block-based timelock (absolute or relative). + pub fn is_block_timelocked(&self, tip_height: absolute::Height) -> bool { self.0 .iter() - .any(|input| input.is_timelocked(tip_height, tip_time)) + .any(|input| input.is_block_timelocked(tip_height)) + } + + /// Whether any contained input is locked by a time-based timelock (absolute or relative). + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_time_timelocked(&self, tip_mtp: absolute::Time) -> Option { + for input in &self.0 { + if input.is_time_timelocked(tip_mtp)? { + return Some(true); + } + } + Some(false) + } + + /// Whether any contained input is locked by any timelock constraint. + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_timelocked( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + for input in &self.0 { + if input.is_timelocked(tip_height, tip_mtp)? { + return Some(true); + } + } + Some(false) } /// Whether all contained inputs are spendable now. - pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - self.0 - .iter() - .all(|input| input.is_spendable_now(tip_height, tip_time)) + /// + /// `tip_mtp` is `MTP(tip)`, or `MTP(spending_block - 1)`, as per BIP-0068. + pub fn is_spendable( + &self, + tip_height: absolute::Height, + tip_mtp: Option, + ) -> Option { + for input in &self.0 { + if !input.is_spendable(tip_height, tip_mtp)? { + return Some(false); + } + } + Some(true) } /// Returns the tx confirmation count this is the smallest in this group. diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 8e5b5c1..b77da7a 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -315,11 +315,13 @@ pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { } /// Filter out inputs that cannot be spent now. -pub fn filter_unspendable_now( +/// +/// If an input's spendability cannot be determined, it will also be filtered out. +pub fn filter_unspendable( tip_height: absolute::Height, - tip_time: absolute::Time, + tip_mtp: Option, ) -> impl Fn(&Input) -> bool { - move |input| input.is_spendable_now(tip_height, tip_time) + move |input| input.is_spendable(tip_height, tip_mtp).unwrap_or(false) } /// No filtering. diff --git a/src/selection.rs b/src/selection.rs index daf07ee..962c686 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -410,9 +410,9 @@ mod tests { }], }; - let status = crate::TxStatus { + let status = crate::ConfirmationStatus { height: absolute::Height::from_consensus(confirmation_height)?, - time: Time::from_consensus(500_000_000)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), }; let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?;