Repository: rust-bitcoin/rust-bitcoin
Pull request #4458 (2025-05-06 19:13:29)

locktimes: replace `MtpAndHeight` type with pair of `BlockMtp` and `BlockHeight`
This is a more involved PR than I'd expected but hopefully the individual commits make sense and are well-motivated. Essentially, my goal was to replace `MtpAndHeight` as used by relative locktimes with a pair of `Mtp` and `Height`.

However, relative locktimes, when given a MTP/Height for the UTXO creation and the chain tip, are roughly modeled as "take a diff of MTPs to get a `relative::MtpInterval`, a diff of heights to get a `relative::HeightInterval`, and compare to the locktimes". *However*, we have no standalone MTP type to "take a diff of", and also there are failure modes when creating the diffs (e.g. if the diff would exceed the range of `MtpInterval` or `HeightInterval`).

So I backed up and decided to use the existing `BlockHeight`/`BlockInterval` as the type to "take a diff of". I needed to introduce a `BlockMtp`/`BlockMtpInterval` to work with MTPs. These types have full-u32 range, unlike the similarly-named types in `units::locktimes::absolute`. I then needed to add some conversion methods. Along the way, I cleaned up the APIs and documentation, added checked arithmetic, etc., as needed.

See the individual commit messages for more detail.

I believe the resulting API is much more consistent and discoverable, even though it has more surface than the old API.

I considered splitting this into 2 PRs but I think the first half of the changes aren't well-motivated with out the second half. Let me know.


Patch:
diff --git a/bitcoin/src/blockdata/block.rs b/bitcoin/src/blockdata/block.rs
index 5fd59aa3..3f2b86fa 100644
--- a/bitcoin/src/blockdata/block.rs
+++ b/bitcoin/src/blockdata/block.rs
@@ -30,7 +30,13 @@
 #[doc(inline)]
 pub use primitives::block::{Block, Checked, Unchecked, Validation, Version, BlockHash, Header, WitnessCommitment};
 #[doc(inline)]
-pub use units::block::{BlockHeight, BlockInterval, TooBigForRelativeBlockHeightIntervalError};
+pub use units::block::{
+    BlockHeight, BlockHeightInterval, TooBigForRelativeBlockHeightIntervalError,
+};
+
+#[deprecated(since = "TBD", note = "use `BlockHeightInterval` instead")]
+#[doc(hidden)]
+pub type BlockInterval = BlockHeightInterval;
 
 impl_hashencode!(BlockHash);
 
diff --git a/bitcoin/src/lib.rs b/bitcoin/src/lib.rs
index 4265be8d..0bcbbe20 100644
--- a/bitcoin/src/lib.rs
+++ b/bitcoin/src/lib.rs
@@ -134,12 +134,16 @@
 #[doc(inline)]
 pub use units::{
     amount::{Amount, Denomination, SignedAmount},
-    block::{BlockHeight, BlockInterval},
+    block::{BlockHeight, BlockHeightInterval, BlockMtp},
     fee_rate::FeeRate,
     time::{self, BlockTime},
     weight::Weight,
 };
 
