Skip to content

Commit 6704eec

Browse files
evanlinjinclaude
andcommitted
feat(core): add median-time-past calculation to CheckPoint
Add MTP calculation methods for CheckPoint following Bitcoin's BIP113: - `median_time_past()`: calculates MTP for current block using previous 11 blocks - `next_median_time_past()`: calculates MTP for next block using current + previous 10 blocks - Returns None when timestamps unavailable or required blocks missing Includes comprehensive tests for all edge cases. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent a161ee2 commit 6704eec

File tree

2 files changed

+251
-3
lines changed

2 files changed

+251
-3
lines changed

crates/core/src/checkpoint.rs

Lines changed: 77 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,64 @@ where
204221
}))
205222
}
206223

224+
/// Calculate the median time past (MTP) for this checkpoint.
225+
///
226+
/// Uses the 11 previous blocks (heights h-11 through h-1, where h is the current height)
227+
/// to compute the MTP for the current block. This is used in Bitcoin's consensus rules
228+
/// for time-based validations (BIP113).
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);
240+
self.middle_timestamp_of_range(earliest_height..current_height)
241+
}
242+
243+
/// Calculate the median time past (MTP) for the next block.
244+
///
245+
/// Uses the 11 most recent blocks (heights h-10 through h, where h is the current height)
246+
/// to compute what the MTP would be if a new block were added at height h+1.
247+
///
248+
/// This differs from [`median_time_past`] which uses blocks h-11 through h-1 to compute
249+
/// the MTP for the current block at height h.
250+
///
251+
/// Returns `None` if the data type doesn't support block times or if any of the required
252+
/// 11 sequential blocks are missing.
253+
///
254+
/// [`median_time_past`]: CheckPoint::median_time_past
255+
pub fn next_median_time_past(&self) -> Option<u32>
256+
where
257+
D: ToBlockTime,
258+
{
259+
let current_height = self.height();
260+
let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT - 1);
261+
self.middle_timestamp_of_range(earliest_height..=current_height)
262+
}
263+
264+
/// Calculates the pseudo-medium timestamp of the given height range.
265+
fn middle_timestamp_of_range(&self, heights: impl IntoIterator<Item = u32>) -> Option<u32>
266+
where
267+
D: ToBlockTime,
268+
{
269+
let mut timestamps = heights
270+
.into_iter()
271+
.map(|height| {
272+
// Return `None` for missing blocks or missing block times
273+
let cp = self.get(height)?;
274+
let block_time = cp.data_ref().to_blocktime();
275+
Some(block_time)
276+
})
277+
.collect::<Option<Vec<u32>>>()?;
278+
timestamps.sort_unstable();
279+
Some(timestamps[timestamps.len().checked_sub(1)? / 2])
280+
}
281+
207282
/// Construct from an iterator of block data.
208283
///
209284
/// 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: 174 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,175 @@ 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_no_timestamps_available() {
81+
// Test with BlockHash (no timestamps available)
82+
let blocks = vec![
83+
(0, hash!("genesis")),
84+
(1, hash!("A")),
85+
(2, hash!("B")),
86+
(3, hash!("C")),
87+
];
88+
89+
let cp = CheckPoint::<BlockHash>::from_blocks(blocks).expect("must construct valid chain");
90+
assert_eq!(cp.median_time_past(), None, "BlockHash has no timestamp");
91+
}
92+
93+
#[test]
94+
fn test_median_time_past_with_timestamps() {
95+
// Create a chain with 12 blocks (heights 0-11) with incrementing timestamps
96+
let blocks: Vec<_> = (0..=11)
97+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
98+
.collect();
99+
100+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
101+
102+
// Height 11: 11 previous blocks (10..0), pseudo-median at index 5 = 1050
103+
assert_eq!(cp.median_time_past(), Some(1050));
104+
105+
// Height 10: 10 previous blocks (9..0), pseudo-median at index 4 = 1040
106+
assert_eq!(cp.get(10).unwrap().median_time_past(), Some(1040));
107+
108+
// Height 5: 5 previous blocks (4..0), pseudo-median at index 2 = 1020
109+
assert_eq!(cp.get(5).unwrap().median_time_past(), Some(1020));
110+
111+
// Height 3: 3 previous blocks (2..0), pseudo-median at index 1 = 1010
112+
assert_eq!(cp.get(3).unwrap().median_time_past(), Some(1010));
113+
114+
// Height 0: genesis has no previous blocks
115+
assert_eq!(cp.get(0).unwrap().median_time_past(), None);
116+
}
117+
118+
#[test]
119+
fn test_both_mtp_methods_comparison() {
120+
// Create a chain with 15 blocks to test both MTP methods
121+
let blocks: Vec<_> = (0..=14)
122+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
123+
.collect();
124+
125+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
126+
127+
// At height 12:
128+
// median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
129+
// next_median_time_past: uses blocks 2-12, median of [1020..1120] = 1070
130+
let cp12 = cp.get(12).unwrap();
131+
assert_eq!(cp12.median_time_past(), Some(1060));
132+
assert_eq!(cp12.next_median_time_past(), Some(1070));
133+
134+
// At height 11:
135+
// median_time_past: uses blocks 0-10, median of [1000..1100] = 1050
136+
// next_median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
137+
let cp11 = cp.get(11).unwrap();
138+
assert_eq!(cp11.median_time_past(), Some(1050));
139+
assert_eq!(cp11.next_median_time_past(), Some(1060));
140+
141+
// Verify the relationship: cp(n).next_mtp == cp(n+1).mtp
142+
assert_eq!(cp11.next_median_time_past(), cp12.median_time_past());
143+
}
144+
145+
#[test]
146+
fn test_next_median_time_past_edge_cases() {
147+
// Test with minimum required blocks (11)
148+
let blocks: Vec<_> = (0..=10)
149+
.map(|i| (i, BlockWithTime(i, 1000 + i * 100)))
150+
.collect();
151+
152+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
153+
154+
// At height 10: next_mtp uses all 11 blocks (0-10)
155+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000]
156+
// Median at index 5 = 1500
157+
assert_eq!(cp.next_median_time_past(), Some(1500));
158+
159+
// At height 9: next_mtp uses blocks 0-9 (10 blocks)
160+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
161+
// Median at index 4 = 1400
162+
assert_eq!(cp.get(9).unwrap().next_median_time_past(), Some(1400));
163+
164+
// Test sparse chain where next_mtp returns None due to missing blocks
165+
let sparse = vec![
166+
(0, BlockWithTime(0, 1000)),
167+
(5, BlockWithTime(5, 1050)),
168+
(10, BlockWithTime(10, 1100)),
169+
];
170+
let sparse_cp = CheckPoint::from_blocks(sparse).expect("must construct valid chain");
171+
172+
// At height 10: next_mtp needs blocks 0-10 but many are missing
173+
assert_eq!(sparse_cp.next_median_time_past(), None);
174+
}
175+
176+
#[test]
177+
fn test_mtp_with_non_monotonic_times() {
178+
// Test both methods with shuffled timestamps
179+
let blocks = vec![
180+
(0, BlockWithTime(0, 1500)),
181+
(1, BlockWithTime(1, 1200)),
182+
(2, BlockWithTime(2, 1800)),
183+
(3, BlockWithTime(3, 1100)),
184+
(4, BlockWithTime(4, 1900)),
185+
(5, BlockWithTime(5, 1300)),
186+
(6, BlockWithTime(6, 1700)),
187+
(7, BlockWithTime(7, 1400)),
188+
(8, BlockWithTime(8, 1600)),
189+
(9, BlockWithTime(9, 1000)),
190+
(10, BlockWithTime(10, 2000)),
191+
(11, BlockWithTime(11, 1050)),
192+
];
193+
194+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
195+
196+
// Height 11:
197+
// median_time_past uses blocks 0-10: sorted
198+
// [1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000] Median at index 5 = 1500
199+
assert_eq!(cp.median_time_past(), Some(1500));
200+
201+
// next_median_time_past uses blocks 1-11: sorted
202+
// [1000,1050,1100,1200,1300,1400,1600,1700,1800,1900,2000] Median at index 5 = 1400
203+
assert_eq!(cp.next_median_time_past(), Some(1400));
204+
205+
// Test with smaller chain to verify sorting at different heights
206+
let cp4 = cp.get(4).unwrap();
207+
// Height 4: previous times [1100, 1800, 1200, 1500] -> sorted [1100, 1200, 1500, 1800]
208+
// Pseudo-median at index 1 = 1200
209+
assert_eq!(cp4.median_time_past(), Some(1200));
210+
}
211+
212+
#[test]
213+
fn test_mtp_sparse_chain_both_methods() {
214+
// Sparse chain missing required sequential blocks
215+
let blocks = vec![
216+
(0, BlockWithTime(0, 1000)),
217+
(3, BlockWithTime(3, 1030)),
218+
(7, BlockWithTime(7, 1070)),
219+
(11, BlockWithTime(11, 1110)),
220+
(15, BlockWithTime(15, 1150)),
221+
];
222+
223+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
224+
225+
// All heights should return None due to missing sequential blocks
226+
assert_eq!(cp.median_time_past(), None);
227+
assert_eq!(cp.next_median_time_past(), None);
228+
assert_eq!(cp.get(11).unwrap().median_time_past(), None);
229+
assert_eq!(cp.get(11).unwrap().next_median_time_past(), None);
230+
}

0 commit comments

Comments
 (0)