/// [`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
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);
+}