Skip to content

Commit cee827d

Browse files
committed
feat(core)!: report missing heights from compute_mtp
Change `CheckPoint::compute_mtp` to return `Result<u32, MissingBlocks>` instead of `Option<u32>`. On failure, `MissingBlocks` lists every height in the MTP window (`h-10..=h`) that the chain is missing, enabling callers to drive a fetch-retry loop against their chain source. This is motivated by light-client chain sources such as Electrum, which do not expose MTP values directly. Callers need to know precisely which block headers to fetch; a bare `None` forces either eager full-chain prefetching or blind re-walking.
1 parent d3eeef7 commit cee827d

2 files changed

Lines changed: 68 additions & 28 deletions

File tree

crates/core/src/checkpoint.rs

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,25 @@ impl<D: ToBlockHash> ToBlockHash for WithMtp<D> {
120120
}
121121
}
122122

123+
/// Error returned by [`CheckPoint::compute_mtp`] when one or more required ancestor
124+
/// checkpoints are missing from the chain.
125+
///
126+
/// The `heights` field lists every height in the MTP window (`h-10..=h`) that the caller
127+
/// needs to fetch and insert before `compute_mtp` can succeed.
128+
#[derive(Debug, Clone, PartialEq, Eq)]
129+
pub struct MissingBlocks {
130+
/// Heights whose checkpoints were expected but not found in the chain.
131+
pub heights: Vec<u32>,
132+
}
133+
134+
impl fmt::Display for MissingBlocks {
135+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136+
write!(f, "missing block data for heights {:?}", self.heights)
137+
}
138+
}
139+
140+
impl core::error::Error for MissingBlocks {}
141+
123142
impl<D> PartialEq for CheckPoint<D> {
124143
fn eq(&self, other: &Self) -> bool {
125144
let self_cps = self.iter().map(|cp| cp.block_id());
@@ -257,31 +276,36 @@ where
257276
/// Note: This is a pseudo-median that takes the higher of the two middle values rather than
258277
/// averaging them. This matches the BIP-0113 specification.
259278
///
260-
/// Returns `None` if any of the 11 required ancestor checkpoints are missing from the chain.
279+
/// Returns `Err(MissingBlocks)` listing every missing height when one or more ancestors in
280+
/// the MTP window are not present in the chain. Callers can use this to drive a fetch-retry
281+
/// loop against their chain source.
261282
///
262283
/// If you want a compile-time guarantee that MTP is always available without walking the
263284
/// chain, store it directly on each checkpoint via [`WithMtp`] and use
264285
/// [`CheckPoint::mtp`] instead.
265-
pub fn compute_mtp(&self) -> Option<u32>
286+
pub fn compute_mtp(&self) -> Result<u32, MissingBlocks>
266287
where
267288
D: ToBlockTime,
268289
{
269290
let current_height = self.height();
270291
let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT - 1);
271292

272-
let mut timestamps = (earliest_height..=current_height)
273-
.map(|height| {
274-
// Return `None` for missing blocks or missing block times
275-
let cp = self.get(height)?;
276-
let block_time = cp.data_ref().to_blocktime();
277-
Some(block_time)
278-
})
279-
.collect::<Option<Vec<u32>>>()?;
293+
let mut timestamps = Vec::with_capacity(Self::MTP_BLOCK_COUNT as usize);
294+
let mut missing = Vec::new();
295+
for height in earliest_height..=current_height {
296+
match self.get(height) {
297+
Some(cp) => timestamps.push(cp.data_ref().to_blocktime()),
298+
None => missing.push(height),
299+
}
300+
}
301+
if !missing.is_empty() {
302+
return Err(MissingBlocks { heights: missing });
303+
}
280304
timestamps.sort_unstable();
281305

282306
// If there are more than 1 middle values, use the higher middle value.
283307
// This is mathematically incorrect, but this is the BIP-0113 specification.
284-
Some(timestamps[timestamps.len() / 2])
308+
Ok(timestamps[timestamps.len() / 2])
285309
}
286310

287311
/// Construct from an iterator of block data.

crates/core/tests/test_checkpoint.rs

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use bdk_core::{CheckPoint, ToBlockHash, ToBlockTime, WithMtp};
1+
use bdk_core::{CheckPoint, MissingBlocks, ToBlockHash, ToBlockTime, WithMtp};
22
use bdk_testenv::{block_id, hash};
33
use bitcoin::hashes::Hash;
44
use bitcoin::BlockHash;
@@ -86,19 +86,19 @@ fn test_median_time_past_with_timestamps() {
8686
let cp = CheckPoint::from_blocks(blocks).expect("must construct valid chain");
8787

8888
// Height 11: 11 previous blocks (11..=1), pseudo-median at index 6 = 1060
89-
assert_eq!(cp.compute_mtp(), Some(1060));
89+
assert_eq!(cp.compute_mtp(), Ok(1060));
9090

9191
// Height 10: 11 previous blocks (10..=0), pseudo-median at index 5 = 1050
92-
assert_eq!(cp.get(10).unwrap().compute_mtp(), Some(1050));
92+
assert_eq!(cp.get(10).unwrap().compute_mtp(), Ok(1050));
9393

9494
// Height 5: 6 previous blocks (5..=0), pseudo-median at index 3 = 1030
95-
assert_eq!(cp.get(5).unwrap().compute_mtp(), Some(1030));
95+
assert_eq!(cp.get(5).unwrap().compute_mtp(), Ok(1030));
9696

9797
// Height 3: 4 previous blocks (3..=0), pseudo-median at index 2 = 1020
98-
assert_eq!(cp.get(3).unwrap().compute_mtp(), Some(1020));
98+
assert_eq!(cp.get(3).unwrap().compute_mtp(), Ok(1020));
9999

100100
// Height 0: 1 block at index 0 = 1000
101-
assert_eq!(cp.get(0).unwrap().compute_mtp(), Some(1000));
101+
assert_eq!(cp.get(0).unwrap().compute_mtp(), Ok(1000));
102102
}
103103

104104
#[test]
@@ -113,23 +113,28 @@ fn test_previous_median_time_past_edge_cases() {
113113
// At height 10: next_mtp uses all 11 blocks (0-10)
114114
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000]
115115
// Median at index 5 = 1500
116-
assert_eq!(cp.compute_mtp(), Some(1500));
116+
assert_eq!(cp.compute_mtp(), Ok(1500));
117117

118118
// At height 9: mtp uses blocks 0-9 (10 blocks)
119119
// Times: [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
120120
// Median at index 5 = 1400
121-
assert_eq!(cp.get(9).unwrap().compute_mtp(), Some(1500));
121+
assert_eq!(cp.get(9).unwrap().compute_mtp(), Ok(1500));
122122

123-
// Test sparse chain where next_mtp returns None due to missing blocks
123+
// Test sparse chain where compute_mtp reports the missing heights
124124
let sparse = vec![
125125
(0, BlockWithTime(0, 1000)),
126126
(5, BlockWithTime(5, 1050)),
127127
(10, BlockWithTime(10, 1100)),
128128
];
129129
let sparse_cp = CheckPoint::from_blocks(sparse).expect("must construct valid chain");
130130

131-
// At height 10: next_mtp needs blocks 0-10 but many are missing
132-
assert_eq!(sparse_cp.compute_mtp(), None);
131+
// At height 10: window is 0..=10; only 0, 5, 10 are present.
132+
assert_eq!(
133+
sparse_cp.compute_mtp(),
134+
Err(MissingBlocks {
135+
heights: vec![1, 2, 3, 4, 6, 7, 8, 9],
136+
})
137+
);
133138
}
134139

