Skip to content

Commit 74d30d8

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 faf520d commit 74d30d8

File tree

2 files changed

+237
-3
lines changed

2 files changed

+237
-3
lines changed

crates/core/src/checkpoint.rs

Lines changed: 65 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> {
@@ -195,6 +208,8 @@ impl<D> CheckPoint<D>
195208
where
196209
D: ToBlockHash + fmt::Debug + Copy,
197210
{
211+
const MTP_BLOCK_COUNT: u32 = 11;
212+
198213
/// Construct a new base [`CheckPoint`] from given `height` and `data` at the front of a linked
199214
/// list.
200215
pub fn new(height: u32, data: D) -> Self {
@@ -208,6 +223,54 @@ where
208223
}))
209224
}
210225

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

0 commit comments

Comments
 (0)