+#[deprecated(since = "TBD", note = "use `BlockHeightInterval` instead")]
+#[doc(hidden)]
+pub type BlockInterval = BlockHeightInterval;
+
 #[doc(inline)]
 pub use crate::{
     address::{Address, AddressType, KnownHrp},
@@ -242,7 +246,7 @@ mod encode_impls {
     //! Encodable/Decodable implementations.
     // While we are deprecating, re-exporting, and generally moving things around just put these here.
 
-    use units::{BlockHeight, BlockInterval};
+    use units::{BlockHeight, BlockHeightInterval};
 
     use crate::consensus::{encode, Decodable, Encodable};
     use crate::io::{BufRead, Write};
@@ -274,5 +278,5 @@ fn consensus_encode<W: Write + ?Sized>(
     }
 
     impl_encodable_for_u32_wrapper!(BlockHeight);
-    impl_encodable_for_u32_wrapper!(BlockInterval);
+    impl_encodable_for_u32_wrapper!(BlockHeightInterval);
 }
diff --git a/bitcoin/src/network/params.rs b/bitcoin/src/network/params.rs
index 35f68bfb..493354e6 100644
--- a/bitcoin/src/network/params.rs
+++ b/bitcoin/src/network/params.rs
@@ -67,7 +67,7 @@
 //! # }
 //! ```
 
-use units::{BlockHeight, BlockInterval};
+use units::{BlockHeight, BlockHeightInterval};
 
 use crate::network::Network;
 #[cfg(doc)]
@@ -92,9 +92,9 @@ pub struct Params {
     /// Minimum blocks including miner confirmation of the total of 2016 blocks in a retargeting period,
     /// (nPowTargetTimespan / nPowTargetSpacing) which is also used for BIP9 deployments.
     /// Examples: 1916 for 95%, 1512 for testchains.
-    pub rule_change_activation_threshold: BlockInterval,
+    pub rule_change_activation_threshold: BlockHeightInterval,
     /// Number of blocks with the same set of rules.
-    pub miner_confirmation_window: BlockInterval,
+    pub miner_confirmation_window: BlockHeightInterval,
     /// Proof of work limit value. It contains the lowest possible difficulty.
     #[deprecated(since = "0.32.0", note = "use `max_attainable_target` instead")]
     pub pow_limit: Target,
@@ -152,8 +152,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(227931), // 000000000000024b89b42a942fe0d9fea3bb44ab7bd1b19115dd6a759c0808b8
         bip65_height: BlockHeight::from_u32(388381), // 000000000000000004c2b624ed5d7756c508d90fd0da2c7c679febfa6c4735f0
         bip66_height: BlockHeight::from_u32(363725), // 00000000000000000379eaa19dce8c9b722d46ae6a57c2f1a988119488b50931
-        rule_change_activation_threshold: BlockInterval::from_u32(1916), // 95%
-        miner_confirmation_window: BlockInterval::from_u32(2016),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(1916), // 95%
+        miner_confirmation_window: BlockHeightInterval::from_u32(2016),
         pow_limit: Target::MAX_ATTAINABLE_MAINNET,
         max_attainable_target: Target::MAX_ATTAINABLE_MAINNET,
         pow_target_spacing: 10 * 60,            // 10 minutes.
@@ -170,8 +170,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(21111), // 0000000023b3a96d3484e5abb3755c413e7d41500f8e2a5c3f0dd01299cd8ef8
         bip65_height: BlockHeight::from_u32(581885), // 00000000007f6655f22f98e72ed80d8b06dc761d5da09df0fa1dc4be4f861eb6
         bip66_height: BlockHeight::from_u32(330776), // 000000002104c8c45e99a8853285a3b592602a3ccde2b832481da85e9e4ba182
-        rule_change_activation_threshold: BlockInterval::from_u32(1512), // 75%
-        miner_confirmation_window: BlockInterval::from_u32(2016),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(1512), // 75%
+        miner_confirmation_window: BlockHeightInterval::from_u32(2016),
         pow_limit: Target::MAX_ATTAINABLE_TESTNET,
         max_attainable_target: Target::MAX_ATTAINABLE_TESTNET,
         pow_target_spacing: 10 * 60,            // 10 minutes.
@@ -187,8 +187,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(21111), // 0000000023b3a96d3484e5abb3755c413e7d41500f8e2a5c3f0dd01299cd8ef8
         bip65_height: BlockHeight::from_u32(581885), // 00000000007f6655f22f98e72ed80d8b06dc761d5da09df0fa1dc4be4f861eb6
         bip66_height: BlockHeight::from_u32(330776), // 000000002104c8c45e99a8853285a3b592602a3ccde2b832481da85e9e4ba182
-        rule_change_activation_threshold: BlockInterval::from_u32(1512), // 75%
-        miner_confirmation_window: BlockInterval::from_u32(2016),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(1512), // 75%
+        miner_confirmation_window: BlockHeightInterval::from_u32(2016),
         pow_limit: Target::MAX_ATTAINABLE_TESTNET,
         max_attainable_target: Target::MAX_ATTAINABLE_TESTNET,
         pow_target_spacing: 10 * 60,            // 10 minutes.
@@ -204,8 +204,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(1),
         bip65_height: BlockHeight::from_u32(1),
         bip66_height: BlockHeight::from_u32(1),
-        rule_change_activation_threshold: BlockInterval::from_u32(1512), // 75%
-        miner_confirmation_window: BlockInterval::from_u32(2016),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(1512), // 75%
+        miner_confirmation_window: BlockHeightInterval::from_u32(2016),
         pow_limit: Target::MAX_ATTAINABLE_TESTNET,
         max_attainable_target: Target::MAX_ATTAINABLE_TESTNET,
         pow_target_spacing: 10 * 60,            // 10 minutes.
@@ -221,8 +221,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(1),
         bip65_height: BlockHeight::from_u32(1),
         bip66_height: BlockHeight::from_u32(1),
-        rule_change_activation_threshold: BlockInterval::from_u32(1916), // 95%
-        miner_confirmation_window: BlockInterval::from_u32(2016),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(1916), // 95%
+        miner_confirmation_window: BlockHeightInterval::from_u32(2016),
         pow_limit: Target::MAX_ATTAINABLE_SIGNET,
         max_attainable_target: Target::MAX_ATTAINABLE_SIGNET,
         pow_target_spacing: 10 * 60,            // 10 minutes.
@@ -238,8 +238,8 @@ impl Params {
         bip34_height: BlockHeight::from_u32(100000000), // not activated on regtest
         bip65_height: BlockHeight::from_u32(1351),
         bip66_height: BlockHeight::from_u32(1251), // used only in rpc tests
-        rule_change_activation_threshold: BlockInterval::from_u32(108), // 75%
-        miner_confirmation_window: BlockInterval::from_u32(144),
+        rule_change_activation_threshold: BlockHeightInterval::from_u32(108), // 75%
+        miner_confirmation_window: BlockHeightInterval::from_u32(144),
         pow_limit: Target::MAX_ATTAINABLE_REGTEST,
         max_attainable_target: Target::MAX_ATTAINABLE_REGTEST,
         pow_target_spacing: 10 * 60,            // 10 minutes.
diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs
index f9dafc05..4a9da6e6 100644
--- a/primitives/src/lib.rs
+++ b/primitives/src/lib.rs
@@ -50,12 +50,16 @@ pub mod _core {
 #[doc(inline)]
 pub use units::{
     amount::{self, Amount, SignedAmount},
-    block::{BlockHeight, BlockInterval},
+    block::{BlockHeight, BlockHeightInterval, BlockMtp, BlockMtpInterval},
     fee_rate::{self, FeeRate},
     time::{self, BlockTime},
     weight::{self, Weight},
 };
 
+#[deprecated(since = "TBD", note = "use `BlockHeightInterval` instead")]
+#[doc(hidden)]
+pub type BlockInterval = BlockHeightInterval;
+
 #[doc(inline)]
 #[cfg(feature = "alloc")]
 pub use self::{
diff --git a/primitives/src/locktime/relative.rs b/primitives/src/locktime/relative.rs
index 053e883c..c36faf7c 100644
--- a/primitives/src/locktime/relative.rs
+++ b/primitives/src/locktime/relative.rs
@@ -14,7 +14,7 @@
 #[rustfmt::skip]                // Keep public re-exports separate.
 #[doc(inline)]
 pub use units::locktime::relative::{HeightInterval, MtpInterval, TimeOverflowError};
-use units::mtp_height::MtpAndHeight;
+use units::{BlockHeight, BlockMtp};
 
 #[deprecated(since = "TBD", note = "use `Mtp` instead")]
 #[doc(hidden)]
@@ -44,9 +44,7 @@
 ///
 /// ```
 /// use bitcoin_primitives::relative;
-/// use bitcoin_primitives::BlockTime;
-/// use bitcoin_primitives::BlockHeight;
-/// use units::mtp_height::MtpAndHeight;
+/// use bitcoin_primitives::{BlockHeight, BlockMtp, BlockTime};
 /// let lock_by_height = relative::LockTime::from_height(144); // 144 blocks, approx 24h.
 /// assert!(lock_by_height.is_block_height());
 ///
@@ -65,14 +63,15 @@
 /// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
 ///
 /// let current_height = BlockHeight::from(100);
+/// let current_mtp = BlockMtp::new(timestamps);
+///
 /// let utxo_height = BlockHeight::from(80);
+/// let utxo_mtp = BlockMtp::new(utxo_timestamps);
 ///
-/// let chain_tip = MtpAndHeight::new(current_height, timestamps);
-/// let utxo_mined_at = MtpAndHeight::new(utxo_height, utxo_timestamps);
 /// let locktime = relative::LockTime::Time(relative::MtpInterval::from_512_second_intervals(10));
 ///
 /// // Check if locktime is satisfied
-/// assert!(locktime.is_satisfied_by(chain_tip, utxo_mined_at));
+/// assert!(locktime.is_satisfied_by(current_height, current_mtp, utxo_height, utxo_mtp));
 /// ```
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@@ -227,10 +226,8 @@ pub const fn is_block_time(self) -> bool { !self.is_block_height() }
     /// ```rust
     /// # use bitcoin_primitives::locktime::relative::HeightInterval;
     /// # use bitcoin_primitives::relative::Time;
-    /// # use units::mtp_height::MtpAndHeight;
-    /// # use bitcoin_primitives::BlockHeight;
+    /// # use bitcoin_primitives::{BlockHeight, BlockMtp, BlockTime};
     /// # 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];
@@ -244,19 +241,26 @@ pub const fn is_block_time(self) -> bool { !self.is_block_height() }
     /// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
     ///
     /// let current_height = BlockHeight::from_u32(100);
+    /// let current_mtp = BlockMtp::new(timestamps);
     /// let utxo_height = BlockHeight::from_u32(80);
+    /// let utxo_mtp = BlockMtp::new(utxo_timestamps);
     ///
-    /// 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));
+    /// assert!(locktime.is_satisfied_by(current_height, current_mtp, utxo_height, utxo_mtp));
     /// ```
-    pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
+    pub fn is_satisfied_by(
+        self,
+        chain_tip_height: BlockHeight,
+        chain_tip_mtp: BlockMtp,
+        utxo_mined_at_height: BlockHeight,
+        utxo_mined_at_mtp: BlockMtp,
+    ) -> 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),
+            LockTime::Blocks(blocks) =>
+                blocks.is_satisfied_by(chain_tip_height, utxo_mined_at_height),
+            LockTime::Time(time) => time.is_satisfied_by(chain_tip_mtp, utxo_mined_at_mtp),
         }
     }
 
@@ -696,38 +700,55 @@ fn test_locktime_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 chain_tip = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps);
-        let utxo_mined_at = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps);
+        let chain_height = BlockHeight::from_u32(100);
+        let chain_mtp = BlockMtp::new(timestamps);
+        let utxo_height = BlockHeight::from_u32(80);
+        let utxo_mtp = BlockMtp::new(utxo_timestamps);
 
         let lock1 = LockTime::Blocks(HeightInterval::from(10));
