Repository: rust-bitcoin/rust-bitcoin
Pull request #4344: Locktime MtpAndHeight
Author: login: aagbotemi
Labels: name: C-units
description: PRs modifying the units crate
name: C-primitives
Created: 2025-04-14T18:08:57Z | Merged: 2025-04-28T13:49:20Z

This PR fixes #4299

- Computed MtpAndHeight structure
- Checked if relative time and height is satisfied by MtpAndHeight
- Compared the Ordering of MtpAndHeight with time and height
- Checked MtpAndHeight satisfaction and comparison in Locktime
- Added unit tests for all the implementation

I've reviewed and adhered to the contribution guidelines


Linked issue #4299: locktime: add `MtpAndHeight` structure and comparison opcodes for locktimes
This is a spinoff of https://github.com/rust-bitcoin/rust-bitcoin/issues/3334 which hopefully has a clearer action plan:

1. In `units` introduce a `MtpAndHeight` structure which can somehow be constructed from a chain of blockheaders.
2. Introduce comparison methods for `relative::LockTime` against this structure.
3. Update the docs to tell users to do this when validating relative locktimes.

See https://github.com/rust-bitcoin/rust-bitcoin/issues/3334#issuecomment-2339010951 for "how to correctly validate relative locktimes with our current API" which is pretty noisy and requires the user manually obtain a MTP and add it to the timestamp being compared to.

Diff:
diff --git a/primitives/src/locktime/relative.rs b/primitives/src/locktime/relative.rs
index 7a08d22965..0c7754020e 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;
-    ///
-    /// # 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");
-    ///
-    /// // 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()));
+    /// # 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;
+    ///
+    /// 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_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 0919efb73a..f4c6813c7b 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 7f8b4413a2..06e53d8b0f 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<u16> 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 0000000000..74b7705ad0
--- /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);
+    }
+}


Reviews:
user: login: yancyribbens
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T18:16:08Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T18:24:58Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T22:32:27Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:37:46Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:39:28Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:41:40Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:53:00Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:55:18Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T23:04:35Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T23:26:47Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T15:01:10Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T15:02:27Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:13:45Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T16:07:31Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T17:31:45Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:06:48Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:07:21Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:08:03Z
user: login: yancyribbens
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:39:26Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T14:22:39Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:53:25Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:53:49Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:54:20Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:56:45Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:17:33Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T23:47:43Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T05:26:26Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T13:58:10Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:04:57Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:36:53Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:57:43Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T21:28:05Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:53:10Z
user: login: jrakibi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T18:01:06Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T13:39:24Z
user: login: Kixunil
body: Looking forward to this! I don't have the brain power to review it deeply now will wait until the changes to use absolute instead of relative.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T17:43:34Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-20T20:20:21Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T22:11:37Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T02:35:01Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T13:27:29Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T13:41:11Z
user: login: apoelstra
body: ACK 469fa6cebd0a9e3506e594dd7c33b423c203b43b; successfully ran local tests
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-23T04:41:24Z
user: login: Kixunil
body: Something feels off about this but I have trouble focusing right now, so I request waiting with merge for two days even if there are two ACKs by then.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:39:17Z
user: login: Kixunil
body: The only blocking concern is the fields being `pub`. The rest looks acceptable feel free to address my other remarks or not.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T17:01:43Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T17:04:28Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T17:07:50Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T17:08:32Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T00:44:31Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T01:12:26Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T01:38:39Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T02:17:23Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T12:25:42Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:35:27Z
user: login: apoelstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:40:55Z
user: login: aagbotemi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T11:27:33Z
user: login: apoelstra
body: ACK 8b47068a2efada30aec21c61ae4be0da4d8e8fc8; successfully ran local tests
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T15:13:50Z
user: login: Kixunil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-25T15:34:47Z
user: login: Kixunil
body: Two more non-blocking issues. OK to merge if we want to address them later and Tobin is not against it.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T01:26:41Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T01:33:12Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T01:36:00Z
user: login: Kixunil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T01:39:33Z
user: login: Kixunil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T01:44:35Z
user: login: Kixunil
body: ACK 8b47068a2efada30aec21c61ae4be0da4d8e8fc8
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T01:46:35Z
user: login: tcharding
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T02:50:55Z
user: login: tcharding
body: ACK 8b47068a2efada30aec21c61ae4be0da4d8e8fc8
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T02:51:25Z
user: login: Kixunil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T12:09:41Z

Review comments:
diff_hunk: @@ -22,6 +22,7 @@ internals = { package = "bitcoin-internals", path = "../internals" }
 
 serde = { version = "1.0.103", default-features = false, features = ["derive"], optional = true }
 arbitrary = { version = "1.4", optional = true }
+bitcoin = "*"
path: units/Cargo.toml
user: login: yancyribbens
body: This doesn't look right.  Also this is probably the cause of your CI failure.
created_at: 2025-04-14T18:16:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +22,7 @@ internals = { package = "bitcoin-internals", path = "../internals" }
 
 serde = { version = "1.0.103", default-features = false, features = ["derive"], optional = true }
 arbitrary = { version = "1.4", optional = true }
+bitcoin = "*"
path: units/Cargo.toml
user: login: aagbotemi
body: Thank you for pointing this out. I will fix it.
created_at: 2025-04-14T18:24:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        match self {
+            LockTime::Blocks(blocks) => blocks.is_satisfied_by_chain_state(chain_state),
+            LockTime::Time(time) => time.is_satisfied_by_chain_state(chain_state),
+        }
+    }
+
+    /// Compares this relative locktime with the chain state
+    ///
+    /// Returns `Ordering::Greater` if the locktime has matured (can be included)
+    /// Returns `Ordering::Less` if the locktime has not yet matured
+    /// Returns `Ordering::Equal` if the locktime is exactly at the threshold
+    pub fn compare_with_chain_state(self, chain_state: MtpAndHeight) -> Ordering {
path: primitives/src/locktime/relative.rs
user: login: tcharding
body: What is the purpose of these 'compare' functions? Why would someone want to compare them and not just call `is_satisfied_by_chain_state`? IIUC all it gives is access to `Equal`.
created_at: 2025-04-14T22:19:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
path: units/src/locktime/mtp_height.rs
user: login: tcharding
body: API design comment: This function adds no value because the fields are public. Either the fields should not be public or this should not exist.
created_at: 2025-04-14T22:20:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
path: units/src/locktime/mtp_height.rs
user: login: tcharding
body: This could use `BlockTime`, right?
created_at: 2025-04-14T22:20:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
+        MtpAndHeight { mtp_seconds, height }
+    }
+
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn compute_mtp_height(height: Height, time: [u32; 11]) -> Self {
path: units/src/locktime/mtp_height.rs
user: login: tcharding
body: Perhas this could just be `new`?
created_at: 2025-04-14T22:21:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
+        MtpAndHeight { mtp_seconds, height }
+    }
+
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn compute_mtp_height(height: Height, time: [u32; 11]) -> Self {
+        // Collect timestamps
+        let mut mtp_timestamps = time;
+        mtp_timestamps.sort_unstable();
+        let mtp = mtp_timestamps[5];
+
+        MtpAndHeight { mtp_seconds: mtp, height }
+    }
+
+    /// Convert the MTP seconds to a Time value for comparison with relative timelocks
+    /// Returns None if the timestamp would overflow the Time type
+    pub fn mtp_as_time(self) -> Option<Time> {
path: units/src/locktime/mtp_height.rs
user: login: tcharding
body: Perhaps returning an error would be better?
created_at: 2025-04-14T22:27:20Z
author_association: MEMBER
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: tcharding
body: I think this should probably be named `is_satisfied_by` and we delete/deprecate all the other `is_satisfied_*` functions. Probably get input from @apoelstra before following this suggestion. 
created_at: 2025-04-14T22:29:06Z
author_association: MEMBER
diff_hunk: @@ -120,6 +135,22 @@ 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
     }
