Skip to content

Commit 66f3f70

Browse files
committed
Merge #2037: Add median-time-past (MTP) calculation to CheckPoint
b623536 feat(core): add median-time-past calculation to CheckPoint (志宇) Pull request description: ## Description This PR adds the ability to calculate median-time-past (MTP) for `CheckPoint` structures, implementing the functionality described in #2036. ## Notes to the reviewers `CheckPoint::median_time_past` calculates the MTP value by looking at the previous 11 blocks (including the current block). ### Why `ToBlockTime` is a separate trait from `ToBlockHash` `ToBlockTime` is intentionally kept as a separate trait with a non-optional return type (`fn to_blocktime(&self) -> u32`). For operations like MTP calculation, block times are either fully available or the calculation is meaningless — there's no useful "partial" or "best-effort" result. By using a separate trait, we get compile-time guarantees: methods like `median_time_past()` use `where D: ToBlockTime` bounds, making it explicit which checkpoint data types support time-based operations. Types without block time data (e.g., `BlockHash`, `BlockId`) simply don't implement the trait. #### How this differs from `prev_blockhash` In contrast, `prev_blockhash() -> Option<BlockHash>` is a method on `ToBlockHash` rather than a separate trait. The `Option` return serves a different purpose — it allows graceful degradation. A `CheckPoint<BlockId>` chain is still useful even without hash linkage validation; callers can simply skip verification when `None` is returned. Additionally, `None` at genesis is semantically meaningful (it marks the chain root), not an error condition. For MTP, there's no equivalent "skip if unavailable" — you either have all 11 timestamps or the calculation fails. This binary requirement is better expressed as a trait bound than runtime `Option` handling. ## Changelog notice ### Added - Introduced `ToBlockTime` trait for types that can return a block time. - Added `median_time_past()` method to `CheckPoint` for calculating MTP according to BIP113 ## Checklists ### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing ### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature Fixes #2036 ACKs for top commit: ValuedMammal: ACK b623536 Tree-SHA512: fac1c06d27d44a4e419ca53bca3a7d38efe7f7a73fbf60508013a2a4b46c5081d47e02f1d1df5250315203770326db90c436c852c693215c493daa036e6cab9d
2 parents 417ec63 + b623536 commit 66f3f70

2 files changed

Lines changed: 183 additions & 3 deletions

File tree

crates/core/src/checkpoint.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use core::fmt;
22
use core::ops::RangeBounds;
33

44
use alloc::sync::Arc;
5+
use alloc::vec::Vec;
56
use bitcoin::{block::Header, BlockHash};
67