135140
#[test]
@@ -155,18 +160,18 @@ fn test_mtp_with_non_monotonic_times() {
155160
// Height 10:
156161
// mtp uses blocks 0-10: sorted
157162
// [1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000] Median at index 5 = 1500
158-
assert_eq!(cp.get(10).unwrap().compute_mtp(), Some(1500));
163+
assert_eq!(cp.get(10).unwrap().compute_mtp(), Ok(1500));
159164

160165
// Height 11:
161166
// mtp uses blocks 1-11: sorted
162167
// [1000,1100,1200,1300,1400,1600,1650,1700,1800,1900,2000] Median at index 5 = 1600
163-
assert_eq!(cp.compute_mtp(), Some(1600));
168+
assert_eq!(cp.compute_mtp(), Ok(1600));
164169

165170
// Test with smaller chain to verify sorting at different heights
166171
let cp3 = cp.get(3).unwrap();
167172
// Height 3: timestamps [1100, 1800, 1200, 1500] -> sorted [1100, 1200, 1500, 1800]
168173
// Pseudo-median at index 2 = 1500
169-
assert_eq!(cp3.compute_mtp(), Some(1500));
174+
assert_eq!(cp3.compute_mtp(), Ok(1500));
170175
}
171176

172177
#[test]
@@ -182,9 +187,20 @@ fn test_mtp_sparse_chain() {
182187

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

185-
// All heights should return None due to missing sequential blocks
186-
assert_eq!(cp.compute_mtp(), None);
187-
assert_eq!(cp.get(11).unwrap().compute_mtp(), None);
190+
// Tip is at height 15, window is 5..=15; present in window: 7, 11, 15.
191+
assert_eq!(
192+
cp.compute_mtp(),
193+
Err(MissingBlocks {
194+
heights: vec![5, 6, 8, 9, 10, 12, 13, 14],
195+
})
196+
);
197+
// At height 11 the window is 1..=11; present in window: 3, 7, 11.
198+
assert_eq!(
199+
cp.get(11).unwrap().compute_mtp(),
200+
Err(MissingBlocks {
201+
heights: vec![1, 2, 4, 5, 6, 8, 9, 10],
202+
})
203+
);
188204
}
189205

190206
#[test]

0 commit comments

Comments
 (0)