Skip to content

Commit f97553f

Browse files
evanlinjinclaude
andcommitted
feat(core): add median-time-past calculation to CheckPoint
Add the ability to calculate median-time-past (MTP) for checkpoints, following Bitcoin's BIP113 specification. MTP is the pseudo-median of up to 11 previous block timestamps, used for time-based validations in consensus rules. - Extend `ToBlockHash` trait with optional `block_time()` method - Add `median_time_past()` method to `CheckPoint` - Return `None` when timestamps unavailable or blocks missing from sparse chain - Include comprehensive tests for all edge cases 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 0aab769 commit f97553f

File tree

2 files changed

+156
-3
lines changed

2 files changed

+156
-3
lines changed

crates/core/src/checkpoint.rs

Lines changed: 47 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;
@@ -60,14 +61,22 @@ impl<D> Drop for CPInner<D> {
6061

6162
/// Trait that converts [`CheckPoint`] `data` to [`BlockHash`].
6263
///
63-
/// Implementations of [`ToBlockHash`] must always return the blocks consensus-defined hash. If
64+
/// Implementations of [`ToBlockHash`] must always return the block's consensus-defined hash. If
6465
/// your type contains extra fields (timestamps, metadata, etc.), these must be ignored. For
6566
/// example, [`BlockHash`] trivially returns itself, [`Header`] calls its `block_hash()`, and a
66-
/// wrapper type around a [`Header`] should delegate to the headers hash rather than derive one
67+
/// wrapper type around a [`Header`] should delegate to the header's hash rather than derive one
6768
/// from other fields.
6869
pub trait ToBlockHash {
6970
/// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type.
7071
fn to_blockhash(&self) -> BlockHash;
72+
73+
/// Returns the block time if available for the data type.
74+
///
75+
/// Default implementation returns `None`. Data types that include timestamp information
76+
/// (such as [`Header`]) should override this method.
77+
fn block_time(&self) -> Option<u32> {
78+
None
79+
}
7180
}
7281

7382
impl ToBlockHash for BlockHash {
@@ -80,6 +89,10 @@ impl ToBlockHash for Header {
8089
fn to_blockhash(&self) -> BlockHash {
8190
self.block_hash()
8291
}
92+
93+
fn block_time(&self) -> Option<u32> {
94+
Some(self.time)
95+
}
8396
}
8497

8598
impl<D> PartialEq for CheckPoint<D> {
@@ -208,6 +221,38 @@ where
208221
}))
209222
}
210223