78
use crate::BlockId;
@@ -56,10 +57,10 @@ impl<D> Drop for CPInner<D> {
5657

5758
/// Trait that converts [`CheckPoint`] `data` to [`BlockHash`].
5859
///
59-
/// Implementations of [`ToBlockHash`] must always return the blocks consensus-defined hash. If
60+
/// Implementations of [`ToBlockHash`] must always return the block's consensus-defined hash. If
6061
/// your type contains extra fields (timestamps, metadata, etc.), these must be ignored. For
6162
/// example, [`BlockHash`] trivially returns itself, [`Header`] calls its `block_hash()`, and a
62-
/// wrapper type around a [`Header`] should delegate to the headers hash rather than derive one
63+
/// wrapper type around a [`Header`] should delegate to the header's hash rather than derive one
6364
/// from other fields.
6465
pub trait ToBlockHash {
6566
/// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type.
@@ -78,6 +79,20 @@ impl ToBlockHash for Header {
7879
}
7980
}
8081

82+
/// Trait that extracts a block time from [`CheckPoint`] `data`.
83+
///
84+
/// `data` types that contain a block time should implement this.
85+
pub trait ToBlockTime {
86+
/// Returns the block time from the [`CheckPoint`] `data`.
87+
fn to_blocktime(&self) -> u32;
88+
}
89+
90+
impl ToBlockTime for Header {
91+
fn to_blocktime(&self) -> u32 {
92+
self.time
93+
}
94+
}
95+
8196
impl<D> PartialEq for CheckPoint<D> {
8297
fn eq(&self, other: &Self) -> bool {
8398
let self_cps = self.iter().map(|cp| cp.block_id());
@@ -191,6 +206,8 @@ impl<D> CheckPoint<D>
191206
where
192207
D: ToBlockHash + fmt::Debug + Copy,
193208
{
209+
const MTP_BLOCK_COUNT: u32 = 11;
210+
194211
/// Construct a new base [`CheckPoint`] from given `height` and `data` at the front of a linked
195212
/// list.
196213
pub fn new(height: u32, data: D) -> Self {
@@ -204,6 +221,38 @@ where
204221
}))
205222
}
206223

224+
/// Calculate the median time past (MTP) for this checkpoint.
225+
///
226+
/// Uses 11 blocks (heights h-10 through h, where h is the current height) to compute the MTP
227+
/// for the current block. This is used in Bitcoin's consensus rules for time-based validations
228+
/// (BIP-0113).
229+
///
230+
/// Note: This is a pseudo-median that doesn't average the two middle values.
231+
///
232+
/// Returns `None` if the data type doesn't support block times or if any of the required
233+
/// 11 sequential blocks are missing.
234+
pub fn median_time_past(&self) -> Option<u32>
235+
where
236+
D: ToBlockTime,
237+
{
238+
let current_height = self.height();
239+
let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT - 1);
240+
241+
let mut timestamps = (earliest_height..=current_height)
242+
.map(|height| {
243+
// Return `None` for missing blocks or missing block times
244+
let cp = self.get(height)?;
245+
let block_time = cp.data_ref().to_blocktime();
246+
Some(block_time)
247+
})
248+
.collect::<Option<Vec<u32>>>()?;
249+
timestamps.sort_unstable();
250+
251+
// If there are more than 1 middle values, use the higher middle value.
252+
// This is mathematically incorrect, but this is the BIP-0113 specification.
253+
Some(timestamps[timestamps.len() / 2])
254+
}
255+
207256
/// Construct from an iterator of block data.
208257
///
209258
/// Returns `Err(None)` if `blocks` doesn't yield any data. If the blocks are not in ascending

crates/core/tests/test_checkpoint.rs

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use bdk_core::CheckPoint;
1+
use bdk_core::{CheckPoint, ToBlockHash, ToBlockTime};
22
use bdk_testenv::{block_id, hash};
3+
use bitcoin::hashes::Hash;
34
use bitcoin::BlockHash;
45