+
+    /// Checks if this relative time locktime has matured given the current chain state
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        let required_seconds = u32::from(self.value()) * 512;
+        chain_state.mtp_seconds >= required_seconds
path: units/src/locktime/relative.rs
user: login: tcharding
body: Is this correct? I would have thought that we would compare 512 second intervals not seconds. Remember locktimes are not intuitive so my question is emphatically a question - perhaps a citation is needed?
created_at: 2025-04-14T22:32:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: I used [Time](https://github.com/rust-bitcoin/rust-bitcoin/blob/7c516121367a420badacfa9695d36e7d09f62c39/units/src/locktime/relative.rs#L60) earlier. But I realised that timestamp could grow beyond `u16`, that's the reason why I used `u32`.
created_at: 2025-04-14T22:37:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
+        MtpAndHeight { mtp_seconds, height }
+    }
+
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn compute_mtp_height(height: Height, time: [u32; 11]) -> Self {
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: Alright. I will make adjustment for that.
created_at: 2025-04-14T22:39:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: Alright. I think deprecating will be better. I will also wait for input from @apoelstra 
created_at: 2025-04-14T22:41:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        match self {
+            LockTime::Blocks(blocks) => blocks.is_satisfied_by_chain_state(chain_state),
+            LockTime::Time(time) => time.is_satisfied_by_chain_state(chain_state),
+        }
+    }
+
+    /// Compares this relative locktime with the chain state
+    ///
+    /// Returns `Ordering::Greater` if the locktime has matured (can be included)
+    /// Returns `Ordering::Less` if the locktime has not yet matured
+    /// Returns `Ordering::Equal` if the locktime is exactly at the threshold
+    pub fn compare_with_chain_state(self, chain_state: MtpAndHeight) -> Ordering {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: I added `compare_with_chain_state`, to compare the locktime with the current state of the chain. `is_satisfied_by_chain_state` returns a bool, but with `compare_with_chain_state`, you can know if the locktime has been included or not yet.
created_at: 2025-04-14T22:53:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: Alright. The content here will replaced by content in `compute_mtp_height`
created_at: 2025-04-14T22:55:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Creates a new `MtpAndHeight` with the given MTP seconds and height values
+    pub fn new(mtp_seconds: u32, height: Height) -> Self {
+        MtpAndHeight { mtp_seconds, height }
+    }
+
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn compute_mtp_height(height: Height, time: [u32; 11]) -> Self {
+        // Collect timestamps
+        let mut mtp_timestamps = time;
+        mtp_timestamps.sort_unstable();
+        let mtp = mtp_timestamps[5];
+
+        MtpAndHeight { mtp_seconds: mtp, height }
+    }
+
+    /// Convert the MTP seconds to a Time value for comparison with relative timelocks
+    /// Returns None if the timestamp would overflow the Time type
+    pub fn mtp_as_time(self) -> Option<Time> {
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: You're absolutely right. Returning an error would provide clearer feedback to the caller.
created_at: 2025-04-14T23:04:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,6 +135,22 @@ 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
     }
+
+    /// Checks if this relative time locktime has matured given the current chain state
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        let required_seconds = u32::from(self.value()) * 512;
+        chain_state.mtp_seconds >= required_seconds
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: This statement from [BIP68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)

> When the relative lock-time is time-based, it is interpreted as a minimum block-time constraint over the input's age. A relative time-based lock-time of zero indicates an input which can be included in any block. More generally, a relative time-based lock-time n can be included into any block produced 512 * n seconds after the mining date of the output it is spending, or any block thereafter. The mining date of the output is equal to the median-time-past of the previous block which mined it.

was what inspired the multiplication by 512. Please let me know if I am correct.
created_at: 2025-04-14T23:26:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: apoelstra
body: Definitely ACK renaming `is_satisfied_by_chain_state` to `is_satisfied_by`, replacing the existing `is_satisfied_by` method which does the same thing but expects the user to have manually computed a MTP and chain height.

I believe the other usecases for comparing a locktime to a specific height or time are PSBT-like situations where you have multiple locktimes and want to determine the strictest one. This usecase is covered by the existing `is_implied_by` method.
created_at: 2025-04-15T15:01:10Z
author_association: MEMBER
diff_hunk: @@ -120,6 +135,22 @@ 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
     }
+
+    /// Checks if this relative time locktime has matured given the current chain state
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        let required_seconds = u32::from(self.value()) * 512;
+        chain_state.mtp_seconds >= required_seconds
path: units/src/locktime/relative.rs
user: login: apoelstra
body: Yes, the two quotes lines of code here look correct according to my understanding of BIP 68. We take the locktime "time" value, multiply by 512 to get seconds, then compare that to the MTP which is already in seconds.
created_at: 2025-04-15T15:02:27Z
author_association: MEMBER
diff_hunk: @@ -120,6 +135,22 @@ 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
     }
+
+    /// Checks if this relative time locktime has matured given the current chain state
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        let required_seconds = u32::from(self.value()) * 512;
+        chain_state.mtp_seconds >= required_seconds
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Okay, thank you
created_at: 2025-04-15T15:13:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: Thank you. I will deprecate the existing `is_satisfied_by`. 

