Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions crates/core/src/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use core::fmt;
use core::ops::RangeBounds;

use alloc::sync::Arc;
use alloc::vec::Vec;
use bitcoin::{block::Header, BlockHash};

use crate::BlockId;
Expand Down Expand Up @@ -60,14 +61,22 @@ impl<D> Drop for CPInner<D> {

/// Trait that converts [`CheckPoint`] `data` to [`BlockHash`].
///
/// Implementations of [`ToBlockHash`] must always return the blocks consensus-defined hash. If
/// Implementations of [`ToBlockHash`] must always return the block's consensus-defined hash. If
/// your type contains extra fields (timestamps, metadata, etc.), these must be ignored. For
/// example, [`BlockHash`] trivially returns itself, [`Header`] calls its `block_hash()`, and a
/// wrapper type around a [`Header`] should delegate to the headers hash rather than derive one
/// wrapper type around a [`Header`] should delegate to the header's hash rather than derive one
/// from other fields.
pub trait ToBlockHash {
/// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type.
fn to_blockhash(&self) -> BlockHash;

/// Returns the block time if available for the data type.
///
/// Default implementation returns `None`. Data types that include timestamp information
/// (such as [`Header`]) should override this method.
fn block_time(&self) -> Option<u32> {
None
}
}

impl ToBlockHash for BlockHash {
Expand All @@ -80,6 +89,10 @@ impl ToBlockHash for Header {
fn to_blockhash(&self) -> BlockHash {
self.block_hash()
}

fn block_time(&self) -> Option<u32> {
Some(self.time)
}
}

impl<D> PartialEq for CheckPoint<D> {
Expand Down Expand Up @@ -195,6 +208,8 @@ impl<D> CheckPoint<D>
where
D: ToBlockHash + fmt::Debug + Copy,
{
const MTP_BLOCK_COUNT: u32 = 11;

/// Construct a new base [`CheckPoint`] from given `height` and `data` at the front of a linked
/// list.
pub fn new(height: u32, data: D) -> Self {
Expand All @@ -208,6 +223,54 @@ where
}))
}

/// Calculate the median time past (MTP) for this checkpoint.
///
/// Uses the 11 previous blocks (heights h-11 through h-1, where h is the current height)
/// to compute the MTP for the current block. This is used in Bitcoin's consensus rules
/// for time-based validations (BIP113).
///
/// Note: This is a pseudo-median that doesn't average the two middle values.
///
/// Returns `None` if the data type doesn't support block times or if any of the required
/// 11 sequential blocks are missing.
pub fn median_time_past(&self) -> Option<u32> {
let current_height = self.height();
let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT);
self._median_time_past(earliest_height..current_height)
}
Comment on lines +236 to +240
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if these new APIs could output wrong/invalid values if the blocks inserted have a wrong/invalid time field e.g malicious block source.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely. We are trusting the chain source here (and everywhere in BDK). Eventually, we should think about how to verify all consensus rules in BDK: difficulty, PoW, MTP, etc.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it not inclusive of the current height?


/// Calculate the median time past (MTP) for the next block.
///
/// Uses the 11 most recent blocks (heights h-10 through h, where h is the current height)
/// to compute what the MTP would be if a new block were added at height h+1.
///
/// This differs from [`median_time_past`] which uses blocks h-11 through h-1 to compute
/// the MTP for the current block at height h.
///
/// Returns `None` if the data type doesn't support block times or if any of the required
/// 11 sequential blocks are missing.
///
/// [`median_time_past`]: CheckPoint::median_time_past
pub fn next_median_time_past(&self) -> Option<u32> {
let current_height = self.height();
let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT - 1);
self._median_time_past(earliest_height..=current_height)
}

fn _median_time_past(&self, heights: impl IntoIterator<Item = u32>) -> Option<u32> {
let mut timestamps = heights
.into_iter()
.map(|height| {
// Return `None` for missing blocks or missing block times
let cp = self.get(height)?;
let block_time = cp.data_ref().block_time()?;
Some(block_time)
})
.collect::<Option<Vec<u32>>>()?;
timestamps.sort_unstable();
Some(timestamps[timestamps.len().checked_sub(1)? / 2])
}

/// Construct from an iterator of block data.
///
/// Returns `Err(None)` if `blocks` doesn't yield any data. If the blocks are not in ascending
Expand Down
173 changes: 172 additions & 1 deletion crates/core/tests/test_checkpoint.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use bdk_core::CheckPoint;
use bdk_core::{CheckPoint, ToBlockHash};
use bdk_testenv::{block_id, hash};
use bitcoin::hashes::Hash;
use bitcoin::BlockHash;

/// Inserting a block that already exists in the checkpoint chain must always succeed.
Expand Down Expand Up @@ -55,3 +56,173 @@ fn checkpoint_destruction_is_sound() {
}
assert_eq!(cp.iter().count() as u32, end);
}

/// Test helper: A block data type that includes timestamp
/// Fields are (height, time)
#[derive(Debug, Clone, Copy)]
struct BlockWithTime(u32, u32);

impl ToBlockHash for BlockWithTime {
fn to_blockhash(&self) -> BlockHash {
// Generate a deterministic hash from the height
let hash_bytes = bitcoin::hashes::sha256d::Hash::hash(&self.0.to_le_bytes());
BlockHash::from_raw_hash(hash_bytes)
}

fn block_time(&self) -> Option<u32> {
Some(self.1)
}
}