-        assert!(lock1.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(lock1.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
         let lock2 = LockTime::Blocks(HeightInterval::from(21));
-        assert!(!lock2.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(!lock2.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
         let lock3 = LockTime::Time(MtpInterval::from_512_second_intervals(10));
-        assert!(lock3.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(lock3.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
         let lock4 = LockTime::Time(MtpInterval::from_512_second_intervals(20000));
-        assert!(!lock4.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(!lock4.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
-        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));
+        assert!(LockTime::ZERO.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
+        assert!(LockTime::from_512_second_intervals(0).is_satisfied_by(
+            chain_height,
+            chain_mtp,
+            utxo_height,
+            utxo_mtp
+        ));
 
         let lock6 = LockTime::from_seconds_floor(5000).unwrap();
-        assert!(lock6.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(lock6.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
         let max_height_lock = LockTime::Blocks(HeightInterval::MAX);
-        assert!(!max_height_lock.is_satisfied_by(chain_tip, utxo_mined_at));
+        assert!(!max_height_lock.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
 
         let max_time_lock = LockTime::Time(MtpInterval::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));
+        assert!(!max_time_lock.is_satisfied_by(chain_height, chain_mtp, utxo_height, utxo_mtp));
+
+        let max_chain_height = BlockHeight::from_u32(u32::MAX);
+        let max_chain_mtp = BlockMtp::new(generate_timestamps(u32::MAX, 100));
+        let max_utxo_height = BlockHeight::MAX;
+        let max_utxo_mtp = max_chain_mtp;
+        assert!(!max_height_lock.is_satisfied_by(
+            max_chain_height,
+            max_chain_mtp,
+            max_utxo_height,
+            max_utxo_mtp
+        ));
+        assert!(!max_time_lock.is_satisfied_by(
+            max_chain_height,
+            max_chain_mtp,
+            max_utxo_height,
+            max_utxo_mtp
+        ));
     }
 }
diff --git a/units/src/block.rs b/units/src/block.rs
index 000f0995..dcb483f1 100644
--- a/units/src/block.rs
+++ b/units/src/block.rs
@@ -22,54 +22,90 @@
 use crate::locktime;
 use crate::locktime::{absolute, relative};
 
-/// The block height, zero denotes the genesis block.
-///
-/// This type is not meant for constructing height based timelocks, this is a general purpose block
-/// height abstraction. For locktimes please see [`locktime::absolute::Height`].
-///
-/// This is a thin wrapper around a `u32` that may take on all values of a `u32`.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
-// Public to try and make it really clear that there are no invariants.
-pub struct BlockHeight(pub u32);
+macro_rules! impl_u32_wrapper {
+    {
+        $(#[$($type_attrs:tt)*])*
+        $type_vis:vis struct $newtype:ident($inner_vis:vis u32);
+    } => {
+        $(#[$($type_attrs)*])*
+        $type_vis struct $newtype($inner_vis u32);
 
-impl BlockHeight {
-    /// Block height 0, the genesis block.
-    pub const ZERO: Self = BlockHeight(0);
+        impl $newtype {
+            /// Block height 0, the genesis block.
+            pub const ZERO: Self = Self(0);
 
-    /// The minimum block height (0), the genesis block.
-    pub const MIN: Self = Self::ZERO;
+            /// The minimum block height (0), the genesis block.
+            pub const MIN: Self = Self::ZERO;
 
-    /// The maximum block height.
-    pub const MAX: Self = BlockHeight(u32::MAX);
+            /// The maximum block height.
+            pub const MAX: Self = Self(u32::MAX);
 
-    /// Constructs a new block height from a `u32`.
-    pub const fn from_u32(inner: u32) -> Self { Self(inner) }
+            /// Constructs a new block height from a `u32`.
+            pub const fn from_u32(inner: u32) -> Self { Self(inner) }
 
-    /// Returns block height as a `u32`.
-    pub const fn to_u32(self) -> u32 { self.0 }
-}
+            /// Returns block height as a `u32`.
+            pub const fn to_u32(self) -> u32 { self.0 }
+        }
 
-impl fmt::Display for BlockHeight {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fmt::Display::fmt(&self.0, f) }
-}
+        impl fmt::Display for $newtype {
+            fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fmt::Display::fmt(&self.0, f) }
+        }
+
+        crate::impl_parse_str_from_int_infallible!($newtype, u32, from);
 
-crate::impl_parse_str_from_int_infallible!(BlockHeight, u32, from);
+        impl From<u32> for $newtype {
+            fn from(inner: u32) -> Self { Self::from_u32(inner) }
+        }
+
+        impl From<$newtype> for u32 {
+            fn from(height: $newtype) -> Self { height.to_u32() }
+        }
 
-impl From<u32> for BlockHeight {
-    fn from(inner: u32) -> Self { Self::from_u32(inner) }
+        #[cfg(feature = "arbitrary")]
+        impl<'a> Arbitrary<'a> for $newtype {
+            fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
+                let choice = u.int_in_range(0..=2)?;
+                match choice {
+                    0 => Ok(Self::ZERO),
+                    1 => Ok(Self::MIN),
+                    2 => Ok(Self::MAX),
+                    _ => Ok(Self::from_u32(u32::arbitrary(u)?)),
+                }
+            }
+        }
+    }
 }
 
-impl From<BlockHeight> for u32 {
-    fn from(height: BlockHeight) -> Self { height.to_u32() }
+impl_u32_wrapper! {
+    /// A block height. Zero denotes the genesis block.
+    ///
+    /// This type is not meant for constructing height based timelocks. It is a general purpose
+    /// blockheight abstraction. For locktimes please see [`locktime::absolute::Height`].
+    ///
+    /// This is a thin wrapper around a `u32` that may take on all values of a `u32`.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+    // Public to try and make it really clear that there are no invariants.
+    pub struct BlockHeight(pub u32);
+}
+
+impl BlockHeight {
+    /// Attempt to subtract two [`BlockHeight`]s, returning `None` in case of overflow.
+    pub fn checked_sub(self, other: Self) -> Option<BlockHeightInterval> {
+        self.0.checked_sub(other.0).map(BlockHeightInterval)
+    }
+
+    /// Attempt to add an interval to this [`BlockHeight`], returning `None` in case of overflow.
+    pub fn checked_add(self, other: BlockHeightInterval) -> Option<Self> {
+        self.0.checked_add(other.0).map(Self)
+    }
 }
 
 impl From<absolute::Height> for BlockHeight {
     /// Converts a [`locktime::absolute::Height`] to a [`BlockHeight`].
     ///
     /// An absolute locktime block height has a maximum value of [`absolute::LOCK_TIME_THRESHOLD`]
-    /// (500,000,000) where as a [`BlockHeight`] is a thin wrapper around a `u32`, the two types are
-    /// not interchangeable.
+    /// minus one, while [`BlockHeight`] may take the full range of `u32`.
     fn from(h: absolute::Height) -> Self { Self::from_u32(h.to_u32()) }
 }
 
@@ -79,72 +115,49 @@ impl TryFrom<BlockHeight> for absolute::Height {
     /// Converts a [`BlockHeight`] to a [`locktime::absolute::Height`].
     ///
     /// An absolute locktime block height has a maximum value of [`absolute::LOCK_TIME_THRESHOLD`]
-    /// (500,000,000) where as a [`BlockHeight`] is a thin wrapper around a `u32`, the two types are
-    /// not interchangeable.
+    /// minus one, while [`BlockHeight`] may take the full range of `u32`.
     fn try_from(h: BlockHeight) -> Result<Self, Self::Error> {
         absolute::Height::from_u32(h.to_u32())
     }
 }
 
-/// The block interval.
-///
-/// Block interval is an integer type denoting the number of blocks that has passed since some point
-/// i.e., this type is meant for usage as a relative block measure.
-///
-/// This type is not meant for constructing relative height based timelocks, this is a general
-/// purpose block interval abstraction. For locktimes please see [`locktime::relative::Height`].
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
-// Public to try and make it really clear that there are no invariants.
-pub struct BlockInterval(pub u32);
-
-impl BlockInterval {
-    /// Block interval 0 i.e., the current block.
-    pub const ZERO: Self = BlockInterval(0);
-
-    /// The minimum block interval (0).
-    pub const MIN: Self = Self::ZERO;
-
-    /// The maximum block interval.
-    pub const MAX: Self = BlockInterval(u32::MAX);
-
-    /// Constructs a new block interval from a `u32`.
-    pub const fn from_u32(inner: u32) -> Self { Self(inner) }
-
-    /// Returns block interval as a `u32`.
-    pub const fn to_u32(self) -> u32 { self.0 }
-}
-
-impl fmt::Display for BlockInterval {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fmt::Display::fmt(&self.0, f) }
+impl_u32_wrapper! {
+    /// An unsigned block interval.
+    ///
+    /// Block interval is an integer type representing a difference between the heights of two blocks.
+    ///
+    /// This type is not meant for constructing relative height based timelocks. It is a general
+    /// purpose block interval abstraction. For locktimes please see [`locktime::relative::Height`].
+    #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+    // Public to try and make it really clear that there are no invariants.
+    pub struct BlockHeightInterval(pub u32);
 }
 
-crate::impl_parse_str_from_int_infallible!(BlockInterval, u32, from);
+impl BlockHeightInterval {
+    /// Attempt to subtract two [`BlockHeightInterval`]s, returning `None` in case of overflow.
+    pub fn checked_sub(self, other: Self) -> Option<Self> { self.0.checked_sub(other.0).map(Self) }
 
-impl From<u32> for BlockInterval {
-    fn from(inner: u32) -> Self { Self::from_u32(inner) }
+    /// Attempt to add two [`BlockHeightInterval`]s, returning `None` in case of overflow.
+    pub fn checked_add(self, other: Self) -> Option<Self> { self.0.checked_add(other.0).map(Self) }
 }
 
-impl From<BlockInterval> for u32 {
-    fn from(height: BlockInterval) -> Self { height.to_u32() }
-}
-
-impl From<relative::HeightInterval> for BlockInterval {
-    /// Converts a [`locktime::relative::HeightInterval`] to a [`BlockInterval`].
+impl From<relative::HeightInterval> for BlockHeightInterval {
+    /// Converts a [`locktime::relative::HeightInterval`] to a [`BlockHeightInterval`].
     ///
     /// A relative locktime block height has a maximum value of `u16::MAX` where as a
-    /// [`BlockInterval`] is a thin wrapper around a `u32`, the two types are not interchangeable.
+    /// [`BlockHeightInterval`] is a thin wrapper around a `u32`, the two types are not interchangeable.
     fn from(h: relative::HeightInterval) -> Self { Self::from_u32(h.to_height().into()) }
 }
 
-impl TryFrom<BlockInterval> for relative::HeightInterval {
+impl TryFrom<BlockHeightInterval> for relative::HeightInterval {
     type Error = TooBigForRelativeBlockHeightIntervalError;
 
-    /// Converts a [`BlockInterval`] to a [`locktime::relative::HeightInterval`].
+    /// Converts a [`BlockHeightInterval`] to a [`locktime::relative::HeightInterval`].
     ///
     /// A relative locktime block height has a maximum value of `u16::MAX` where as a
-    /// [`BlockInterval`] is a thin wrapper around a `u32`, the two types are not interchangeable.
-    fn try_from(h: BlockInterval) -> Result<Self, Self::Error> {
+    /// [`BlockHeightInterval`] is a thin wrapper around a `u32`, the two types are not interchangeable.
+    fn try_from(h: BlockHeightInterval) -> Result<Self, Self::Error> {
         let h = h.to_u32();
 
         if h > u32::from(u16::MAX) {
@@ -154,6 +167,121 @@ fn try_from(h: BlockInterval) -> Result<Self, Self::Error> {
     }
 }
 
+impl_u32_wrapper! {
+    /// The median timestamp of 11 consecutive blocks.
+    ///
+    /// This type is not meant for constructing time-based timelocks. It is a general purpose
+    /// MTP abstraction. For locktimes please see [`locktime::absolute::Mtp`].
+    ///
+    /// This is a thin wrapper around a `u32` that may take on all values of a `u32`.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+    // Public to try and make it really clear that there are no invariants.
+    pub struct BlockMtp(pub u32);
+}
+
+impl BlockMtp {
+    /// Constructs a [`BlockMtp`] by computing the median‐time‐past from the last 11 block timestamps
+    ///
+    /// Because block timestamps are not monotonic, this function internally sorts them;
+    /// it is therefore not important what order they appear in the array; use whatever
+    /// is most convenient.
+    pub fn new(mut timestamps: [crate::BlockTime; 11]) -> Self {
+        timestamps.sort_unstable();
+        Self::from_u32(u32::from(timestamps[5]))
+    }
+
+    /// Attempt to subtract two [`BlockMtp`]s, returning `None` in case of overflow.
+    pub fn checked_sub(self, other: Self) -> Option<BlockMtpInterval> {
+        self.0.checked_sub(other.0).map(BlockMtpInterval)
+    }
+
+    /// Attempt to add an interval to this [`BlockMtp`], returning `None` in case of overflow.
+    pub fn checked_add(self, other: BlockMtpInterval) -> Option<Self> {
+        self.0.checked_add(other.0).map(Self)
+    }
+}
+
+impl From<absolute::Mtp> for BlockMtp {
+    /// Converts a [`locktime::absolute::Mtp`] to a [`BlockMtp`].
+    ///
+    /// An absolute locktime MTP has a minimum value of [`absolute::LOCK_TIME_THRESHOLD`],
+    /// while [`BlockMtp`] may take the full range of `u32`.
+    fn from(h: absolute::Mtp) -> Self { Self::from_u32(h.to_u32()) }
+}
+
+impl TryFrom<BlockMtp> for absolute::Mtp {
+    type Error = absolute::ConversionError;
+
+    /// Converts a [`BlockHeight`] to a [`locktime::absolute::Height`].
+    ///
+    /// An absolute locktime MTP has a minimum value of [`absolute::LOCK_TIME_THRESHOLD`],
+    /// while [`BlockMtp`] may take the full range of `u32`.
+    fn try_from(h: BlockMtp) -> Result<Self, Self::Error> { absolute::Mtp::from_u32(h.to_u32()) }
+}
+
+impl_u32_wrapper! {
+    /// An unsigned difference between two [`BlockMtp`]s.
+    ///
+    /// This type is not meant for constructing time-based timelocks. It is a general purpose
+    /// MTP abstraction. For locktimes please see [`locktime::relative::MtpInterval`].
+    ///
+    /// This is a thin wrapper around a `u32` that may take on all values of a `u32`.
+    #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+    // Public to try and make it really clear that there are no invariants.
+    pub struct BlockMtpInterval(pub u32);
+}
+
+impl BlockMtpInterval {
+    /// Converts a [`BlockMtpInterval`] to a [`locktime::relative::MtpInterval`], rounding down.
+    ///
+    /// Relative timelock MTP intervals have a resolution of 512 seconds, while
+    /// [`BlockMtpInterval`], like all block timestamp types, has a one-second resolution.
+    ///
+    /// # Errors
+    ///
+    /// Errors if the MTP is out-of-range (in excess of 512 times `u16::MAX` seconds, or about
+    /// 388 days) for a time-based relative locktime.
+    #[inline]
+    pub const fn to_relative_mtp_interval_floor(
+        self,
+    ) -> Result<relative::MtpInterval, relative::TimeOverflowError> {
+        relative::MtpInterval::from_seconds_floor(self.to_u32())
+    }
+
+    /// Converts a [`BlockMtpInterval`] to a [`locktime::relative::MtpInterval`], rounding up.
+    ///
+    /// Relative timelock MTP intervals have a resolution of 512 seconds, while
+    /// [`BlockMtpInterval`], like all block timestamp types, has a one-second resolution.
+    ///
+    /// # Errors
+    ///
+    /// Errors if the MTP is out-of-range (in excess of 512 times `u16::MAX` seconds, or about
+    /// 388 days) for a time-based relative locktime.
+    #[inline]
+    pub const fn to_relative_mtp_interval_ceil(
+        self,
+    ) -> Result<relative::MtpInterval, relative::TimeOverflowError> {
+        relative::MtpInterval::from_seconds_ceil(self.to_u32())
+    }
+
+    /// Attempt to subtract two [`BlockMtpInterval`]s, returning `None` in case of overflow.
+    pub fn checked_sub(self, other: Self) -> Option<Self> { self.0.checked_sub(other.0).map(Self) }
+
+    /// Attempt to add two [`BlockMtpInterval`]s, returning `None` in case of overflow.
+    pub fn checked_add(self, other: Self) -> Option<Self> { self.0.checked_add(other.0).map(Self) }
+}
+
+impl From<relative::MtpInterval> for BlockMtpInterval {
+    /// Converts a [`locktime::relative::MtpInterval`] to a [`BlockMtpInterval `].
+    ///
+    /// A relative locktime MTP interval has a resolution of 512 seconds, and a maximum value
+    /// of `u16::MAX` 512-second intervals. [`BlockMtpInterval`] may take the full range of
+    /// `u32`.
+    fn from(h: relative::MtpInterval) -> Self { Self::from_u32(h.to_seconds()) }
+}
+
 /// Error returned when the block interval is too big to be used as a relative lock time.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct TooBigForRelativeBlockHeightIntervalError(u32);
@@ -175,96 +303,141 @@ impl std::error::Error for TooBigForRelativeBlockHeightIntervalError {}
 crate::internal_macros::impl_op_for_references! {
     // height - height = interval
     impl ops::Sub<BlockHeight> for BlockHeight {
-        type Output = BlockInterval;
+        type Output = BlockHeightInterval;
 
         fn sub(self, rhs: BlockHeight) -> Self::Output {
             let interval = self.to_u32() - rhs.to_u32();
-            BlockInterval::from_u32(interval)
+            BlockHeightInterval::from_u32(interval)
         }
     }
 
     // height + interval = height
-    impl ops::Add<BlockInterval> for BlockHeight {
+    impl ops::Add<BlockHeightInterval> for BlockHeight {
         type Output = BlockHeight;
 
-        fn add(self, rhs: BlockInterval) -> Self::Output {
+        fn add(self, rhs: BlockHeightInterval) -> Self::Output {
             let height = self.to_u32() + rhs.to_u32();
             BlockHeight::from_u32(height)
         }
     }
 
     // height - interval = height
-    impl ops::Sub<BlockInterval> for BlockHeight {
+    impl ops::Sub<BlockHeightInterval> for BlockHeight {
         type Output = BlockHeight;
 
-        fn sub(self, rhs: BlockInterval) -> Self::Output {
+        fn sub(self, rhs: BlockHeightInterval) -> Self::Output {
             let height = self.to_u32() - rhs.to_u32();
             BlockHeight::from_u32(height)
         }
     }
 
     // interval + interval = interval
-    impl ops::Add<BlockInterval> for BlockInterval {
-        type Output = BlockInterval;
+    impl ops::Add<BlockHeightInterval> for BlockHeightInterval {
+        type Output = BlockHeightInterval;
 
-        fn add(self, rhs: BlockInterval) -> Self::Output {
+        fn add(self, rhs: BlockHeightInterval) -> Self::Output {
             let height = self.to_u32() + rhs.to_u32();
-            BlockInterval::from_u32(height)
+            BlockHeightInterval::from_u32(height)
         }
     }
 
     // interval - interval = interval
-    impl ops::Sub<BlockInterval> for BlockInterval {
-        type Output = BlockInterval;
+    impl ops::Sub<BlockHeightInterval> for BlockHeightInterval {
+        type Output = BlockHeightInterval;
 
-        fn sub(self, rhs: BlockInterval) -> Self::Output {
+        fn sub(self, rhs: BlockHeightInterval) -> Self::Output {
             let height = self.to_u32() - rhs.to_u32();
-            BlockInterval::from_u32(height)
+            BlockHeightInterval::from_u32(height)
+        }
+    }
+
+    // height - height = interval
+    impl ops::Sub<BlockMtp> for BlockMtp {
+        type Output = BlockMtpInterval;
+
+        fn sub(self, rhs: BlockMtp) -> Self::Output {
+            let interval = self.to_u32() - rhs.to_u32();
+            BlockMtpInterval::from_u32(interval)
+        }
+    }
+
+    // height + interval = height
+    impl ops::Add<BlockMtpInterval> for BlockMtp {
+        type Output = BlockMtp;
+
+        fn add(self, rhs: BlockMtpInterval) -> Self::Output {
+            let height = self.to_u32() + rhs.to_u32();
+            BlockMtp::from_u32(height)
+        }
+    }
+
+    // height - interval = height
+    impl ops::Sub<BlockMtpInterval> for BlockMtp {
+        type Output = BlockMtp;
+
+        fn sub(self, rhs: BlockMtpInterval) -> Self::Output {
+            let height = self.to_u32() - rhs.to_u32();
+            BlockMtp::from_u32(height)
+        }
+    }
+
+    // interval + interval = interval
+    impl ops::Add<BlockMtpInterval> for BlockMtpInterval {
+        type Output = BlockMtpInterval;
+
+        fn add(self, rhs: BlockMtpInterval) -> Self::Output {
+            let height = self.to_u32() + rhs.to_u32();
+            BlockMtpInterval::from_u32(height)
+        }
+    }
+
+    // interval - interval = interval
+    impl ops::Sub<BlockMtpInterval> for BlockMtpInterval {
+        type Output = BlockMtpInterval;
+
+        fn sub(self, rhs: BlockMtpInterval) -> Self::Output {
+            let height = self.to_u32() - rhs.to_u32();
+            BlockMtpInterval::from_u32(height)
         }
     }
 }
 
-crate::internal_macros::impl_add_assign!(BlockInterval);
-crate::internal_macros::impl_sub_assign!(BlockInterval);
+crate::internal_macros::impl_add_assign!(BlockHeightInterval);
+crate::internal_macros::impl_sub_assign!(BlockHeightInterval);
+crate::internal_macros::impl_add_assign!(BlockMtpInterval);
+crate::internal_macros::impl_sub_assign!(BlockMtpInterval);
 
-impl core::iter::Sum for BlockInterval {
+impl core::iter::Sum for BlockHeightInterval {
     fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
         let sum = iter.map(|interval| interval.0).sum();
-        BlockInterval::from_u32(sum)
+        BlockHeightInterval::from_u32(sum)
     }
 }
 
-impl<'a> core::iter::Sum<&'a BlockInterval> for BlockInterval {
+impl<'a> core::iter::Sum<&'a BlockHeightInterval> for BlockHeightInterval {
     fn sum<I>(iter: I) -> Self
     where
-        I: Iterator<Item = &'a BlockInterval>,
+        I: Iterator<Item = &'a BlockHeightInterval>,
     {
         let sum = iter.map(|interval| interval.0).sum();
-        BlockInterval::from_u32(sum)
+        BlockHeightInterval::from_u32(sum)
     }
 }
 
-#[cfg(feature = "arbitrary")]
-impl<'a> Arbitrary<'a> for BlockHeight {
-    fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
-        let choice = u.int_in_range(0..=2)?;
-        match choice {
-            0 => Ok(BlockHeight::MIN),
-            1 => Ok(BlockHeight::MAX),
-            _ => Ok(BlockHeight::from_u32(u32::arbitrary(u)?)),
-        }
+impl core::iter::Sum for BlockMtpInterval {
+    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+        let sum = iter.map(|interval| interval.0).sum();
+        BlockMtpInterval::from_u32(sum)
     }
 }
 
-#[cfg(feature = "arbitrary")]
-impl<'a> Arbitrary<'a> for BlockInterval {
-    fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
-        let choice = u.int_in_range(0..=2)?;
-        match choice {
-            0 => Ok(BlockInterval::MIN),
-            1 => Ok(BlockInterval::MAX),
-            _ => Ok(BlockInterval::from_u32(u32::arbitrary(u)?)),
-        }
+impl<'a> core::iter::Sum<&'a BlockMtpInterval> for BlockMtpInterval {
+    fn sum<I>(iter: I) -> Self
+    where
+        I: Iterator<Item = &'a BlockMtpInterval>,
+    {
+        let sum = iter.map(|interval| interval.0).sum();
+        BlockMtpInterval::from_u32(sum)
     }
 }
 
@@ -277,17 +450,19 @@ fn sanity_check() {
         let height: u32 = BlockHeight(100).into();
         assert_eq!(height, 100);
 
-        let interval: u32 = BlockInterval(100).into();
+        let interval: u32 = BlockHeightInterval(100).into();
         assert_eq!(interval, 100);
 
-        let interval_from_height: BlockInterval = relative::HeightInterval::from(10u16).into();
+        let interval_from_height: BlockHeightInterval =
+            relative::HeightInterval::from(10u16).into();
         assert_eq!(interval_from_height.to_u32(), 10u32);
 
         let invalid_height_greater =
-            relative::HeightInterval::try_from(BlockInterval(u32::from(u16::MAX) + 1));
+            relative::HeightInterval::try_from(BlockHeightInterval(u32::from(u16::MAX) + 1));
         assert!(invalid_height_greater.is_err());
 
-        let valid_height = relative::HeightInterval::try_from(BlockInterval(u32::from(u16::MAX)));
+        let valid_height =
+            relative::HeightInterval::try_from(BlockHeightInterval(u32::from(u16::MAX)));
         assert!(valid_height.is_ok());
     }
 
@@ -295,39 +470,41 @@ fn sanity_check() {
     #[test]
     fn all_available_ops() {
         // height - height = interval
-        assert!(BlockHeight(10) - BlockHeight(7) == BlockInterval(3));
+        assert!(BlockHeight(10) - BlockHeight(7) == BlockHeightInterval(3));
 
         // height + interval = height
-        assert!(BlockHeight(100) + BlockInterval(1) == BlockHeight(101));
+        assert!(BlockHeight(100) + BlockHeightInterval(1) == BlockHeight(101));
 
         // height - interval == height
-        assert!(BlockHeight(100) - BlockInterval(1) == BlockHeight(99));
+        assert!(BlockHeight(100) - BlockHeightInterval(1) == BlockHeight(99));
 
         // interval + interval = interval
-        assert!(BlockInterval(1) + BlockInterval(2) == BlockInterval(3));
+        assert!(BlockHeightInterval(1) + BlockHeightInterval(2) == BlockHeightInterval(3));
 
         // interval - interval = interval
-        assert!(BlockInterval(10) - BlockInterval(7) == BlockInterval(3));
+        assert!(BlockHeightInterval(10) - BlockHeightInterval(7) == BlockHeightInterval(3));
 
         assert!(
-            [BlockInterval(1), BlockInterval(2), BlockInterval(3)].iter().sum::<BlockInterval>()
-                == BlockInterval(6)
+            [BlockHeightInterval(1), BlockHeightInterval(2), BlockHeightInterval(3)]
+                .iter()
+                .sum::<BlockHeightInterval>()
+                == BlockHeightInterval(6)
         );
         assert!(
-            [BlockInterval(4), BlockInterval(5), BlockInterval(6)]
+            [BlockHeightInterval(4), BlockHeightInterval(5), BlockHeightInterval(6)]
                 .into_iter()
-                .sum::<BlockInterval>()
-                == BlockInterval(15)
+                .sum::<BlockHeightInterval>()
+                == BlockHeightInterval(15)
         );
 
         // interval += interval
-        let mut int = BlockInterval(1);
-        int += BlockInterval(2);
-        assert_eq!(int, BlockInterval(3));
+        let mut int = BlockHeightInterval(1);
+        int += BlockHeightInterval(2);
+        assert_eq!(int, BlockHeightInterval(3));
 
         // interval -= interval
-        let mut int = BlockInterval(10);
-        int -= BlockInterval(7);
-        assert_eq!(int, BlockInterval(3));
+        let mut int = BlockHeightInterval(10);
+        int -= BlockHeightInterval(7);
+        assert_eq!(int, BlockHeightInterval(3));
     }
 }
diff --git a/units/src/lib.rs b/units/src/lib.rs
index 41c33b01..4625a7cb 100644
--- a/units/src/lib.rs
+++ b/units/src/lib.rs
@@ -48,7 +48,6 @@ pub mod _core {
 pub mod block;
 pub mod fee_rate;
 pub mod locktime;
-pub mod mtp_height;
 pub mod parse;
 pub mod time;
 pub mod weight;
@@ -57,10 +56,14 @@ pub mod _core {
 #[rustfmt::skip]
 pub use self::{
     amount::{Amount, SignedAmount},
-    block::{BlockHeight, BlockInterval},
+    block::{BlockHeight, BlockHeightInterval, BlockMtp, BlockMtpInterval},
     fee_rate::FeeRate,
     result::{NumOpError, NumOpResult, MathOp},
     time::BlockTime,
     weight::Weight
 };
 pub(crate) use self::result::OptionExt;
+
+#[deprecated(since = "TBD", note = "use `BlockHeightInterval` instead")]
+#[doc(hidden)]
+pub type BlockInterval = BlockHeightInterval;
diff --git a/units/src/locktime/absolute.rs b/units/src/locktime/absolute.rs
index 1df4b04c..214d048a 100644
--- a/units/src/locktime/absolute.rs
+++ b/units/src/locktime/absolute.rs
@@ -154,6 +154,22 @@ impl Mtp {
     /// The maximum MTP allowable in a locktime (Sun Feb 07 2106 06:28:15 GMT+0000).
     pub const MAX: Self = Mtp(u32::MAX);
 
+    /// Constructs an [`Mtp`] by computing the median‐time‐past from the last 11 block timestamps
+    ///
+    /// Because block timestamps are not monotonic, this function internally sorts them;
+    /// it is therefore not important what order they appear in the array; use whatever
+    /// is most convenient.
+    ///
+    /// # Errors
+    ///
+    /// If the median block timestamp is not in the allowable range of MTPs in a
+    /// locktime: `[500_000_000, 2^32 - 1]`. Because there is a consensus rule that MTP
+    /// be monotonically increasing, and the MTP of the first 11 blocks exceeds `500_000_000`
+    /// for every real-life chain, this error typically cannot be hit in practice.
+    pub fn new(timestamps: [crate::BlockTime; 11]) -> Result<Self, ConversionError> {
+        crate::BlockMtp::new(timestamps).try_into()
+    }
+
     /// Constructs a new [`Mtp`] from a big-endian hex-encoded `u32`.
     ///
     /// The input string may or may not contain a typical hex prefix e.g., `0x`.
@@ -539,4 +555,32 @@ fn locktime_unit_display() {
         assert_eq!(format!("{}", blocks), "expected lock-by-blockheight (must be < 500000000)");
         assert_eq!(format!("{}", seconds), "expected lock-by-blocktime (must be >= 500000000)");
     }
+
+    #[test]
+    fn valid_chain_computes_mtp() {
+        use crate::BlockTime;
+
+        let mut timestamps = [
+            BlockTime::from_u32(500_000_010),
+            BlockTime::from_u32(500_000_003),
+            BlockTime::from_u32(500_000_005),
+            BlockTime::from_u32(500_000_008),
+            BlockTime::from_u32(500_000_001),
+            BlockTime::from_u32(500_000_004),
+            BlockTime::from_u32(500_000_006),
+            BlockTime::from_u32(500_000_009),
+            BlockTime::from_u32(500_000_002),
+            BlockTime::from_u32(500_000_007),
+            BlockTime::from_u32(500_000_000),
+        ];
+
+        // Try various reorderings
+        assert_eq!(Mtp::new(timestamps).unwrap().to_u32(), 500_000_005);
+        timestamps.reverse();
+        assert_eq!(Mtp::new(timestamps).unwrap().to_u32(), 500_000_005);
+        timestamps.sort();
+        assert_eq!(Mtp::new(timestamps).unwrap().to_u32(), 500_000_005);
+        timestamps.reverse();
+        assert_eq!(Mtp::new(timestamps).unwrap().to_u32(), 500_000_005);
+    }
 }
diff --git a/units/src/locktime/relative.rs b/units/src/locktime/relative.rs
index 47dde70a..af3136d0 100644
--- a/units/src/locktime/relative.rs
+++ b/units/src/locktime/relative.rs
@@ -9,8 +9,6 @@
 #[cfg(feature = "serde")]
 use serde::{Deserialize, Serialize};
 
-use crate::mtp_height::MtpAndHeight;
-
 #[deprecated(since = "TBD", note = "use `HeightIterval` instead")]
 #[doc(hidden)]
 pub type Height = HeightInterval;
@@ -59,21 +57,26 @@ pub const fn to_consensus_u32(self) -> u32 {
     /// Determines whether a relative‐height locktime has matured, taking into account
     /// both the chain tip and the height at which the UTXO was confirmed.
     ///
+    /// If you have two height intervals `x` and `y`, and want to know whether `x`
+    /// is satisfied by `y`, use `x >= y`.
+    ///
     /// # 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).
+    /// - `self` – the relative block‐height delay (`h`) required after confirmation.
+    /// - `chain_tip` – the height of the current chain tip
+    /// - `utxo_mined_at` – the height of the UTXO’s confirmation block
     ///
     /// # 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 u32::from(self.to_height()).checked_add(utxo_mined_at.to_height().to_u32()) {
-            Some(target_height) => chain_tip.to_height().to_u32() >= target_height,
-            None => false,
-        }
+    pub fn is_satisfied_by(
+        self,
+        chain_tip: crate::BlockHeight,
+        utxo_mined_at: crate::BlockHeight,
+    ) -> bool {
+        chain_tip
+            .checked_sub(utxo_mined_at)
+            .and_then(|diff: crate::BlockHeightInterval| diff.try_into().ok())
+            .map_or(false, |diff: Self| diff >= self)
     }
 }
 
@@ -154,6 +157,12 @@ pub const fn from_seconds_ceil(seconds: u32) -> Result<Self, TimeOverflowError>
         }
     }
 
+    /// Represents the [`MtpInterval`] as an integer number of seconds.
+    #[inline]
+    pub const fn to_seconds(self) -> u32 {
+        self.0 as u32 * 512 // u16->u32 cast ok, const context
+    }
+
     /// Returns the inner `u16` value.
     #[inline]
     #[must_use]
@@ -174,22 +183,26 @@ pub const fn to_consensus_u32(self) -> u32 {
     /// Determines whether a relative‑time lock has matured, taking into account both
     /// the UTXO’s Median Time Past at confirmation and the required delay.
     ///
+    /// If you have two MTP intervals `x` and `y`, and want to know whether `x`
+    /// is satisfied by `y`, use `x >= y`.
+    ///
     /// # 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.
+    /// - `self` – the relative time delay (`t`) in 512‑second intervals.
+    /// - `chain_tip` – the MTP of the current chain tip
+    /// - `utxo_mined_at` – the MTP of the UTXO’s confirmation block
     ///
     /// # 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.to_512_second_intervals()).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,
-        }
+    pub fn is_satisfied_by(
+        self,
+        chain_tip: crate::BlockMtp,
+        utxo_mined_at: crate::BlockMtp,
+    ) -> bool {
+        chain_tip
+            .checked_sub(utxo_mined_at)
+            .and_then(|diff: crate::BlockMtpInterval| diff.to_relative_mtp_interval_floor().ok())
+            .map_or(false, |diff: Self| diff >= self)
     }
 }
 
@@ -264,7 +277,7 @@ mod tests {
     use internals::serde_round_trip;
 
     use super::*;
-    use crate::{BlockHeight, BlockTime};
+    use crate::BlockTime;
 
     const MAXIMUM_ENCODABLE_SECONDS: u32 = u16::MAX as u32 * 512;
 
@@ -349,6 +362,8 @@ pub fn encode_decode_time() {
 
     #[test]
     fn test_time_chain_state() {
+        use crate::BlockMtp;
+
         let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200);
         let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
 
@@ -361,49 +376,48 @@ fn test_time_chain_state() {
         // Test case 1: Satisfaction (current_mtp >= utxo_mtp + required_seconds)
         // 10 intervals × 512 seconds = 5120 seconds
         let time_lock = MtpInterval::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);
+        let chain_state1 = BlockMtp::new(timestamps);
+        let utxo_state1 = BlockMtp::new(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);
+        let chain_state2 = BlockMtp::new(timestamps2);
+        let utxo_state2 = BlockMtp::new(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 = MtpInterval::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);
+        let chain_state3 = BlockMtp::new(timestamps3);
+        let utxo_state3 = BlockMtp::new(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 = MtpInterval::MAX;
-        let chain_state4 = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps);
-        let utxo_state4 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps);
+        let chain_state4 = BlockMtp::new(timestamps);
+        let utxo_state4 = BlockMtp::new(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);
+        use crate::BlockHeight;
 
         let height_lock = HeightInterval(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);
+        let chain_state1 = BlockHeight::from_u32(100);
+        let utxo_state1 = BlockHeight::from_u32(80);
         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);
+        let chain_state2 = BlockHeight::from_u32(89);
+        let utxo_state2 = BlockHeight::from_u32(80);
         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 = HeightInterval::MAX;
-        let chain_state3 = MtpAndHeight::new(BlockHeight::from_u32(1000), timestamps);
-        let utxo_state3 = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps);
+        let chain_state3 = BlockHeight::from_u32(1000);
+        let utxo_state3 = BlockHeight::from_u32(80);
         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
deleted file mode 100644
index 74b7705a..00000000
--- a/units/src/mtp_height.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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);
-    }
-}


Test patch:
diff --git a/units/tests/api.rs b/units/tests/api.rs
index 41b39bb4..a1956d10 100644
--- a/units/tests/api.rs
+++ b/units/tests/api.rs
@@ -14,8 +14,8 @@
 // These imports test "typical" usage by user code.
 use bitcoin_units::locktime::{absolute, relative}; // Typical usage is `absolute::Height`.
 use bitcoin_units::{
-    amount, block, fee_rate, locktime, parse, weight, Amount, BlockHeight, BlockInterval,
-    BlockTime, FeeRate, SignedAmount, Weight,
+    amount, block, fee_rate, locktime, parse, weight, Amount, BlockHeight, BlockInterval, BlockMtp,
+    BlockMtpInterval, BlockTime, FeeRate, SignedAmount, Weight,
 };
 
 /// A struct that includes all public non-error enums.
@@ -43,6 +43,8 @@ struct Structs {
     j: relative::Time,
     k: Weight,
     l: BlockTime,
+    m: BlockMtp,
+    n: BlockMtpInterval,
 }
 
 impl Structs {
@@ -60,6 +62,8 @@ fn max() -> Self {
             j: relative::Time::MAX,
             k: Weight::MAX,
             l: BlockTime::from_u32(u32::MAX),
+            m: BlockMtp::MAX,
+            n: BlockMtpInterval::MAX,
         }
     }
 }
@@ -90,6 +94,8 @@ struct CommonTraits {
     j: relative::Time,
     k: Weight,
     l: BlockTime,
+    m: BlockMtp,
+    n: BlockMtpInterval,
 }
 
 /// A struct that includes all types that implement `Default`.
@@ -100,6 +106,7 @@ struct Default {
     c: BlockInterval,
     d: relative::Height,
     e: relative::Time,
+    f: BlockMtpInterval,
 }
 
 /// A struct that includes all public error types.
@@ -147,7 +154,8 @@ fn api_can_use_modules_from_crate_root() {
 #[test]
 fn api_can_use_types_from_crate_root() {
     use bitcoin_units::{
-        Amount, BlockHeight, BlockInterval, BlockTime, FeeRate, SignedAmount, Weight,
+        Amount, BlockHeight, BlockInterval, BlockMtp, BlockMtpInterval, BlockTime, FeeRate,
+        SignedAmount, Weight,
     };
 }
 
@@ -164,7 +172,7 @@ fn api_can_use_all_types_from_module_amount() {
 #[test]
 fn api_can_use_all_types_from_module_block() {
     use bitcoin_units::block::{
-        BlockHeight, BlockInterval, TooBigForRelativeBlockHeightIntervalError,
+        BlockHeight, BlockHeightInterval, TooBigForRelativeBlockHeightIntervalError,
     };
 }
 
@@ -257,6 +265,7 @@ fn regression_default() {
         c: BlockInterval::ZERO,
         d: relative::Height::ZERO,
         e: relative::Time::ZERO,
+        f: BlockMtpInterval::ZERO,
     };
     assert_eq!(got, want);
 }
@@ -298,6 +307,8 @@ fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
             j: relative::Time::arbitrary(u)?,
             k: Weight::arbitrary(u)?,
             l: BlockTime::arbitrary(u)?,
+            m: BlockMtp::arbitrary(u)?,
+            n: BlockMtpInterval::arbitrary(u)?,
         };
         Ok(a)
     }