224+
/// Calculates the median-time-past (MTP) for this checkpoint.
225+
///
226+
/// The MTP is defined as the pseudo-median timestamp of up to 11 previous blocks before this
227+
/// current one. This is used in Bitcoin's consensus rules for time-based validations (BIP113).
228+
///
229+
/// It is pseudo-median as it doesn't take the average of the two middle values and therefore
230+
/// is not a true statistical median.
231+
///
232+
/// Returns `None` if:
233+
/// - The data type doesn't support block times
234+
/// - Required sequential blocks are missing from the sparse chain (we need blocks at heights h,
235+
/// h-1, h-2, ..., h-10 where h is the current block's height)
236+
pub fn median_time_past(&self) -> Option<u32> {
237+
const MTP_BLOCK_COUNT: usize = 11;
238+
239+
let current_height = self.height();
240+
let earliest_height = current_height.saturating_sub((MTP_BLOCK_COUNT) as u32);
241+
242+
// Calculate the pseudo-median (as defined in BIP113)
243+
let mut timestamps = (earliest_height..current_height)
244+
.rev()
245+
.map(|height| {
246+
// Return `None` for missing blocks or missing block times
247+
let cp = self.get(height)?;
248+
let block_time = cp.data_ref().block_time()?;
249+
Some(block_time)
250+
})
251+
.collect::<Option<Vec<u32>>>()?;
252+
timestamps.sort_unstable();
253+
Some(timestamps[timestamps.len().checked_sub(1)? / 2])
254+
}
255+
211256
/// Construct from an iterator of block data.
212257
///
213258
/// 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: 109 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};
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,110 @@ 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+
fn block_time(&self) -> Option<u32> {
73+
Some(self.1)
74+
}
75+
}
76+
77+
#[test]
78+
fn test_median_time_past_with_no_timestamps_available() {
79+
// Test with BlockHash (no timestamps available)
80+
let blocks = vec![
81+
(0, hash!("genesis")),
82+
(1, hash!("A")),
83+
(2, hash!("B")),
84+
(3, hash!("C")),
85+
];
86+
87+
let cp = CheckPoint::<BlockHash>::from_blocks(blocks).expect("must construct valid chain");
88+
assert_eq!(cp.median_time_past(), None, "BlockHash has no timestamp");
89+
}
90+
91+
#[test]
92+
fn test_median_time_past_with_timestamps() {
93+
// Create a chain with 12 blocks (heights 0-11) with incrementing timestamps
94+
let blocks: Vec<_> = (0..=11)
95+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
96+
.collect();
97+
98+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
99+
100+
// Height 11: 11 previous blocks (10..0), pseudo-median at index 5 = 1050
101+
assert_eq!(cp.median_time_past(), Some(1050));
102+
103+
// Height 10: 10 previous blocks (9..0), pseudo-median at index 4 = 1040
104+
assert_eq!(cp.get(10).unwrap().median_time_past(), Some(1040));
105+
106+
// Height 5: 5 previous blocks (4..0), pseudo-median at index 2 = 1020
107+
assert_eq!(cp.get(5).unwrap().median_time_past(), Some(1020));
108+
109+
// Height 0: genesis has no previous blocks
110+
assert_eq!(cp.get(0).unwrap().median_time_past(), None);
111+
}
112+
113+
#[test]
114+
fn test_median_time_past_sparse_chain() {
115+
// Sparse chain with gaps: only heights 0, 5, 10, 15, 20
116+
let blocks = vec![
117+
(0, BlockWithTime(0, 1000)),
118+
(5, BlockWithTime(5, 1050)),
119+
(10, BlockWithTime(10, 1100)),
120+
(15, BlockWithTime(15, 1150)),
121+
(20, BlockWithTime(20, 1200)),
122+
];
123+
124+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
125+
126+
// All non-genesis heights should return None due to missing sequential blocks
127+
assert_eq!(cp.median_time_past(), None); // Height 20: missing 19..11
128+
assert_eq!(cp.get(10).unwrap().median_time_past(), None); // Height 10: missing 9..1
129+
assert_eq!(cp.get(5).unwrap().median_time_past(), None); // Height 5: missing 4..1
130+
assert_eq!(cp.get(0).unwrap().median_time_past(), None); // Genesis: no previous blocks
131+
}
132+
133+
#[test]
134+
fn test_median_time_past_with_sequential_blocks() {
135+
// Complete sequential chain from 0 to 5
136+
let blocks: Vec<_> = (0..=5)
137+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
138+
.collect();
139+
140+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
141+
142+
// Height 5: 5 previous blocks, pseudo-median at index 2 = 1020
143+
assert_eq!(cp.median_time_past(), Some(1020));
144+
145+
// Height 3: 3 previous blocks, pseudo-median at index 1 = 1010
146+
assert_eq!(cp.get(3).unwrap().median_time_past(), Some(1010));
147+
}
148+
149+
#[test]
150+
fn test_median_time_past_unsorted_timestamps() {
151+
// Non-monotonic timestamps to test sorting
152+
let blocks = vec![
153+
(0, BlockWithTime(0, 1000)),
154+
(1, BlockWithTime(1, 1100)), // Jump forward
155+
(2, BlockWithTime(2, 1050)), // Back
156+
(3, BlockWithTime(3, 1150)), // Forward
157+
(4, BlockWithTime(4, 1075)), // Back
158+
];
159+
160+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
161+
162+
// Height 4: previous times [1150, 1050, 1100, 1000] -> sorted [1000, 1050, 1100, 1150]
163+
// Pseudo-median at index 1 = 1050
164+
assert_eq!(cp.median_time_past(), Some(1050));
165+
}

0 commit comments

Comments
 (0)