diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index 7719b87c5f..b8c598e040 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,7 +69,7 @@ fn main() -> anyhow::Result<()> { println!("Local tip: {}", chain.tip().height()); let canonical_view = - chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()); + chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()); let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 81b250909a..55980f0cf2 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -322,7 +322,7 @@ fn get_balance( ) -> anyhow::Result { let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_chain - .canonical_view( + .canonicalize( recv_graph.graph(), recv_chain.tip().block_id(), Default::default(), @@ -331,7 +331,7 @@ fn get_balance( outpoints.into_iter().map(|(_, op)| op), bdk_chain::taints_unowned(&recv_graph.index), |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); Ok(balance) } @@ -642,7 +642,7 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Retrieve the expected unconfirmed txids and spks from the graph. let exp_spk_txids = chain - .canonical_view(graph.graph(), chain_tip, Default::default()) + .canonicalize(graph.graph(), chain_tip, Default::default()) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -658,7 +658,7 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); let canonical_txids = chain - .canonical_view(graph.graph(), chain_tip, Default::default()) + .canonicalize(graph.graph(), chain_tip, Default::default()) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 74e6c05fd9..a1caa813b3 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -94,19 +94,19 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { - let view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(tx_graph.graph(), chain.tip().block_id(), Default::default()); let txs = view.txs(); assert_eq!(txs.count(), exp_txs); } fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { - let view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(tx_graph.graph(), chain.tip().block_id(), Default::default()); let utxos = view.filter_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_txos); } fn run_filter_chain_unspents(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_utxos: usize) { - let view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(tx_graph.graph(), chain.tip().block_id(), Default::default()); let utxos = view.filter_unspent_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_utxos); } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index 27be1bcbcd..3b0faf2251 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -84,12 +84,12 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { // Check balance let op = graph.index.outpoints().clone(); let bal = chain - .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) .balance( op.into_iter().map(|(_, o)| o), bdk_chain::taints_unowned(&graph.index), |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/benches/trust_classification.rs b/crates/chain/benches/trust_classification.rs index e4e4674da6..578097c9d5 100644 --- a/crates/chain/benches/trust_classification.rs +++ b/crates/chain/benches/trust_classification.rs @@ -7,7 +7,8 @@ use std::collections::BTreeMap; use bdk_chain::{ - local_chain::LocalChain, CanonicalView, ChainPosition, ConfirmationBlockTime, TxGraph, + local_chain::LocalChain, CanonicalView, ChainPosition, ConfirmationBlockTime, Timelocks, + TxGraph, }; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, Script, ScriptBuf, Transaction, TxIn, TxOut, Txid}; @@ -23,7 +24,7 @@ fn make_chain() -> LocalChain { // The three predicates `classify_outpoints` takes, faking what a wallet would pass: // `does_taint` = the tx pulls in coins that aren't ours; `is_settled` = the tx is confirmed; -// `is_locked` = the tx has timelocked outputs. +// `timelocks` = no output is timelocked. #[allow(clippy::type_complexity)] fn classify_outpoints_predicates<'a>( view: &'a CanonicalView, @@ -31,7 +32,7 @@ fn classify_outpoints_predicates<'a>( ) -> ( impl FnMut(&bdk_chain::CanonicalTx>) -> bool + 'a, impl Fn(&ChainPosition) -> bool, - impl Fn(&bdk_chain::CanonicalTxOut>) -> bool, + impl Fn(&bdk_chain::CanonicalTxOut>) -> Timelocks, ) { let owned = owned.clone(); let is_mine = move |spk: &Script| spk == owned.as_script(); @@ -50,10 +51,11 @@ fn classify_outpoints_predicates<'a>( let is_settled = |pos: &ChainPosition| matches!(pos, ChainPosition::Confirmed { .. }); - let is_locked = - |_txout: &bdk_chain::CanonicalTxOut>| false; + let timelocks = |_txout: &bdk_chain::CanonicalTxOut>| { + Timelocks::default() + }; - (does_taint, is_settled, is_locked) + (does_taint, is_settled, timelocks) } // Per-UTXO memoized classification (`classify_outpoints`). @@ -63,8 +65,8 @@ fn run_classify( owned: &ScriptBuf, ) { let outpoints = utxo_txids.iter().map(|&txid| OutPoint::new(txid, 0)); - let (does_taint, is_settled, is_locked) = classify_outpoints_predicates(view, owned); - for item in view.classify_outpoints(outpoints, does_taint, is_settled, is_locked) { + let (does_taint, is_settled, timelocks) = classify_outpoints_predicates(view, owned); + for item in view.classify_outpoints(outpoints, does_taint, is_settled, timelocks) { std::hint::black_box(item); } } @@ -420,7 +422,7 @@ fn bench_fan_in(c: &mut Criterion) { for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { let label = format!("{width}w×{depth}d"); let (chain, tx_graph, utxo_txids, owned) = setup_fan_in(width, depth); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { b.iter(|| run_classify(&utxo_txids, &view, &owned)); }); @@ -433,7 +435,7 @@ fn bench_disjoint(c: &mut Criterion) { for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { let label = format!("{width}w×{depth}d"); let (chain, tx_graph, utxo_txids, owned) = setup_disjoint(width, depth); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { b.iter(|| run_classify(&utxo_txids, &view, &owned)); }); @@ -446,7 +448,7 @@ fn bench_untrusted_fan_in(c: &mut Criterion) { for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { let label = format!("{width}w×{depth}d"); let (chain, tx_graph, utxo_txids, owned) = setup_untrusted_fan_in(width, depth); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { b.iter(|| run_classify(&utxo_txids, &view, &owned)); }); @@ -459,7 +461,7 @@ fn bench_diamond(c: &mut Criterion) { for (shared, utxos) in [(5, 20), (10, 50), (20, 100), (50, 500)] { let label = format!("{shared}s×{utxos}u"); let (chain, tx_graph, utxo_txids, owned) = setup_diamond(shared, utxos); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { b.iter(|| run_classify(&utxo_txids, &view, &owned)); }); @@ -472,7 +474,7 @@ fn bench_tainted_midchain(c: &mut Criterion) { for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { let label = format!("{width}w×{depth}d"); let (chain, tx_graph, utxo_txids, owned) = setup_tainted_midchain(width, depth); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { b.iter(|| run_classify(&utxo_txids, &view, &owned)); }); diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 7c60fab82f..21f74b0e75 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -6,15 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, local_chain::LocalChain}; //! # use bdk_core::BlockId; //! # use bitcoin::hashes::Hash; //! # let tx_graph = TxGraph::::default(); //! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); //! let chain_tip = chain.tip().block_id(); //! let params = CanonicalParams::default(); -//! let task = CanonicalTask::new(&tx_graph, chain_tip, params); -//! let view = chain.canonicalize(task); +//! let view = chain.canonicalize(&tx_graph, chain_tip, params); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -23,12 +22,15 @@ //! ``` use crate::collections::{HashMap, HashSet}; +use alloc::collections::BinaryHeap; use alloc::sync::Arc; use alloc::vec::Vec; -use core::{fmt, ops::RangeBounds}; +use core::{cmp::Reverse, fmt, ops::RangeBounds}; -use bdk_core::BlockId; -use bitcoin::{constants::COINBASE_MATURITY, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use bdk_core::{BlockId, BlockQueries}; +use bitcoin::{ + absolute, constants::COINBASE_MATURITY, relative, OutPoint, ScriptBuf, Transaction, TxOut, Txid, +}; use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; @@ -40,14 +42,22 @@ pub enum Eligibility { /// [`classify_outpoints`](CanonicalView::classify_outpoints). Typically confirmed deeply /// enough to be unlikely to be replaced, but the caller decides. Settled, - /// An output not yet spendable by consensus: an immature coinbase output, or one the - /// `is_locked` predicate reports as timelocked. May be unconfirmed; its [`Trust`] is not - /// evaluated. + /// An output not yet spendable by consensus: an immature coinbase output, or one whose + /// [`Timelocks`] are not met. May be unconfirmed; its [`Trust`] is not evaluated. Immature, /// An output not yet settled. Unsettled(Trust), } +/// The timelocks of the spend path the caller will use for an output. A path can have both. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Timelocks { + /// The `nLockTime` the spending transaction needs (BIP-65, e.g. miniscript `after`). + pub absolute: Option, + /// The `nSequence` the spending input needs (BIP-112, e.g. miniscript `older`). + pub relative: Option, +} + /// Describes whether an [`Unsettled`](Eligibility::Unsettled) output is trusted, untrusted, or of /// unknown trust because the `CanonicalView` doesn't have its full ancestry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -60,6 +70,64 @@ pub enum Trust { Unknown, } +/// Internal per-transaction entry in [`Canonical`]. +#[derive(Clone, Debug)] +pub(crate) struct CanonicalEntry

{ + pub(crate) tx: Arc, + pub(crate) pos: P, + pub(crate) prev_mtp: Option, +} + +/// Orders `txs` topologically with Kahn's algorithm: each tx comes after the txs in `txs` it +/// spends. Of the txs whose parents are all placed, the one with the smallest `(key, txid)` comes +/// next. +pub(crate) fn topological_order( + txs: &HashMap>, + key: impl Fn(&CanonicalEntry

) -> K, +) -> Vec { + let mut children = HashMap::>::new(); + // Per tx in `txs`, the number of its inputs spending a tx in `txs`. + let mut parent_counts = HashMap::::new(); + for (&txid, entry) in txs { + let count = parent_counts.entry(txid).or_default(); + for txin in &entry.tx.input { + let parent_txid = txin.previous_output.txid; + if txs.contains_key(&parent_txid) { + *count += 1; + children.entry(parent_txid).or_default().push(txid); + } + } + } + + // A min-heap of the txs whose parents are all placed. It mixes depths on purpose: a tx with a + // smaller key goes before a shallower one it doesn't depend on, but never before its own + // ancestors, as it only enters once they're placed. With the view's `ChainPosition` key, this + // lists every confirmed tx before any unconfirmed one (a confirmed tx's ancestors are + // confirmed too), as a wallet's tx list wants; ordering level by level would not. + let ready_key = |txid: Txid| Reverse((key(&txs[&txid]), txid)); + let mut ready: BinaryHeap<_> = parent_counts + .iter() + .filter(|(_, &count)| count == 0) + .map(|(&txid, _)| ready_key(txid)) + .collect(); + let mut order = Vec::with_capacity(txs.len()); + while let Some(Reverse((_, txid))) = ready.pop() { + order.push(txid); + for &child in children.get(&txid).into_iter().flatten() { + let count = parent_counts + .get_mut(&child) + .expect("child must be counted"); + *count -= 1; + // Only once every parent is placed. + if *count == 0 { + ready.push(ready_key(child)); + } + } + } + debug_assert_eq!(order.len(), txs.len(), "canonical txs must be acyclic"); + order +} + /// A single canonical transaction with its position. /// /// This struct represents a transaction that has been determined to be canonical (not @@ -112,6 +180,25 @@ pub struct CanonicalTxOut

{ pub spent_by: Option<(P, Txid)>, /// Whether this output is on a coinbase transaction. pub is_on_coinbase: bool, + /// The median-time-past of the block *preceding* the confirmation height, if computed. + /// + /// This is the MTP at `confirmation_height - 1` (saturating at genesis), which is the + /// reference value used to evaluate BIP-68 relative timelocks on spends of this output, + /// matching Bitcoin Core's `CalculateSequenceLocks`. It is *not* the MTP at the + /// confirmation height itself. + /// + /// `None` means the value is unknown, which happens whenever any of these hold: + /// + /// - MTP computation was not enabled; see + /// [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp). + /// - The output's transaction is not confirmed. + /// - The output's transaction is confirmed only *transitively*, through a descendant's anchor, + /// so its own confirmation height genuinely isn't known. + /// - The chain could not resolve the full 11-block window the MTP medians over. + /// + /// So a confirmed output can have `None` here even with MTP enabled. Do not read `None` as + /// "MTP was off", and never treat it as an absent or satisfied timelock. + pub prev_mtp: Option, } impl Ord for CanonicalTxOut

{ @@ -162,6 +249,53 @@ impl CanonicalTxOut> { true } + /// Whether `timelocks` are met for a transaction spending this output in the block after + /// `tip`, where `tip_mtp` is the MTP at `tip` (see [`Canonical::tip_mtp`]). + /// + /// This follows consensus (BIP-65, BIP-68, BIP-113). An unconfirmed output is treated as + /// confirming in the block after `tip`, as Bitcoin Core does for mempool coins. A lock that + /// needs an unknown MTP (`None` [`prev_mtp`](Self::prev_mtp) or `tip_mtp`) counts as unmet. + /// + /// If the position is confirmed [`transitively`](ChainPosition::Confirmed::transitively), the + /// confirmation height may be an upper bound (see [`confirmation_height_upper_bound`]), so + /// this method may return false-negatives. + /// + /// [`confirmation_height_upper_bound`]: ChainPosition::confirmation_height_upper_bound + pub fn are_timelocks_met(&self, timelocks: Timelocks, tip: u32, tip_mtp: Option) -> bool { + let next_height = tip.saturating_add(1); + let absolute_met = match timelocks.absolute { + None => true, + Some(absolute::LockTime::Blocks(h)) => h.to_consensus_u32() < next_height, + Some(absolute::LockTime::Seconds(t)) => { + tip_mtp.is_some_and(|mtp| t.to_consensus_u32() < mtp) + } + }; + let relative_met = match timelocks.relative { + None => true, + Some(relative::LockTime::Blocks(n)) => { + let conf_height = self + .pos + .confirmation_height_upper_bound() + .unwrap_or(next_height); + conf_height.saturating_add(n.value().into()) <= next_height + } + Some(relative::LockTime::Time(n)) => { + let coin_mtp = if self.pos.is_confirmed() { + self.prev_mtp + } else { + tip_mtp + }; + match (coin_mtp, tip_mtp) { + (Some(coin_mtp), Some(tip_mtp)) => { + u64::from(coin_mtp) + u64::from(n.value()) * 512 <= u64::from(tip_mtp) + } + _ => false, + } + } + }; + absolute_met && relative_met + } + /// Whether the utxo is/was/will be spendable with chain `tip`. /// /// This method does not take into account the lock time. @@ -211,21 +345,23 @@ impl CanonicalTxOut> { /// [`CanonicalTxs`]) /// /// The view maintains: -/// - A list of canonical transactions +/// - A list of canonical transactions in topological order /// - A mapping of outpoints to the transactions that spend them /// - The chain tip used for canonicalization /// /// [`TxGraph`]: crate::TxGraph #[derive(Debug)] pub struct Canonical { - /// List of canonical transaction IDs. + /// Canonical transaction IDs in topological order. pub(crate) order: Vec, - /// Map of transaction IDs to their transaction data and position. - pub(crate) txs: HashMap, P)>, + /// Map of transaction IDs to their transaction data, position, and MTP. + pub(crate) txs: HashMap>, /// Map of outpoints to the transaction ID that spends them. pub(crate) spends: HashMap, /// The chain tip at the time this view was created. pub(crate) tip: BlockId, + /// Median-time-past at the chain tip height. + pub(crate) tip_mtp: Option, /// Marker for the anchor type. pub(crate) _anchor: core::marker::PhantomData, } @@ -247,14 +383,16 @@ impl Canonical { pub(crate) fn new( tip: BlockId, order: Vec, - txs: HashMap, P)>, + txs: HashMap>, spends: HashMap, + tip_mtp: Option, ) -> Self { Self { tip, order, txs, spends, + tip_mtp, _anchor: core::marker::PhantomData, } } @@ -264,15 +402,24 @@ impl Canonical { self.tip } + /// Get the MTP at the chain tip height. + /// + /// Returns `None` if MTP was not computed. + pub fn tip_mtp(&self) -> Option { + self.tip_mtp + } + /// Get a single canonical transaction by its transaction ID. /// /// Returns `Some(CanonicalTx)` if the transaction exists in the canonical set, /// or `None` if the transaction doesn't exist or was excluded due to conflicts. pub fn tx(&self, txid: Txid) -> Option> { - self.txs - .get(&txid) - .cloned() - .map(|(tx, pos)| CanonicalTx { pos, txid, tx }) + let entry = self.txs.get(&txid)?; + Some(CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + }) } /// Get a single canonical transaction output. @@ -285,38 +432,43 @@ impl Canonical { /// - The output index is out of bounds /// - The transaction was excluded due to conflicts pub fn txout(&self, op: OutPoint) -> Option> { - let (tx, pos) = self.txs.get(&op.txid)?; + let entry = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; - let txout = tx.output.get(vout)?; + let txout = entry.tx.output.get(vout)?; let spent_by = self.spends.get(&op).map(|spent_by_txid| { - let (_, spent_by_pos) = &self.txs[spent_by_txid]; - (spent_by_pos.clone(), *spent_by_txid) + let spent_by_entry = &self.txs[spent_by_txid]; + (spent_by_entry.pos.clone(), *spent_by_txid) }); Some(CanonicalTxOut { - pos: pos.clone(), + pos: entry.pos.clone(), outpoint: op, txout: txout.clone(), spent_by, - is_on_coinbase: tx.is_coinbase(), + is_on_coinbase: entry.tx.is_coinbase(), + prev_mtp: entry.prev_mtp, }) } - /// Get an iterator over all canonical transactions in order. + /// Get an iterator over all canonical transactions in topological order. + /// + /// Every transaction comes after the transactions it spends. Otherwise, a [`CanonicalView`] + /// orders transactions by [`ChainPosition`] (confirmed by anchor, then unconfirmed by first + /// seen), then by txid; [`CanonicalTxs`] orders them by txid. /// - /// Transactions are returned in canonical order, with confirmed transactions ordered by - /// block height and position, followed by unconfirmed transactions. + /// So a [`CanonicalView`] lists every confirmed transaction before any unconfirmed one: a + /// confirmed transaction's canonical ancestors are confirmed too (canonicalization confirms + /// them transitively), so no unconfirmed transaction ever has to come first. /// /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonicalize(&tx_graph, chain_tip, Default::default()); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -327,8 +479,12 @@ impl Canonical { /// ``` pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { self.order.iter().map(|&txid| { - let (tx, pos) = self.txs[&txid].clone(); - CanonicalTx { pos, txid, tx } + let entry = &self.txs[&txid]; + CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + } }) } @@ -344,14 +500,13 @@ impl Canonical { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonicalize(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -375,14 +530,13 @@ impl Canonical { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonicalize(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -428,7 +582,7 @@ impl Canonical { impl CanonicalView { /// Classify each of the given `outpoints` by its [spend eligibility](Eligibility). /// This is the primitive behind [`balance`](Self::balance). - /// + /// /// Callers that need richer handling (coin selection, coin control, or /// wallet-specific categories) can fold over this instead of `balance`. /// @@ -444,18 +598,20 @@ impl CanonicalView { /// [`taints_unowned`] to classify everything foreign as untrusted. /// * `is_settled` - Returns `true` for the [position](ChainPosition) of a transaction we /// consider settled (unlikely to be replaced), for example one with enough confirmations. - /// * `is_locked` - Returns `true` for a txout whose timelock is not met yet, classifying it as - /// [`Immature`](Eligibility::Immature). For an output confirmed - /// [`transitively`](ChainPosition::Confirmed::transitively), the confirmation height is only - /// an upper bound, so a relative timelock may look unmet when it is already met. + /// * `timelocks` - Returns the [`Timelocks`] of the spend path the caller will use for a txout. + /// A txout whose timelocks are not met (see + /// [`are_timelocks_met`](CanonicalTxOut::are_timelocks_met)) is + /// [`Immature`](Eligibility::Immature). Time-based locks need MTP, so on a view without it + /// (see [`CanonicalViewTask::with_mtp`]) they are never met. pub fn classify_outpoints<'a>( &'a self, outpoints: impl IntoIterator + 'a, mut does_taint: impl FnMut(&CanonicalTx>) -> bool + 'a, is_settled: impl Fn(&ChainPosition) -> bool + 'a, - is_locked: impl Fn(&CanonicalTxOut>) -> bool + 'a, + timelocks: impl Fn(&CanonicalTxOut>) -> Timelocks + 'a, ) -> impl Iterator>, Eligibility)> + 'a { let tip = self.tip.height; + let tip_mtp = self.tip_mtp; // Shared across outpoints so an ancestor reached by several of them is only walked once. let mut cache = HashMap::::new(); outpoints @@ -463,7 +619,9 @@ impl CanonicalView { .filter_map(move |op| self.txout(op)) .filter(|txo| txo.spent_by.is_none()) .map(move |txout| { - let eligibility = if !txout.is_mature(tip) || is_locked(&txout) { + let eligibility = if !txout.is_mature(tip) + || !txout.are_timelocks_met(timelocks(&txout), tip, tip_mtp) + { Eligibility::Immature } else if is_settled(&txout.pos) { Eligibility::Settled @@ -581,7 +739,7 @@ impl CanonicalView { /// This is a fold over [`classify_outpoints`](Self::classify_outpoints): each output's value is /// added to the bucket matching its [`Eligibility`]. /// - /// See `classify_outpoints` for `does_taint` and `is_settled` meaning. + /// See `classify_outpoints` for `does_taint`, `is_settled` and `timelocks` meaning. /// /// The position of an output confirmed [`transitively`](ChainPosition::Confirmed::transitively) /// carries a descendant's anchor, so its confirmation height may be an upper bound (see @@ -600,7 +758,7 @@ impl CanonicalView { /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); + /// # let view = chain.canonicalize(&tx_graph, chain_tip, CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// let tip_height = view.tip().height; /// // Calculate balance requiring 6 confirmations. @@ -611,7 +769,7 @@ impl CanonicalView { /// pos.confirmation_height_upper_bound() /// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6) /// }, - /// |_txout| false, + /// |_txout| Default::default(), /// ); /// ``` pub fn balance( @@ -619,9 +777,9 @@ impl CanonicalView { outpoints: impl IntoIterator, does_taint: impl FnMut(&CanonicalTx>) -> bool, is_settled: impl Fn(&ChainPosition) -> bool, - is_locked: impl Fn(&CanonicalTxOut>) -> bool, + timelocks: impl Fn(&CanonicalTxOut>) -> Timelocks, ) -> Balance { - self.classify_outpoints(outpoints, does_taint, is_settled, is_locked) + self.classify_outpoints(outpoints, does_taint, is_settled, timelocks) .collect() } } @@ -664,13 +822,27 @@ where } impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`](crate::CanonicalReason)s - /// into [`ChainPosition`]s. - /// - /// This is the second phase of the canonicalization pipeline. The resulting task - /// queries the chain to verify anchors for transitively anchored transactions and - /// produces a [`CanonicalView`] with resolved chain positions. - pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { - CanonicalViewTask::new(tx_graph, self.tip, self.order, self.txs, self.spends) + /// Creates a [`CanonicalViewTask`] that resolves + /// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. Blocks resolved during + /// phase 1 are reused without redundant queries; requests still outstanding in `queries` are + /// dropped and made again if needed. If `queries` is for a different tip than this set, its + /// blocks are not reused at all. + /// + /// To also compute median-time-past (MTP) values for the resulting view, call + /// [`CanonicalViewTask::with_mtp`] on the returned task. + pub fn view_task<'g, B>( + self, + tx_graph: &'g TxGraph, + queries: BlockQueries, + ) -> CanonicalViewTask<'g, A, B> { + // Blocks fetched for another tip may not be in this tip's chain. + let queries = if queries.tip() == self.tip { + queries + } else { + BlockQueries::new(self.tip) + }; + CanonicalViewTask::new(tx_graph, self.order, self.txs, self.spends, queries) } } diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 693a3229bb..900175b4ce 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,3 +1,4 @@ +use crate::canonical::{topological_order, CanonicalEntry}; use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; use crate::{Anchor, CanonicalTxs, TxGraph}; @@ -5,10 +6,12 @@ use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bdk_core::{ + BlockCandidateResolution, BlockId, BlockQueries, ChainTask, TaskProgress, ToBlockHash, +}; use bitcoin::{Transaction, Txid}; -type CanonicalMap = HashMap, CanonicalReason)>; +type CanonicalMap = HashMap>>; type NotCanonicalSet = HashSet; /// Represents the current stage of canonicalization processing. @@ -28,7 +31,7 @@ enum CanonicalStage { } impl CanonicalStage { - fn advance(&mut self) { + fn next_stage(&mut self) { *self = match self { CanonicalStage::AssumedTxs => Self::AnchoredTxs, CanonicalStage::AnchoredTxs => Self::SeenTxs, @@ -56,9 +59,10 @@ pub struct CanonicalParams { /// (via [`CanonicalReason`](crate::CanonicalReason)). The output is a [`CanonicalTxs`] which can /// then be further processed by [`CanonicalViewTask`](crate::CanonicalViewTask) to resolve reasons /// into [`ChainPosition`](crate::ChainPosition)s. -pub struct CanonicalTask<'g, A> { +pub struct CanonicalTask<'g, A, B> { tx_graph: &'g TxGraph, - chain_tip: BlockId, + + queries: BlockQueries, unprocessed_assumed_txs: Box)> + 'g>, unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, @@ -68,160 +72,156 @@ pub struct CanonicalTask<'g, A> { canonical: CanonicalMap, not_canonical: NotCanonicalSet, - // Store canonical transactions in order - canonical_order: Vec, - // Track the current stage of processing current_stage: CanonicalStage, } -impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { - type Output = CanonicalTxs; +impl<'g, A: Anchor, B: ToBlockHash> ChainTask for CanonicalTask<'g, A, B> { + type Output = (CanonicalTxs, BlockQueries); fn tip(&self) -> BlockId { - self.chain_tip + self.queries.tip() } - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - CanonicalStage::AssumedTxs => { - if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - continue; - } - } - CanonicalStage::AnchoredTxs => { - if let Some((_txid, _, anchors)) = self.unprocessed_anchored_txs.front() { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } - CanonicalStage::SeenTxs => { - if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { - // A coinbase tx cannot be in the mempool, so a `last_seen` value on one is - // not evidence of it being canonical. - if !self.is_canonicalized(txid) && !tx.is_coinbase() { - let observed_in = ObservedIn::Mempool(last_seen); - self.mark_canonical( - txid, - tx, - CanonicalReason::from_observed_in(observed_in), - ); - } - continue; - } - } - CanonicalStage::LeftOverTxs => { - if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { - let is_evicted = self - .tx_graph - .get_tx_node(txid) - .expect("leftover transaction must exist") - .is_evicted(); - if !self.is_canonicalized(txid) && !tx.is_coinbase() && !is_evicted { - let observed_in = ObservedIn::Block(height); - self.mark_canonical( - txid, - tx, - CanonicalReason::from_observed_in(observed_in), - ); - } - continue; - } - } - CanonicalStage::Finished => return None, - } - - self.current_stage.advance(); - } + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() } - fn resolve_query(&mut self, response: ChainResponse) { + fn poll(&mut self) -> TaskProgress { match self.current_stage { + CanonicalStage::AssumedTxs => { + if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { + if !self.is_canonicalized(txid) { + self.mark_canonical(txid, tx, CanonicalReason::assumed()); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } CanonicalStage::AnchoredTxs => { - // Process directly anchored transaction response + // We query block data lazily, one frontier tx at a time, rather than batching all + // anchor heights up front. This is deliberate: anchored txs are processed + // latest-first, and marking one canonical sweeps all its ancestors transitively + // (see `mark_canonical`) without any further queries. So the number of queries is + // bounded by the *frontier* of each confirmed subgraph, not the total tx count. + // Eager batching would over-query heights for ancestors that transitivity covers + // for free. + // + // The trade-off: a driver where each query is a network round-trip (e.g. a future + // Bitcoin Core RPC driver) sees one serial round-trip per frontier tx, instead of + // a hashmap lookup. Batching these is tracked in #2403. if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.pop_front() { - // Find the anchor that matches the confirmed BlockId - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - match best_anchor { - Some(best_anchor) => { - // Transaction has a confirmed anchor - if !self.is_canonicalized(txid) { - self.mark_canonical( - txid, - tx, - CanonicalReason::from_anchor(best_anchor), - ); - } + if self.is_canonicalized(txid) { + return TaskProgress::Advanced; + } + + match self + .queries + .resolve_candidates(anchors.iter().map(|a| (a, a.anchor_block()))) + { + BlockCandidateResolution::Confirmed(a) => { + self.mark_canonical(txid, tx, CanonicalReason::from_anchor(a.clone())); + } + BlockCandidateResolution::Query(heights) => { + self.unprocessed_anchored_txs + .push_front((txid, tx, anchors)); + return TaskProgress::Query(heights); + } + BlockCandidateResolution::Awaiting => { + self.unprocessed_anchored_txs + .push_front((txid, tx, anchors)); + return TaskProgress::Blocked; } - None => { - // No confirmed anchor found, add to leftover transactions for later - // processing + BlockCandidateResolution::NotConfirmed => { + // No anchor confirms this tx; defer to the leftover stage. self.unprocessed_leftover_txs.push_back(( txid, tx, anchors .iter() .last() - .expect( - "tx taken from `unprocessed_anchored_txs` so it must have at least one anchor", - ) + .expect("must have at least one anchor") .anchor_block() .height, - )) + )); } } + } else { + self.current_stage.next_stage(); } + TaskProgress::Advanced } - CanonicalStage::AssumedTxs - | CanonicalStage::SeenTxs - | CanonicalStage::LeftOverTxs - | CanonicalStage::Finished => { - // These stages don't generate queries and shouldn't receive responses - debug_assert!( - false, - "resolve_query called for stage {:?} which doesn't generate queries", - self.current_stage - ); + CanonicalStage::SeenTxs => { + if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { + // A coinbase tx cannot be in the mempool, so a `last_seen` value on one is + // not evidence of it being canonical. + if !self.is_canonicalized(txid) && !tx.is_coinbase() { + let observed_in = ObservedIn::Mempool(last_seen); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced } + CanonicalStage::LeftOverTxs => { + if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { + let is_evicted = self + .tx_graph + .get_tx_node(txid) + .expect("leftover transaction must exist") + .is_evicted(); + if !self.is_canonicalized(txid) && !tx.is_coinbase() && !is_evicted { + let observed_in = ObservedIn::Block(height); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::Finished => TaskProgress::Done, } } + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + fn finish(self) -> Self::Output { - let mut view_order = Vec::new(); - let mut view_txs = HashMap::new(); let mut view_spends = HashMap::new(); - for txid in &self.canonical_order { - if let Some((tx, reason)) = self.canonical.get(txid) { - view_order.push(*txid); - - // Add spends - if !tx.is_coinbase() { - for input in &tx.input { - view_spends.insert(input.previous_output, *txid); - } + for (txid, entry) in &self.canonical { + if !entry.tx.is_coinbase() { + for input in &entry.tx.input { + view_spends.insert(input.previous_output, *txid); } - - view_txs.insert(*txid, (tx.clone(), reason.clone())); } } - CanonicalTxs::new(self.chain_tip, view_order, view_txs, view_spends) + ( + CanonicalTxs::new( + self.queries.tip(), + topological_order(&self.canonical, |_| ()), + self.canonical, + view_spends, + None, + ), + self.queries, + ) } } -impl<'g, A: Anchor> CanonicalTask<'g, A> { +impl<'g, A: Anchor, B: ToBlockHash> CanonicalTask<'g, A, B> { /// Creates a new canonicalization task. pub fn new(tx_graph: &'g TxGraph, chain_tip: BlockId, params: CanonicalParams) -> Self { let anchors = tx_graph.all_anchors(); @@ -244,7 +244,6 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { Self { tx_graph, - chain_tip, unprocessed_assumed_txs, unprocessed_anchored_txs, @@ -254,8 +253,9 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { canonical: HashMap::new(), not_canonical: HashSet::new(), - canonical_order: Vec::new(), current_stage: CanonicalStage::default(), + + queries: BlockQueries::new(chain_tip), } } @@ -271,7 +271,7 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { // `tx` double spends itself. let mut detected_self_double_spend = false; let mut undo_not_canonical = Vec::::new(); - let mut staged_canonical = Vec::<(Txid, Arc, CanonicalReason)>::new(); + let mut undo_canonical = Vec::::new(); // Process ancestors TxAncestors::new_include_root( @@ -320,8 +320,12 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { return None; } - staged_canonical.push((this_txid, tx.clone(), this_reason.clone())); - canonical_entry.insert((tx.clone(), this_reason)); + undo_canonical.push(this_txid); + canonical_entry.insert(CanonicalEntry { + tx: tx.clone(), + pos: this_reason, + prev_mtp: None, + }); Some(this_txid) }, ) @@ -329,18 +333,12 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { if detected_self_double_spend { // Undo changes - for (txid, _, _) in staged_canonical { + for txid in undo_canonical { self.canonical.remove(&txid); } for txid in undo_not_canonical { self.not_canonical.remove(&txid); } - return; - } - - // Add to canonical order - for (txid, _, _) in &staged_canonical { - self.canonical_order.push(*txid); } } } @@ -490,9 +488,13 @@ mod tests { // Create canonicalization task and canonicalize using the two-step pipeline let params = CanonicalParams::default(); let task = CanonicalTask::new(&tx_graph, chain_tip, params); - let canonical_txs = chain.canonicalize(task); - let view_task = canonical_txs.view_task(&tx_graph); - let canonical_view = chain.canonicalize(view_task); + let output = chain.run_task(task); + // `CanonicalTask::Output` is `(CanonicalTxs, BlockQueries)`. Both halves must be + // `Debug` or callers cannot `dbg!()` it, or `.unwrap()` it out of a `Result`. + let _ = alloc::format!("{output:?}"); + let (txs, queries) = output; + let view_task = txs.view_task(&tx_graph, queries); + let canonical_view = chain.run_task(view_task); // Should have one canonical transaction assert_eq!(canonical_view.txs().len(), 1); diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index d981bcef44..7760f353ab 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -4,20 +4,25 @@ use crate::canonical_task::{CanonicalReason, ObservedIn}; use crate::collections::{HashMap, VecDeque}; use crate::tx_graph::TxDescendants; use alloc::collections::BTreeSet; -use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; -use bitcoin::{OutPoint, Transaction, Txid}; +use bdk_core::{ + median_time_past, BlockCandidateResolution, BlockId, BlockQueries, ChainTask, TaskProgress, + ToBlockHash, ToBlockTime, +}; +use bitcoin::{OutPoint, Txid}; +use crate::canonical::{topological_order, CanonicalEntry}; use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; /// Represents the current stage of view task processing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ViewStage { - /// Processing transactions to resolve their chain positions. + /// Verifying anchors for transitively anchored transactions. #[default] ResolvingPositions, + /// Fetching blocks needed for MTP computation. + FetchingMtpBlocks, /// All processing is complete. Finished, } @@ -29,195 +34,341 @@ enum ViewStage { /// reason into a concrete [`ChainPosition`] (confirmed or unconfirmed). For transitively /// anchored transactions, it queries the chain to check if they have their own direct /// anchors. -pub struct CanonicalViewTask<'g, A> { +/// +/// When `with_mtp()` is called, this task also computes median-time-past (MTP) values +/// for confirmed heights and stores them in the resulting [`CanonicalView`]. +pub struct CanonicalViewTask<'g, A, B> { tx_graph: &'g TxGraph, - tip: BlockId, - /// Transactions in canonical order with their reasons. + queries: BlockQueries, + canonical_order: Vec, - canonical_txs: HashMap, CanonicalReason)>, + canonical_txs: HashMap>>, spends: HashMap, - - /// Transactions that need anchor verification (transitively anchored). unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, - - /// Resolved direct anchors for transitively anchored transactions. direct_anchors: HashMap, + // MTP support — `extract_time` being `Some` means MTP is enabled. + extract_time: Option u32>, + current_stage: ViewStage, } -impl<'g, A: Anchor> CanonicalViewTask<'g, A> { +impl<'g, A: Anchor, B> CanonicalViewTask<'g, A, B> { /// Creates a new [`CanonicalViewTask`]. /// - /// Accepts canonical transaction data and a reference to the [`TxGraph`]. - /// Scans transactions to find those needing anchor verification. - pub fn new( + /// Accepts canonical transaction data, a reference to the [`TxGraph`], and blocks + /// already fetched during phase 1 (to avoid redundant queries). + pub(crate) fn new( tx_graph: &'g TxGraph, - tip: BlockId, order: Vec, - txs: HashMap, CanonicalReason)>, + txs: HashMap>>, spends: HashMap, + mut queries: BlockQueries, ) -> Self { + // Reuse only resolved blocks. A height phase 1 left in flight is requested again if + // needed, so this task only ever waits on heights it announced itself. + queries.clear_pending(); let all_anchors = tx_graph.all_anchors(); let mut unprocessed_anchor_checks = VecDeque::new(); + let mut direct_anchors = HashMap::new(); for txid in &order { - if let Some((_, reason)) = txs.get(txid) { - if matches!(reason, CanonicalReason::ObservedIn { .. }) { + // `CanonicalTask::finish` builds `order` from the keys of `txs`. + let entry = match txs.get(txid) { + Some(entry) => entry, + None => { + debug_assert!(false, "every txid in `order` must have an entry in `txs`"); continue; } - if reason.is_transitive() || reason.is_assumed() { + }; + match &entry.pos { + CanonicalReason::Anchor { + anchor, + descendant: None, + } => { + // Non-transitive anchor — already resolved. + direct_anchors.insert(*txid, anchor.clone()); + } + CanonicalReason::Anchor { .. } | CanonicalReason::Assumed { .. } => { + // Transitive or assumed — needs anchor verification. The tx may have no + // anchors of its own: a transitive reason carries the *descendant's* anchor, + // and an assumed one needs no chain evidence at all. if let Some(anchors) = all_anchors.get(txid) { unprocessed_anchor_checks.push_back((*txid, anchors)); } } + CanonicalReason::ObservedIn { .. } => {} } } Self { tx_graph, - tip, + queries, canonical_order: order, canonical_txs: txs, spends, unprocessed_anchor_checks, - direct_anchors: HashMap::new(), + direct_anchors, + extract_time: None, current_stage: ViewStage::default(), } } } -impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { +impl<'g, A: Anchor, B: ToBlockHash + ToBlockTime> CanonicalViewTask<'g, A, B> { + /// Enable MTP (median-time-past) computation. + /// + /// When enabled, the task will fetch additional blocks needed to compute MTP values. + /// For each confirmed tx it computes the MTP of the block *preceding* the confirmation + /// height (per BIP-68, matching Bitcoin Core's `CalculateSequenceLocks`), exposed on + /// [`CanonicalTxOut::prev_mtp`](crate::CanonicalTxOut::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 + } +} + +impl<'g, A: Anchor, B: ToBlockHash> ChainTask for CanonicalViewTask<'g, A, B> { type Output = CanonicalView; fn tip(&self) -> BlockId { - self.tip + self.queries.tip() } - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } - ViewStage::Finished => return None, - } - - self.current_stage = ViewStage::Finished; - } + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() } - fn resolve_query(&mut self, response: ChainResponse) { + fn poll(&mut self) -> TaskProgress { match self.current_stage { ViewStage::ResolvingPositions => { if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - if let Some(best_anchor) = best_anchor { - self.direct_anchors.insert(txid, best_anchor); + match self + .queries + .resolve_candidates(anchors.iter().map(|a| (a, a.anchor_block()))) + { + BlockCandidateResolution::Confirmed(anchor) => { + self.direct_anchors.insert(txid, anchor.clone()); + } + BlockCandidateResolution::Query(heights) => { + self.unprocessed_anchor_checks.push_front((txid, anchors)); + return TaskProgress::Query(heights); + } + BlockCandidateResolution::Awaiting => { + self.unprocessed_anchor_checks.push_front((txid, anchors)); + return TaskProgress::Blocked; + } + // No anchor confirms this tx; leave it without a direct anchor. + BlockCandidateResolution::NotConfirmed => {} } + TaskProgress::Advanced + } else { + self.current_stage = ViewStage::FetchingMtpBlocks; + TaskProgress::Advanced } } - ViewStage::Finished => { - debug_assert!(false, "resolve_query called in Finished stage"); + ViewStage::FetchingMtpBlocks => { + if self.extract_time.is_none() { + self.current_stage = ViewStage::Finished; + return TaskProgress::Advanced; + } + + // Collect all MTP heights needed (an 11-block window per target height). + // + // For the tip we need MTP(tip.height) (BIP-113). For each confirmed tx we need + // the MTP of the block *preceding* the confirmation height (BIP-68), so we take + // `confirmation_height - 1` (saturating at genesis) — the same value read in + // `finish`. + // + // A `BTreeSet` rather than a `Vec`: the windows overlap heavily (two txs in one + // block give identical windows, adjacent blocks share 10 of 11 heights), and this + // is rebuilt on every poll of this stage. It also makes the emitted `Query` + // ascending and deterministic — `direct_anchors` is a `HashMap`, so iterating its + // values would otherwise vary the height order between runs. + let required: BTreeSet = core::iter::once(self.queries.tip().height) + .chain( + self.direct_anchors + .values() + .map(|a| a.anchor_block().height.saturating_sub(1)), + ) + .flat_map(|h| h.saturating_sub(10)..=h) + .collect(); + + // `request` is additive: a non-empty result is new work to fetch. + let needed = self.queries.request(required.iter().copied()); + if !needed.is_empty() { + return TaskProgress::Query(needed); + } + + // No new heights. If any required height is still in-flight, wait for the driver; + // otherwise every MTP block is resolved and we can finish. + if required.iter().any(|h| self.queries.get(*h).is_none()) { + return TaskProgress::Blocked; + } + + self.current_stage = ViewStage::Finished; + TaskProgress::Advanced } + ViewStage::Finished => TaskProgress::Done, } } + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + fn finish(self) -> Self::Output { - let mut view_order = Vec::new(); - let mut view_txs = HashMap::new(); + // Helper: compute MTP for a given height from the blocks fetched into `queries`. + // Returns `None` when MTP was not enabled, so callers do not each repeat that check. + let try_compute_mtp_at = |h: u32| -> Option { + let extract_time = self.extract_time?; + median_time_past(h, |mtp_h| { + self.queries + .get(mtp_h) + .and_then(|b| b.as_ref()) + .map(extract_time) + }) + }; + + // Compute tip MTP. + let tip_mtp = try_compute_mtp_at(self.queries.tip().height); + + let mut view_txs = HashMap::>::new(); for txid in &self.canonical_order { - if let Some((tx, reason)) = self.canonical_txs.get(txid) { - view_order.push(*txid); - - // Get transaction node for first_seen/last_seen info - let tx_node = match self.tx_graph.get_tx_node(*txid) { - Some(tx_node) => tx_node, - None => { - debug_assert!(false, "tx node must exist!"); - continue; - } + let (tx, reason) = match self.canonical_txs.get(txid) { + Some(CanonicalEntry { tx, pos, .. }) => (tx, pos), + None => { + debug_assert!(false, "missing entry in `canonical_txs`"); + continue; + } + }; + + // Get transaction node for first_seen/last_seen info. + let tx_node = match self.tx_graph.get_tx_node(*txid) { + Some(tx_node) => tx_node, + None => { + debug_assert!(false, "tx node must exist!"); + continue; + } + }; + + // Handle directly anchored txs first. + if let Some(anchor) = self.direct_anchors.get(txid) { + let tx = tx.clone(); + let pos = ChainPosition::Confirmed { + anchor: anchor.clone(), + transitively: None, }; + // BIP-68 evaluates relative timelocks against the MTP of the block *before* the + // coin's height (Core's `std::max(nCoinHeight - 1, 0)`), so compute MTP at + // `confirmation_height - 1`, saturating to genesis for coins at height 0. + let prev_mtp = try_compute_mtp_at(anchor.anchor_block().height.saturating_sub(1)); + view_txs.insert(*txid, CanonicalEntry { tx, pos, prev_mtp }); + continue; + } - // Determine chain position based on reason - let chain_position = match reason { - CanonicalReason::Assumed { .. } => { - match self.direct_anchors.get(txid) { - // it has a direct anchor found - // regardless if it's directly or transitively assumed canonical - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => TxDescendants::new_exclude_root( - self.tx_graph, - *txid, - // ensure descendant is canonical - |_, desc_txid| -> Option { - self.canonical_txs - .contains_key(&desc_txid) - .then_some(desc_txid) - }, - ) - // ensure descendant has direct anchor - .find_map(|desc_txid| { - self.direct_anchors.get(&desc_txid).map(|anchor| { - ChainPosition::Confirmed { - anchor, - transitively: Some(desc_txid), - } - }) - }) - .unwrap_or(ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }), - } - } - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match self.direct_anchors.get(txid) { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: *descendant, - }, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - }, - CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { - ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: Some(*last_seen), - }, - ObservedIn::Block(_) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: None, + let pos = match reason { + // Since the canonicalization algorithm processes assumed txs first (and therefore + // all their ancestors), some assumed txs may actually be confirmed in the best + // chain. Therefore we need to check all assumed txs if themselves, or any + // descendant of theirs, is anchored in the best chain. + CanonicalReason::Assumed { .. } => { + TxDescendants::new_include_root( + self.tx_graph, + *txid, + // Only explore canonical descendants. + |_, desc_txid| -> Option { + self.canonical_txs + .contains_key(&desc_txid) + .then_some(desc_txid) }, + ) + // Find the earliest instance of an anchored tx. + .find_map(|desc_txid| { + self.direct_anchors + .get(&desc_txid) + .map(|anchor| ChainPosition::Confirmed { + anchor: anchor.clone(), + transitively: Some(desc_txid), + }) + }) + .unwrap_or(ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }) + } + CanonicalReason::Anchor { anchor, descendant } => ChainPosition::Confirmed { + anchor: anchor.clone(), + transitively: *descendant, + }, + CanonicalReason::ObservedIn { observed_in, .. } => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + // `ObservedIn::Block` usually means no mempool sighting, but not always: + // `txids_by_descending_last_seen` filters out evicted txs, so an evicted tx + // never reaches the mempool stage and can arrive here transitively, as the + // ancestor of a stale-anchored tx, while genuinely having a `last_seen`. + // Report it — `last_seen` is documented as `None` only when the tx was never + // seen in the mempool, and `first_seen` above is passed through on the same + // terms. + last_seen: match observed_in { + ObservedIn::Block(_) => tx_node.last_seen, + // For a transitively-marked ancestor this is the *descendant's* + // sighting, which is only a lower bound: a child cannot sit in the + // mempool without its parent. Take whichever is later, so a direct + // observation of this tx is never discarded in favour of an inferred + // one. `None` sorts below `Some`, so this is `seen_at` when the tx has + // no sighting of its own. Ordinarily they agree — the mempool stage runs + // newest-first, so a sweep only reaches an ancestor whose own sighting is + // older — but evicted txs are filtered out of that stage. + ObservedIn::Mempool(seen_at) => Some(*seen_at).max(tx_node.last_seen), }, - }; - - view_txs.insert(*txid, (tx.clone(), chain_position.cloned())); - } + }, + }; + view_txs.insert( + *txid, + CanonicalEntry { + tx: tx.clone(), + pos, + // No MTP: this stage handles txs without a direct anchor, so even when the + // position is `Confirmed` (transitively, via a descendant's anchor) this + // tx's own confirmation height is unknown and there is no block to median + // around. + prev_mtp: None, + }, + ); } - CanonicalView::new(self.tip, view_order, view_txs, self.spends) + // Topological, otherwise by position: confirmed by anchor, then unconfirmed by first seen. + let view_order = topological_order(&view_txs, |entry| entry.pos.clone()); + + CanonicalView::new( + self.queries.tip(), + view_order, + view_txs, + self.spends, + tip_mtp, + ) } } diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 9f61788df7..4b468c6ffe 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -449,13 +449,11 @@ where /// that can be used to determine which transactions are canonical based on the provided /// parameters. The task handles the stateless canonicalization logic and can be polled /// for anchor verification requests. - /// - /// [`CanonicalView`]: crate::CanonicalView - pub fn canonical_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, params: CanonicalParams, - ) -> CanonicalTask<'_, A> { + ) -> CanonicalTask<'_, A, B> { self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 901f279172..2bd88493f7 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -4,8 +4,8 @@ use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, Merge, TxGraph}; -use bdk_core::{ChainQuery, ToBlockHash}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalTask, CanonicalView, Merge, TxGraph}; +use bdk_core::{ChainTask, TaskProgress, ToBlockHash, ToBlockTime}; pub use bdk_core::{CheckPoint, CheckPointEntry, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -86,28 +86,6 @@ impl PartialEq for LocalChain { // Methods for `LocalChain` impl LocalChain { - /// Check if a block is in the chain. - /// - /// # Arguments - /// * `block` - The block to check - /// * `chain_tip` - The chain tip to check against - /// - /// # Returns - /// * `Some(true)` if the block is in the chain - /// * `Some(false)` if the block is not in the chain - /// * `None` if it cannot be determined - pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { - let chain_tip_cp = match self.tip.get(chain_tip.height) { - // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` can - // be identified in chain - Some(cp) if cp.hash() == chain_tip.hash => cp, - _ => return None, - }; - chain_tip_cp - .get(block.height) - .map(|cp| cp.hash() == block.hash) - } - /// Get the chain tip. /// /// # Returns @@ -116,58 +94,6 @@ impl LocalChain { self.tip.block_id() } - /// Canonicalize a transaction graph using this chain. - /// - /// This method processes any type implementing [`ChainQuery`], handling all its requests - /// to determine which transactions are canonical, and returns the query's output. - /// - /// # Example - /// - /// ``` - /// # use bdk_chain::{CanonicalTask, CanonicalParams, TxGraph, local_chain::LocalChain}; - /// # use bdk_core::BlockId; - /// # use bitcoin::hashes::Hash; - /// # let tx_graph: TxGraph = TxGraph::default(); - /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// let chain_tip = chain.tip().block_id(); - /// let task = CanonicalTask::new(&tx_graph, chain_tip, CanonicalParams::default()); - /// let view = chain.canonicalize(task); - /// ``` - pub fn canonicalize(&self, mut task: Q) -> Q::Output - where - Q: ChainQuery, - { - let chain_tip = task.tip(); - while let Some(request) = task.next_query() { - let mut best_block_id = None; - for block_id in &request { - if self.is_block_in_chain(*block_id, chain_tip) == Some(true) { - best_block_id = Some(*block_id); - break; - } - } - task.resolve_query(best_block_id); - } - task.finish() - } - - /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. - /// - /// This is equivalent to: - /// ```ignore - /// let canonical_txs = chain.canonicalize(tx_graph.canonical_task(tip, params)); - /// let view = chain.canonicalize(canonical_txs.view_task(tx_graph)); - /// ``` - pub fn canonical_view( - &self, - tx_graph: &TxGraph, - tip: BlockId, - params: CanonicalParams, - ) -> CanonicalView { - let canonical_txs = self.canonicalize(tx_graph.canonical_task(tip, params)); - self.canonicalize(canonical_txs.view_task(tx_graph)) - } - /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// @@ -486,6 +412,110 @@ where Ok(changeset) } + /// Check if a block is in the chain. + /// + /// # Returns + /// * `Some(true)` if the block is in the chain + /// * `Some(false)` if the block is not in the chain + /// * `None` if it cannot be determined + pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { + let chain_tip_cp = match self.tip.get(chain_tip.height) { + Some(cp) if cp.hash() == chain_tip.hash => cp, + _ => return None, + }; + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) + } + + /// Drive a [`ChainTask`] to completion against this chain. + /// + /// This method processes any type implementing [`ChainTask`], handling all its requests + /// against this chain and returning the task's output. The chain responds with its data + /// type `D`, so `LocalChain

` responds with headers and `LocalChain` + /// responds with hashes. + /// + /// # Panics + /// + /// If the task returns [`TaskProgress::Blocked`]. This driver resolves every height a task + /// announces via [`TaskProgress::Query`] before polling it again, so a task that follows the + /// [`ChainTask`] contract never has anything outstanding here and never blocks. Reaching + /// this means the task is waiting on heights it never announced. + pub fn run_task(&self, mut task: T) -> T::Output + where + T: ChainTask, + D: Clone, + { + loop { + match task.poll() { + TaskProgress::Advanced => continue, + TaskProgress::Done => return task.finish(), + TaskProgress::Query(heights) => { + debug_assert!(!heights.is_empty(), "TaskProgress::Query must not be empty"); + for height in heights { + let data = self + .get(height) + .filter(|cp| { + let chain_tip = task.tip(); + self.is_block_in_chain(cp.block_id(), chain_tip) == Some(true) + }) + .map(|cp| cp.data()); + task.resolve_query(height, data); + } + } + // Every `Query` height is resolved before the next poll, so a task that announces + // what it needs never has anything outstanding here — `Blocked` means it is + // waiting on heights it never put in a `Query`. + // + // Serving that anyway (by draining `unresolved_queries()`) would be the wrong + // kindness: it hides the bug behind the one driver everyone tests against, while a + // streaming driver — which launches fetches only for announced heights — waits + // forever on I/O that was never started. Fail here, where it is cheap to find. + TaskProgress::Blocked => { + panic!( + "ChainTask returned Blocked to a synchronous driver: it is waiting on \ + heights it never announced via TaskProgress::Query" + ) + } + } + } + } + + /// Canonicalize a transaction graph against this chain and return a [`CanonicalView`]. + /// + /// This is equivalent to: + /// ```ignore + /// let (txs, queries) = chain.run_task(CanonicalTask::new(&tx_graph, tip, params)); + /// let view = chain.run_task(txs.view_task(tx_graph, queries)); + /// ``` + pub fn canonicalize( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView { + let (txs, queries) = self.run_task(CanonicalTask::new(tx_graph, tip, params)); + self.run_task(txs.view_task(tx_graph, queries)) + } + + /// Like [`canonicalize`](Self::canonicalize), but also computes median-time-past (MTP) + /// values. Requires `D: ToBlockTime` (e.g. `LocalChain
`). + /// + /// See [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp) for what is + /// computed and the block data it requires. + pub fn canonicalize_with_mtp( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView + where + D: ToBlockTime, + { + let (txs, queries) = self.run_task(CanonicalTask::new(tx_graph, tip, params)); + self.run_task(txs.view_task(tx_graph, queries).with_mtp()) + } + fn _check_changeset_is_applied(&self, changeset: &ChangeSet) -> bool { let mut cur = self.tip.clone(); for (&exp_height, exp_data) in changeset.blocks.iter().rev() { diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 3c6ab69e59..8211226829 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -135,8 +135,8 @@ use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; +use bdk_core::{ConfirmationBlockTime, ToBlockHash}; use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; use core::ops::{Deref, RangeInclusive}; @@ -996,11 +996,11 @@ impl TxGraph { /// for anchor verification requests. /// /// [`CanonicalView`]: crate::CanonicalView - pub fn canonical_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, params: CanonicalParams, - ) -> CanonicalTask<'_, A> { + ) -> CanonicalTask<'_, A, B> { CanonicalTask::new(self, chain_tip, params) } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index c40c034aad..278349dd20 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -4,10 +4,13 @@ use std::collections::BTreeMap; use bdk_chain::{ local_chain::LocalChain, BlockId, CanonicalTxOut, ChainPosition, ConfirmationBlockTime, - Eligibility, Trust, TxGraph, + Eligibility, Timelocks, Trust, TxGraph, }; use bdk_testenv::{hash, utils::new_tx}; -use bitcoin::{key::Secp256k1, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use bitcoin::{ + absolute, key::Secp256k1, relative, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, + TxOut, +}; use miniscript::Descriptor; /// Builds an `is_settled` predicate requiring at least `min_confirmations` confirmations. @@ -90,8 +93,7 @@ fn test_is_settled_boundary() { }; let _ = tx_graph.insert_anchor(txid, anchor_height_5); - let canonical_view = - chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let canonical_view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let tip_height = canonical_view.tip().height; // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) @@ -99,7 +101,7 @@ fn test_is_settled_boundary() { [outpoint], |_tx| false, // leave trust to ancestry settled(tip_height, 1), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000)); @@ -110,7 +112,7 @@ fn test_is_settled_boundary() { [outpoint], |_tx| false, // leave trust to ancestry settled(tip_height, 6), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000)); assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO); @@ -120,7 +122,7 @@ fn test_is_settled_boundary() { [outpoint], |_tx| false, // leave trust to ancestry settled(tip_height, 7), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance_7_conf.confirmed, Amount::ZERO); assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000)); @@ -195,8 +197,7 @@ fn test_min_confirmations_with_untrusted_tx() { }; let _ = tx_graph.insert_anchor(txid, anchor); - let canonical_view = - chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let canonical_view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let tip_height = canonical_view.tip().height; // Test with min_confirmations = 5 and everything tainted @@ -204,7 +205,7 @@ fn test_min_confirmations_with_untrusted_tx() { [outpoint], |_tx| true, // taint everything settled(tip_height, 5), - |_txout| false, + |_txout| Default::default(), ); // Should be untrusted pending (not enough confirmations and not trusted) @@ -217,7 +218,7 @@ fn test_min_confirmations_with_untrusted_tx() { [outpoint], |_tx| false, // leave trust to ancestry settled(tip_height, 5), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance.trusted_pending, Amount::from_sat(25_000)); assert_eq!(balance.untrusted_pending, Amount::ZERO); @@ -358,8 +359,7 @@ fn test_min_confirmations_multiple_transactions() { ); outpoints.push(outpoint2); - let canonical_view = - chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let canonical_view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let tip_height = canonical_view.tip().height; // Test with min_confirmations = 5 @@ -370,7 +370,7 @@ fn test_min_confirmations_multiple_transactions() { outpoints.clone(), |_tx| false, settled(tip_height, 5), - |_txout| false, + |_txout| Default::default(), ); assert_eq!( @@ -391,7 +391,7 @@ fn test_min_confirmations_multiple_transactions() { outpoints, |_tx| false, settled(tip_height, 10), - |_txout| false, + |_txout| Default::default(), ); assert_eq!( @@ -496,7 +496,7 @@ fn test_balance_taint_propagates_through_unconfirmed_ancestry() { .into_iter() .collect::>(); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); // Our unspent owned outputs: `trusted` and `chained` (the others are spent). let utxos = [ @@ -514,7 +514,7 @@ fn test_balance_taint_propagates_through_unconfirmed_ancestry() { .any(|txin| !owned.contains(&txin.previous_output)) }, |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance.confirmed, Amount::ZERO); @@ -549,11 +549,16 @@ fn test_balance_is_settled_is_authoritative_for_unconfirmed() { let _ = tx_graph.insert_tx(tx); let _ = tx_graph.insert_seen_at(txid, 1000); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); // An `is_settled` that claims everything is settled counts the (mature, non-coinbase) // unconfirmed output as settled rather than dropping it, even when it is tainted. - let balance = view.balance([OutPoint::new(txid, 0)], |_| true, |_| true, |_txout| false); + let balance = view.balance( + [OutPoint::new(txid, 0)], + |_| true, + |_| true, + |_txout| Default::default(), + ); assert_eq!(balance.confirmed, Amount::from_sat(50_000)); assert_eq!(balance.immature, Amount::ZERO); assert_eq!(balance.trusted_pending, Amount::ZERO); @@ -621,7 +626,7 @@ fn test_balance_taint_stops_at_settled_ancestor() { .into_iter() .collect::>(); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); // `child` is the only UTXO (`settled_foreign:0` is spent by it). let by_op = view @@ -634,7 +639,7 @@ fn test_balance_taint_stops_at_settled_ancestor() { .any(|txin| !owned.contains(&txin.previous_output)) }, |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ) .map(|(txout, eligibility)| (txout.outpoint, eligibility)) .collect::>(); @@ -696,14 +701,19 @@ fn test_classify_immature_and_settled() { ); } - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let ops = [ OutPoint::new(coinbase_txid, 0), OutPoint::new(normal_txid, 0), ]; let by_op = view - .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false) + .classify_outpoints( + ops, + |_| false, + |pos| pos.is_confirmed(), + |_txout| Default::default(), + ) .map(|(txout, eligibility)| (txout.outpoint, eligibility)) .collect::>(); assert_eq!( @@ -713,7 +723,12 @@ fn test_classify_immature_and_settled() { assert_eq!(by_op[&OutPoint::new(normal_txid, 0)], Eligibility::Settled); // The balance buckets reflect the same classification. - let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_txout| false); + let balance = view.balance( + ops, + |_| false, + |pos| pos.is_confirmed(), + |_txout| Default::default(), + ); assert_eq!(balance.immature, Amount::from_sat(50_000)); assert_eq!(balance.confirmed, Amount::from_sat(30_000)); } @@ -790,7 +805,7 @@ fn test_balance_taint_shared_ancestor() { .into_iter() .collect::>(); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); // Both children descend from the same tainting `foreign`, so both are untrusted. let balance = view.balance( @@ -805,7 +820,7 @@ fn test_balance_taint_shared_ancestor() { .any(|txin| !owned.contains(&txin.previous_output)) }, |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); assert_eq!(balance.trusted_pending, Amount::ZERO); assert_eq!(balance.untrusted_pending, Amount::from_sat(29_000 + 19_000)); @@ -850,7 +865,7 @@ fn test_classify_skips_spent_and_unknown() { let _ = tx_graph.insert_seen_at(tx.compute_txid(), 1000); } - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let classified = view .classify_outpoints( @@ -861,7 +876,7 @@ fn test_classify_skips_spent_and_unknown() { ], |_| false, |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ) .collect::>(); @@ -898,7 +913,7 @@ fn test_immature_coinbase_stays_immature_when_unsettled() { }, ); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let tip_height = view.tip().height; // Require 3 confirmations to be settled. @@ -907,7 +922,7 @@ fn test_immature_coinbase_stays_immature_when_unsettled() { [OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3), - |_txout| false, + |_txout| Default::default(), ) .map(|(txout, e)| (txout.outpoint, e)) .collect::>(); @@ -945,7 +960,7 @@ fn test_mature_coinbase_is_settled_not_immature() { }, ); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let tip_height = view.tip().height; let by_op = view @@ -953,7 +968,7 @@ fn test_mature_coinbase_is_settled_not_immature() { [OutPoint::new(txid, 0)], |_| false, settled(tip_height, 3), - |_txout| false, + |_txout| Default::default(), ) .map(|(txout, e)| (txout.outpoint, e)) .collect::>(); @@ -1002,14 +1017,14 @@ fn test_unsettled_unknown_when_parent_root_missing() { } // `missing_root` is not inserted. - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let by_op = view .classify_outpoints( [OutPoint::new(child2_txid, 0)], |_| false, |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ) .map(|(txout, e)| (txout.outpoint, e)) .collect::>(); @@ -1057,14 +1072,14 @@ fn test_evicted_stale_anchored_tx_not_canonical() { let _ = tx_graph.insert_seen_at(txid, 100); let _ = tx_graph.insert_evicted_at(txid, 200); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); assert!( !view.txs().any(|tx| tx.txid == txid), "evicted leftover tx must not be canonical" ); } -/// A settled output for which `is_locked` returns true is classified `Immature` and counted in +/// A settled output whose `Timelocks` are unmet is classified `Immature` and counted in /// `Balance::immature` instead of `confirmed`. #[test] fn test_classify_locked() { @@ -1095,30 +1110,39 @@ fn test_classify_locked() { }, ); - let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); let ops = [OutPoint::new(txid, 0)]; - // Timelock unmet + // Timelock unmet: the spend needs height 2, the tip is at 1. + let locked = |_: &CanonicalTxOut<_>| Timelocks { + absolute: Some(absolute::LockTime::from_consensus(2)), + relative: None, + }; let (_, eligibility) = view - .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), |_| true) + .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed(), locked) .next() .unwrap(); assert_eq!(eligibility, Eligibility::Immature); - let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| true); + let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), locked); assert_eq!(balance.immature, Amount::from_sat(40_000)); assert_eq!(balance.confirmed, Amount::ZERO); - // Timelock met - let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed(), |_| false); + // No timelock + let balance = view.balance( + ops, + |_| false, + |pos| pos.is_confirmed(), + |_| Timelocks::default(), + ); assert_eq!(balance.confirmed, Amount::from_sat(40_000)); assert_eq!(balance.immature, Amount::ZERO); } -/// `is_locked` gets what a wallet needs to evaluate its descriptors' timelocks: the spk, and the -/// position to count confirmations. Both outputs are `Immature` while unconfirmed (here also -/// untrusted). Once confirmed, the `older(100)` one is `Immature` until its 100th confirmation, -/// and the `after(200)` one until the tip reaches 200. +/// `timelocks` gets what a wallet needs to look up its descriptors' timelocks: the spk. Both +/// outputs are `Immature` while unconfirmed (here also untrusted). Once confirmed, the +/// `older(100)` one is `Immature` until its 100th confirmation, and the `after(200)` one until the +/// tip reaches 200. #[test] fn test_classify_locked_descriptor_timelocks() { let key = "tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/0/*"; @@ -1128,10 +1152,12 @@ fn test_classify_locked_descriptor_timelocks() { desc.at_derivation_index(0).unwrap().script_pubkey() }; let (csv_spk, cltv_spk) = (spk("older(100)"), spk("after(200)")); - let is_locked = |tip: u32, txout: &CanonicalTxOut>| { + let timelocks = |txout: &CanonicalTxOut>| { let spk = &txout.txout.script_pubkey; - (*spk == csv_spk && txout.pos.confirmations_lower_bound(tip) < 100) - || (*spk == cltv_spk && tip < 200) + Timelocks { + absolute: (*spk == cltv_spk).then(|| absolute::LockTime::from_consensus(200)), + relative: (*spk == csv_spk).then(|| relative::LockTime::from_height(100)), + } }; let mut chain = @@ -1159,17 +1185,11 @@ fn test_classify_locked_descriptor_timelocks() { let _ = tx_graph.insert_seen_at(txid, 100); let classify = |chain: &LocalChain, tx_graph: &TxGraph| { - let view = chain.canonical_view(tx_graph, chain.tip().block_id(), Default::default()); - let tip = view.tip().height; + let view = chain.canonicalize(tx_graph, chain.tip().block_id(), Default::default()); let ops = [OutPoint::new(txid, 0), OutPoint::new(txid, 1)]; - view.classify_outpoints( - ops, - |_| true, - |pos| pos.is_confirmed(), - |o| is_locked(tip, o), - ) - .map(|(_, eligibility)| eligibility) - .collect::>() + view.classify_outpoints(ops, |_| true, |pos| pos.is_confirmed(), timelocks) + .map(|(_, eligibility)| eligibility) + .collect::>() }; assert_eq!(classify(&chain, &tx_graph), [Eligibility::Immature; 2]); @@ -1188,3 +1208,257 @@ fn test_classify_locked_descriptor_timelocks() { assert_eq!(classify(&chain, &tx_graph), expected, "tip {tip}"); } } + +/// `are_timelocks_met` follows consensus for each kind of lock, one step either side of the +/// boundary, and counts a lock that needs an unknown MTP as unmet. +#[test] +fn test_are_timelocks_met_boundaries() { + // Above `LOCKTIME_THRESHOLD`, so an absolute lock this large is a time. + const MTP: u32 = 600_000_000; + let tip = 100; + + let txout = |pos, prev_mtp| CanonicalTxOut { + pos, + outpoint: OutPoint::null(), + txout: TxOut::NULL, + spent_by: None, + is_on_coinbase: false, + prev_mtp, + }; + let confirmed_at_90 = ChainPosition::Confirmed { + anchor: ConfirmationBlockTime { + block_id: BlockId { + height: 90, + hash: hash!("90"), + }, + confirmation_time: 0, + }, + transitively: None, + }; + let unconfirmed = ChainPosition::Unconfirmed { + first_seen: Some(1), + last_seen: Some(1), + }; + // Confirmed at 90, with the block before it 1024s (two intervals) older than the tip's MTP. + let confirmed = txout(confirmed_at_90, Some(MTP - 1024)); + let no_prev_mtp = txout(confirmed_at_90, None); + let mempool = txout(unconfirmed, None); + + let abs = |n| Timelocks { + absolute: Some(absolute::LockTime::from_consensus(n)), + relative: None, + }; + let blocks = |n| Timelocks { + absolute: None, + relative: Some(relative::LockTime::from_height(n)), + }; + let time = |n| Timelocks { + absolute: None, + relative: Some(relative::LockTime::from_512_second_intervals(n)), + }; + + let cases = [ + // (name, txout, timelocks, tip_mtp, expected) + ("no lock", &mempool, Timelocks::default(), None, true), + // Spendable in block 101, so a lock height of 100 is met (BIP-65). + ("abs height met", &confirmed, abs(100), None, true), + ("abs height unmet", &confirmed, abs(101), None, false), + // Met once the lock time is below the tip's MTP (BIP-113). + ("abs time met", &confirmed, abs(MTP - 1), Some(MTP), true), + ("abs time unmet", &confirmed, abs(MTP), Some(MTP), false), + ("abs time, no mtp", &confirmed, abs(MTP - 1), None, false), + // Confirmed at 90, spendable in block 101: 101 - 90 = 11 blocks (BIP-68). + ("rel blocks met", &confirmed, blocks(11), None, true), + ("rel blocks unmet", &confirmed, blocks(12), None, false), + // An unconfirmed output confirms in block 101 at the earliest. + ("rel blocks 0, mempool", &mempool, blocks(0), None, true), + ("rel blocks 1, mempool", &mempool, blocks(1), None, false), + // `prev_mtp` is 1024s (two intervals) below the tip's MTP. + ("rel time met", &confirmed, time(2), Some(MTP), true), + ("rel time unmet", &confirmed, time(3), Some(MTP), false), + ("rel time, no tip mtp", &confirmed, time(0), None, false), + ("no prev mtp", &no_prev_mtp, time(0), Some(MTP), false), + ("rel time 0, mempool", &mempool, time(0), Some(MTP), true), + ( + "both, one unmet", + &confirmed, + Timelocks { + absolute: Some(absolute::LockTime::from_consensus(100)), + relative: Some(relative::LockTime::from_height(12)), + }, + None, + false, + ), + ]; + for (name, txout, timelocks, tip_mtp, expected) in cases { + assert_eq!( + txout.are_timelocks_met(timelocks, tip, tip_mtp), + expected, + "{name}" + ); + } +} + +/// An evicted transaction swept transitively as the ancestor of a stale-anchored transaction must +/// still report the mempool sighting it genuinely has. +/// +/// `ChainPosition::Unconfirmed::last_seen` is documented as `None` only when the transaction was +/// never seen in the mempool *and* only seen in a conflicting chain. An evicted transaction fails +/// the first condition (it has a real `last_seen`), but +/// `TxGraph::txids_by_descending_last_seen` filters evicted transactions out, so it never reaches +/// the mempool stage of canonicalization and instead arrives via `ObservedIn::Block`. Forcing +/// `None` there would report `first_seen: Some(_), last_seen: None`, i.e. "first seen at t, never +/// seen", since `first_seen` is passed through unconditionally. +#[test] +fn test_evicted_ancestor_of_stale_anchored_tx_keeps_last_seen() { + let blocks: BTreeMap = + [(0, hash!("genesis")), (1, hash!("b1")), (2, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + + let mut tx_graph = TxGraph::default(); + + // Parent: seen in the mempool at 100, then evicted at 200. + let parent = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("grandparent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let parent_txid = parent.compute_txid(); + let _ = tx_graph.insert_tx(parent.clone()); + let _ = tx_graph.insert_seen_at(parent_txid, 100); + let _ = tx_graph.insert_evicted_at(parent_txid, 200); + + // Child: spends the parent and is anchored only in a block that is not in the best chain, so + // it is canonicalized in the leftover stage as `ObservedIn::Block`. It has no mempool + // sighting of its own, so it is not evicted and the leftover stage does not skip it. + let child = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(parent_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(2) + }; + let child_txid = child.compute_txid(); + let _ = tx_graph.insert_tx(child); + let _ = tx_graph.insert_anchor( + child_txid, + ConfirmationBlockTime { + block_id: BlockId { + height: 1, + hash: hash!("stale"), + }, + confirmation_time: 123456, + }, + ); + + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); + + let parent_pos = view + .txs() + .find(|c| c.txid == parent_txid) + .expect("parent is swept canonical as an ancestor of the child") + .pos; + + match parent_pos { + ChainPosition::Unconfirmed { + first_seen, + last_seen, + } => { + assert_eq!(first_seen, Some(100), "first_seen is passed through"); + assert_eq!( + last_seen, + Some(100), + "an evicted tx was seen in the mempool, so its last_seen must be reported" + ); + } + pos => panic!("parent must be unconfirmed, got {pos:?}"), + } +} + +/// A transitively-marked ancestor must not lose a later mempool sighting of its own. +/// +/// `ObservedIn::Mempool` carries the *descendant's* `last_seen`, which is only an inference: a +/// child cannot sit in the mempool without its parent. `TxGraph::txids_by_descending_last_seen` +/// iterates newest-first and `mark_canonical` skips anything already canonical, so a sweep +/// normally only reaches an ancestor whose own sighting is older. Evicted transactions are +/// filtered out of that iteration, though, so an evicted ancestor can be swept with a timestamp +/// older than the one we directly observed for it. +#[test] +fn test_evicted_ancestor_keeps_its_own_later_last_seen() { + let blocks: BTreeMap = [(0, hash!("genesis")), (1, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + + let mut tx_graph = TxGraph::::default(); + + // Parent: directly observed in the mempool at 300, then evicted at 400. The eviction keeps + // it out of the mempool stage, so it is only ever marked canonical transitively. + let parent = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("grandparent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let parent_txid = parent.compute_txid(); + let _ = tx_graph.insert_tx(parent); + let _ = tx_graph.insert_seen_at(parent_txid, 300); + let _ = tx_graph.insert_evicted_at(parent_txid, 400); + + // Child: spends the parent, seen at 200 — *earlier* than the parent's own sighting. + let child = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(parent_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(2) + }; + let child_txid = child.compute_txid(); + let _ = tx_graph.insert_tx(child); + let _ = tx_graph.insert_seen_at(child_txid, 200); + + let view = chain.canonicalize(&tx_graph, chain.tip().block_id(), Default::default()); + + let parent_pos = view + .txs() + .find(|c| c.txid == parent_txid) + .expect("parent is swept canonical as an ancestor of the child") + .pos; + + match parent_pos { + ChainPosition::Unconfirmed { + first_seen, + last_seen, + } => { + assert_eq!(first_seen, Some(300)); + assert_eq!( + last_seen, + Some(300), + "the parent's own sighting is later than the child's, so it must win; \ + reporting the child's 200 would also put last_seen before first_seen" + ); + } + pos => panic!("parent must be unconfirmed, got {pos:?}"), + } +} diff --git a/crates/chain/tests/test_canonical_view_mtp.rs b/crates/chain/tests/test_canonical_view_mtp.rs new file mode 100644 index 0000000000..84c83d6f1a --- /dev/null +++ b/crates/chain/tests/test_canonical_view_mtp.rs @@ -0,0 +1,251 @@ +#![cfg(feature = "miniscript")] + +//! Integration test for median-time-past (MTP) computation via +//! [`LocalChain::canonicalize_with_mtp`]. + +use std::collections::BTreeMap; + +use bdk_chain::{ + local_chain::LocalChain, BlockQueries, CanonicalTask, ChainPosition, ConfirmationBlockTime, + TxGraph, +}; +use bdk_testenv::{hash, utils::new_tx}; +use bitcoin::{ + block::Header, hashes::Hash, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, +}; + +/// Build a block header with an explicit `time`. +fn header(prev_blockhash: BlockHash, time: u32) -> Header { + Header { + version: bitcoin::block::Version::default(), + prev_blockhash, + merkle_root: bitcoin::hash_types::TxMerkleNode::all_zeros(), + time, + bits: bitcoin::CompactTarget::default(), + nonce: 0, + } +} + +/// Build a connected chain of blocks `0..=tip_height` where each block's timestamp equals its +/// height (the block at height `h` has `time == h`). +/// +/// With timestamps equal to heights, the median-time-past at height `h` (the median of the 11 +/// timestamps in `h-10..=h`) is simply the middle height, `h - 5`. +fn chain_with_time_equal_to_height(tip_height: u32) -> LocalChain
{ + let mut headers = vec![header(BlockHash::all_zeros(), 0)]; // genesis at height 0 + for h in 1..=tip_height { + let prev = headers[(h - 1) as usize].block_hash(); + headers.push(header(prev, h)); + } + let blocks: BTreeMap = headers + .into_iter() + .enumerate() + .map(|(height, hdr)| (height as u32, hdr)) + .collect(); + LocalChain::from_blocks(blocks).expect("chain connects from genesis") +} + +/// Insert a transaction into `tx_graph` spending a distinct parent (identified by `seed`) and +/// anchor it at `height`. +/// +/// Returns the txid of the inserted transaction. +fn confirm_tx_at( + tx_graph: &mut TxGraph, + chain: &LocalChain
, + seed: u8, + height: u32, +) -> bitcoin::Txid { + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(bitcoin::Txid::from_byte_array([seed; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(height).unwrap().block_id(), + confirmation_time: 0, + }, + ); + txid +} + +#[test] +fn canonicalize_with_mtp_computes_median_time_past() { + // A 13-block chain (heights 0..=12) where each block's time equals its height. + let chain = chain_with_time_equal_to_height(12); + + // A parent with no anchor and no `seen_at`, and a child spending it confirmed at height 11. + // The parent is canonical only *transitively*, through the child's anchor. + let mut tx_graph = TxGraph::default(); + let parent = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("grandparent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(60_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(0) + }; + let parent_txid = parent.compute_txid(); + let _ = tx_graph.insert_tx(parent); + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(parent_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(11).unwrap().block_id(), + confirmation_time: 0, + }, + ); + + // Canonicalize with MTP enabled. + let view = chain.canonicalize_with_mtp(&tx_graph, chain.tip().block_id(), Default::default()); + + // Tip is at height 12, so its MTP is the median of heights 2..=12, which is 7. + assert_eq!(view.tip_mtp(), Some(7)); + + // The transaction is confirmed at height 11. Its output's `prev_mtp` is the MTP of the + // block *preceding* the confirmation height (BIP-68), i.e. MTP at height 10 = median of + // heights 0..=10 == 5. + let canonical_tx = view + .txs() + .find(|c| c.txid == txid) + .expect("tx is canonical"); + assert!(matches!(canonical_tx.pos, ChainPosition::Confirmed { .. })); + let txo = view.txout(OutPoint::new(txid, 0)).expect("output exists"); + assert_eq!(txo.prev_mtp, Some(5)); + + // The parent's own confirmation height is unknown, so its output has no `prev_mtp`, even + // though MTP is on and the chain holds the full window (the child's `Some(5)` shows both). + let parent_tx = view + .txs() + .find(|c| c.txid == parent_txid) + .expect("parent is canonical"); + assert!(matches!( + parent_tx.pos, + ChainPosition::Confirmed { + transitively: Some(_), + .. + } + )); + let parent_txo = view + .txout(OutPoint::new(parent_txid, 0)) + .expect("output exists"); + assert_eq!(parent_txo.prev_mtp, None); +} + +#[test] +fn prev_mtp_saturates_for_low_confirmation_heights() { + // A 13-block chain (heights 0..=12) where each block's time equals its height. + let chain = chain_with_time_equal_to_height(12); + let mut tx_graph = TxGraph::default(); + + // Confirmed at genesis (height 0): `confirmation_height - 1` saturates to 0, matching + // Core's `std::max(nCoinHeight - 1, 0)`. prev_mtp = MTP(0) = median of heights 0..=0 == 0. + let genesis_txid = confirm_tx_at(&mut tx_graph, &chain, 1, 0); + + // Confirmed at height 3: prev_mtp = MTP(2) = median of heights 0..=2 == 1. The MTP window + // start (`2 - 10`) also saturates to 0. + let low_txid = confirm_tx_at(&mut tx_graph, &chain, 2, 3); + + let view = chain.canonicalize_with_mtp(&tx_graph, chain.tip().block_id(), Default::default()); + + let genesis_txo = view + .txout(OutPoint::new(genesis_txid, 0)) + .expect("output exists"); + assert_eq!(genesis_txo.prev_mtp, Some(0)); + + let low_txo = view + .txout(OutPoint::new(low_txid, 0)) + .expect("output exists"); + assert_eq!(low_txo.prev_mtp, Some(1)); +} + +#[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. + let txo = view.txout(OutPoint::new(txid, 0)).expect("output exists"); + assert_eq!(txo.prev_mtp, None); + assert_eq!(view.tip_mtp(), None); +} + +/// Phase 2 drops requests still outstanding in the `BlockQueries` it is handed (e.g. left by a +/// phase 1 run on another driver), and requests them again itself. Otherwise it would wait on a +/// height `run_task` never saw in a `Query`, and `run_task` would panic on `Blocked`. +#[test] +fn view_task_requests_again_heights_left_pending() { + let chain = chain_with_time_equal_to_height(12); + let tx_graph = TxGraph::::default(); + let tip = chain.tip().block_id(); + + let (txs, _) = chain.run_task(CanonicalTask::new(&tx_graph, tip, Default::default())); + let mut queries = BlockQueries::new(tip); + let _ = queries.request([tip.height].into_iter()); + + let view = chain.run_task(txs.view_task(&tx_graph, queries).with_mtp()); + assert_eq!(view.tip_mtp(), Some(7)); +} + +/// Phase 2 does not reuse blocks fetched for a different tip, since they may not be in its chain. +#[test] +fn view_task_ignores_queries_for_another_tip() { + let chain = chain_with_time_equal_to_height(12); + let tx_graph = TxGraph::::default(); + let tip = chain.tip().block_id(); + + let (txs, _) = chain.run_task(CanonicalTask::new(&tx_graph, tip, Default::default())); + let mut queries = BlockQueries::new(chain.get(11).expect("height 11 exists").block_id()); + let _ = queries.request([tip.height].into_iter()); + queries.resolve(tip.height, None); + + let view = chain.run_task(txs.view_task(&tx_graph, queries).with_mtp()); + assert_eq!(view.tip_mtp(), Some(7)); +} diff --git a/crates/chain/tests/test_canonical_view_task.rs b/crates/chain/tests/test_canonical_view_task.rs index 397819f4fc..44e66063f3 100644 --- a/crates/chain/tests/test_canonical_view_task.rs +++ b/crates/chain/tests/test_canonical_view_task.rs @@ -2,18 +2,18 @@ mod common; -use bdk_chain::{CanonicalReason, ChainPosition}; +use bdk_chain::{local_chain::LocalChain, CanonicalReason, ChainPosition}; use bdk_testenv::{block_id, hash, local_chain}; -use bitcoin::Txid; +use bitcoin::{BlockHash, Txid}; use common::*; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; #[test] fn test_assumed_canonical_scenarios() { // scenario: "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical" // create a local chain - let local_chain = local_chain![ + let local_chain: LocalChain = local_chain![ (0, hash!("genesis")), (1, hash!("block1")), (2, hash!("block2")), @@ -88,7 +88,7 @@ fn test_assumed_canonical_scenarios() { // build task & canonicalize let canonical_params = env.canonicalization_params; let canonical_task = env.tx_graph.canonical_task(chain_tip, canonical_params); - let canonical_txs = local_chain.canonicalize(canonical_task); + let (canonical_txs, queries) = local_chain.run_task(canonical_task); // assert canonical transactions let exp_canonical_txids: HashSet = exp_canonical_txs @@ -165,8 +165,8 @@ fn test_assumed_canonical_scenarios() { ]; // build task & resolve positions - let view_task = canonical_txs.view_task(&env.tx_graph); - let canonical_view = local_chain.canonicalize(view_task); + let view_task = canonical_txs.view_task(&env.tx_graph, queries); + let canonical_view = local_chain.run_task(view_task); // assert final positions for (tx_name, exp_position) in exp_positions { @@ -188,3 +188,73 @@ fn test_assumed_canonical_scenarios() { ); } } + +/// `txs()` is in topological order, otherwise by chain position: `b` @3 (spends `a` twice) and `c` +/// @2 come after `a` @1 but before the unconfirmed `x`, and `d` (spends `b` and `c`) comes last. +#[test] +fn test_txs_topological_order() { + let local_chain: LocalChain = local_chain![ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")) + ]; + let tx_templates = [ + TxTemplate { + tx_name: "a", + inputs: &[TxInTemplate::Bogus], + outputs: &[ + TxOutTemplate::new(10_000, None), + TxOutTemplate::new(10_000, None), + TxOutTemplate::new(10_000, None), + ], + anchors: &[block_id!(1, "block1")], + ..Default::default() + }, + TxTemplate { + tx_name: "x", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10_000, None)], + last_seen: Some(100), + ..Default::default() + }, + TxTemplate { + tx_name: "b", + inputs: &[TxInTemplate::PrevTx("a", 0), TxInTemplate::PrevTx("a", 1)], + outputs: &[TxOutTemplate::new(19_000, None)], + anchors: &[block_id!(3, "block3")], + ..Default::default() + }, + TxTemplate { + tx_name: "c", + inputs: &[TxInTemplate::PrevTx("a", 2)], + outputs: &[TxOutTemplate::new(9_000, None)], + anchors: &[block_id!(2, "block2")], + ..Default::default() + }, + TxTemplate { + tx_name: "d", + inputs: &[TxInTemplate::PrevTx("b", 0), TxInTemplate::PrevTx("c", 0)], + outputs: &[TxOutTemplate::new(27_000, None)], + last_seen: Some(200), + ..Default::default() + }, + ]; + let env = init_graph(&tx_templates); + let name_of: HashMap = env + .txid_to_name + .iter() + .map(|(&name, &txid)| (txid, name)) + .collect(); + + let order: Vec<&str> = local_chain + .canonicalize( + &env.tx_graph, + local_chain.tip().block_id(), + env.canonicalization_params, + ) + .txs() + .map(|tx| name_of[&tx.txid]) + .collect(); + assert_eq!(order, ["a", "c", "b", "x", "d"]); +} diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index c6e9443cec..9943ba0f28 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -538,7 +538,7 @@ fn test_list_owned_txouts() { .unwrap_or_else(|| panic!("block must exist at {height}")); let canonical_view = - local_chain.canonical_view(graph.graph(), chain_tip, Default::default()); + local_chain.canonicalize(graph.graph(), chain_tip, Default::default()); let txouts = canonical_view .filter_outpoints(graph.index.outpoints().iter().cloned()) @@ -552,7 +552,7 @@ fn test_list_owned_txouts() { graph.index.outpoints().iter().map(|(_, op)| *op), bdk_chain::taints_unowned(&graph.index), |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); let confirmed_txouts_txid = txouts @@ -857,7 +857,7 @@ fn test_get_chain_position() { // check chain position let chain_pos = chain - .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) .txs() .find_map(|canon_tx| { if canon_tx.txid == txid { diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index e6758559d9..8f614113f0 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -1435,3 +1435,44 @@ fn merge_chains_with_prev_blockhash() { .into_iter() .for_each(TestLocalChain::run); } + +/// A [`ChainTask`] that reports [`TaskProgress::Blocked`] without ever having announced the +/// height it is waiting on in a [`TaskProgress::Query`]. +/// +/// This is the only way a task reaches `run_task`'s `Blocked` arm: the driver resolves every +/// announced height before polling again, so a task that announces what it needs never has +/// anything outstanding. Such a task would also hang a streaming driver, which starts fetches +/// only for announced heights, so the synchronous driver panics rather than covering for it. +struct BlockedTask { + tip: BlockId, +} + +impl bdk_chain::ChainTask for BlockedTask { + type Output = (); + + fn tip(&self) -> BlockId { + self.tip + } + + fn poll(&mut self) -> bdk_chain::TaskProgress { + bdk_chain::TaskProgress::Blocked + } + + fn resolve_query(&mut self, _height: u32, _response: Option) {} + + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + core::iter::empty() + } + + fn finish(self) -> Self::Output {} +} + +#[test] +#[should_panic(expected = "heights it never announced")] +fn run_task_panics_when_a_task_blocks_without_querying() { + let chain = local_chain![(0, hash!("_")), (1, hash!("A"))]; + + chain.run_task(BlockedTask { + tip: chain.tip().block_id(), + }); +} diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 24b7b947de..af4780eb73 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1015,7 +1015,7 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { chain - .canonical_view(tx_graph, tip.block_id(), Default::default()) + .canonicalize(tx_graph, tip.block_id(), Default::default()) .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() @@ -1024,7 +1024,7 @@ fn test_chain_spends() { tx_graph: &TxGraph| -> HashMap> { chain - .canonical_view(tx_graph, tip.block_id(), Default::default()) + .canonicalize(tx_graph, tip.block_id(), Default::default()) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1198,14 +1198,14 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); let canonical_txs: Vec<_> = chain - .canonical_view(&graph, chain.tip().block_id(), Default::default()) + .canonicalize(&graph, chain.tip().block_id(), Default::default()) .txs() .collect(); assert!(canonical_txs.is_empty()); // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); - let view = chain.canonical_view(&graph, chain.tip().block_id(), Default::default()); + let view = chain.canonicalize(&graph, chain.tip().block_id(), Default::default()); let mut canonical_txs = view.txs(); assert_eq!(canonical_txs.next().map(|tx| tx.txid).unwrap(), txids[0]); drop(canonical_txs); @@ -1213,7 +1213,7 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); let canonical_txids: Vec<_> = chain - .canonical_view(&graph, chain.tip().block_id(), Default::default()) + .canonicalize(&graph, chain.tip().block_id(), Default::default()) .txs() .map(|tx| tx.txid) .collect(); diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 2f6f1fd6bb..5c28cb2ff6 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -1003,7 +1003,7 @@ fn test_tx_conflict_handling() { for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let canonical_view = local_chain.canonical_view( + let canonical_view = local_chain.canonicalize( &env.tx_graph, chain_tip, env.canonicalization_params.clone(), @@ -1024,6 +1024,27 @@ fn test_tx_conflict_handling() { scenario.name ); + // `txs()` lists parents before children, and every confirmed tx before any unconfirmed one. + let mut placed = HashSet::new(); + let mut seen_unconfirmed = false; + for tx in canonical_view.txs() { + assert!( + tx.tx.input.iter().all(|txin| { + let parent_txid = txin.previous_output.txid; + !txs.contains(&parent_txid) || placed.contains(&parent_txid) + }), + "\n[{}] 'txs' lists a tx before its parent", + scenario.name + ); + assert!( + !(seen_unconfirmed && tx.pos.is_confirmed()), + "\n[{}] 'txs' lists a confirmed tx after an unconfirmed one", + scenario.name + ); + seen_unconfirmed |= !tx.pos.is_confirmed(); + placed.insert(tx.txid); + } + let txouts = canonical_view .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) @@ -1064,7 +1085,7 @@ fn test_tx_conflict_handling() { env.indexer.outpoints().iter().map(|(_, op)| *op), bdk_chain::taints_unowned(&env.indexer), |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); assert_eq!( balance, scenario.exp_balance, @@ -1078,7 +1099,7 @@ fn test_tx_conflict_handling() { /// height is only an upper bound until its own anchor is known. #[test] fn transitively_confirmed_tx_reports_descendant_anchor_height() { - let chain = local_chain![ + let chain: LocalChain = local_chain![ (0, hash!("genesis")), (1, hash!("parent_block")), (5, hash!("child_block")) @@ -1110,7 +1131,7 @@ fn transitively_confirmed_tx_reports_descendant_anchor_height() { let parent_position = |tx_graph: &TxGraph| { chain - .canonical_view(tx_graph, tip, Default::default()) + .canonicalize(tx_graph, tip, Default::default()) .txs() .find(|tx| tx.txid == parent_txid) .expect("parent must be canonical") diff --git a/crates/core/src/block_queries.rs b/crates/core/src/block_queries.rs new file mode 100644 index 0000000000..67995feb6d --- /dev/null +++ b/crates/core/src/block_queries.rs @@ -0,0 +1,273 @@ +use crate::collections::BTreeMap; +use crate::{BlockId, ToBlockHash}; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +/// Tracks block-height queries: which heights have been requested, +/// which are still pending, and the resolved data. +/// +/// Every answer is for the chain ending at [`tip`](Self::tip), so a height has only one right +/// answer, however many times it is fetched. +/// +/// This is a helper for [`ChainTask`](crate::ChainTask) implementors that need to +/// track which block heights have been requested from the chain source and which +/// responses have been received. It deduplicates requests and stores resolved blocks. +/// +/// # Usage +/// +/// 1. Call [`request`](Self::request) with the heights you need. It returns only the heights that +/// haven't been requested or resolved yet. +/// 2. Feed responses back via [`resolve`](Self::resolve). +/// 3. Look up resolved blocks with [`get`](Self::get). +/// 4. Check for outstanding requests with [`unresolved`](Self::unresolved). +#[derive(Debug)] +pub struct BlockQueries { + tip: BlockId, + pending: BTreeSet, + blocks: BTreeMap>, +} + +impl BlockQueries { + /// Creates an empty `BlockQueries` for the chain ending at `tip`. + pub fn new(tip: BlockId) -> Self { + Self { + tip, + pending: BTreeSet::new(), + blocks: BTreeMap::new(), + } + } + + /// The chain tip that every answer is relative to. + pub fn tip(&self) -> BlockId { + self.tip + } + + /// Filters out already-resolved/pending heights, inserts new ones into pending, + /// and returns the list of newly requested heights. + pub fn request(&mut self, heights: impl Iterator) -> Vec { + heights + .filter(|h| !self.blocks.contains_key(h)) + .filter(|h| self.pending.insert(*h)) + .collect() + } + + /// Marks a height as resolved. Removes from pending and inserts into blocks. + /// + /// `block` is `None` when the chain source **has no block** at `height`. This is terminal: + /// the height leaves [`unresolved`](Self::unresolved) and [`request`](Self::request) will + /// never return it again. So do not pass `None` for a *failed* fetch; leave the height + /// pending and retry it, or the failure is silently recorded as "no such block". + /// + /// `block` must be the block at `height` in the chain ending at [`tip`](Self::tip), so a + /// second answer for a height repeats the first. `height` need not be pending: after + /// [`clear_pending`](Self::clear_pending), a late answer to a dropped request is recorded + /// like any other. + pub fn resolve(&mut self, height: u32, block: Option) { + self.pending.remove(&height); + self.blocks.insert(height, block); + } + + /// Iterates over heights that are still pending (unresolved). + pub fn unresolved(&self) -> impl Iterator + '_ { + self.pending.iter().copied() + } + + /// Drops every pending request, keeping only resolved blocks. + /// + /// Call this when handing a `BlockQueries` to a new task, so that every height the new task + /// waits on is one it requested itself. A dropped height is returned by + /// [`request`](Self::request) again. + pub fn clear_pending(&mut self) { + self.pending.clear(); + } + + /// Looks up a resolved block by height. + pub fn get(&self, height: u32) -> Option<&Option> { + self.blocks.get(&height) + } +} + +impl BlockQueries { + /// Resolves a set of candidate blocks against the blocks fetched so far. + /// + /// Each candidate is a pair of an opaque payload and a [`BlockId`]. A candidate is *in-chain* + /// when the block resolved at that `BlockId`'s height is present and its hash matches; the + /// first such candidate's payload is returned as + /// [`Confirmed`](BlockCandidateResolution::Confirmed). + /// + /// When no candidate is confirmed, the result tells the caller how to make progress: + /// - [`Query`](BlockCandidateResolution::Query): heights that still need fetching. These come + /// from [`request`](Self::request), so the list is *additive* (each height announced once) + /// and never empty. + /// - [`Awaiting`](BlockCandidateResolution::Awaiting): nothing new to request, but some heights + /// are still in-flight; wait for them to resolve. + /// - [`NotConfirmed`](BlockCandidateResolution::NotConfirmed): every candidate height is + /// resolved and none matched. + /// + /// `candidates` is cloned and traversed up to three times, so prefer a cheaply cloneable + /// iterator (e.g. a `map` over a slice or set). + pub fn resolve_candidates( + &mut self, + candidates: impl Iterator + Clone, + ) -> BlockCandidateResolution { + // A candidate is confirmed only by a resolved, present block whose hash matches. Unresolved + // (`None`), absent (`Some(None)`), or mismatched blocks do not confirm it. + for (c, id) in candidates.clone() { + if let Some(Some(b)) = self.get(id.height) { + if b.to_blockhash() == id.hash { + return BlockCandidateResolution::Confirmed(c.clone()); + } + } + } + + let heights = self.request(candidates.clone().map(|(_, id)| id.height)); + if !heights.is_empty() { + return BlockCandidateResolution::Query(heights); + } + + // Nothing new to request: wait if any candidate height is still in-flight, otherwise all + // are resolved and none matched. + if candidates + .clone() + .any(|(_, id)| self.get(id.height).is_none()) + { + return BlockCandidateResolution::Awaiting; + } + + BlockCandidateResolution::NotConfirmed + } +} + +/// Outcome of [`BlockQueries::resolve_candidates`]. +/// +/// A "candidate" is some value `C` that claims to live at a particular [`BlockId`] (height + +/// hash). Resolution checks those claims against the blocks fetched so far and reports which of +/// four states applies. The generic `C` is carried through opaquely so the confirmed candidate can +/// be returned to the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BlockCandidateResolution { + /// One candidate's block is resolved and its hash matches, so this candidate is in-chain. + Confirmed(C), + /// These *newly requested* heights must be fetched before the candidates can be decided. + /// Always non-empty. + Query(Vec), + /// Nothing new to request, but some candidate heights are still in-flight; the candidates + /// can't be decided until the driver resolves them. + Awaiting, + /// Every candidate height is resolved and none match, so no candidate is in-chain. + NotConfirmed, +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::vec; + use bitcoin::{hashes::Hash, BlockHash}; + + fn bhash(n: u8) -> BlockHash { + BlockHash::from_byte_array([n; 32]) + } + + fn id(height: u32, hash: u8) -> BlockId { + BlockId { + height, + hash: bhash(hash), + } + } + + #[test] + fn resolve_candidates_scenarios() { + struct Case { + name: &'static str, + /// Pre-populate the `BlockQueries` to set up the scenario. + setup: fn(&mut BlockQueries), + candidates: Vec, + expected: BlockCandidateResolution, + /// Heights still outstanding after the call. + expected_unresolved: Vec, + } + + let cases = [ + Case { + name: "confirmed when the block is resolved and its hash matches", + setup: |q| { + q.request([5].into_iter()); + q.resolve(5, Some(bhash(5))); + }, + candidates: vec![id(5, 5)], + expected: BlockCandidateResolution::Confirmed(id(5, 5)), + expected_unresolved: vec![], + }, + Case { + name: "confirmed picks the matching candidate among several", + setup: |q| { + q.request([4, 5].into_iter()); + q.resolve(4, Some(bhash(99))); // resolved but mismatched + q.resolve(5, Some(bhash(5))); // matches + }, + candidates: vec![id(4, 4), id(5, 5)], + expected: BlockCandidateResolution::Confirmed(id(5, 5)), + expected_unresolved: vec![], + }, + Case { + name: "query for a height not yet requested", + setup: |_q| {}, + candidates: vec![id(7, 7)], + expected: BlockCandidateResolution::Query(vec![7]), + expected_unresolved: vec![7], // the call records the request + }, + Case { + name: "awaiting when a requested height is still in-flight", + setup: |q| { + q.request([7].into_iter()); + }, + candidates: vec![id(7, 7)], + expected: BlockCandidateResolution::Awaiting, + expected_unresolved: vec![7], + }, + Case { + name: "query again for a height dropped by clear_pending", + setup: |q| { + q.request([7].into_iter()); + q.clear_pending(); + }, + candidates: vec![id(7, 7)], + expected: BlockCandidateResolution::Query(vec![7]), + expected_unresolved: vec![7], + }, + Case { + name: "not confirmed when the block is resolved but mismatched", + setup: |q| { + q.request([7].into_iter()); + q.resolve(7, Some(bhash(99))); + }, + candidates: vec![id(7, 7)], + expected: BlockCandidateResolution::NotConfirmed, + expected_unresolved: vec![], + }, + Case { + name: "not confirmed when the block is absent", + setup: |q| { + q.request([7].into_iter()); + q.resolve(7, None); + }, + candidates: vec![id(7, 7)], + expected: BlockCandidateResolution::NotConfirmed, + expected_unresolved: vec![], + }, + ]; + + for case in cases { + let mut q = BlockQueries::::new(id(10, 10)); + (case.setup)(&mut q); + + let res = q.resolve_candidates(case.candidates.iter().map(|id| (*id, *id))); + assert_eq!(res, case.expected, "scenario: {}", case.name); + assert!( + q.unresolved().eq(case.expected_unresolved.iter().copied()), + "scenario: {} (unresolved mismatch)", + case.name + ); + } + } +} diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs deleted file mode 100644 index 660c1c74ff..0000000000 --- a/crates/core/src/chain_query.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Trait for query-based canonicalization against blockchain data. -//! -//! The [`ChainQuery`] trait provides a sans-IO interface for algorithms that -//! need to verify block confirmations against a chain source. - -use crate::BlockId; -use alloc::vec::Vec; - -/// A request containing [`BlockId`]s to check for confirmation in the chain. -pub type ChainRequest = Vec; - -/// Response containing the best confirmed [`BlockId`], if any. -pub type ChainResponse = Option; - -/// A trait for types that verify block confirmations against blockchain data. -/// -/// This trait enables a sans-IO loop: the caller drives the task by repeatedly -/// calling [`next_query`](Self::next_query) and [`resolve_query`](Self::resolve_query). -/// Once `next_query` returns `None`, call [`finish`](Self::finish) to get the output. -/// -/// `resolve_query` must only be called after `next_query` returns `Some`. -/// Calling `resolve_query` or `finish` out of sequence is a programming error. -pub trait ChainQuery { - /// The final output type produced when the query process is complete. - type Output; - - /// Returns the chain tip used as the reference point for all queries. - fn tip(&self) -> BlockId; - - /// Returns the next query needed, or `None` if no more queries are required. - #[must_use] - fn next_query(&mut self) -> Option; - - /// Resolves a query with the given response. - fn resolve_query(&mut self, response: ChainResponse); - - /// Completes the query process and returns the final output. - /// - /// This should be called once [`next_query`](Self::next_query) returns `None`. - fn finish(self) -> Self::Output; -} diff --git a/crates/core/src/chain_task.rs b/crates/core/src/chain_task.rs new file mode 100644 index 0000000000..7dfc24f66b --- /dev/null +++ b/crates/core/src/chain_task.rs @@ -0,0 +1,143 @@ +//! Sans-IO trait for tasks that need to query block data from a chain source. +//! +//! See [`ChainTask`] for the cooperative protocol between a **task** (the implementor) +//! and a **driver** (the caller that has access to the chain source), including the +//! driver loop contract. + +use crate::BlockId; +use alloc::vec::Vec; +use bitcoin::BlockHash; + +/// Progress indicator returned by [`ChainTask::poll`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskProgress { + /// Internal progress was made. The driver should call [`poll`](ChainTask::poll) again. + /// + /// Implementations should return this at stage transitions and after processing + /// individual items, rather than looping internally. This keeps `poll()` doing + /// one unit of work per call, giving the driver observability into progress and + /// an opportunity to cancel or log between steps. + Advanced, + /// The task needs block data at these **newly requested** heights. The driver should + /// resolve each height via [`resolve_query`](ChainTask::resolve_query) and call + /// [`poll`](ChainTask::poll) again. + /// + /// This variant is *additive*: a given height appears in exactly one `Query`, when it is + /// first needed. Implementations dedup against already-requested heights, so the vector is + /// guaranteed to be non-empty and to contain only heights the driver has not seen before. + /// A driver can therefore launch a fetch for each height without tracking duplicates. + Query(Vec), + /// The task cannot make progress until previously requested heights are resolved, and it + /// has no *new* heights to request. The driver should resolve at least one outstanding + /// height (see [`unresolved_queries`](ChainTask::unresolved_queries)) and call + /// [`poll`](ChainTask::poll) again. + /// + /// A synchronous driver that resolves every [`Query`](Self::Query) height before re-polling + /// never observes this variant (nothing is ever in-flight). It exists for streaming drivers + /// that issue fetches without blocking and poll opportunistically: `Query` says "launch I/O", + /// `Blocked` says "block until some lands". + Blocked, + /// The task is complete. The driver should call [`finish`](ChainTask::finish). + Done, +} + +/// A sans-IO task that queries block data by height from a chain source. +/// +/// `ChainTask` defines a cooperative protocol between a **task** (the implementor) +/// and a **driver** (the caller that has access to the chain source). The task +/// declares what block heights it needs, the driver fetches them, and the task +/// makes progress until it produces a final output. +/// +/// # The driver loop +/// +/// A correct driver calls [`poll`](Self::poll) in a loop, matching the returned +/// [`TaskProgress`]. A simple **synchronous** driver resolves each query inline: +/// +/// ```ignore +/// loop { +/// match task.poll() { +/// TaskProgress::Advanced => continue, +/// TaskProgress::Done => return task.finish(), +/// TaskProgress::Query(heights) => { +/// debug_assert!(!heights.is_empty()); +/// for h in heights { +/// let block = chain_source.get(h); +/// task.resolve_query(h, block); +/// } +/// } +/// // A synchronous driver resolves every query before re-polling, so nothing is +/// // ever in-flight and this variant cannot occur. +/// TaskProgress::Blocked => unreachable!(), +/// } +/// } +/// ``` +/// +/// A **streaming** driver instead launches fetches on [`Query`](TaskProgress::Query) without +/// blocking and re-polls; when the task returns [`Blocked`](TaskProgress::Blocked) +/// it blocks on the next fetch to complete, resolves it, and re-polls. +/// +/// # The block type `B` +/// +/// The generic parameter `B` is the block data the task receives from the driver. +/// It defaults to [`BlockHash`] but can be any type (e.g. [`Header`](bitcoin::block::Header)) +/// depending on the chain source. A response of `None` means the chain source has +/// no block at that height. +/// +/// # Contract for implementors +/// +/// - [`poll`](Self::poll) should do one logical unit of work and return: +/// - [`TaskProgress::Advanced`] when internal progress was made (e.g. a stage transition or +/// processing a single item). The driver will call `poll()` again. +/// - [`TaskProgress::Query`] when the task needs block data at one or more *newly requested* +/// heights. Dedup against already-requested heights so each height is announced once and the +/// vector is never empty. +/// - [`TaskProgress::Blocked`] when the task has no new heights to request but cannot progress +/// until previously requested heights are resolved. +/// - [`TaskProgress::Done`] when all processing is complete. +/// - Prefer returning [`TaskProgress::Advanced`] over looping internally. This gives the driver +/// control between steps for progress reporting, cancellation, or logging. +/// - [`finish`](Self::finish) consumes the task and produces the final output. It should only be +/// called after `poll` returns [`TaskProgress::Done`]. +pub trait ChainTask { + /// The final output produced when the task completes. + type Output; + + /// The chain tip that serves as the reference point for all queries. + fn tip(&self) -> BlockId; + + /// Drive the task forward, returning its progress status. + /// + /// Each call should perform one logical unit of work. The driver calls this + /// in a loop, matching the returned [`TaskProgress`]. + fn poll(&mut self) -> TaskProgress; + + /// Provides the block data (or `None`) for a previously requested `height`. + /// + /// A response of `None` signals that the chain source **has no block** at that height: + /// it is an answer, not an error. The task records it so it can distinguish "not yet + /// queried" from "queried but absent", and treats every anchor at that height as not + /// in-chain. + /// + /// Resolving is terminal: a height is answered once, and the task will not ask for it + /// again. A driver that *failed* to fetch a height (I/O error, timeout) must therefore + /// **not** call this with `None`: that would silently record "no such block" and, for an + /// anchor that is really in-chain, downgrade a confirmed transaction to unconfirmed with + /// no error surfaced anywhere. Leave the height unresolved instead and retry it; see + /// [`unresolved_queries`](Self::unresolved_queries). + fn resolve_query(&mut self, height: u32, response: Option); + + /// Returns heights that have been requested but not yet resolved. + /// + /// This is the retry set: a height a fetch failed for stays here until the driver + /// resolves it, so a driver can re-issue those fetches (and report progress) after an + /// error. Once [`resolve_query`](Self::resolve_query) is called for a height it leaves + /// this set permanently. + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a; + + /// Consumes the task and returns the final output. + /// + /// # Panics + /// + /// May panic if called before [`poll`](Self::poll) returns [`TaskProgress::Done`]. + fn finish(self) -> Self::Output; +} diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 02c1f5cd99..acf805fce2 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -197,6 +197,26 @@ impl ToBlockTime for Header { } } +/// Number of blocks used to compute the median time past (BIP-0113). +const MTP_BLOCK_COUNT: u32 = 11; + +/// Compute the median time past (MTP) at `height` from block timestamps (BIP-0113). +/// +/// MTP is the median of the timestamps of the 11 blocks at heights `height-10..=height`. +/// `block_time` returns the timestamp of the block at a given height, or `None` if it is +/// unavailable; if any of the 11 blocks is missing this returns `None`. +/// +/// Note: this is a pseudo-median: for an even count it takes the higher of the two middle values +/// rather than averaging them, per the BIP-0113 specification. +pub fn median_time_past(height: u32, block_time: impl Fn(u32) -> Option) -> Option { + let start = height.saturating_sub(MTP_BLOCK_COUNT - 1); + let mut timestamps = (start..=height) + .map(block_time) + .collect::>>()?; + timestamps.sort_unstable(); + Some(timestamps[timestamps.len() / 2]) +} + impl PartialEq for CheckPoint { fn eq(&self, other: &Self) -> bool { let self_cps = self.iter().map(|cp| cp.block_id()); @@ -363,8 +383,6 @@ impl CheckPoint where D: ToBlockHash + fmt::Debug + Clone, { - 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 { @@ -394,22 +412,9 @@ where where D: ToBlockTime, { - let current_height = self.height(); - let earliest_height = current_height.saturating_sub(Self::MTP_BLOCK_COUNT - 1); - - let mut timestamps = (earliest_height..=current_height) - .map(|height| { - // Return `None` for missing blocks or missing block times - let cp = self.get(height)?; - let block_time = cp.data_ref().to_blocktime(); - Some(block_time) - }) - .collect::>>()?; - timestamps.sort_unstable(); - - // If there are more than 1 middle values, use the higher middle value. - // This is mathematically incorrect, but this is the BIP-0113 specification. - Some(timestamps[timestamps.len() / 2]) + median_time_past(self.height(), |height| { + Some(self.get(height)?.data_ref().to_blocktime()) + }) } /// Construct from an iterator of block data. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c9c97f3ef8..12a435cf89 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -89,5 +89,8 @@ pub use merge::*; pub mod spk_client; -mod chain_query; -pub use chain_query::*; +mod chain_task; +pub use chain_task::*; + +mod block_queries; +pub use block_queries::*; diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 514d4015d0..6caf9af8a4 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -57,7 +57,7 @@ fn get_balance( ) -> anyhow::Result { let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_chain - .canonical_view( + .canonicalize( recv_graph.graph(), recv_chain.tip().block_id(), Default::default(), @@ -66,7 +66,7 @@ fn get_balance( outpoints.into_iter().map(|(_, op)| op), bdk_chain::taints_unowned(&recv_graph.index), |pos| pos.is_confirmed(), - |_txout| false, + |_txout| Default::default(), ); Ok(balance) } @@ -181,7 +181,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; @@ -210,7 +210,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index a14a8397c9..7260842237 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,7 +100,7 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; @@ -129,7 +129,7 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 1095f15255..11924e9933 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,7 +97,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; @@ -126,7 +126,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } + { chain.canonicalize(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?;