56
/// Inserting a block that already exists in the checkpoint chain must always succeed.
@@ -55,3 +56,133 @@ fn checkpoint_destruction_is_sound() {
5556
}
5657
assert_eq!(cp.iter().count() as u32, end);
5758
}
59+
60+
/// Test helper: A block data type that includes timestamp
61+
/// Fields are (height, time)
62+
#[derive(Debug, Clone, Copy)]
63+
struct BlockWithTime(u32, u32);
64+
65+
impl ToBlockHash for BlockWithTime {
66+
fn to_blockhash(&self) -> BlockHash {
67+
// Generate a deterministic hash from the height
68+
let hash_bytes = bitcoin::hashes::sha256d::Hash::hash(&self.0.to_le_bytes());
69+
BlockHash::from_raw_hash(hash_bytes)
70+
}
71+
}
72+
73+
impl ToBlockTime for BlockWithTime {
74+
fn to_blocktime(&self) -> u32 {
75+
self.1
76+
}
77+
}
78+
79+
#[test]
80+
fn test_median_time_past_with_timestamps() {
81+
// Create a chain with 12 blocks (heights 0-11) with incrementing timestamps
82+
let blocks: Vec<_> = (0..=11)
83+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
84+
.collect();
85+
86+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
87+
88+
// Height 11: 11 previous blocks (11..=1), pseudo-median at index 6 = 1060
89+
assert_eq!(cp.median_time_past(), Some(1060));
90+
91+
// Height 10: 11 previous blocks (10..=0), pseudo-median at index 5 = 1050
92+
assert_eq!(cp.get(10).unwrap().median_time_past(), Some(1050));
93+
94+
// Height 5: 6 previous blocks (5..=0), pseudo-median at index 3 = 1030
95+
assert_eq!(cp.get(5).unwrap().median_time_past(), Some(1030));
96+
97+
// Height 3: 4 previous blocks (3..=0), pseudo-median at index 2 = 1020
98+
assert_eq!(cp.get(3).unwrap().median_time_past(), Some(1020));
99+
100+
// Height 0: 1 block at index 0 = 1000
101+
assert_eq!(cp.get(0).unwrap().median_time_past(), Some(1000));
102+
}
103+
104+
#[test]
105+
fn test_previous_median_time_past_edge_cases() {
106+
// Test with minimum required blocks (11)
107+
let blocks: Vec<_> = (0..=10)
108+
.map(|i| (i, BlockWithTime(i, 1000 + i * 100)))
109+
.collect();
110+
111+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
112+
113+
// At height 10: next_mtp uses all 11 blocks (0-10)
114+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000]
115+
// Median at index 5 = 1500
116+
assert_eq!(cp.median_time_past(), Some(1500));
117+
118+
// At height 9: mtp uses blocks 0-9 (10 blocks)
119+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
120+
// Median at index 5 = 1400
121+
assert_eq!(cp.get(9).unwrap().median_time_past(), Some(1500));
122+
123+
// Test sparse chain where next_mtp returns None due to missing blocks
124+
let sparse = vec![
125+
(0, BlockWithTime(0, 1000)),
126+
(5, BlockWithTime(5, 1050)),
127+
(10, BlockWithTime(10, 1100)),
128+
];
129+
let sparse_cp = CheckPoint::from_blocks(sparse).expect("must construct valid chain");
130+
131+
// At height 10: next_mtp needs blocks 0-10 but many are missing
132+
assert_eq!(sparse_cp.median_time_past(), None);
133+
}
134+
135+
#[test]
136+
fn test_mtp_with_non_monotonic_times() {
137+
// Test both methods with shuffled timestamps
138+
let blocks = vec![
139+
(0, BlockWithTime(0, 1500)),
140+
(1, BlockWithTime(1, 1200)),
141+
(2, BlockWithTime(2, 1800)),
142+
(3, BlockWithTime(3, 1100)),
143+
(4, BlockWithTime(4, 1900)),
144+
(5, BlockWithTime(5, 1300)),
145+
(6, BlockWithTime(6, 1700)),
146+
(7, BlockWithTime(7, 1400)),
147+
(8, BlockWithTime(8, 1600)),
148+
(9, BlockWithTime(9, 1000)),
149+
(10, BlockWithTime(10, 2000)),
150+
(11, BlockWithTime(11, 1650)),
151+
];
152+
153+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
154+
155+
// Height 10:
156+
// mtp uses blocks 0-10: sorted
157+
// [1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000] Median at index 5 = 1500
158+
assert_eq!(cp.get(10).unwrap().median_time_past(), Some(1500));
159+
160+
// Height 11:
161+
// mtp uses blocks 1-11: sorted
162+
// [1000,1100,1200,1300,1400,1600,1650,1700,1800,1900,2000] Median at index 5 = 1600
163+
assert_eq!(cp.median_time_past(), Some(1600));
164+
165+
// Test with smaller chain to verify sorting at different heights
166+
let cp3 = cp.get(3).unwrap();
167+
// Height 3: timestamps [1100, 1800, 1200, 1500] -> sorted [1100, 1200, 1500, 1800]
168+
// Pseudo-median at index 2 = 1500
169+
assert_eq!(cp3.median_time_past(), Some(1500));
170+
}
171+
172+
#[test]
173+
fn test_mtp_sparse_chain() {
174+
// Sparse chain missing required sequential blocks
175+
let blocks = vec![
176+
(0, BlockWithTime(0, 1000)),
177+
(3, BlockWithTime(3, 1030)),
178+
(7, BlockWithTime(7, 1070)),
179+
(11, BlockWithTime(11, 1110)),
180+
(15, BlockWithTime(15, 1150)),
181+
];
182+
183+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
184+
185+
// All heights should return None due to missing sequential blocks
186+
assert_eq!(cp.median_time_past(), None);
187+
assert_eq!(cp.get(11).unwrap().median_time_past(), None);
188+
}

0 commit comments

Comments
 (0)