From: 志宇 Date: Thu, 10 Sep 2026 10:57:35 +0000 (+0000) Subject: docs(chain): document that MTP needs a contiguous 11-block window X-Git-Tag: pre-fold-backup~2 X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/encode/struct.EncoderStringWriter.html?a=commitdiff_plain;h=d8f625248bfe2b8e48dcc10014bc800db6ebda98;p=bdk docs(chain): document that MTP needs a contiguous 11-block window 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 Claude-Session: https://claude.ai/code/session_01NND8BhEmYTYdLRv4dbUUro --- diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index 2c864ab0..49d5bcac 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -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 diff --git a/crates/chain/tests/test_canonical_view_mtp.rs b/crates/chain/tests/test_canonical_view_mtp.rs index b6e6b499..627739d7 100644 --- a/crates/chain/tests/test_canonical_view_mtp.rs +++ b/crates/chain/tests/test_canonical_view_mtp.rs @@ -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 = { + 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); +}