]> Untitled Git - bdk/commitdiff
docs(chain): document that MTP needs a contiguous 11-block window
author志宇 <hello@evanlinjin.me>
Thu, 10 Sep 2026 10:57:35 +0000 (10:57 +0000)
committer志宇 <hello@evanlinjin.me>
Fri, 11 Sep 2026 02:00:24 +0000 (02:00 +0000)
An MTP at height `h` medians the timestamps at `h-10..=h`, so any height
in that window the driver cannot resolve makes the whole result `None` —
silently, since a gap is indistinguishable from a reorged-out block.

This is the normal case for a `LocalChain` built by syncing: electrum and
esplora insert a handful of checkpoints, not every height, so such a
chain yields `None` for both `prev_mtp` and `tip_mtp` until those clients
fetch the preceding window for each confirmed height.

Document the requirement on `with_mtp` and pin the behaviour with a test
over a chain holding only heights {0, 50, 100}, which also shows that the
gaps do not affect canonicalization itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NND8BhEmYTYdLRv4dbUUro

crates/chain/src/canonical_view_task.rs
crates/chain/tests/test_canonical_view_mtp.rs

index 2c864ab09c8fe9d591112c0e07a21b2ffe3afbb4..49d5bcac3432b75f30ccacef6b75009cf98aff3a 100644 (file)
@@ -116,6 +116,24 @@ impl<'g, A: Anchor, B: ToBlockHash + ToBlockTime> CanonicalViewTask<'g, A, B> {
     /// [`CanonicalTx::prev_mtp`](crate::CanonicalTx::prev_mtp). For the tip it computes
     /// MTP(tip.height) (per BIP-113), accessible via
     /// [`tip_mtp()`](crate::Canonical::tip_mtp).
+    ///
+    /// # `None` means unknown, not zero
+    ///
+    /// An MTP at height `h` is the median of the timestamps at `h-10..=h`, so it is `Some`
+    /// only when the driver resolves all eleven of those heights. A window with any gap in it
+    /// yields `None`, since a partial window has no defined median — and a gap cannot be told
+    /// apart from a block that is genuinely absent.
+    ///
+    /// This is not an error, and a driver is under no obligation to hold every height. A
+    /// [`LocalChain`](crate::local_chain::LocalChain) built by syncing (electrum, esplora)
+    /// keeps a handful of checkpoints rather than every block, so it yields `None` for both
+    /// `prev_mtp` and `tip_mtp` until the sync client fetches the preceding window for each
+    /// confirmed height.
+    ///
+    /// Treat `None` as "MTP unknown here". It is safe for display and for reporting, but a
+    /// caller evaluating a BIP-68 or BIP-113 timelock cannot decide the lock from it — and
+    /// must not read it as "no timelock" or as a timestamp of zero, either of which would
+    /// report an immature coin as spendable.
     pub fn with_mtp(mut self) -> Self {
         self.extract_time = Some(B::to_blocktime);
         self
index b6e6b4999b74325fbee21367ab7c7914d46286db..627739d7f14a50bb9c62d1e3af70232420145a31 100644 (file)
@@ -168,3 +168,33 @@ fn prev_mtp_differs_from_tip_mtp() {
     assert_eq!(view.tip_mtp(), Some(7));
     assert_ne!(canonical_tx.prev_mtp, view.tip_mtp());
 }
+
+#[test]
+fn sparse_chain_yields_no_mtp_but_still_canonicalizes() {
+    // A chain holding only heights {0, 50, 100} — the shape a synced `LocalChain` has, since
+    // electrum/esplora insert a handful of checkpoints rather than every height.
+    let blocks: BTreeMap<u32, Header> = {
+        let genesis = header(BlockHash::all_zeros(), 0);
+        // The heights are not adjacent, so `prev_blockhash` linkage between them is arbitrary;
+        // `LocalChain` only requires that it connects from genesis.
+        let at_50 = header(genesis.block_hash(), 50);
+        let at_100 = header(at_50.block_hash(), 100);
+        [(0, genesis), (50, at_50), (100, at_100)].into()
+    };
+    let chain = LocalChain::from_blocks(blocks).expect("chain connects from genesis");
+    let mut tx_graph = TxGraph::default();
+
+    let txid = confirm_tx_at(&mut tx_graph, &chain, 1, 50);
+
+    let view = chain.canonicalize_with_mtp(&tx_graph, chain.tip().block_id(), Default::default());
+
+    // Canonicalization itself is unaffected by the gaps.
+    let canonical_tx = view.txs().find(|c| c.txid == txid).expect("tx is canonical");
+    assert!(matches!(canonical_tx.pos, ChainPosition::Confirmed { .. }));
+
+    // But MTP needs *every* height in an 11-block window: `prev_mtp` would need 39..=49 and
+    // `tip_mtp` would need 90..=100, none of which this chain holds. Both are silently `None`
+    // rather than an error — a gap is indistinguishable from a reorged-out block.
+    assert_eq!(canonical_tx.prev_mtp, None);
+    assert_eq!(view.tip_mtp(), None);
+}