Skip to content

Commit 46e751f

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 46e751f

File tree

2 files changed

+282
-3
lines changed

2 files changed

+282
-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: 217 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,218 @@ 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+
}
166+
167+
#[test]
168+
fn test_both_mtp_methods_comparison() {
169+
// Create a chain with 15 blocks to test both MTP methods
170+
let blocks: Vec<_> = (0..=14)
171+
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
172+
.collect();
173+
174+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
175+
176+
// At height 12:
177+
// median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
178+
// next_median_time_past: uses blocks 2-12, median of [1020..1120] = 1070
179+
let cp12 = cp.get(12).unwrap();
180+
assert_eq!(cp12.median_time_past(), Some(1060));
181+
assert_eq!(cp12.next_median_time_past(), Some(1070));
182+
183+
// At height 11:
184+
// median_time_past: uses blocks 0-10, median of [1000..1100] = 1050
185+
// next_median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
186+
let cp11 = cp.get(11).unwrap();
187+
assert_eq!(cp11.median_time_past(), Some(1050));
188+
assert_eq!(cp11.next_median_time_past(), Some(1060));
189+
190+
// Verify the relationship: cp(n).next_mtp == cp(n+1).mtp
191+
assert_eq!(cp11.next_median_time_past(), cp12.median_time_past());
192+
}
193+
194+
#[test]
195+
fn test_next_median_time_past_edge_cases() {
196+
// Test with minimum required blocks (11)
197+
let blocks: Vec<_> = (0..=10)
198+
.map(|i| (i, BlockWithTime(i, 1000 + i * 100)))
199+
.collect();
200+
201+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
202+
203+
// At height 10: next_mtp uses all 11 blocks (0-10)
204+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000]
205+
// Median at index 5 = 1500
206+
assert_eq!(cp.next_median_time_past(), Some(1500));
207+
208+
// At height 9: next_mtp uses blocks 0-9 (10 blocks)
209+
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
210+
// Median at index 4 = 1400
211+
assert_eq!(cp.get(9).unwrap().next_median_time_past(), Some(1400));
212+
213+
// Test sparse chain where next_mtp returns None due to missing blocks
214+
let sparse = vec![
215+
(0, BlockWithTime(0, 1000)),
216+
(5, BlockWithTime(5, 1050)),
217+
(10, BlockWithTime(10, 1100)),
218+
];
219+
let sparse_cp = CheckPoint::from_blocks(sparse).expect("must construct valid chain");
220+
221+
// At height 10: next_mtp needs blocks 0-10 but many are missing
222+
assert_eq!(sparse_cp.next_median_time_past(), None);
223+
}
224+
225+
#[test]
226+
fn test_mtp_with_non_monotonic_times() {
227+
// Test both methods with shuffled timestamps
228+
let blocks = vec![
229+
(0, BlockWithTime(0, 1500)),
230+
(1, BlockWithTime(1, 1200)),
231+
(2, BlockWithTime(2, 1800)),
232+
(3, BlockWithTime(3, 1100)),
233+
(4, BlockWithTime(4, 1900)),
234+
(5, BlockWithTime(5, 1300)),
235+
(6, BlockWithTime(6, 1700)),
236+
(7, BlockWithTime(7, 1400)),
237+
(8, BlockWithTime(8, 1600)),
238+
(9, BlockWithTime(9, 1000)),
239+
(10, BlockWithTime(10, 2000)),
240+
(11, BlockWithTime(11, 1050)),
241+
];
242+
243+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
244+
245+
// Height 11:
246+
// median_time_past uses blocks 0-10: sorted
247+
// [1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000] Median at index 5 = 1500
248+
assert_eq!(cp.median_time_past(), Some(1500));
249+
250+
// next_median_time_past uses blocks 1-11: sorted
251+
// [1000,1050,1100,1200,1300,1400,1600,1700,1800,1900,2000] Median at index 5 = 1400
252+
assert_eq!(cp.next_median_time_past(), Some(1400));
253+
}
254+
255+
#[test]
256+
fn test_mtp_sparse_chain_both_methods() {
257+
// Sparse chain missing required sequential blocks
258+
let blocks = vec![
259+
(0, BlockWithTime(0, 1000)),
260+
(3, BlockWithTime(3, 1030)),
261+
(7, BlockWithTime(7, 1070)),
262+
(11, BlockWithTime(11, 1110)),
263+
(15, BlockWithTime(15, 1150)),
264+
];
265+
266+
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
267+
268+
// All heights should return None due to missing sequential blocks
269+
assert_eq!(cp.median_time_past(), None);
270+
assert_eq!(cp.next_median_time_past(), None);
271+
assert_eq!(cp.get(11).unwrap().median_time_past(), None);
272+
assert_eq!(cp.get(11).unwrap().next_median_time_past(), None);
273+
}

0 commit comments

Comments
 (0)