feat(locktime): implement MtpAndHeight structure and validation logic
- Add MtpAndHeight for relative locktime checks - Include unit tests for time/height comparisons - Fix API design for mtp_as_time() error handling - Update documentation and dependencies - Fix BlockTime, CI, remove Ordering, and PR discussion fixed - Fix UTXO height and timestamps - Fix: chain_state and utxo_state handled seperately for is_satisfied_by - Fix: panic on overflow fixed with check_add - Fix: documentation updated and trailing whitespaces removed - docs(mtpheight): documentation updated - used accessors to_height and to_mtp over From impl
This commit is contained in:
parent
267deee60d
commit
8b47068a2e
|
@ -14,6 +14,7 @@ use crate::{relative, TxIn};
|
|||
#[rustfmt::skip] // Keep public re-exports separate.
|
||||
#[doc(inline)]
|
||||
pub use units::locktime::relative::{Height, Time, TimeOverflowError};
|
||||
use units::mtp_height::MtpAndHeight;
|
||||
|
||||
/// A relative lock time value, representing either a block height or time (512 second intervals).
|
||||
///
|
||||
|
@ -35,17 +36,35 @@ pub use units::locktime::relative::{Height, Time, TimeOverflowError};
|
|||
///
|
||||
/// ```
|
||||
/// use bitcoin_primitives::relative;
|
||||
/// use bitcoin_primitives::BlockTime;
|
||||
/// use bitcoin_primitives::BlockHeight;
|
||||
/// use units::mtp_height::MtpAndHeight;
|
||||
/// let lock_by_height = relative::LockTime::from_height(144); // 144 blocks, approx 24h.
|
||||
/// assert!(lock_by_height.is_block_height());
|
||||
///
|
||||
/// let lock_by_time = relative::LockTime::from_512_second_intervals(168); // 168 time intervals, approx 24h.
|
||||
/// assert!(lock_by_time.is_block_time());
|
||||
///
|
||||
/// // Check if a lock time is satisfied by a given height or time.
|
||||
/// let height = relative::Height::from(150);
|
||||
/// let time = relative::Time::from_512_second_intervals(200);
|
||||
/// assert!(lock_by_height.is_satisfied_by(height, time));
|
||||
/// assert!(lock_by_time.is_satisfied_by(height, time));
|
||||
/// fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] {
|
||||
/// let mut timestamps = [BlockTime::from_u32(0); 11];
|
||||
/// for (i, ts) in timestamps.iter_mut().enumerate() {
|
||||
/// *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into()));
|
||||
/// }
|
||||
/// timestamps
|
||||
/// }
|
||||
/// // time extracted from BlockHeader
|
||||
/// let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200);
|
||||
/// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
|
||||
///
|
||||
/// let current_height = BlockHeight::from(100);
|
||||
/// let utxo_height = BlockHeight::from(80);
|
||||
///
|
||||
/// let chain_tip = MtpAndHeight::new(current_height, timestamps);
|
||||
/// let utxo_mined_at = MtpAndHeight::new(utxo_height, utxo_timestamps);
|
||||
/// let locktime = relative::LockTime::Time(relative::Time::from_512_second_intervals(10));
|
||||
///
|
||||
/// // Check if locktime is satisfied
|
||||
/// assert!(locktime.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
|
@ -193,29 +212,42 @@ impl LockTime {
|
|||
#[inline]
|
||||
pub const fn is_block_time(self) -> bool { !self.is_block_height() }
|
||||
|
||||
/// Returns true if this [`relative::LockTime`] is satisfied by either height or time.
|
||||
///
|
||||
/// Returns true if this [`relative::LockTime`] is satisfied by the given chain state.
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// # use bitcoin_primitives::Sequence;
|
||||
/// # use bitcoin_primitives::relative;
|
||||
/// # use bitcoin_primitives::locktime::relative::Height;
|
||||
/// # use bitcoin_primitives::relative::Time;
|
||||
/// # use units::mtp_height::MtpAndHeight;
|
||||
/// # use bitcoin_primitives::BlockHeight;
|
||||
/// # use bitcoin_primitives::relative::LockTime;
|
||||
/// # use bitcoin_primitives::BlockTime;
|
||||
///
|
||||
/// # let required_height = 100; // 100 blocks.
|
||||
/// # let intervals = 70; // Approx 10 hours.
|
||||
/// # let current_height = || relative::Height::from(required_height + 10);
|
||||
/// # let current_time = || relative::Time::from_512_second_intervals(intervals + 10);
|
||||
/// # let lock = Sequence::from_height(required_height).to_relative_lock_time().expect("valid height");
|
||||
/// fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] {
|
||||
/// let mut timestamps = [BlockTime::from_u32(0); 11];
|
||||
/// for (i, ts) in timestamps.iter_mut().enumerate() {
|
||||
/// *ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into()));
|
||||
/// }
|
||||
/// timestamps
|
||||
/// }
|
||||
/// // time extracted from BlockHeader
|
||||
/// let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200);
|
||||
/// let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
|
||||
///
|
||||
/// // Users that have chain data can get the current height and time to check against a lock.
|
||||
/// assert!(lock.is_satisfied_by(current_height(), current_time()));
|
||||
/// let current_height = BlockHeight::from_u32(100);
|
||||
/// let utxo_height = BlockHeight::from_u32(80);
|
||||
///
|
||||
/// let chain_tip = MtpAndHeight::new(current_height, timestamps);
|
||||
/// let utxo_mined_at = MtpAndHeight::new(utxo_height, utxo_timestamps);
|
||||
/// let locktime = LockTime::Time(Time::from_512_second_intervals(10));
|
||||
///
|
||||
/// // Check if locktime is satisfied
|
||||
/// assert!(locktime.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn is_satisfied_by(self, h: Height, t: Time) -> bool {
|
||||
if let Ok(true) = self.is_satisfied_by_height(h) {
|
||||
true
|
||||
} else {
|
||||
matches!(self.is_satisfied_by_time(t), Ok(true))
|
||||
pub fn is_satisfied_by(self, chain_tip: MtpAndHeight, utxo_mined_at: MtpAndHeight) -> bool {
|
||||
match self {
|
||||
LockTime::Blocks(blocks) => blocks.is_satisfied_by(chain_tip, utxo_mined_at),
|
||||
LockTime::Time(time) => time.is_satisfied_by(chain_tip, utxo_mined_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -468,6 +500,8 @@ impl std::error::Error for IncompatibleTimeError {}
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use units::{BlockHeight, BlockTime};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
@ -525,30 +559,6 @@ mod tests {
|
|||
assert!(!lock_by_time1.is_same_unit(lock_by_height1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfied_by_height() {
|
||||
let height = Height::from(10);
|
||||
let time = Time::from_512_second_intervals(70);
|
||||
|
||||
let lock_by_height = LockTime::from(height);
|
||||
|
||||
assert!(!lock_by_height.is_satisfied_by(Height::from(9), time));
|
||||
assert!(lock_by_height.is_satisfied_by(Height::from(10), time));
|
||||
assert!(lock_by_height.is_satisfied_by(Height::from(11), time));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfied_by_time() {
|
||||
let height = Height::from(10);
|
||||
let time = Time::from_512_second_intervals(70);
|
||||
|
||||
let lock_by_time = LockTime::from(time);
|
||||
|
||||
assert!(!lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(69)));
|
||||
assert!(lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(70)));
|
||||
assert!(lock_by_time.is_satisfied_by(height, Time::from_512_second_intervals(71)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_correctly_implies() {
|
||||
let height = Height::from(10);
|
||||
|
@ -654,4 +664,52 @@ mod tests {
|
|||
assert_eq!(err.expected(), height);
|
||||
assert!(!format!("{}", err).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locktime_chain_state() {
|
||||
fn generate_timestamps(start: u32, step: u16) -> [BlockTime; 11] {
|
||||
let mut timestamps = [BlockTime::from_u32(0); 11];
|
||||
for (i, ts) in timestamps.iter_mut().enumerate() {
|
||||
*ts = BlockTime::from_u32(start.saturating_sub((step * i as u16).into()));
|
||||
}
|
||||
timestamps
|
||||
}
|
||||
|
||||
let timestamps: [BlockTime; 11] = generate_timestamps(1_600_000_000, 200);
|
||||
let utxo_timestamps: [BlockTime; 11] = generate_timestamps(1_599_000_000, 200);
|
||||
|
||||
let chain_tip = MtpAndHeight::new(BlockHeight::from_u32(100), timestamps);
|
||||
let utxo_mined_at = MtpAndHeight::new(BlockHeight::from_u32(80), utxo_timestamps);
|
||||
|
||||
let lock1 = LockTime::Blocks(Height::from(10));
|
||||
assert!(lock1.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let lock2 = LockTime::Blocks(Height::from(21));
|
||||
assert!(!lock2.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let lock3 = LockTime::Time(Time::from_512_second_intervals(10));
|
||||
assert!(lock3.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let lock4 = LockTime::Time(Time::from_512_second_intervals(20000));
|
||||
assert!(!lock4.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
assert!(LockTime::ZERO.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
assert!(LockTime::from_512_second_intervals(0).is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let lock6 = LockTime::from_seconds_floor(5000).unwrap();
|
||||
assert!(lock6.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let max_height_lock = LockTime::Blocks(Height::MAX);
|
||||
assert!(!max_height_lock.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let max_time_lock = LockTime::Time(Time::MAX);
|
||||
assert!(!max_time_lock.is_satisfied_by(chain_tip, utxo_mined_at));
|
||||
|
||||
let max_chain_tip =
|
||||
MtpAndHeight::new(BlockHeight::from_u32(u32::MAX), generate_timestamps(u32::MAX, 100));
|
||||
let max_utxo_mined_at =
|
||||
MtpAndHeight::new(BlockHeight::MAX, generate_timestamps(u32::MAX, 100));
|
||||
assert!(!max_height_lock.is_satisfied_by(max_chain_tip, max_utxo_mined_at));
|
||||
assert!(!max_time_lock.is_satisfied_by(max_chain_tip, max_utxo_mined_at));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue