From 8b47068a2efada30aec21c61ae4be0da4d8e8fc8 Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Mon, 14 Apr 2025 16:28:27 +0100 Subject: [PATCH] feat(locktime): implement MtpAndHeight structure and validation logic - Add MtpAndHeight for relative locktime checks - Include unit tests for time/height comparisons - Fix API design for mtp_as_time() error handling - Update documentation and dependencies - Fix BlockTime, CI, remove Ordering, and PR discussion fixed - Fix UTXO height and timestamps - Fix: chain_state and utxo_state handled seperately for is_satisfied_by - Fix: panic on overflow fixed with check_add - Fix: documentation updated and trailing whitespaces removed - docs(mtpheight): documentation updated - used accessors to_height and to_mtp over From impl --- primitives/src/locktime/relative.rs | 150 +++++++++++++++++++--------- units/src/lib.rs | 1 + units/src/locktime/relative.rs | 112 +++++++++++++++++++++ units/src/mtp_height.rs | 68 +++++++++++++ 4 files changed, 285 insertions(+), 46 deletions(-) create mode 100644 units/src/mtp_height.rs diff --git a/primitives/src/locktime/relative.rs b/primitives/src/locktime/relative.rs index 7a08d2296..0c7754020 100644 --- a/primitives/src/locktime/relative.rs +++ b/primitives/src/locktime/relative.rs @@ -14,6 +14,7 @@ use crate::{relative, TxIn}; #[rustfmt::skip] // Keep public re-exports separate. #[doc(inline)] pub use units::locktime::relative::{Height, Time, TimeOverflowError}; +use units::mtp_height::MtpAndHeight; /// A relative lock time value, representing either a block height or time (512 second intervals). /// @@ -35,17 +36,35 @@ pub use units::locktime::relative::{Height, Time, TimeOverflowError}; /// /// ``` /// use bitcoin_primitives::relative; +/// use bitcoin_primitives::BlockTime; +/// use bitcoin_primitives::BlockHeight; +/// use units::mtp_height::MtpAndHeight; /// let lock_by_height = relative::LockTime::from_height(144); // 144 blocks, approx 24h. /// assert!(lock_by_height.is_block_height()); /// /// let lock_by_time = relative::LockTime::from_512_second_intervals(168); // 168 time intervals, approx 24h. /// assert!(lock_by_time.is_block_time()); /// -/// // Check if a lock time is satisfied by a given height or time. -/// let height = relative::Height::from(150); -/// let time = relative::Time::from_512_second_intervals(200); -/// assert!(lock_by_height.is_satisfied_by(height, time)); -/// assert!(lock_by_time.is_satisfied_by(height, time)); +/// fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] { +/// let mut timestamps = [BlockTime::from_u32(0); 11]; +/// for (i, ts) in timestamps.iter_mut().enumerate() { +/// *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into())); +/// } +/// timestamps +/// } +/// // time extracted from BlockHeader +/// let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200); +/// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200); +/// +/// let current_height = BlockHeight::from(100); +/// let utxo_height = BlockHeight::from(80); +/// +/// let chain_tip = MtpAndHeight::new(current_height, timestamps); +/// let utxo_mined_at = MtpAndHeight::new(utxo_height, utxo_timestamps); +/// let locktime = relative::LockTime::Time(relative::Time::from_512_second_intervals(10)); +/// +/// // Check if locktime is satisfied +/// assert!(locktime.is_satisfied_by(chain_tip, utxo_mined_at)); /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -193,29 +212,42 @@ impl LockTime { #[inline] pub const fn is_block_time(self) -> bool { !self.is_block_height() } - /// Returns true if this [`relative::LockTime`] is satisfied by either height or time. - /// + /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state. /// # Examples /// /// ```rust - /// # use bitcoin_primitives::Sequence; - /// # use bitcoin_primitives::relative; + /// # use bitcoin_primitives::locktime::relative::Height; + /// # use bitcoin_primitives::relative::Time; + /// # use units::mtp_height::MtpAndHeight; + /// # use bitcoin_primitives::BlockHeight; + /// # use bitcoin_primitives::relative::LockTime; + /// # use bitcoin_primitives::BlockTime; /// - /// # let required_height = 100; // 100 blocks. - /// # let intervals = 70; // Approx 10 hours. - /// # let current_height = || relative::Height::from(required_height + 10); - /// # let current_time = || relative::Time::from_512_second_intervals(intervals + 10); - /// # let lock = Sequence::from_height(required_height).to_relative_lock_time().expect("valid height"); + /// fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] { + /// let mut timestamps = [BlockTime::from_u32(0); 11]; + /// for (i, ts) in timestamps.iter_mut().enumerate() { + /// *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into())); + /// } + /// timestamps + /// } + /// // time extracted from BlockHeader + /// let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200); + /// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200); /// - /// // Users that have chain data can get the current height and time to check against a lock. - /// assert!(lock.is_satisfied_by(current_height(), current_time())); + /// let current_height = BlockHeight::from_u32(100); + /// let utxo_height = BlockHeight::from_u32(80); + /// + /// let chain_tip = MtpAndHeight::new(current_height, timestamps); + /// let utxo_mined_at = MtpAndHeight::new(utxo_height, utxo_timestamps); + /// let locktime = LockTime::Time(Time::from_512_second_intervals(10)); + /// + /// // Check if locktime is satisfied + /// assert!(locktime.is_satisfied_by(chain_tip, utxo_mined_at)); /// ``` - #[inline] - pub fn is_satisfied_by(self, h: Height, t: Time) -> bool { - if let Ok(true) = self.is_satisfied_by_height(h) { - true - } else { - matches!(self.is_satisfied_by_time(t), Ok(true)) + pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool { + match self { + LockTime::Blocks(blocks) => blocks.is_satisfied_by(chain_tip, utxo_mined_at), + LockTime::Time(time) => time.is_satisfied_by(chain_tip, utxo_mined_at), } } @@ -468,6 +500,8 @@ impl std::error::Error for IncompatibleTimeError {} #[cfg(test)] mod tests { + use units::{BlockHeight, BlockTime}; + use super::*; #[test] @@ -525,30 +559,6 @@ mod tests { assert!(!lock_by_time1.is_same_unit(lock_by_height1)); } - #[test] - fn satisfied_by_height() { - let height = Height::from(10); - let time = Time::from_512_second_intervals(70); - - let lock_by_height = LockTime::from(height); - - assert!(!lock_by_height.is_satisfied_by(Height::from(9), time)); - assert!(lock_by_height.is_satisfied_by(Height::from(10), time)); - assert!(lock_by_height.is_satisfied_by(Height::from(11), time)); - } - - #[test] - fn satisfied_by_time() { - let height = Height::from(10); - let time = Time::from_512_second_intervals(70); - - let lock_by_time = LockTime::from(time); - - assert!(!lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(69))); - assert!(lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(70))); - assert!(lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(71))); - } - #[test] fn height_correctly_implies() { let height = Height::from(10); @@ -654,4 +664,52 @@ mod tests { assert_eq!(err.expected(), height); assert!(!format!("{}", err).is_empty()); } + + #[test] + fn test_locktime_chain_state() { + fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] { + let mut timestamps = [BlockTime::from_u32(0); 11]; + for (i, ts) in timestamps.iter_mut().enumerate() { + *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into())); + } + timestamps + } + + let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200); + let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200); + + let chain_tip = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps); + let utxo_mined_at = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + + let lock1 = LockTime::Blocks(Height::from(10)); + assert!(lock1.is_satisfied_by(chain_tip, utxo_mined_at)); + + let lock2 = LockTime::Blocks(Height::from(21)); + assert!(!lock2.is_satisfied_by(chain_tip, utxo_mined_at)); + + let lock3 = LockTime::Time(Time::from_512_second_intervals(10)); + assert!(lock3.is_satisfied_by(chain_tip, utxo_mined_at)); + + let lock4 = LockTime::Time(Time::from_512_second_intervals(20000)); + assert!(!lock4.is_satisfied_by(chain_tip, utxo_mined_at)); + + assert!(LockTime::ZERO.is_satisfied_by(chain_tip, utxo_mined_at)); + assert!(LockTime::from_512_second_intervals(0).is_satisfied_by(chain_tip, utxo_mined_at)); + + let lock6 = LockTime::from_seconds_floor(5000).unwrap(); + assert!(lock6.is_satisfied_by(chain_tip, utxo_mined_at)); + + let max_height_lock = LockTime::Blocks(Height::MAX); + assert!(!max_height_lock.is_satisfied_by(chain_tip, utxo_mined_at)); + + let max_time_lock = LockTime::Time(Time::MAX); + assert!(!max_time_lock.is_satisfied_by(chain_tip, utxo_mined_at)); + + let max_chain_tip = + MtpAndHeight::new(BlockHeight::from_u32(u32::MAX), generate_timestamps(u32::MAX, 100)); + let max_utxo_mined_at = + MtpAndHeight::new(BlockHeight::MAX, generate_timestamps(u32::MAX, 100)); + assert!(!max_height_lock.is_satisfied_by(max_chain_tip, max_utxo_mined_at)); + assert!(!max_time_lock.is_satisfied_by(max_chain_tip, max_utxo_mined_at)); + } } diff --git a/units/src/lib.rs b/units/src/lib.rs index 0919efb73..f4c6813c7 100644 --- a/units/src/lib.rs +++ b/units/src/lib.rs @@ -34,6 +34,7 @@ pub mod amount; pub mod block; pub mod fee_rate; pub mod locktime; +pub mod mtp_height; pub mod parse; pub mod time; pub mod weight; diff --git a/units/src/locktime/relative.rs b/units/src/locktime/relative.rs index 7f8b4413a..06e53d8b0 100644 --- a/units/src/locktime/relative.rs +++ b/units/src/locktime/relative.rs @@ -9,6 +9,8 @@ use arbitrary::{Arbitrary, Unstructured}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +use crate::mtp_height::MtpAndHeight; + /// A relative lock time lock-by-blockheight value. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -39,6 +41,26 @@ impl Height { pub const fn to_consensus_u32(self) -> u32 { self.0 as u32 // cast safety: u32 is wider than u16 on all architectures } + + /// Determines whether a relative‐height locktime has matured, taking into account + /// both the chain tip and the height at which the UTXO was confirmed. + /// + /// # Parameters + /// - `self` – The relative block‐height delay (`h`) required after confirmation. + /// - `chain_tip` – The current chain state (contains the tip height). + /// - `utxo_mined_at` – The chain state at the UTXO’s confirmation block (contains that height). + /// + /// # Returns + /// - `true` if a UTXO locked by `self` can be spent in a block after `chain_tip`. + /// - `false` if the UTXO is still locked at `chain_tip`. + pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool { + // let chain_tip_height = BlockHeight::from(chain_tip); + // let utxo_mined_at_height = BlockHeight::from(utxo_mined_at); + match self.to_consensus_u32().checked_add(utxo_mined_at.to_height().to_u32()) { + Some(target_height) => chain_tip.to_height().to_u32() >= target_height, + None => false, + } + } } impl From for Height { @@ -120,6 +142,27 @@ impl Time { pub const fn to_consensus_u32(self) -> u32 { (1u32 << 22) | self.0 as u32 // cast safety: u32 is wider than u16 on all architectures } + + /// Determines whether a relative‑time lock has matured, taking into account both + /// the UTXO’s Median Time Past at confirmation and the required delay. + /// + /// # Parameters + /// - `self` – The relative time delay (`t`) in 512‑second intervals. + /// - `chain_tip` – The current chain state, providing the tip’s MTP. + /// - `utxo_mined_at` – The chain state at the UTXO’s confirmation, providing its MTP. + /// + /// # Returns + /// - `true` if the relative‐time lock has expired by the tip’s MTP + /// - `false` if the lock has not yet expired by the tip’s MTP + pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool { + match u32::from(self.value()).checked_mul(512) { + Some(seconds) => match seconds.checked_add(utxo_mined_at.to_mtp().to_u32()) { + Some(required_seconds) => chain_tip.to_mtp().to_u32() >= required_seconds, + None => false, + }, + None => false, + } + } } crate::impl_parse_str_from_int_infallible!(Time, u16, from_512_second_intervals); @@ -193,6 +236,7 @@ mod tests { use internals::serde_round_trip; use super::*; + use crate::{BlockHeight, BlockTime}; const MAXIMUM_ENCODABLE_SECONDS: u32 = u16::MAX as u32 * 512; @@ -265,4 +309,72 @@ mod tests { serde_round_trip!(Time::MIN); serde_round_trip!(Time::MAX); } + + fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] { + let mut timestamps = [BlockTime::from_u32(0); 11]; + for (i, ts) in timestamps.iter_mut().enumerate() { + *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into())); + } + timestamps + } + + #[test] + fn test_time_chain_state() { + let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200); + let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200); + + let timestamps2: [BlockTime; 11] = generate_timestamps(1_599_995_119, 200); + let utxo_timestamps2: [BlockTime; 11] = generate_timestamps(1_599_990_000, 200); + + let timestamps3: [BlockTime; 11] = generate_timestamps(1_600_050_000, 200); + let utxo_timestamps3: [BlockTime; 11] = generate_timestamps(1_599_990_000, 200); + + // Test case 1: Satisfaction (current_mtp >= utxo_mtp + required_seconds) + // 10 intervals × 512 seconds = 5120 seconds + let time_lock = Time::from_512_second_intervals(10); + let chain_state1 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps); + let utxo_state1 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + assert!(time_lock.is_satisfied_by(chain_state1, utxo_state1)); + + // Test case 2: Not satisfied (current_mtp < utxo_mtp + required_seconds) + let chain_state2 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps2); + let utxo_state2 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps2); + assert!(!time_lock.is_satisfied_by(chain_state2, utxo_state2)); + + // Test case 3: Test with a larger value (100 intervals = 51200 seconds) + let larger_lock = Time::from_512_second_intervals(100); + let chain_state3 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps3); + let utxo_state3 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps3); + assert!(larger_lock.is_satisfied_by(chain_state3, utxo_state3)); + + // Test case 4: Overflow handling - tests that is_satisfied_by handles overflow gracefully + let max_time_lock = Time::MAX; + let chain_state4 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps); + let utxo_state4 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + assert!(!max_time_lock.is_satisfied_by(chain_state4, utxo_state4)); + } + + #[test] + fn test_height_chain_state() { + let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200); + let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200); + + let height_lock = Height(10); + + // Test case 1: Satisfaction (current_height >= utxo_height + required) + let chain_state1 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps); + let utxo_state1 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + assert!(height_lock.is_satisfied_by(chain_state1, utxo_state1)); + + // Test case 2: Not satisfied (current_height < utxo_height + required) + let chain_state2 = MtpAndHeight::new(BlockHeight::from_u32(89), timestamps); + let utxo_state2 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + assert!(!height_lock.is_satisfied_by(chain_state2, utxo_state2)); + + // Test case 3: Overflow handling - tests that is_satisfied_by handles overflow gracefully + let max_height_lock = Height::MAX; + let chain_state3 = MtpAndHeight::new(BlockHeight::from_u32(1000), timestamps); + let utxo_state3 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps); + assert!(!max_height_lock.is_satisfied_by(chain_state3, utxo_state3)); + } } diff --git a/units/src/mtp_height.rs b/units/src/mtp_height.rs new file mode 100644 index 000000000..74b7705ad --- /dev/null +++ b/units/src/mtp_height.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Median Time Past (MTP) and height - used for working lock times. + +use crate::{BlockHeight, BlockTime}; + +/// A structure containing both Median Time Past (MTP) and current +/// absolute block height, used for validating relative locktimes. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct MtpAndHeight { + /// The Median Time Past (median of the last 11 blocks' timestamps) + mtp: BlockTime, + /// The current block height, + height: BlockHeight, +} + +impl MtpAndHeight { + /// Constructs an [`MtpAndHeight`] by computing the median‐time‐past from the last 11 block timestamps + /// + /// # Parameters + /// + /// * `height` - The absolute height of the chain tip + /// * `timestamps` - An array of timestamps from the most recent 11 blocks, where + /// - `timestamps[0]` is the timestamp at height `height - 10` + /// - `timestamps[1]` is the timestamp at height `height - 9` + /// - … + /// - `timestamps[10]` is the timestamp at height `height` + pub fn new(height: BlockHeight, timestamps: [BlockTime; 11]) -> Self { + let mut mtp_timestamps = timestamps; + mtp_timestamps.sort_unstable(); + let mtp = mtp_timestamps[5]; + + MtpAndHeight { mtp, height } + } + + /// Returns the median-time-past component. + pub fn to_mtp(self) -> BlockTime { self.mtp } + + /// Returns the block-height component. + pub fn to_height(self) -> BlockHeight { self.height } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_chain_computes_mtp() { + let height = BlockHeight::from_u32(100); + let timestamps = [ + BlockTime::from_u32(10), + BlockTime::from_u32(3), + BlockTime::from_u32(5), + BlockTime::from_u32(8), + BlockTime::from_u32(1), + BlockTime::from_u32(4), + BlockTime::from_u32(6), + BlockTime::from_u32(9), + BlockTime::from_u32(2), + BlockTime::from_u32(7), + BlockTime::from_u32(0), + ]; + + let result = MtpAndHeight::new(height, timestamps); + assert_eq!(result.height, height); + assert_eq!(result.mtp.to_u32(), 5); + } +}