But I will like point out that renaming `is_satisfied_by_chain_state` to `is_satisfied_by` will give duplicate definition. Is there a way to fix this duplicate definition. Is it okay if I delete it?
created_at: 2025-04-15T16:07:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: I am trying to be careful about deleting the existing `is_satisfied_by`, in case a project is using it already, to prevent it from breaking.
created_at: 2025-04-15T17:31:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        match self {
+            LockTime::Blocks(blocks) => blocks.is_satisfied_by_chain_state(chain_state),
+            LockTime::Time(time) => time.is_satisfied_by_chain_state(chain_state),
+        }
+    }
+
+    /// Compares this relative locktime with the chain state
+    ///
+    /// Returns `Ordering::Greater` if the locktime has matured (can be included)
+    /// Returns `Ordering::Less` if the locktime has not yet matured
+    /// Returns `Ordering::Equal` if the locktime is exactly at the threshold
+    pub fn compare_with_chain_state(self, chain_state: MtpAndHeight) -> Ordering {
path: primitives/src/locktime/relative.rs
user: login: tcharding
body: The `bool` covers less than and greater than - what is the benefit of being able to determine equal?
created_at: 2025-04-16T07:06:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
path: units/src/locktime/mtp_height.rs
user: login: tcharding
body: `BlockTime` is exactly this, a UNIX timestamp.
created_at: 2025-04-16T07:07:21Z
author_association: MEMBER
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: tcharding
body: No need to deprecate, just clobber the old one.
created_at: 2025-04-16T07:08:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time, TimeOverflowError};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn new(height: Height, time: [u32; 11]) -> Self {
+        // Collect timestamps
+        let mut mtp_timestamps = time;
+        mtp_timestamps.sort_unstable();
+        let mtp = mtp_timestamps[5];
+
+        MtpAndHeight { mtp_seconds: mtp, height }
+    }
+
+    /// Convert the MTP seconds to a Time value for comparison with relative timelocks
+    ///
+    /// # Errors
+    /// Returns a [`TimeOverflowError`] if the `mtp_seconds` value exceeds the valid range
+    /// for a [`Time`] object.
+    pub fn mtp_as_time(self) -> Result<Time, TimeOverflowError> {
+        Time::from_seconds_floor(self.mtp_seconds)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::vec::Vec;
path: units/src/locktime/mtp_height.rs
user: login: yancyribbens
body: putting this behind `#[cfg(feature = "alloc")]` should fix your CI failures.
created_at: 2025-04-16T12:39:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: apoelstra
body: Yes, it will break downstream code -- but either they were computing their MTP by hand and this new change will save them some effort, or they weren't and this new change will fix their broken code. Either case is worth the breakage. But I appreciate your caution.
created_at: 2025-04-16T14:22:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time, TimeOverflowError};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
+    /// The current block height,
+    pub height: Height,
+}
+
+impl MtpAndHeight {
+    /// Build from the tip height and exactly 11 time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The current chain height
+    /// * `time` - Exactly 11 most recent time of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
+    pub fn new(height: Height, time: [u32; 11]) -> Self {
+        // Collect timestamps
+        let mut mtp_timestamps = time;
+        mtp_timestamps.sort_unstable();
+        let mtp = mtp_timestamps[5];
+
+        MtpAndHeight { mtp_seconds: mtp, height }
+    }
+
+    /// Convert the MTP seconds to a Time value for comparison with relative timelocks
+    ///
+    /// # Errors
+    /// Returns a [`TimeOverflowError`] if the `mtp_seconds` value exceeds the valid range
+    /// for a [`Time`] object.
+    pub fn mtp_as_time(self) -> Result<Time, TimeOverflowError> {
+        Time::from_seconds_floor(self.mtp_seconds)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::vec::Vec;
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: Got that. Thank you.
created_at: 2025-04-16T17:53:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: Got that. Thank you.
created_at: 2025-04-16T17:53:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use super::relative::{Height, Time};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp_seconds: u32,
path: units/src/locktime/mtp_height.rs
user: login: aagbotemi
body: Got that. Thank you.
created_at: 2025-04-16T17:54:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -339,6 +339,26 @@ impl LockTime {
             L::Blocks(height) => Err(IncompatibleTimeError { time, height }),
         }
     }
+
+    /// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
+    pub fn is_satisfied_by_chain_state(self, chain_state: MtpAndHeight) -> bool {
+        match self {
+            LockTime::Blocks(blocks) => blocks.is_satisfied_by_chain_state(chain_state),
+            LockTime::Time(time) => time.is_satisfied_by_chain_state(chain_state),
+        }
+    }
+
+    /// Compares this relative locktime with the chain state
+    ///
+    /// Returns `Ordering::Greater` if the locktime has matured (can be included)
+    /// Returns `Ordering::Less` if the locktime has not yet matured
+    /// Returns `Ordering::Equal` if the locktime is exactly at the threshold
+    pub fn compare_with_chain_state(self, chain_state: MtpAndHeight) -> Ordering {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: Thought about it again, and realize that the `bool` covers everything. I have removed `compare_with_chain_state`. Thank you.
created_at: 2025-04-16T17:56:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: apoelstra
body: In 1dfb5416634ddee70f1f91f6b4a9305a07fa4522:

For relative locktimes, you also need to know the height that the UTXO was included in.
created_at: 2025-04-16T19:17:33Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: tcharding
body: Ooph, I've reviewed this twice and missed that - I told you locktimes were hard to work on.
created_at: 2025-04-16T23:47:43Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Yes Yes. Pushed for review.

Please, I have a question for confirmation, we also need to know the mtp of the UTXO right?
created_at: 2025-04-17T05:26:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: apoelstra
body: Yep. From [BIP 68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)

>The timespan starts from the median-time-past of the output’s previous block, and ends at the MTP of the previous block. If
created_at: 2025-04-17T13:58:10Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: apoelstra
body: I guess the API should take `utxo_state: MtpAndHeight, chain_state: MtpAndHeight`. Unfortunate that it's easy to get these swapped around and the type system doesn't help, but I don't see a better solution.
created_at: 2025-04-17T14:04:57Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Okay. I ealier thought about seperating it too. I will make the adjustments, then when we get a better solution, we can fix that.
created_at: 2025-04-17T14:36:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,11 @@ impl Height {
     pub const fn to_consensus_u32(self) -> u32 {
         self.0 as u32 // cast safety: u32 is wider than u16 on all architectures
     }
+
+    /// Checks if this relative height locktime has matured given the current chain state
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Pushed for review
created_at: 2025-04-17T15:57:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -161,6 +174,14 @@ impl fmt::Display for TimeOverflowError {
 #[cfg(feature = "std")]
 impl std::error::Error for TimeOverflowError {}
 
+impl Add for Height {
+    type Output = Self;
+
+    fn add(self, rhs: Self) -> Self::Output {
+        Height(self.0 + rhs.0)
path: units/src/locktime/relative.rs
user: login: apoelstra
body: In 760fd8368542c7ca0023f8ddd7f76b1bee20a1bc:

This will panic on overflow (and correspondingly `is_satisfied_by` will panic on overflow). Probably you want to add a `checked_add` function instead. Should also add a unit test that triggers this.

Also, can you squash your two commits?

Also, there is trailing whitespace at several places in your new code. Can you remove that? The format bot will get it but it's nice to avoid if you can.
created_at: 2025-04-17T21:28:05Z
author_association: MEMBER
diff_hunk: @@ -161,6 +174,14 @@ impl fmt::Display for TimeOverflowError {
 #[cfg(feature = "std")]
 impl std::error::Error for TimeOverflowError {}
 
+impl Add for Height {
+    type Output = Self;
+
+    fn add(self, rhs: Self) -> Self::Output {
+        Height(self.0 + rhs.0)
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Used `check_add` to prevent panic on overflow, and I've also written unit test, squashed the commits, and removed white trailing spaces.

Pushed for review.
created_at: 2025-04-18T09:53:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,25 @@ 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 (`n`) required after confirmation.
+    /// - `chain_state` – The current chain state (contains the tip height).
+    /// - `utxo_state` – The chain state at the UTXO’s confirmation block (contains that height).
+    ///
+    /// # Returns
+    /// - `true` if `chain_state.height >= utxo_state.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight, utxo_state: MtpAndHeight) -> bool {
+        match self.0.checked_add(utxo_state.height.0) {
+            Some(target_height) => chain_state.height.0 >= target_height,
+            None => false,
+        }
+    }
path: units/src/locktime/relative.rs
user: login: jrakibi
body: in `is_satisfied_by` didn't we miss applying a `-1` adjustment before comparig to current chain heigh/mtp

This is required according BIP-68:
```
            // NOTE: Subtract 1 to maintain nLockTime semantics
            // BIP 68 relative lock times have the semantics of calculating
            // the first block or time at which the transaction would be
            // valid.
            // ...
            // nMinHeight = std::max(nMinHeight, nCoinHeight + (int)(txin.nSequence & CTxIn::SEQUENCE_LOCKTIME_MASK) - 1);

```
So according to how BIP-68 defines relative locktimes this implementation looks **1 block too late**.
To align with the BIP semantics, maybe we should subtract 1:
```rust
Some(target_height) => chain_state.height.0 > target_height.saturating_sub(1),
```

same should be applied for mtp based checks.
created_at: 2025-04-19T18:00:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,25 @@ 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 (`n`) required after confirmation.
+    /// - `chain_state` – The current chain state (contains the tip height).
+    /// - `utxo_state` – The chain state at the UTXO’s confirmation block (contains that height).
+    ///
+    /// # Returns
+    /// - `true` if `chain_state.height >= utxo_state.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight, utxo_state: MtpAndHeight) -> bool {
+        match self.0.checked_add(utxo_state.height.0) {
+            Some(target_height) => chain_state.height.0 >= target_height,
+            None => false,
+        }
+    }
path: units/src/locktime/relative.rs
user: login: apoelstra
body: We could use `>=` rather than `> x.saturating_sub(1)`.

But this is interesting ... I had thought that if you set a relative locktime of 0, this meant "you cannot chain unconfirmed transactions, each tx needs to go into its own block". But I guess I was wrong and actually locktime 1 is needed for this.

created_at: 2025-04-20T13:39:24Z
author_association: MEMBER
diff_hunk: @@ -193,29 +209,41 @@ 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;
-    ///
-    /// # 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");
-    ///
-    /// // 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()));
+    /// # use bitcoin_primitives::locktime::relative::Height;
+    /// # use bitcoin_primitives::relative::Time;
+    /// # use bitcoin_primitives::relative::MtpAndHeight;
+    /// # use bitcoin_primitives::relative::LockTime;
+    /// # use bitcoin_primitives::BlockTime;
+    ///
+    /// 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 = Height::from(100);
+    /// let utxo_height = Height::from(80);
+    ///
+    /// let chain_state = MtpAndHeight::new(current_height, timestamps);
+    /// let utxo_state = 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_state, utxo_state));
     /// ```
-    #[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_state: MtpAndHeight, utxo_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: Kixunil
body: Nit: `utxo_mined_at` might be more understandable.
Also maybe `current_best_block` or `chain_tip` instead of `chain_state`
created_at: 2025-04-20T17:41:20Z
author_association: COLLABORATOR
diff_hunk: @@ -193,29 +209,41 @@ 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;
-    ///
-    /// # 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");
-    ///
-    /// // 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()));
+    /// # use bitcoin_primitives::locktime::relative::Height;
+    /// # use bitcoin_primitives::relative::Time;
+    /// # use bitcoin_primitives::relative::MtpAndHeight;
+    /// # use bitcoin_primitives::relative::LockTime;
+    /// # use bitcoin_primitives::BlockTime;
+    ///
+    /// 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 = Height::from(100);
+    /// let utxo_height = Height::from(80);
+    ///
+    /// let chain_state = MtpAndHeight::new(current_height, timestamps);
+    /// let utxo_state = 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_state, utxo_state));
     /// ```
-    #[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_state: MtpAndHeight, utxo_state: MtpAndHeight) -> bool {
path: primitives/src/locktime/relative.rs
user: login: aagbotemi
body: Alright.
created_at: 2025-04-20T20:20:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +41,25 @@ 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 (`n`) required after confirmation.
+    /// - `chain_state` – The current chain state (contains the tip height).
+    /// - `utxo_state` – The chain state at the UTXO’s confirmation block (contains that height).
+    ///
+    /// # Returns
+    /// - `true` if `chain_state.height >= utxo_state.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_state: MtpAndHeight, utxo_state: MtpAndHeight) -> bool {
+        match self.0.checked_add(utxo_state.height.0) {
+            Some(target_height) => chain_state.height.0 >= target_height,
+            None => false,
+        }
+    }
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Please, what's the submission. do we intend to use `> x.saturating_sub(1)`.
I think `>=` has fixed the issue, without the need to subtract 1
created_at: 2025-04-21T22:11:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
path: units/src/mtp_height.rs
user: login: tcharding
body: Perhaps

```suggestion
//! Median Time Past (MTP) and height - used for working lock times.
```
created_at: 2025-04-22T02:31:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use crate::{BlockHeight, BlockTime};
+
+/// A structure containing both Median Time Past (MTP) and current block height,
+/// used for validating relative locktimes.
path: units/src/mtp_height.rs
user: login: tcharding
body: Perhaps

```suggestion
/// A structure containing both Median Time Past (MTP) and current
/// absolute block height, used for validating relative locktimes.
```
created_at: 2025-04-22T02:32:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use crate::{BlockHeight, BlockTime};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
+}
+
+impl MtpAndHeight {
+    /// Build from the tip height and exactly 11 block-time (for MTP).
+    ///
+    /// # Parameters
+    /// * `height` - The height of the chain tip
+    /// * `timestamps` - Exactly 11 most recent timestamps of block headers in chronological order
+    ///
+    /// # Returns
+    /// * A new `MtpAndHeight` with computed MTP and the provided height
path: units/src/mtp_height.rs
user: login: tcharding
body: ```suggestion
    /// Constructs an [`MtpAndHeight`] after calculating the MTP.
    ///
    /// # Parameters
    ///
    /// * `height` - The absolute height of the chain tip.
    /// * `timestamps` - Timestamps form the most recent blocks.
```
created_at: 2025-04-22T02:32:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use crate::{BlockHeight, BlockTime};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
path: units/src/mtp_height.rs
user: login: tcharding
body: I don't think these should be public because `new` has too much logic in it.
created_at: 2025-04-22T02:33:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use crate::{BlockHeight, BlockTime};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
path: units/src/mtp_height.rs
user: login: apoelstra
body: Yeah, agreed. I had originally felt that this was ok because there are no invariants to maintain (other than "this height is a MTP", which is always true in some abstract sense). But I think you're right that a MTP is a MTP and should be treated distinctly from a blocktime. (Though we should provide an explicit conversion function, and maybe even a `From` impl that takes you from MTP back to `BlockTime`.)
created_at: 2025-04-22T13:27:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,60 @@
+// SPDX-License-Identifier: CC0-1.0
+
+//! Provides [`MtpAndHeight`] structure for the `rust-bitcoin` `relative::LockTime` type.
+
+use crate::{BlockHeight, BlockTime};
+
+/// A structure containing both Median Time Past (MTP) and current 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
path: units/src/mtp_height.rs
user: login: aagbotemi
body: Okay, I'll make the fix
created_at: 2025-04-22T13:41:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+        match u32::from(self.0).checked_add(utxo_mined_at.height.0) {
path: units/src/locktime/relative.rs
user: login: Kixunil
body: Nit: this breaks sanity rule, `self.to_consensus_u32().checked_add(utxo_mined_at.height.to_consensus_u32())` would be better.
created_at: 2025-04-23T16:48:12Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+        match u32::from(self.0).checked_add(utxo_mined_at.height.0) {
+            Some(target_height) => chain_tip.height.0 >= target_height,
+            None => false,
path: units/src/locktime/relative.rs
user: login: Kixunil
body: So if overflow happens it means the resulting number would be higher than the maximum number that can be stored in `target_height` so that's why this is valid. Writing this down because I was too brain-dead to understand before. And perhaps it can help someone reading this.
created_at: 2025-04-23T16:51:58Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
path: units/src/locktime/relative.rs
user: login: Kixunil
body: FTR, the `_by` suffix feels weird if there are two things but I don't mind, it's at least consistent.
created_at: 2025-04-23T16:52:35Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
path: units/src/locktime/relative.rs
user: login: Kixunil
body: I'm not sure if it should mention overflow since it looks like a special something that callers need to worry about but they don't. Maybe we should just write "`true` if a UTXO locked by `self` can be spent in a block after `chain_tip`".
created_at: 2025-04-23T16:57:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+// 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
path: units/src/mtp_height.rs
user: login: Kixunil
body: These two are still public.
created_at: 2025-04-23T16:58:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+// 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
+}
+
+impl MtpAndHeight {
+    /// Constructs an [`MtpAndHeight`] after calculating the MTP.
+    ///
+    /// # Parameters
+    ///
+    /// * `height` - The absolute height of the chain tip.
+    /// * `timestamps` - Timestamps form the most recent blocks.
path: units/src/mtp_height.rs
user: login: Kixunil
body: I think clarification that `timestamps[10]` is the timestamp from block `height` would be nice.
created_at: 2025-04-23T17:00:22Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+        match u32::from(self.0).checked_add(utxo_mined_at.height.0) {
path: units/src/locktime/relative.rs
user: login: apoelstra
body: We should do a followup where we pull the internals into an `encapsulate` module which will force this.
created_at: 2025-04-23T17:04:27Z
author_association: MEMBER
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+        match u32::from(self.0).checked_add(utxo_mined_at.height.0) {
+            Some(target_height) => chain_tip.height.0 >= target_height,
+            None => false,
path: units/src/locktime/relative.rs
user: login: apoelstra
body: Equivalently, but maybe easier to think about -- this fails only if you have a locktime which veers into `u32::MAX` territory, which is many millenia away. And because relative locktime heights are only 16 bits, you can't even *construct* such a locktime for many millenia.

So you don't need to think too carefully about these edge cases, is the behavior sensible, does it match Core, etc.
created_at: 2025-04-23T17:07:50Z
author_association: MEMBER
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
path: units/src/locktime/relative.rs
user: login: apoelstra
body: Agreed -- might as well update this comment when you update the `pub` specifiers.
created_at: 2025-04-23T17:08:32Z
author_association: MEMBER
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
+    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+        match u32::from(self.0).checked_add(utxo_mined_at.height.0) {
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Will update it to `self.to_consensus_u32().checked_add(utxo_mined_at.height.to_u32())`. `BlockHeight` has `to_u32()` and not `to_consensus_u32()` yet.
created_at: 2025-04-24T00:44:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+// 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
+}
+
+impl MtpAndHeight {
+    /// Constructs an [`MtpAndHeight`] after calculating the MTP.
+    ///
+    /// # Parameters
+    ///
+    /// * `height` - The absolute height of the chain tip.
+    /// * `timestamps` - Timestamps form the most recent blocks.
path: units/src/mtp_height.rs
user: login: aagbotemi
body: Alright, thank you for pointing this out.
created_at: 2025-04-24T01:12:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,25 @@ 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 (`n`) 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 `chain_tip.height >= utxo_mined_at.height + self`, i.e.
+    ///   the required number of blocks have passed since the UTXO was mined.
+    /// - `false` otherwise or safely handles overflow by returning false if the addition would overflow
path: units/src/locktime/relative.rs
user: login: aagbotemi
body: Alright, will update the documentation.
created_at: 2025-04-24T01:38:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+// 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)
+    pub mtp: BlockTime,
+    /// The current block height,
+    pub height: BlockHeight,
path: units/src/mtp_height.rs
user: login: aagbotemi
body: fixed them
created_at: 2025-04-24T02:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+// 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 }
+    }
+}
+
+impl From<MtpAndHeight> for BlockTime {
+    fn from(state: MtpAndHeight) -> Self { state.mtp }
path: units/src/mtp_height.rs
user: login: apoelstra
body: In f647cb44be53714f89b88a6cba822128e671578a:

Can we also add explicit `to_mtp` and `to_height` accessors?
created_at: 2025-04-24T12:25:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,70 @@
+// 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 }
+    }
+}
+
+impl From<MtpAndHeight> for BlockTime {
+    fn from(state: MtpAndHeight) -> Self { state.mtp }
path: units/src/mtp_height.rs
user: login: aagbotemi
body: Alright. do we want the accessors take the role of `From` impl in `Height::is_satisfied_by()` and `Time::is_satisfied_by()`? or we have the accessors and `From` impl together?
created_at: 2025-04-24T14:35:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+// 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 }
+    }
+}
+
+impl From<MtpAndHeight> for BlockTime {
+    fn from(state: MtpAndHeight) -> Self { state.mtp }
path: units/src/mtp_height.rs
user: login: apoelstra
body: My vote is yes. @Kixunil are you strongly opposed to having `From` impls that project `MtpAndHeight` to its two components?
created_at: 2025-04-24T14:40:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,70 @@
+// 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 }
+    }
+}
+
+impl From<MtpAndHeight> for BlockTime {
+    fn from(state: MtpAndHeight) -> Self { state.mtp }
path: units/src/mtp_height.rs
user: login: aagbotemi
body: Fixed the accessors while waiting for @Kixunil opinion. Pushed for review.
created_at: 2025-04-25T11:27:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+// 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 }
+    }
+}
+
+impl From<MtpAndHeight> for BlockTime {
+    fn from(state: MtpAndHeight) -> Self { state.mtp }
path: units/src/mtp_height.rs
user: login: Kixunil
body: On one hand it discards data, which feels weird so I'd lean to staying conservative, it can be added later, on the other hand, I don't see much possibility for abuse. Even `mtp.into()` doesn't seem too foot-gunny here. So not strongly opposed.
created_at: 2025-04-25T15:34:47Z
author_association: COLLABORATOR
diff_hunk: @@ -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
+/// }
path: primitives/src/locktime/relative.rs
user: login: Kixunil
body: I'd prefer the example to look more realistic. Nobody is going to generate the timestamps precedurally (but it's OK to have it as hidden function to replace the IO calls). So roughly:

```rust
let current_height = fetch_best_block_height();
let block_headers = core::array::try_from_fn(|i| fetch_block_header(current_height - 11 + i)?;
let block_times = block_headers.map(|header| header.block_time);
let current_mtp_and_height = MtpAndHeight::new(current_height, block_times);
let utxo_mined_at = load_utxo_from_db(utxo_out_point);
// perform the check here
```
created_at: 2025-04-28T01:18:00Z
author_association: COLLABORATOR
diff_hunk: @@ -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;
-    ///
-    /// # 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");
-    ///
-    /// // 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()));
+    /// # 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;
+    ///
+    /// 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_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));
path: primitives/src/locktime/relative.rs
user: login: Kixunil
body: This looks very similar/same as above. Perhaps we should have a common example somewhere and just have links to it.
created_at: 2025-04-28T01:18:59Z
author_association: COLLABORATOR
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: Kixunil
body: So I've just realized there's no straightforward way to restore this information from a database. If one computes this for a UTXO and stores it then restoring requires re-computing median (or do something silly like providing 11 equal values).

We need a function (`from_stored_height_and_mtp`?) that just constructs it from a single timestamp and the documentation clearly states that it should only be called when the time comes from previous computation.
created_at: 2025-04-28T01:23:52Z
author_association: COLLABORATOR
diff_hunk: @@ -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
+/// }
path: primitives/src/locktime/relative.rs
user: login: tcharding
body: +1
created_at: 2025-04-28T01:33:12Z
author_association: MEMBER
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: tcharding
body: Can we just derive `serde` instead? The private fields are to force the usage of the constructor API not to protect an invariant.
created_at: 2025-04-28T01:36:00Z
author_association: MEMBER
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: Kixunil
body: I'd rather not force `serde` on people and also I don't think there exists any standardized serialization for this, so this is a no-go. Yes, the function that computes should be shorter (`new` is a good name) and obvious while the one that loads it should be scarier. Feel free to suggest alternative names.
created_at: 2025-04-28T01:39:33Z
author_association: COLLABORATOR
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: Kixunil
body: Copilot suggested these names:


    from_saved_values
    from_persisted_state
    from_database_record
    restore
    from_stored_data
    from_serialized
    reload
    from_precomputed_values
    rehydrate
    from_validated_state

created_at: 2025-04-28T01:44:35Z
author_association: COLLABORATOR
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: tcharding
body: > So I've just realized there's no straightforward way to restore this information from a database.

I thought one wrote to and from a database using `serde`. How else does one write a type with private fields to a database?
created_at: 2025-04-28T02:50:55Z
author_association: MEMBER
diff_hunk: @@ -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 }
+    }
+
path: units/src/mtp_height.rs
user: login: Kixunil
body: By calling the public functions to retrieve them.
created_at: 2025-04-28T12:09:41Z
author_association: COLLABORATOR

PR comments:
user: login: coveralls
created_at: 2025-04-14T18:11:10Z
body: ## Pull Request Test Coverage Report for [Build 14663492022](https://coveralls.io/builds/73383955)


### Details

* **136** of **137**   **(99.27%)**  changed or added relevant lines in **3** files are covered.
* **2** unchanged lines in **1** file lost coverage.
* Overall coverage increased (+**0.06%**) to **84.142%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [units/src/locktime/relative.rs](https://coveralls.io/builds/73383955/source?filename=units%2Fsrc%2Flocktime%2Frelative.rs#L163) | 66 | 67 | 98.51%
<!-- | **Total:** | **136** | **137** | **99.27%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [primitives/src/locktime/relative.rs](https://coveralls.io/builds/73383955/source?filename=primitives%2Fsrc%2Flocktime%2Frelative.rs#L344) | 2 | 99.23% |
<!-- | **Total:** | **2** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/73383955/badge)](https://coveralls.io/builds/73383955) |
| :-- | --: |
| Change from base [Build 14650246743](https://coveralls.io/builds/73374078): |  0.06% |
| Covered Lines: | 22730 |
| Relevant Lines: | 27014 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: tcharding
created_at: 2025-04-14T22:08:01Z
body: Can you squash this into a single patch please.
author_association: MEMBER
user: login: tcharding
created_at: 2025-04-14T22:18:30Z
body: It looks like you are more or less on the right track. Please be warned, locktimes are extremely curly and this PR will need lot of review time with the reviewer's brain fully functioning (for me at least). So expect a fair few iterations on this. I'm not exactly sure where to start, i.e. at what height to give my review feedback. Please take my (and all) review as suggestions because even the most experienced bitcoin devs make mistakes when it comes to lock times. Do ask questions, don't blindly follow anyone! Thanks for taking this on.

author_association: MEMBER
user: login: aagbotemi
created_at: 2025-04-14T22:26:37Z
body: > Can you squash this into a single patch please.

Alright, I will do that
author_association: CONTRIBUTOR
user: login: aagbotemi
created_at: 2025-04-14T22:28:47Z
body: > It looks like you are more or less on the right track. Please be warned, locktimes are extremely curly and this PR will need lot of review time with the reviewer's brain fully functioning (for me at least). So expect a fair few iterations on this. I'm not exactly sure where to start, i.e. at what height to give my review feedback. Please take my (and all) review as suggestions because even the most experienced bitcoin devs make mistakes when it comes to lock times. Do ask questions, don't blindly follow anyone! Thanks for taking this on.

Okay @tcharding. Thank you. I will patiently wait for the review, and make correction where necessary
author_association: CONTRIBUTOR
user: login: apoelstra
created_at: 2025-04-19T14:27:39Z
body: Ok, done reviewing 01775b80214fa87df2a782a6fd7e711a77299c17. Sorry to keep iterating like this! We are close. I think:

* `MtpAndHeight` should live in the crate root rather than in `relative`.
* Similarly `MtpAndHeight` should store a `BlockHeight` rather than a `relative::Height` and `MtpAndHeight::new` should take a `BlockHeight`
* ...we should drop the `mtp_as_time` method. It's not clear what it is for. It will fail for every single block timestamp after 1971 (i.e. every single block timestamp).
* Currently `MtpAndHeight::new` takes a `[BlockTime; 11]`. This is exactly the data it needs but it's a bit awkward to construct. OTOH `BlockTime` is `Copy` so it's not *too* much hassle. @tcharding think we can do better?

In general, `MtpAndHeight` represents an *absolute* MTP and height. It should not be using types from the `relative` module or inheriting limits from them. The difference between two `MtpAndHeight`s represents a *relative* offset which can be compared to a *relative* locktime.
author_association: MEMBER
user: login: aagbotemi
created_at: 2025-04-19T15:32:28Z
body: Thank you so much for the review @apoelstra.
I will work on the new fixes and push for review.
author_association: CONTRIBUTOR
user: login: aagbotemi
created_at: 2025-04-19T15:37:51Z
body: > * Currently `MtpAndHeight::new` takes a `[BlockTime; 11]`. This is exactly the data it needs but it's a bit awkward to construct. OTOH `BlockTime` is `Copy` so it's not _too_ much hassle. @tcharding think we can do better?

Earlier, I thought about if `MtpAndHeight::new` can take `BlockTime`(the mtp) instead of `[BlockTime; 11]`. I will wait for @tcharding's comment
author_association: CONTRIBUTOR
user: login: tcharding
created_at: 2025-04-20T20:16:44Z
body: > think we can do better?

I had been pondering this lazily since reviewing last time. Would something like this be useful?

```rust
    /// Creates an `MtpAndHeight` from an iterator that yields the latest block headers.
    ///
    /// We check exactly 11 headers so the first header yielded is expected to be the current chain tip. However
    /// if `iter` yields exactly 11 items the order does not matter because we sort the time values and check all
    /// height values.
    ///
    /// # Errors
    ///
    /// Errors if the `iter` does not yield 11 headers. 
    // FIXME: According to spec, how is the MTP of any of the first 10 blocks calculated?
    pub fn from_latest_headers(iter: Impl Iterator<Item=Header>) -> Result<MtpAndHeight, MissingHeadersError> {
        // ...
    }
```
author_association: MEMBER
user: login: aagbotemi
created_at: 2025-04-20T20:27:52Z
body: > Would something like this be useful?

This is spot on (thought about it). I think I'll just have to add the primitives to the units Cargo.toml
author_association: CONTRIBUTOR
user: login: tcharding
created_at: 2025-04-21T00:03:40Z
body: If we want to use `Header` the API has to live in `primitives`. `primitives` depends on `units` not the other way around.
author_association: MEMBER
user: login: tcharding
created_at: 2025-04-21T00:09:38Z
body: One solution is to use an extension trait, another is to add a stand alone function to `primitives` e.g

```rust
pub fn mtp_and_height_from_headers(iter: impl Iterator<Item=Header>) -> Result<MtpAndHeigh, SomeError> {
    let mut buf: [11; BlockTime];
    // copy times out of iterator and call MtpAndHeight::new()
    // get chain tip height
}
```

Neither solution is pretty, perhaps there is a better one? However I believe this would be better left to a follow up PR. You could just outline the problem in the PR description of this PR and/or add an issue saying 'if/when #4344 merges consider adding some API to `primitives` to allow getting an `MtpAndHeight` from a list/iterator of headers". 
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-21T13:07:32Z
body: What if we took an iterator of `BlockTime`s? I think this is probably less convenient than `[BlockTime; 11]` in the typical case but more convenient in the worst case.

I don't think there's any sensible way to use `Header` because of the circular dependency. This is a `units` type, can be implemented in terms of other `units` types, and we should be willing to accept a bit of API pain to retain that.
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-21T13:08:00Z
body: ...and sure, if we want to add something to `primitives` that's fine, and we can do that later.
author_association: MEMBER
user: login: aagbotemi
created_at: 2025-04-21T22:42:28Z
body: Pushed for review. As discussed that getting the list of BlockTime from iteration of Header will be done in another issue.
author_association: CONTRIBUTOR
user: login: tcharding
created_at: 2025-04-22T02:29:18Z
body: I'm a bit lost as to what stage this PR is upto so I'm not sure what level to review at. 

- The same API changes are needed in `primitives::locktime::absolute`
 - I wouldn't bother with the `is_satisfied` example  in the rustdocs of `LockTime` because there is too much glue code needed IMO
 - Just an idea: The `MtpAndHeight` isnt' really a unit type, its more of an abstraction than that. We don't really need to keep the `is_satisfied_by` function in `units`. All this logic could live in `primitives` and that way we'd open up the way for a cleaner API (@apoelstra what do you think?) 
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-22T13:26:21Z
body: > * The same API changes are needed in `primitives::locktime::absolute`

If we need to do this (I haven't checked the code) then we should do it in a followup PR, which should be much easier to review since it'd mostly just copy stuff out of the `relative` module.

> * Just an idea: The `MtpAndHeight` isnt' really a unit type, its more of an abstraction than that. We don't really need to keep the `is_satisfied_by` function in `units`. All this logic could live in `primitives` and that way we'd open up the way for a cleaner API (@apoelstra what do you think?)

I don't agree. `MtpAndHeight` is clearly a unit. It is named as a unit. It is computed as a linear function of other units.

Maybe we only want to add `Mtp` to units, have the locktime functions here take a `Mtp` (which can have an awkward constructor that takes 11 blocktimes in an array) and a `Height` separately, and then in `primitives` we can introduce the combo type, constructors from `Header`, convenience methods for locktimes, etc.


author_association: MEMBER
user: login: Kixunil
created_at: 2025-04-22T15:58:05Z
body: I don't like the iterator idea because it makes the API fallible. If you really want to make it ergonomic with array of headers:

```rust
// Could be also called `GetBlockTime` to be a verb which is idiomatic but then the method should be also called `get_block_time` which is not idiomatic because of leading `get_`.
pub trait BlockTimeSource {
    fn block_time(&self) -> BlockTime;
}

// impls for BlockTime and &T where T: BlockTimeSource

// in primitives impls for Header and Block

fn new(height: BlockHeight, time: [impl BlockTImeSource; 11]) -> Self {
    let mut time = time.map(|time| time.block_time());
    time.sort_unstable();
    // ...
}
```

But really, users can equally easily call `map` on their array of headers so this seems quite a lot of complexity to remove one method. (or two, if the users need to call `each_ref` too)

So perhaps we should just have an example that calls `.map` on an array of headers.
author_association: COLLABORATOR
user: login: apoelstra
created_at: 2025-04-22T17:28:50Z
body: Yeah, all my ideas also wind up on "it's simpler to just take an array of headers and make the user do the transformations himself".
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-22T20:22:08Z
body: utACK 469fa6cebd0a9e3506e594dd7c33b423c203b43b

We should also do the absolute module but in another PR.
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-24T12:25:04Z
body: In f647cb44be53714f89b88a6cba822128e671578a:

Missing a blank line in your commit message after the first line. This confuses some tooling.
author_association: MEMBER
user: login: apoelstra
created_at: 2025-04-26T20:32:27Z
body: Can I merge this?
author_association: MEMBER
user: login: aagbotemi
created_at: 2025-04-26T20:51:10Z
body: > Can I merge this?

From me, I have fixed all the comments raised.
author_association: CONTRIBUTOR
user: login: apoelstra
created_at: 2025-04-26T23:59:04Z
body: Thanks -- @Kixunil can you ACK (or OK me to merge it without your ack) (or say that you want more time to review it).
author_association: MEMBER
user: login: Kixunil
created_at: 2025-04-28T01:03:14Z
body: Sorry, I had to leave abruptly and couldn't review. Will look at it now but @tcharding had some objections about scope creep in `units` and I actually somewhat agree with it. Though this type is not particularly complicated.

So if we agree it's scope creep at the moment, we can move it to `bitcoin` and move back later. Of course doing the move in this PR would reduce history. I'm not really sure what's best here and I think I don't mind either way.
author_association: COLLABORATOR
user: login: tcharding
created_at: 2025-04-28T01:09:02Z
body: I'm ok to merge this. It achieves the stated aim, as far as I understand it, of removing the burden from users for calculating the delta between the chain state when a UTXO was merged and the current chain state (chain tip) so that the relative locktime `is_satisfied_by` API is more ergonomic and less of a foot gun.

I have doubts about the actual API and also about the utility of the `MtpAndHeight` type because:

- The type does not actually abstract fully i.e., one can pass in any block height and any set of timestamps to the constructor. IMO it would be better if we only accepted headers and the type then fully encapsulated a chain state.
- The `units` types are now worse because the low level type (eg `Time`) concerns itself with a higher abstraction (`MtpAndHeight`). IMO this PR should not touch the `units::locktime` modules.

So in my personal opinion we should:

- Remove `to_` from getter methods of `MtpAndHeight`
- Move the `MtpAndHeight` type to primitives and make the constructor take an array of 11 headers. 
- In `primitives::relative::LockTime` use:

```rust
    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
        match self {
            LockTime::Blocks(_) => {
                let delta = chain_tip.height() - utxo_mined_at.height();
                if delta.to_u32() >= u16::MAX.into() {
                    return false;
                }
                let relative = Height::from_height(delta.to_u32() as u16); // cast ok because checked above.
                self.is_satisfied_by_height(relative).expect("no error because of match")
            },
            LockTime::Time(_) => {
                let delta = chain_tip.mtp().to_u32() - utxo_mined_at.mtp().to_u32();
                let relative = Time::from_seconds_floor(delta).unwrap();
                self.is_satisfied_by_time(relative).expect("no error because of match")
            }
        }
    }
```

I'm not sure if we should keep the `relative::LockTime::is_satisfied_by_height()` (and `by_time`) APIs or just inline them into the code above?

author_association: MEMBER
user: login: tcharding
created_at: 2025-04-28T01:12:36Z
body: I was writing while @Kixunil posted. I'm not totally sure but I think the order of merge doesn't matter from a strategy perspective. This PR can just patch then `relative::LockTime` where ever it is living when the PR is ready to merge. It will be harder on @aagbotemi if the code is moved but thems the breaks, this was bound to be a long running PR.
author_association: MEMBER
user: login: Kixunil
created_at: 2025-04-28T01:35:20Z
body: > * IMO it would be better if we only accepted headers and the type then fully encapsulated a chain state.

Headers don't inherently store their own height so this wouldn't help. This type is ultimately at the boundary of the system and it's the inherent limitation of all code that you gotta put valid data *somewhere*.

> * low level type (eg `Time`) concerns itself with a higher abstraction (`MtpAndHeight`). IMO this PR should not touch the `units::locktime` modules.

Well, that's because we have high-level `is_satisfied_by` methods.

> * Remove `to_` from getter methods of `MtpAndHeight`

Oh, yes, that would be better.

> * Move the `MtpAndHeight` type to primitives and make the constructor take an array of 11 headers only.

This is moot

> * In `primitives::relative::LockTime` use:

Yes, I think that makes sense but I'd like to remove the panic.


author_association: COLLABORATOR
user: login: tcharding
created_at: 2025-04-28T01:37:38Z
body: Full disclosure - I was momentarily confused and thought height was in the header. Bad Tobin no biscuit.
author_association: MEMBER
user: login: tcharding
created_at: 2025-04-28T01:39:52Z
body: I guess that invalidates my idea to move `MtpAndHeight` to `primitives` because actually all the type does is tie together the height/time so that the delta can be calculated from two args instead of 4.

EDIT: Oh I just restated what 'This is moot means'. 

author_association: MEMBER
user: login: tcharding
created_at: 2025-04-28T01:41:23Z
body: Lets merge and I'll write a follow up issue.
author_association: MEMBER