#[test]
fn test_median_time_past_with_no_timestamps_available() {
// Test with BlockHash (no timestamps available)
let blocks = vec![
(0, hash!("genesis")),
(1, hash!("A")),
(2, hash!("B")),
(3, hash!("C")),
];

let cp = CheckPoint::<BlockHash>::from_blocks(blocks).expect("must construct valid chain");
assert_eq!(cp.median_time_past(), None, "BlockHash has no timestamp");
}

#[test]
fn test_median_time_past_with_timestamps() {
// Create a chain with 12 blocks (heights 0-11) with incrementing timestamps
let blocks: Vec<_> = (0..=11)
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
.collect();

let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");

// Height 11: 11 previous blocks (10..0), pseudo-median at index 5 = 1050
assert_eq!(cp.median_time_past(), Some(1050));

// Height 10: 10 previous blocks (9..0), pseudo-median at index 4 = 1040
assert_eq!(cp.get(10).unwrap().median_time_past(), Some(1040));

// Height 5: 5 previous blocks (4..0), pseudo-median at index 2 = 1020
assert_eq!(cp.get(5).unwrap().median_time_past(), Some(1020));

// Height 3: 3 previous blocks (2..0), pseudo-median at index 1 = 1010
assert_eq!(cp.get(3).unwrap().median_time_past(), Some(1010));

// Height 0: genesis has no previous blocks
assert_eq!(cp.get(0).unwrap().median_time_past(), None);
}

#[test]
fn test_both_mtp_methods_comparison() {
// Create a chain with 15 blocks to test both MTP methods
let blocks: Vec<_> = (0..=14)
.map(|i| (i, BlockWithTime(i, 1000 + i * 10)))
.collect();

let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");

// At height 12:
// median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
// next_median_time_past: uses blocks 2-12, median of [1020..1120] = 1070
let cp12 = cp.get(12).unwrap();
assert_eq!(cp12.median_time_past(), Some(1060));
assert_eq!(cp12.next_median_time_past(), Some(1070));

// At height 11:
// median_time_past: uses blocks 0-10, median of [1000..1100] = 1050
// next_median_time_past: uses blocks 1-11, median of [1010..1110] = 1060
let cp11 = cp.get(11).unwrap();
assert_eq!(cp11.median_time_past(), Some(1050));
assert_eq!(cp11.next_median_time_past(), Some(1060));

// Verify the relationship: cp(n).next_mtp == cp(n+1).mtp
assert_eq!(cp11.next_median_time_past(), cp12.median_time_past());
}

#[test]
fn test_next_median_time_past_edge_cases() {
// Test with minimum required blocks (11)
let blocks: Vec<_> = (0..=10)
.map(|i| (i, BlockWithTime(i, 1000 + i * 100)))
.collect();

let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");

// At height 10: next_mtp uses all 11 blocks (0-10)
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000]
// Median at index 5 = 1500
assert_eq!(cp.next_median_time_past(), Some(1500));

// At height 9: next_mtp uses blocks 0-9 (10 blocks)
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
// Median at index 4 = 1400
assert_eq!(cp.get(9).unwrap().next_median_time_past(), Some(1400));

// Test sparse chain where next_mtp returns None due to missing blocks
let sparse = vec![
(0, BlockWithTime(0, 1000)),
(5, BlockWithTime(5, 1050)),
(10, BlockWithTime(10, 1100)),
];
let sparse_cp = CheckPoint::from_blocks(sparse).expect("must construct valid chain");

// At height 10: next_mtp needs blocks 0-10 but many are missing
assert_eq!(sparse_cp.next_median_time_past(), None);
}

#[test]
fn test_mtp_with_non_monotonic_times() {
// Test both methods with shuffled timestamps
let blocks = vec![
(0, BlockWithTime(0, 1500)),
(1, BlockWithTime(1, 1200)),
(2, BlockWithTime(2, 1800)),
(3, BlockWithTime(3, 1100)),
(4, BlockWithTime(4, 1900)),
(5, BlockWithTime(5, 1300)),
(6, BlockWithTime(6, 1700)),
(7, BlockWithTime(7, 1400)),
(8, BlockWithTime(8, 1600)),
(9, BlockWithTime(9, 1000)),
(10, BlockWithTime(10, 2000)),
(11, BlockWithTime(11, 1050)),
];

let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");

// Height 11:
// median_time_past uses blocks 0-10: sorted
// [1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000] Median at index 5 = 1500
assert_eq!(cp.median_time_past(), Some(1500));

// next_median_time_past uses blocks 1-11: sorted
// [1000,1050,1100,1200,1300,1400,1600,1700,1800,1900,2000] Median at index 5 = 1400
assert_eq!(cp.next_median_time_past(), Some(1400));

// Test with smaller chain to verify sorting at different heights
let cp4 = cp.get(4).unwrap();
// Height 4: previous times [1100, 1800, 1200, 1500] -> sorted [1100, 1200, 1500, 1800]
// Pseudo-median at index 1 = 1200
assert_eq!(cp4.median_time_past(), Some(1200));
}

#[test]
fn test_mtp_sparse_chain_both_methods() {
// Sparse chain missing required sequential blocks
let blocks = vec![
(0, BlockWithTime(0, 1000)),
(3, BlockWithTime(3, 1030)),
(7, BlockWithTime(7, 1070)),
(11, BlockWithTime(11, 1110)),
(15, BlockWithTime(15, 1150)),
];

let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");

// All heights should return None due to missing sequential blocks
assert_eq!(cp.median_time_past(), None);
assert_eq!(cp.next_median_time_past(), None);
assert_eq!(cp.get(11).unwrap().median_time_past(), None);
assert_eq!(cp.get(11).unwrap().next_median_time_past(), None);
}
Loading