diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index e79bde6720..a346a2db0e 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,7 +69,8 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); - let canonical_view = graph.canonical_view(&chain, chain.tip().block_id(), Default::default()); + let canonical_view = + chain.canonical_view(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 b75d474caa..2aeede6283 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -5,7 +5,7 @@ use bdk_chain::{ bitcoin::{Address, Amount, Txid}, local_chain::{CheckPoint, LocalChain}, spk_txout::SpkTxOutIndex, - Balance, BlockId, CanonicalizationParams, IndexedTxGraph, Merge, + Balance, BlockId, IndexedTxGraph, Merge, }; use bdk_testenv::{ anyhow, @@ -318,11 +318,14 @@ fn get_balance( recv_chain: &LocalChain, recv_graph: &IndexedTxGraph>, ) -> anyhow::Result { - let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let balance = recv_chain + .canonical_view( + recv_graph.graph(), + recv_chain.tip().block_id(), + Default::default(), + ) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -631,8 +634,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _txid_2 = core.send_raw_transaction(&tx1b)?; // Retrieve the expected unconfirmed txids and spks from the graph. - let exp_spk_txids = graph - .canonical_view(&chain, chain_tip, Default::default()) + let exp_spk_txids = chain + .canonical_view(graph.graph(), chain_tip, Default::default()) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -647,8 +650,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Update graph with evicted tx. let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); - let canonical_txids = graph - .canonical_view(&chain, chain_tip, CanonicalizationParams::default()) + let canonical_txids = chain + .canonical_view(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 074e38cc42..74e6c05fd9 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -1,4 +1,3 @@ -use bdk_chain::CanonicalizationParams; use bdk_chain::{keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph}; use bdk_core::{BlockId, CheckPoint}; use bdk_core::{ConfirmationBlockTime, TxUpdate}; @@ -95,31 +94,19 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { - let view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let view = chain.canonical_view(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 = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let view = chain.canonical_view(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 = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let view = chain.canonical_view(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 5907c76a00..97917ce35b 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -1,7 +1,7 @@ use bdk_chain::{ keychain_txout::{InsertDescriptorError, KeychainTxOutIndex}, local_chain::LocalChain, - CanonicalizationParams, IndexedTxGraph, + IndexedTxGraph, }; use bdk_core::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bitcoin::{ @@ -82,10 +82,9 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { .unwrap(); // Check balance - let chain_tip = chain.tip().block_id(); let op = graph.index.outpoints().clone(); - let bal = graph - .canonical_view(chain, chain_tip, CanonicalizationParams::default()) + let bal = chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical.rs similarity index 57% rename from crates/chain/src/canonical_view.rs rename to crates/chain/src/canonical.rs index 0191f45071..3a32d0fdf6 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical.rs @@ -6,14 +6,15 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, 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 = CanonicalizationParams::default(); -//! let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params).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); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -23,37 +24,37 @@ use crate::collections::HashMap; use alloc::sync::Arc; -use core::{fmt, ops::RangeBounds}; - use alloc::vec::Vec; +use core::{fmt, ops::RangeBounds}; use bdk_core::BlockId; -use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; - -use crate::{ - spk_txout::SpkTxOutIndex, tx_graph::TxNode, Anchor, Balance, CanonicalIter, CanonicalReason, - CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, +use bitcoin::{ + constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, }; -/// A single canonical transaction with its chain position. +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; + +/// A single canonical transaction with its position. /// /// This struct represents a transaction that has been determined to be canonical (not -/// conflicted). It includes the transaction itself along with its position in the chain (confirmed -/// or unconfirmed). +/// conflicted). It includes the transaction itself along with its position information. +/// The position type `P` is generic - it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct CanonicalTx { - /// The position of this transaction in the chain. +pub struct CanonicalTx

{ + /// The position of this transaction. /// - /// This indicates whether the transaction is confirmed (and at what height) or - /// unconfirmed (most likely pending in the mempool). - pub pos: ChainPosition, + /// When `P` is [`ChainPosition`], this indicates whether the transaction is confirmed + /// (and at what height) or unconfirmed (most likely pending in the mempool). + pub pos: P, /// The transaction ID (hash) of this transaction. pub txid: Txid, /// The full transaction. pub tx: Arc, } -impl Ord for CanonicalTx { +impl Ord for CanonicalTx

{ fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.pos .cmp(&other.pos) @@ -62,151 +63,180 @@ impl Ord for CanonicalTx { } } -impl PartialOrd for CanonicalTx { +impl PartialOrd for CanonicalTx

{ + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A canonical transaction output with position and spend information. +/// +/// The position type `P` is generic - it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalTxOut

{ + /// The position of the transaction in `outpoint` in the overall chain. + pub pos: P, + /// The location of the `TxOut`. + pub outpoint: OutPoint, + /// The `TxOut`. + pub txout: TxOut, + /// The txid and position of the transaction (if any) that has spent this output. + pub spent_by: Option<(P, Txid)>, + /// Whether this output is on a coinbase transaction. + pub is_on_coinbase: bool, +} + +impl Ord for CanonicalTxOut

{ + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.pos + .cmp(&other.pos) + // Tie-break with `outpoint` and `spent_by`. + .then_with(|| self.outpoint.cmp(&other.outpoint)) + .then_with(|| self.spent_by.cmp(&other.spent_by)) + } +} + +impl PartialOrd for CanonicalTxOut

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -/// A view of canonical transactions from a [`TxGraph`]. +impl CanonicalTxOut> { + /// Whether the `txout` is considered mature. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_mature(&self, tip: u32) -> bool { + if self.is_on_coinbase { + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => { + debug_assert!(false, "coinbase tx can never be unconfirmed"); + return false; + } + }; + let age = tip.saturating_sub(conf_height); + if age + 1 < COINBASE_MATURITY { + return false; + } + } + + true + } + + /// Whether the utxo is/was/will be spendable with chain `tip`. + /// + /// This method does not take into account the lock time. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { + if !self.is_mature(tip) { + return false; + } + + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => return false, + }; + if conf_height > tip { + return false; + } + + // if the spending tx is confirmed within tip height, the txout is no longer spendable + if let Some(spend_height) = self + .spent_by + .as_ref() + .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) + { + if spend_height <= tip { + return false; + } + } + + true + } +} + +/// Canonical set of transactions from a [`TxGraph`]. /// -/// `CanonicalView` provides an ordered, conflict-resolved view of transactions. It determines +/// `Canonical` provides an ordered, conflict-resolved set of transactions. It determines /// which transactions are canonical (non-conflicted) based on the current chain state and /// provides methods to query transaction data, unspent outputs, and balances. /// +/// The position type `P` is generic: +/// - [`ChainPosition`] for resolved views (aka [`CanonicalView`]) +/// - [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved results (aka +/// [`CanonicalTxs`]) +/// /// The view maintains: /// - An ordered list of canonical transactions in topological-spending order /// - A mapping of outpoints to the transactions that spend them /// - The chain tip used for canonicalization +/// +/// [`TxGraph`]: crate::TxGraph #[derive(Debug)] -pub struct CanonicalView { - /// Ordered list of transaction IDs in in topological-spending order. - order: Vec, - /// Map of transaction IDs to their transaction data and chain position. - txs: HashMap, ChainPosition)>, +pub struct Canonical { + /// Ordered list of transaction IDs in topological-spending order. + pub(crate) order: Vec, + /// Map of transaction IDs to their transaction data and position. + pub(crate) txs: HashMap, P)>, /// Map of outpoints to the transaction ID that spends them. - spends: HashMap, + pub(crate) spends: HashMap, /// The chain tip at the time this view was created. - tip: BlockId, + pub(crate) tip: BlockId, + /// Marker for the anchor type. + pub(crate) _anchor: core::marker::PhantomData, } -impl CanonicalView { - /// Create a new canonical view from a transaction graph. - /// - /// This constructor analyzes the given [`TxGraph`] and creates a canonical view of all - /// transactions, resolving conflicts and ordering them according to their chain position. - /// - /// # Returns - /// - /// Returns `Ok(CanonicalView)` on success, or an error if the chain oracle fails. - pub fn new<'g, C>( - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result - where - C: ChainOracle, - { - fn find_direct_anchor( - tx_node: &TxNode<'_, Arc, A>, - chain: &C, - chain_tip: BlockId, - ) -> Result, C::Error> { - tx_node - .anchors - .iter() - .find_map(|a| -> Option> { - match chain.is_block_in_chain(a.anchor_block(), chain_tip) { - Ok(Some(true)) => Some(Ok(a.clone())), - Ok(Some(false)) | Ok(None) => None, - Err(err) => Some(Err(err)), - } - }) - .transpose() - } - - let mut view = Self { - tip: chain_tip, - order: vec![], - txs: HashMap::new(), - spends: HashMap::new(), - }; - - for r in CanonicalIter::new(tx_graph, chain, chain_tip, params) { - let (txid, tx, why) = r?; - - let tx_node = match tx_graph.get_tx_node(txid) { - Some(tx_node) => tx_node, - None => { - // TODO: Have the `CanonicalIter` return `TxNode`s. - debug_assert!(false, "tx node must exist!"); - continue; - } - }; +/// Type alias for canonical transactions with resolved [`ChainPosition`]s. +pub type CanonicalView = Canonical>; - view.order.push(txid); +/// Type alias for canonical transactions with unresolved +/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s. +pub type CanonicalTxs = Canonical>; - if !tx.is_coinbase() { - view.spends - .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); - } - - let pos = match why { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - 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, - }, - }, - }; - view.txs.insert(txid, (tx_node.tx, pos)); +impl Canonical { + /// Creates a [`Canonical`] from its constituent parts. + /// + /// This internal constructor is used by [`CanonicalTask`] to build the canonical set + /// after completing the canonicalization process. It takes the processed transaction + /// data including the canonical ordering, transaction map with positions, and + /// spend information. + pub(crate) fn new( + tip: BlockId, + order: Vec, + txs: HashMap, P)>, + spends: HashMap, + ) -> Self { + Self { + tip, + order, + txs, + spends, + _anchor: core::marker::PhantomData, } + } - Ok(view) + /// Get the chain tip used to construct this canonical set. + pub fn tip(&self) -> BlockId { + self.tip } /// Get a single canonical transaction by its transaction ID. /// - /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, + /// 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> { + pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) .cloned() @@ -219,10 +249,10 @@ impl CanonicalView { /// spent and by which transaction. /// /// Returns `None` if: - /// - The transaction doesn't exist in the canonical view + /// - The transaction doesn't exist in the canonical set /// - The output index is out of bounds /// - The transaction was excluded due to conflicts - pub fn txout(&self, op: OutPoint) -> Option> { + pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; let txout = tx.output.get(vout)?; @@ -230,8 +260,8 @@ impl CanonicalView { let (_, spent_by_pos) = &self.txs[spent_by_txid]; (spent_by_pos.clone(), *spent_by_txid) }); - Some(FullTxOut { - chain_position: pos.clone(), + Some(CanonicalTxOut { + pos: pos.clone(), outpoint: op, txout: txout.clone(), spent_by, @@ -247,12 +277,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -261,7 +293,7 @@ impl CanonicalView { /// // Get the total number of canonical transactions /// println!("Total canonical transactions: {}", view.txs().len()); /// ``` - pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { + pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { self.order.iter().map(|&txid| { let (tx, pos) = self.txs[&txid].clone(); CanonicalTx { pos, txid, tx } @@ -271,7 +303,7 @@ impl CanonicalView { /// Get a filtered list of outputs from the given outpoints. /// /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator - /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical view. + /// of `(identifier, canonical_txout)` pairs for outpoints that exist in the canonical set. /// Non-existent outpoints are silently filtered out. /// /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses @@ -280,12 +312,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -295,7 +329,7 @@ impl CanonicalView { pub fn filter_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { outpoints .into_iter() .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) @@ -309,12 +343,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -324,11 +360,40 @@ impl CanonicalView { pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { self.filter_outpoints(outpoints) .filter(|(_, txo)| txo.spent_by.is_none()) } + /// List transaction IDs that are expected to exist for the given script pubkeys. + /// + /// This method is primarily used for synchronization with external sources, helping to + /// identify which transactions are expected to exist for a set of script pubkeys. It's + /// commonly used with + /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) + /// to inform sync operations about known transactions. + pub fn list_expected_spk_txids<'v, I>( + &'v self, + indexer: &'v impl AsRef>, + spk_index_range: impl RangeBounds + 'v, + ) -> impl Iterator + 'v + where + I: fmt::Debug + Clone + Ord + 'v, + { + let indexer = indexer.as_ref(); + self.txs().flat_map(move |c_tx| -> Vec<_> { + let range = &spk_index_range; + let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); + relevant_spks + .into_iter() + .filter(|(i, _)| range.contains(i)) + .map(|(_, spk)| (spk, c_tx.txid)) + .collect() + }) + } +} + +impl CanonicalView { /// Calculate the total balance of the given outpoints. /// /// This method computes a detailed balance breakdown for a set of outpoints, categorizing @@ -355,12 +420,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalParams, 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 view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( @@ -372,7 +438,7 @@ impl CanonicalView { pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, &FullTxOut) -> bool, + mut trust_predicate: impl FnMut(&O, &CanonicalTxOut>) -> bool, min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -381,7 +447,7 @@ impl CanonicalView { let mut confirmed = Amount::ZERO; for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) { - match &txout.chain_position { + match &txout.pos { ChainPosition::Confirmed { anchor, .. } => { let confirmation_height = anchor.confirmation_height_upper_bound(); let confirmations = self @@ -421,31 +487,16 @@ impl CanonicalView { confirmed, } } +} - /// List transaction IDs that are expected to exist for the given script pubkeys. +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`](crate::CanonicalReason)s + /// into [`ChainPosition`]s. /// - /// This method is primarily used for synchronization with external sources, helping to - /// identify which transactions are expected to exist for a set of script pubkeys. It's - /// commonly used with - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) - /// to inform sync operations about known transactions. - pub fn list_expected_spk_txids<'v, I>( - &'v self, - indexer: &'v impl AsRef>, - spk_index_range: impl RangeBounds + 'v, - ) -> impl Iterator + 'v - where - I: fmt::Debug + Clone + Ord + 'v, - { - let indexer = indexer.as_ref(); - self.txs().flat_map(move |c_tx| -> Vec<_> { - let range = &spk_index_range; - let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); - relevant_spks - .into_iter() - .filter(|(i, _)| range.contains(i)) - .map(|(_, spk)| (spk, c_tx.txid)) - .collect() - }) + /// 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) } } diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs deleted file mode 100644 index 204ead4511..0000000000 --- a/crates/chain/src/canonical_iter.rs +++ /dev/null @@ -1,344 +0,0 @@ -use crate::collections::{HashMap, HashSet, VecDeque}; -use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, ChainOracle, TxGraph}; -use alloc::boxed::Box; -use alloc::collections::BTreeSet; -use alloc::sync::Arc; -use alloc::vec::Vec; -use bdk_core::BlockId; -use bitcoin::{Transaction, Txid}; - -type CanonicalMap = HashMap, CanonicalReason)>; -type NotCanonicalSet = HashSet; - -/// Modifies the canonicalization algorithm. -#[derive(Debug, Default, Clone)] -pub struct CanonicalizationParams { - /// Transactions that will supercede all other transactions. - /// - /// In case of conflicting transactions within `assume_canonical`, transactions that appear - /// later in the list (have higher index) have precedence. - pub assume_canonical: Vec, -} - -/// Iterates over canonical txs. -pub struct CanonicalIter<'g, A, C> { - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - - unprocessed_assumed_txs: Box)> + 'g>, - unprocessed_anchored_txs: - Box, &'g BTreeSet)> + 'g>, - unprocessed_seen_txs: Box, u64)> + 'g>, - unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, - - canonical: CanonicalMap, - not_canonical: NotCanonicalSet, - - queue: VecDeque, -} - -impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> { - /// Constructs [`CanonicalIter`]. - pub fn new( - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Self { - let anchors = tx_graph.all_anchors(); - let unprocessed_assumed_txs = Box::new( - params - .assume_canonical - .into_iter() - .rev() - .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), - ); - let unprocessed_anchored_txs = Box::new( - tx_graph - .txids_by_descending_anchor_height() - .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), - ); - let unprocessed_seen_txs = Box::new( - tx_graph - .txids_by_descending_last_seen() - .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), - ); - Self { - tx_graph, - chain, - chain_tip, - unprocessed_assumed_txs, - unprocessed_anchored_txs, - unprocessed_seen_txs, - unprocessed_leftover_txs: VecDeque::new(), - canonical: HashMap::new(), - not_canonical: HashSet::new(), - queue: VecDeque::new(), - } - } - - /// Whether this transaction is already canonicalized. - fn is_canonicalized(&self, txid: Txid) -> bool { - self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) - } - - /// Mark transaction as canonical if it is anchored in the best chain. - fn scan_anchors( - &mut self, - txid: Txid, - tx: Arc, - anchors: &BTreeSet, - ) -> Result<(), C::Error> { - for anchor in anchors { - let in_chain_opt = self - .chain - .is_block_in_chain(anchor.anchor_block(), self.chain_tip)?; - if in_chain_opt == Some(true) { - self.mark_canonical(txid, tx, CanonicalReason::from_anchor(anchor.clone())); - return Ok(()); - } - } - // cannot determine - self.unprocessed_leftover_txs.push_back(( - txid, - tx, - anchors - .iter() - .last() - .expect( - "tx taken from `unprocessed_txs_with_anchors` so it must atleast have an anchor", - ) - .confirmation_height_upper_bound(), - )); - Ok(()) - } - - /// Marks `tx` and it's ancestors as canonical and mark all conflicts of these as - /// `not_canonical`. - /// - /// The exception is when it is discovered that `tx` double spends itself (i.e. two of it's - /// inputs conflict with each other), then no changes will be made. - /// - /// The logic works by having two loops where one is nested in another. - /// * The outer loop iterates through ancestors of `tx` (including `tx`). We can transitively - /// assume that all ancestors of `tx` are also canonical. - /// * The inner loop loops through conflicts of ancestors of `tx`. Any descendants of conflicts - /// are also conflicts and are transitively considered non-canonical. - /// - /// If the inner loop ends up marking `tx` as non-canonical, then we know that it double spends - /// itself. - fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { - let starting_txid = txid; - let mut is_starting_tx = true; - - // We keep track of changes made so far so that we can undo it later in case we detect that - // `tx` double spends itself. - let mut detected_self_double_spend = false; - let mut undo_not_canonical = Vec::::new(); - - // `staged_queue` doubles as the `undo_canonical` data. - let staged_queue = TxAncestors::new_include_root( - self.tx_graph, - tx, - |_: usize, tx: Arc| -> Option { - let this_txid = tx.compute_txid(); - let this_reason = if is_starting_tx { - is_starting_tx = false; - reason.clone() - } else { - reason.to_transitive(starting_txid) - }; - - use crate::collections::hash_map::Entry; - let canonical_entry = match self.canonical.entry(this_txid) { - // Already visited tx before, exit early. - Entry::Occupied(_) => return None, - Entry::Vacant(entry) => entry, - }; - - // Any conflicts with a canonical tx can be added to `not_canonical`. Descendants - // of `not_canonical` txs can also be added to `not_canonical`. - for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) { - TxDescendants::new_include_root( - self.tx_graph, - conflict_txid, - |_: usize, txid: Txid| -> Option<()> { - if self.not_canonical.insert(txid) { - undo_not_canonical.push(txid); - Some(()) - } else { - None - } - }, - ) - .run_until_finished() - } - - if self.not_canonical.contains(&this_txid) { - // Early exit if self-double-spend is detected. - detected_self_double_spend = true; - return None; - } - canonical_entry.insert((tx, this_reason)); - Some(this_txid) - }, - ) - .collect::>(); - - if detected_self_double_spend { - for txid in staged_queue { - self.canonical.remove(&txid); - } - for txid in undo_not_canonical { - self.not_canonical.remove(&txid); - } - } else { - self.queue.extend(staged_queue); - } - } -} - -impl Iterator for CanonicalIter<'_, A, C> { - type Item = Result<(Txid, Arc, CanonicalReason), C::Error>; - - fn next(&mut self) -> Option { - loop { - if let Some(txid) = self.queue.pop_front() { - let (tx, reason) = self - .canonical - .get(&txid) - .cloned() - .expect("reason must exist"); - return Some(Ok((txid, tx, reason))); - } - - if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - } - - if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { - if !self.is_canonicalized(txid) { - if let Err(err) = self.scan_anchors(txid, tx, anchors) { - return Some(Err(err)); - } - } - continue; - } - - if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { - debug_assert!( - !tx.is_coinbase(), - "Coinbase txs must not have `last_seen` (in mempool) value" - ); - if !self.is_canonicalized(txid) { - let observed_in = ObservedIn::Mempool(last_seen); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { - if !self.is_canonicalized(txid) && !tx.is_coinbase() { - let observed_in = ObservedIn::Block(height); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - return None; - } - } -} - -/// Represents when and where a transaction was last observed in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum ObservedIn { - /// The transaction was last observed in a block of height. - Block(u32), - /// The transaction was last observed in the mempool at the given unix timestamp. - Mempool(u64), -} - -/// The reason why a transaction is canonical. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CanonicalReason { - /// This transaction is explicitly assumed to be canonical by the caller, superceding all other - /// canonicalization rules. - Assumed { - /// Whether it is a descendant that is assumed to be canonical. - descendant: Option, - }, - /// This transaction is anchored in the best chain by `A`, and therefore canonical. - Anchor { - /// The anchor that anchored the transaction in the chain. - anchor: A, - /// Whether the anchor is of the transaction's descendant. - descendant: Option, - }, - /// This transaction does not conflict with any other transaction with a more recent - /// [`ObservedIn`] value or one that is anchored in the best chain. - ObservedIn { - /// The [`ObservedIn`] value of the transaction. - observed_in: ObservedIn, - /// Whether the [`ObservedIn`] value is of the transaction's descendant. - descendant: Option, - }, -} - -impl CanonicalReason { - /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other - /// transactions. - pub fn assumed() -> Self { - Self::Assumed { descendant: None } - } - - /// Constructs a [`CanonicalReason`] from an `anchor`. - pub fn from_anchor(anchor: A) -> Self { - Self::Anchor { - anchor, - descendant: None, - } - } - - /// Constructs a [`CanonicalReason`] from an `observed_in` value. - pub fn from_observed_in(observed_in: ObservedIn) -> Self { - Self::ObservedIn { - observed_in, - descendant: None, - } - } - - /// Contruct a new [`CanonicalReason`] from the original which is transitive to `descendant`. - /// - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant, but is transitively relevant. - pub fn to_transitive(&self, descendant: Txid) -> Self { - match self { - CanonicalReason::Assumed { .. } => Self::Assumed { - descendant: Some(descendant), - }, - CanonicalReason::Anchor { anchor, .. } => Self::Anchor { - anchor: anchor.clone(), - descendant: Some(descendant), - }, - CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { - observed_in: *observed_in, - descendant: Some(descendant), - }, - } - } - - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant. - pub fn descendant(&self) -> &Option { - match self { - CanonicalReason::Assumed { descendant, .. } => descendant, - CanonicalReason::Anchor { descendant, .. } => descendant, - CanonicalReason::ObservedIn { descendant, .. } => descendant, - } - } -} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs new file mode 100644 index 0000000000..0cb8ffbf4a --- /dev/null +++ b/crates/chain/src/canonical_task.rs @@ -0,0 +1,502 @@ +use crate::collections::{HashMap, HashSet, VecDeque}; +use crate::tx_graph::{TxAncestors, TxDescendants}; +use crate::{Anchor, CanonicalTxs, TxGraph}; +use alloc::boxed::Box; +use alloc::collections::BTreeSet; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bitcoin::{Transaction, Txid}; + +type CanonicalMap = HashMap, CanonicalReason)>; +type NotCanonicalSet = HashSet; + +/// Represents the current stage of canonicalization processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum CanonicalStage { + /// Processing transactions assumed to be canonical. + #[default] + AssumedTxs, + /// Processing directly anchored transactions. + AnchoredTxs, + /// Processing transactions seen in mempool. + SeenTxs, + /// Processing leftover transactions. + LeftOverTxs, + /// All processing is complete. + Finished, +} + +impl CanonicalStage { + fn advance(&mut self) { + *self = match self { + CanonicalStage::AssumedTxs => Self::AnchoredTxs, + CanonicalStage::AnchoredTxs => Self::SeenTxs, + CanonicalStage::SeenTxs => Self::LeftOverTxs, + CanonicalStage::LeftOverTxs => Self::Finished, + CanonicalStage::Finished => Self::Finished, + }; + } +} + +/// Modifies the canonicalization algorithm. +#[derive(Debug, Default, Clone)] +pub struct CanonicalParams { + /// Transactions that will supersede all other transactions. + /// + /// In case of conflicting transactions within `assume_canonical`, transactions that appear + /// later in the list (have higher index) have precedence. + pub assume_canonical: Vec, +} + +/// Determines which transactions are canonical without resolving chain positions. +/// +/// This task implements the first phase of canonicalization: it walks the transaction +/// graph and determines which transactions are canonical (non-conflicting) and why +/// (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> { + tx_graph: &'g TxGraph, + chain_tip: BlockId, + + unprocessed_assumed_txs: Box)> + 'g>, + unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, + unprocessed_seen_txs: Box, u64)> + 'g>, + unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, + + 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; + + fn tip(&self) -> BlockId { + self.chain_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() { + debug_assert!( + !tx.is_coinbase(), + "Coinbase txs must not have `last_seen` (in mempool) value" + ); + if !self.is_canonicalized(txid) { + 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() { + if !self.is_canonicalized(txid) && !tx.is_coinbase() { + 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 resolve_query(&mut self, response: ChainResponse) { + match self.current_stage { + CanonicalStage::AnchoredTxs => { + // Process directly anchored transaction response + 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), + ); + } + } + None => { + // No confirmed anchor found, add to leftover transactions for later + // processing + 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", + ) + .confirmation_height_upper_bound(), + )) + } + } + } + } + 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 + ); + } + } + } + + 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); + } + } + + view_txs.insert(*txid, (tx.clone(), reason.clone())); + } + } + + CanonicalTxs::new(self.chain_tip, view_order, view_txs, view_spends) + } +} + +impl<'g, A: Anchor> CanonicalTask<'g, A> { + /// Creates a new canonicalization task. + pub fn new(tx_graph: &'g TxGraph, chain_tip: BlockId, params: CanonicalParams) -> Self { + let anchors = tx_graph.all_anchors(); + let unprocessed_assumed_txs = Box::new( + params + .assume_canonical + .into_iter() + .rev() + .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), + ); + let unprocessed_anchored_txs: VecDeque<_> = tx_graph + .txids_by_descending_anchor_height() + .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))) + .collect(); + let unprocessed_seen_txs = Box::new( + tx_graph + .txids_by_descending_last_seen() + .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), + ); + + Self { + tx_graph, + chain_tip, + + unprocessed_assumed_txs, + unprocessed_anchored_txs, + unprocessed_seen_txs, + unprocessed_leftover_txs: VecDeque::new(), + + canonical: HashMap::new(), + not_canonical: HashSet::new(), + + canonical_order: Vec::new(), + current_stage: CanonicalStage::default(), + } + } + + fn is_canonicalized(&self, txid: Txid) -> bool { + self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) + } + + fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { + let starting_txid = txid; + let mut is_starting_tx = true; + + // We keep track of changes made so far so that we can undo it later in case we detect that + // `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(); + + // Process ancestors + TxAncestors::new_include_root( + self.tx_graph, + tx, + |_: usize, tx: Arc| -> Option { + let this_txid = tx.compute_txid(); + let this_reason = if is_starting_tx { + is_starting_tx = false; + reason.clone() + } else { + // This is an ancestor being marked transitively + reason.to_transitive(starting_txid) + }; + + use crate::collections::hash_map::Entry; + let canonical_entry = match self.canonical.entry(this_txid) { + // Already visited tx before, exit early. + Entry::Occupied(_) => return None, + Entry::Vacant(entry) => entry, + }; + + // Prune conflicts + // + // Any conflicts with a canonical tx can be added to `not_canonical`. Descendants + // of `not_canonical` txs can also be added to `not_canonical`. + for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) { + TxDescendants::new_include_root( + self.tx_graph, + conflict_txid, + |_: usize, txid: Txid| -> Option<()> { + if self.not_canonical.insert(txid) { + undo_not_canonical.push(txid); + Some(()) + } else { + None + } + }, + ) + .run_until_finished() + } + + if self.not_canonical.contains(&this_txid) { + // Early exit if self-double-spend is detected. + detected_self_double_spend = true; + return None; + } + + staged_canonical.push((this_txid, tx.clone(), this_reason.clone())); + canonical_entry.insert((tx.clone(), this_reason)); + Some(this_txid) + }, + ) + .run_until_finished(); + + if detected_self_double_spend { + // Undo changes + for (txid, _, _) in staged_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); + } + } +} + +/// Represents when and where a transaction was last observed in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ObservedIn { + /// The transaction was last observed in a block of height. + Block(u32), + /// The transaction was last observed in the mempool at the given unix timestamp. + Mempool(u64), +} + +/// The reason why a transaction is canonical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanonicalReason { + /// This transaction is explicitly assumed to be canonical by the caller, superceding all other + /// canonicalization rules. + Assumed { + /// Whether it is a descendant that is assumed to be canonical. + descendant: Option, + }, + /// This transaction is anchored in the best chain by `A`, and therefore canonical. + Anchor { + /// The anchor that anchored the transaction in the chain. + anchor: A, + /// Whether the anchor is of the transaction's descendant. + descendant: Option, + }, + /// This transaction does not conflict with any other transaction with a more recent + /// [`ObservedIn`] value or one that is anchored in the best chain. + ObservedIn { + /// The [`ObservedIn`] value of the transaction. + observed_in: ObservedIn, + /// Whether the [`ObservedIn`] value is of the transaction's descendant. + descendant: Option, + }, +} + +impl CanonicalReason { + /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other + /// transactions. + pub fn assumed() -> Self { + Self::Assumed { descendant: None } + } + + /// Constructs a [`CanonicalReason`] from an `anchor`. + pub fn from_anchor(anchor: A) -> Self { + Self::Anchor { + anchor, + descendant: None, + } + } + + /// Constructs a [`CanonicalReason`] from an `observed_in` value. + pub fn from_observed_in(observed_in: ObservedIn) -> Self { + Self::ObservedIn { + observed_in, + descendant: None, + } + } + + /// Construct a new [`CanonicalReason`] from the original which is transitive to `descendant`. + /// + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant, but is transitively relevant. + pub fn to_transitive(&self, descendant: Txid) -> Self { + match self { + CanonicalReason::Assumed { .. } => Self::Assumed { + descendant: Some(descendant), + }, + CanonicalReason::Anchor { anchor, .. } => Self::Anchor { + anchor: anchor.clone(), + descendant: Some(descendant), + }, + CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { + observed_in: *observed_in, + descendant: Some(descendant), + }, + } + } + + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant. + pub fn descendant(&self) -> &Option { + match self { + CanonicalReason::Assumed { descendant, .. } => descendant, + CanonicalReason::Anchor { descendant, .. } => descendant, + CanonicalReason::ObservedIn { descendant, .. } => descendant, + } + } + + /// Returns true if this reason represents a transitive canonicalization + /// (i.e., the transaction is canonical because of its descendant). + pub fn is_transitive(&self) -> bool { + self.descendant().is_some() + } + + /// Returns true if this reason is [`CanonicalReason::Assumed`]. + pub fn is_assumed(&self) -> bool { + matches!(self, CanonicalReason::Assumed { .. }) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::local_chain::LocalChain; + use crate::ChainPosition; + use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; + + #[test] + fn test_canonicalization_task_sans_io() { + // Create a simple chain + let blocks = [ + (0, BlockHash::all_zeros()), + (1, BlockHash::from_byte_array([1; 32])), + (2, BlockHash::from_byte_array([2; 32])), + ]; + let chain = LocalChain::from_blocks(blocks.into_iter().collect()).unwrap(); + let chain_tip = chain.tip().block_id(); + + // Create a simple transaction graph + let mut tx_graph = TxGraph::default(); + + // Add a transaction + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: bitcoin::Amount::from_sat(1000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }; + let _ = tx_graph.insert_tx(tx.clone()); + let txid = tx.compute_txid(); + + // Add an anchor at height 1 + let anchor = crate::ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 12345, + }; + let _ = tx_graph.insert_anchor(txid, anchor); + + // 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); + + // Should have one canonical transaction + assert_eq!(canonical_view.txs().len(), 1); + let canon_tx = canonical_view.txs().next().unwrap(); + assert_eq!(canon_tx.txid, txid); + assert_eq!(canon_tx.tx.compute_txid(), txid); + + // Should be confirmed (anchored) + assert!(matches!(canon_tx.pos, ChainPosition::Confirmed { .. })); + } +} diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs new file mode 100644 index 0000000000..d92e304741 --- /dev/null +++ b/crates/chain/src/canonical_view_task.rs @@ -0,0 +1,301 @@ +//! Phase 2 task: resolves canonical reasons into chain positions. + +use crate::canonical_task::{CanonicalReason, ObservedIn}; +use crate::collections::{HashMap, HashSet, 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 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. + #[default] + ResolvingPositions, + /// All processing is complete. + Finished, +} + +/// Resolves [`CanonicalReason`]s into [`ChainPosition`]s. +/// +/// This task implements the second phase of canonicalization: given a set of canonical +/// transactions with their reasons (from [`CanonicalTask`](crate::CanonicalTask)), it resolves each +/// 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> { + tx_graph: &'g TxGraph, + tip: BlockId, + + /// Transactions in canonical order with their reasons. + canonical_order: Vec, + canonical_txs: HashMap, CanonicalReason)>, + 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, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor> CanonicalViewTask<'g, A> { + /// 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( + tx_graph: &'g TxGraph, + tip: BlockId, + order: Vec, + txs: HashMap, CanonicalReason)>, + spends: HashMap, + ) -> Self { + let all_anchors = tx_graph.all_anchors(); + + let mut unprocessed_anchor_checks = VecDeque::new(); + for txid in &order { + if let Some((_, reason)) = txs.get(txid) { + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + if reason.is_transitive() || reason.is_assumed() { + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + } + } + + Self { + tx_graph, + tip, + canonical_order: order, + canonical_txs: txs, + spends, + unprocessed_anchor_checks, + direct_anchors: HashMap::new(), + current_stage: ViewStage::default(), + } + } +} + +/// Topologically sort [`Txid`]s (parents before children) using Kahn's algorithm. +/// +/// Given a set of canonical transactions and their spending relationships, +/// returns a new ordering where for every spending relationship A -> B +/// (where B spends an output of A), A appears before B. +/// +/// The algorithm works in three phases: +/// +/// 1. **Build the dependency graph**: Using the `spends` map, derive +/// parent->child edges. Each `(outpoint, child_txid)` entry means +/// `outpoint.txid` (the parent) must come before `child_txid`. Only edges +/// where both parent and child are in the canonical set are considered. +/// +/// 2. **Find roots**: [`Txid`]s with `in_degree == 0` have no canonical +/// parents and can appear first. These seed the processing queue. +/// +/// 3. **BFS traversal**: Dequeue a [`Txid`], append it to the result, +/// and decrement the `in_degree` of each of its children. Whenever a +/// child reaches `in_degree == 0`, all its parents have been placed, +/// so it is enqueued. +/// +/// # Note +/// +/// The relative order among unrelated transactions (those with no spending +/// relationship) is not guaranteed to be deterministic across runs, since +/// it depends on `HashMap` iteration order. +fn sort_topologically(order: &[Txid], spends: &HashMap) -> Vec { + // Set of canonical txids — we only consider parent→child edges where + // both sides are in this set. + let canonical_set: HashSet = order.iter().copied().collect(); + + // in_degree[txid] tracks how many canonical parents this tx spends from. + // A tx with in_degree 0 has no canonical parents and can appear first. + let mut in_degree: HashMap = order.iter().map(|&txid| (txid, 0)).collect(); + + // Adjacency list: maps each parent txid to the children that spend its + // outputs. Derived from `spends` where each entry (outpoint → child_txid) + // gives us an edge from outpoint.txid (parent) to child_txid. + let mut children: HashMap> = HashMap::new(); + + for (outpoint, &child) in spends { + let parent = outpoint.txid; + // Only consider edges where the parent is also canonical — spending + // from a tx outside this canonical set is not a dependency. + if canonical_set.contains(&parent) { + children.entry(parent).or_default().push(child); + *in_degree.entry(child).or_default() += 1; + } + } + + // Seed the queue with root transactions (those with no canonical parents). + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, &d)| d == 0) + .map(|(&txid, _)| txid) + .collect(); + + // Process the queue: each time we "remove" a tx from the graph, we + // decrement the in_degree of its children. When a child reaches + // in_degree 0, all its parents have been placed so it is ready. + let mut sorted = Vec::with_capacity(order.len()); + while let Some(txid) = queue.pop_front() { + sorted.push(txid); + if let Some(deps) = children.get(&txid) { + for &child in deps { + let d = in_degree.get_mut(&child).unwrap(); + *d -= 1; + if *d == 0 { + queue.push_back(child); + } + } + } + } + + sorted +} + +impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.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 resolve_query(&mut self, response: ChainResponse) { + 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); + } + } + } + ViewStage::Finished => { + debug_assert!(false, "resolve_query called in Finished stage"); + } + } + } + + fn finish(self) -> Self::Output { + let mut view_order = Vec::new(); + 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; + } + }; + + // 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, + }, + }, + }; + + view_txs.insert(*txid, (tx.clone(), chain_position.cloned())); + } + } + + let view_order = sort_topologically(&view_order, &self.spends); + CanonicalView::new(self.tip, view_order, view_txs, self.spends) + } +} diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 43d41e2ed4..7ec88fba8f 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -1,4 +1,4 @@ -use bitcoin::{constants::COINBASE_MATURITY, OutPoint, TxOut, Txid}; +use bitcoin::Txid; use crate::Anchor; @@ -161,100 +161,6 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FullTxOut { - /// The position of the transaction in `outpoint` in the overall chain. - pub chain_position: ChainPosition, - /// The location of the `TxOut`. - pub outpoint: OutPoint, - /// The `TxOut`. - pub txout: TxOut, - /// The txid and chain position of the transaction (if any) that has spent this output. - pub spent_by: Option<(ChainPosition, Txid)>, - /// Whether this output is on a coinbase transaction. - pub is_on_coinbase: bool, -} - -impl Ord for FullTxOut { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - self.chain_position - .cmp(&other.chain_position) - // Tie-break with `outpoint` and `spent_by`. - .then_with(|| self.outpoint.cmp(&other.outpoint)) - .then_with(|| self.spent_by.cmp(&other.spent_by)) - } -} - -impl PartialOrd for FullTxOut { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl FullTxOut { - /// Whether the `txout` is considered mature. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_mature(&self, tip: u32) -> bool { - if self.is_on_coinbase { - let conf_height = match self.chain_position.confirmation_height_upper_bound() { - Some(height) => height, - None => { - debug_assert!(false, "coinbase tx can never be unconfirmed"); - return false; - } - }; - let age = tip.saturating_sub(conf_height); - if age + 1 < COINBASE_MATURITY { - return false; - } - } - - true - } - - /// Whether the utxo is/was/will be spendable with chain `tip`. - /// - /// This method does not take into account the lock time. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { - if !self.is_mature(tip) { - return false; - } - - let conf_height = match self.chain_position.confirmation_height_upper_bound() { - Some(height) => height, - None => return false, - }; - if conf_height > tip { - return false; - } - - // if the spending tx is confirmed within tip height, the txout is no longer spendable - if let Some(spend_height) = self - .spent_by - .as_ref() - .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) - { - if spend_height <= tip { - return false; - } - } - - true - } -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { diff --git a/crates/chain/src/chain_oracle.rs b/crates/chain/src/chain_oracle.rs deleted file mode 100644 index 08e697ed4c..0000000000 --- a/crates/chain/src/chain_oracle.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::BlockId; - -/// Represents a service that tracks the blockchain. -/// -/// The main method is [`is_block_in_chain`] which determines whether a given block of [`BlockId`] -/// is an ancestor of the `chain_tip`. -/// -/// [`is_block_in_chain`]: Self::is_block_in_chain -pub trait ChainOracle { - /// Error type. - type Error: core::fmt::Debug; - - /// Determines whether `block` of [`BlockId`] exists as an ancestor of `chain_tip`. - /// - /// If `None` is returned, it means the implementation cannot determine whether `block` exists - /// under `chain_tip`. - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error>; - - /// Get the best chain's chain tip. - fn get_chain_tip(&self) -> Result; -} diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 6933784017..496367ef26 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -1,14 +1,13 @@ //! Contains the [`IndexedTxGraph`] and associated types. Refer to the //! [`IndexedTxGraph`] documentation for more. -use core::{convert::Infallible, fmt::Debug}; +use core::fmt::Debug; use alloc::{sync::Arc, vec::Vec}; use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Indexer, Merge, - TxPosInBlock, + Anchor, BlockId, CanonicalParams, CanonicalTask, Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -427,36 +426,31 @@ where } } +impl AsRef> for IndexedTxGraph { + fn as_ref(&self) -> &TxGraph { + &self.graph + } +} + impl IndexedTxGraph where A: Anchor, { - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - self.graph.try_canonical_view(chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`](crate::CanonicalView) of + /// transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] + /// 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. /// - /// This is the infallible version of [`try_canonical_view`](Self::try_canonical_view). - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, + /// [`CanonicalView`]: crate::CanonicalView + pub fn canonical_task( + &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - self.graph.canonical_view(chain, chain_tip, params) - } -} - -impl AsRef> for IndexedTxGraph { - fn as_ref(&self) -> &TxGraph { - &self.graph + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index be9170b1a5..41ed7cc098 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -42,12 +42,12 @@ mod tx_data_traits; pub use tx_data_traits::*; pub mod tx_graph; pub use tx_graph::TxGraph; -mod chain_oracle; -pub use chain_oracle::*; -mod canonical_iter; -pub use canonical_iter::*; -mod canonical_view; -pub use canonical_view::*; +mod canonical_task; +pub use canonical_task::*; +mod canonical; +pub use canonical::*; +mod canonical_view_task; +pub use canonical_view_task::*; #[doc(hidden)] pub mod example_utils; diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5c938ee473..ee1d67fe0d 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -1,13 +1,12 @@ -//! The [`LocalChain`] is a local implementation of [`ChainOracle`]. +//! The [`LocalChain`] is a local chain of checkpoints. -use core::convert::Infallible; use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{BlockId, ChainOracle, Merge}; -pub use bdk_core::{CheckPoint, CheckPointIter}; -use bdk_core::{CheckPointEntry, ToBlockHash}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, Merge, TxGraph}; +use bdk_core::{ChainQuery, ToBlockHash}; +pub use bdk_core::{CheckPoint, CheckPointEntry, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -61,7 +60,7 @@ where Ok(init_cp) } -/// This is a local implementation of [`ChainOracle`]. +/// A local chain of checkpoints. #[derive(Debug, Clone)] pub struct LocalChain { tip: CheckPoint, @@ -73,33 +72,90 @@ impl PartialEq for LocalChain { } } -impl ChainOracle for LocalChain { - type Error = Infallible; - - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error> { +// 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 Ok(None), + _ => return None, }; - match chain_tip_cp.get(block.height) { - Some(cp) => Ok(Some(cp.hash() == block.hash)), - None => Ok(None), + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) + } + + /// Get the chain tip. + /// + /// # Returns + /// The [`BlockId`] of the chain tip. + pub fn chain_tip(&self) -> BlockId { + 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() } - fn get_chain_tip(&self) -> Result { - Ok(self.tip.block_id()) + /// 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)) } -} -// Methods for `LocalChain` -impl LocalChain { /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 72f8f4876f..560217b3b6 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -21,18 +21,25 @@ //! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called //! canonicalization is required to get a conflict-free view of transactions. //! -//! * [`canonical_iter`](TxGraph::canonical_iter) returns a [`CanonicalIter`] which performs -//! incremental canonicalization. This is useful when you only need to check specific transactions -//! (e.g., verifying whether a few unconfirmed transactions are canonical) without computing the -//! entire canonical view. -//! * [`canonical_view`](TxGraph::canonical_view) returns a [`CanonicalView`] which provides a -//! complete canonical view of the graph. This is required for typical wallet operations like -//! querying balances, listing outputs, transactions, and UTXOs. You must construct this first -//! before performing these operations. +//! The canonicalization process uses a two-step, sans-IO approach: //! -//! All these methods require a `chain` and `chain_tip` argument. The `chain` must be a -//! [`ChainOracle`] implementation (such as [`LocalChain`](crate::local_chain::LocalChain)) which -//! identifies which blocks exist under a given `chain_tip`. +//! 1. **Create a canonicalization task** using [`canonical_task`]: ```ignore let task = +//! tx_graph.canonical_task(params);```This creates a [`CanonicalTask`] that encapsulates the +//! canonicalization logic without performing any I/O operations. +//! +//! 2. **Execute the task** with a chain oracle to obtain a [`CanonicalView`]: ```ignore let view = +//! chain.canonicalize(task); ``` The chain oracle (such as +//! [`LocalChain`](crate::local_chain::LocalChain)) handles all anchor verification queries from +//! the task. +//! +//! The [`CanonicalView`] provides a complete canonical view of the graph. This is required for +//! typical wallet operations like querying balances, listing outputs, transactions, and UTXOs. +//! You must construct this view before performing these operations. +//! +//! The separation between task creation and execution (sans-IO pattern) enables: +//! * Better testability - tasks can be tested without a real chain +//! * Flexibility - different chain oracle implementations can be used +//! * Clean separation of concerns - canonicalization logic is isolated from I/O //! //! The canonicalization algorithm uses the following associated data to determine which //! transactions have precedence over others: @@ -116,14 +123,15 @@ //! let changeset = graph.apply_update(update); //! assert!(changeset.is_empty()); //! ``` +//! //! [`insert_txout`]: TxGraph::insert_txout +//! [`CanonicalView`]: crate::CanonicalView +//! [`canonical_task`]: TxGraph::canonical_task use crate::collections::*; -use crate::BlockId; -use crate::CanonicalIter; -use crate::CanonicalView; -use crate::CanonicalizationParams; -use crate::{Anchor, ChainOracle, Merge}; +use crate::CanonicalParams; +use crate::CanonicalTask; +use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; @@ -131,10 +139,7 @@ use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; -use core::{ - convert::Infallible, - ops::{Deref, RangeInclusive}, -}; +use core::ops::{Deref, RangeInclusive}; impl From> for TxUpdate { fn from(graph: TxGraph) -> Self { @@ -972,6 +977,22 @@ impl TxGraph { let _ = self.insert_evicted_at(txid, evicted_at); } } + + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] + /// 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( + &'_ self, + chain_tip: BlockId, + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + CanonicalTask::new(self, chain_tip, params) + } } impl TxGraph { @@ -1000,36 +1021,6 @@ impl TxGraph { }) } - /// Returns a [`CanonicalIter`]. - pub fn canonical_iter<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalIter<'a, A, C> { - CanonicalIter::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - CanonicalView::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - CanonicalView::new(self, chain, chain_tip, params).expect("infallible") - } - /// Construct a `TxGraph` from a `changeset`. pub fn from_changeset(changeset: ChangeSet) -> Self { let mut graph = Self::default(); diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 29f36169ad..7bdac78a50 100644 --- a/crates/chain/tests/common/tx_template.rs +++ b/crates/chain/tests/common/tx_template.rs @@ -4,7 +4,7 @@ use bdk_testenv::utils::DESCRIPTORS; use rand::distributions::{Alphanumeric, DistString}; use std::collections::HashMap; -use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalizationParams}; +use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalParams}; use bitcoin::{ locktime::absolute::LockTime, secp256k1::Secp256k1, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, @@ -57,7 +57,7 @@ pub struct TxTemplateEnv<'a, A> { pub tx_graph: TxGraph, pub indexer: SpkTxOutIndex, pub txid_to_name: HashMap<&'a str, Txid>, - pub canonicalization_params: CanonicalizationParams, + pub canonicalization_params: CanonicalParams, } #[allow(dead_code)] @@ -79,7 +79,7 @@ pub fn init_graph<'a, A: Anchor + Clone + 'a>( }); let mut txid_to_name = HashMap::<&'a str, Txid>::new(); - let mut canonicalization_params = CanonicalizationParams::default(); + let mut canonicalization_params = CanonicalParams::default(); for (bogus_txin_vout, tx_tmp) in tx_templates.into_iter().enumerate() { let tx = Transaction { version: transaction::Version::non_standard(0), diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 3c0d54381c..2cbe021033 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -53,9 +53,8 @@ fn test_min_confirmations_parameter() { }; let _ = tx_graph.insert_anchor(txid, anchor_height_5); - let chain_tip = chain.tip().block_id(); let canonical_view = - tx_graph.canonical_view(&chain, chain_tip, CanonicalizationParams::default()); + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( @@ -142,11 +141,8 @@ fn test_min_confirmations_with_untrusted_tx() { }; let _ = tx_graph.insert_anchor(txid, anchor); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let canonical_view = + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); // Test with min_confirmations = 5 and untrusted predicate let balance = canonical_view.balance( @@ -263,11 +259,8 @@ fn test_min_confirmations_multiple_transactions() { ); outpoints.push(((), outpoint2)); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let canonical_view = + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed diff --git a/crates/chain/tests/test_canonical_view_task.rs b/crates/chain/tests/test_canonical_view_task.rs new file mode 100644 index 0000000000..397819f4fc --- /dev/null +++ b/crates/chain/tests/test_canonical_view_task.rs @@ -0,0 +1,190 @@ +#![cfg(feature = "miniscript")] + +mod common; + +use bdk_chain::{CanonicalReason, ChainPosition}; +use bdk_testenv::{block_id, hash, local_chain}; +use bitcoin::Txid; +use common::*; +use std::collections::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![ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + (5, hash!("block5")), + (6, hash!("block6")), + (7, hash!("block7")), + (8, hash!("block8")), + (9, hash!("block9")), + (10, hash!("block10")) + ]; + let chain_tip = local_chain.tip().block_id(); + + // create arrays before scenario to avoid lifetime issues + let tx_templates = [ + TxTemplate { + tx_name: "txA", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(100000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "txB", + inputs: &[TxInTemplate::PrevTx("txA", 0)], + outputs: &[TxOutTemplate::new(50000, Some(0))], + anchors: &[block_id!(5, "block5")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "txC", + inputs: &[TxInTemplate::PrevTx("txB", 0)], + outputs: &[TxOutTemplate::new(25000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: true, + }, + ]; + + let exp_canonical_txs = HashSet::from(["txA", "txB", "txC"]); + + let env = init_graph(&tx_templates); + + // get the actual txid from given tx_name. + let txid_c = *env.txid_to_name.get("txC").unwrap(); + + // build the expected `CanonicalReason` with specific descendant txid's + // + // in this scenario: txC is assumed canonical, and it's descendant of txB and txA + // therefore the whole chain should become assumed canonical. + // + // the descendant txid field refers to the directly **assumed canonical txC** + let exp_reasons = vec![ + ( + "txA", + CanonicalReason::Assumed { + descendant: Some(txid_c), + }, + ), + ( + "txB", + CanonicalReason::Assumed { + descendant: Some(txid_c), + }, + ), + ("txC", CanonicalReason::Assumed { descendant: None }), + ]; + + // 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); + + // assert canonical transactions + let exp_canonical_txids: HashSet = exp_canonical_txs + .iter() + .map(|tx_name| { + *env.txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name") + }) + .collect::>(); + + let canonical_txids = canonical_txs + .txs() + .map(|canonical_tx| canonical_tx.txid) + .collect::>(); + + assert_eq!( + canonical_txids, exp_canonical_txids, + "[{}] canonical transactions mismatch", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical" + ); + + // assert canonical reasons + for (tx_name, exp_reason) in exp_reasons { + let txid = env + .txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name"); + + let canonical_reason = canonical_txs + .txs() + .find(|ctx| &ctx.txid == txid) + .expect("expected txid should exist in canonical txs") + .pos; + + assert_eq!( + canonical_reason, exp_reason, + "[{}] canonical reason mismatch for {}", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name + ) + } + + let txid_b = *env.txid_to_name.get("txB").unwrap(); + + // build the expected `ChainPosition` with specific txid's for transitively confirmed txs. + // + // in this scenario: + // + // txA: should be confirmed transitively by txB. + // txB: should be confirmed, has a direct anchor(block5). + // txC: should be unconfirmed, has been assumed canonical though has no direct anchors. + let exp_positions = vec![ + ( + "txA", + ChainPosition::Confirmed { + anchor: block_id!(5, "block5"), + transitively: Some(txid_b), + }, + ), + ( + "txB", + ChainPosition::Confirmed { + anchor: block_id!(5, "block5"), + transitively: None, + }, + ), + ( + "txC", + ChainPosition::Unconfirmed { + first_seen: None, + last_seen: None, + }, + ), + ]; + + // build task & resolve positions + let view_task = canonical_txs.view_task(&env.tx_graph); + let canonical_view = local_chain.canonicalize(view_task); + + // assert final positions + for (tx_name, exp_position) in exp_positions { + let txid = *env + .txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name"); + + let canonical_position = canonical_view + .txs() + .find(|ctx| ctx.txid == txid) + .expect("expected txid should exist in canonical view") + .pos; + + assert_eq!( + canonical_position, exp_position, + "[{}] canonical position mismatch for {}", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name + ); + } +} diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 2a33f3b1c3..96cafcb8ed 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -10,8 +10,7 @@ use bdk_chain::{ indexer::keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, spk_txout::SpkTxOutIndex, - tx_graph, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, - SpkIterator, + tx_graph, Balance, ChainPosition, ConfirmationBlockTime, DescriptorExt, SpkIterator, }; use bdk_testenv::{ anyhow::{self}, @@ -304,7 +303,7 @@ fn insert_relevant_txs() { } /// Ensure consistency IndexedTxGraph list_* and balance methods. These methods lists -/// relevant txouts and utxos from the information fetched from a ChainOracle (here a LocalChain). +/// relevant txouts and utxos from the information fetched from a LocalChain. /// /// Test Setup: /// @@ -469,28 +468,28 @@ fn test_list_owned_txouts() { .get(height) .map(|cp| cp.block_id()) .unwrap_or_else(|| panic!("block must exist at {height}")); - let txouts = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + + let canonical_view = + local_chain.canonical_view(graph.graph(), chain_tip, Default::default()); + + let txouts = canonical_view .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let utxos = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let utxos = canonical_view .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let balance = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) - .balance( - graph.index.outpoints().iter().cloned(), - |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 1, - ); + let balance = canonical_view.balance( + graph.index.outpoints().iter().cloned(), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), + 0, + ); let confirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -501,7 +500,7 @@ fn test_list_owned_txouts() { let unconfirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -512,7 +511,7 @@ fn test_list_owned_txouts() { let confirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -523,7 +522,7 @@ fn test_list_owned_txouts() { let unconfirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -788,12 +787,8 @@ fn test_get_chain_position() { } // check chain position - let chain_pos = graph - .canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let chain_pos = chain + .canonical_view(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_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index b2a3596085..2eab614cb2 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -2,13 +2,14 @@ #[macro_use] mod common; -use bdk_chain::{collections::*, BlockId, CanonicalizationParams, ConfirmationBlockTime}; +use bdk_chain::{collections::*, BlockId, ConfirmationBlockTime}; use bdk_chain::{ local_chain::LocalChain, tx_graph::{self, CalculateFeeError}, tx_graph::{ChangeSet, TxGraph}, - Anchor, ChainOracle, ChainPosition, Merge, + Anchor, ChainPosition, Merge, }; +use bdk_testenv::local_chain; use bdk_testenv::{block_id, hash, utils::new_tx}; use bitcoin::hex::FromHex; use bitcoin::Witness; @@ -758,7 +759,7 @@ fn test_walk_ancestors() { let tx_node = graph.get_tx_node(tx.compute_txid())?; for block in tx_node.anchors { match local_chain.is_block_in_chain(block.anchor_block(), tip.block_id()) { - Ok(Some(true)) => return None, + Some(true) => return None, _ => continue, } } @@ -1014,8 +1015,8 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + chain + .canonical_view(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() @@ -1023,8 +1024,8 @@ fn test_chain_spends() { let build_canonical_positions = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap> { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + chain + .canonical_view(tx_graph, tip.block_id(), Default::default()) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1197,35 +1198,23 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .into_iter() .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); - let canonical_txs: Vec<_> = graph - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let canonical_txs: Vec<_> = chain + .canonical_view(&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 canonical_view = graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); - let mut canonical_txs = canonical_view.txs(); + let view = chain.canonical_view(&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); // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); - let canonical_txids: Vec<_> = graph - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let canonical_txids: Vec<_> = chain + .canonical_view(&graph, chain.tip().block_id(), Default::default()) .txs() .map(|tx| tx.txid) .collect(); @@ -1537,3 +1526,512 @@ fn test_get_first_seen_of_a_tx() { let first_seen = graph.get_tx_node(txid).unwrap().first_seen; assert_eq!(first_seen, Some(seen_at)); } + +/// A helper structure to constructs multiple [`TxGraph`] scenarios, used in +/// `test_list_ordered_canonical_txs`. +struct Scenario<'a> { + /// Name of the test scenario + name: &'a str, + /// Transaction templates + tx_templates: &'a [TxTemplate<'a, BlockId>], + /// Names of txs that must exist in the output of `list_canonical_txs` + exp_chain_txs: Vec<&'a str>, +} + +/// A helper method to assert the expected topological order for a given [`Vec`]. +fn is_ordered_topologically(txs: Vec, tx_graph: TxGraph) -> bool { + let mut seen: HashSet = HashSet::new(); + + for txid in txs { + let tx = tx_graph.get_tx(txid).expect("should exist"); + let inputs: Vec = tx + .input + .iter() + .map(|txin| txin.previous_output.txid) + .collect(); + + // assert that all the txin's have been seen already + for input_txid in inputs { + if !seen.contains(&input_txid) { + return false; + } + } + + // Add current transaction to seen set + seen.insert(txid); + } + + true +} + +#[test] +fn test_list_ordered_canonical_txs() { + // chain + let local_chain: LocalChain = local_chain!( + (0, hash!("A")), + (1, hash!("B")), + (2, hash!("C")), + (3, hash!("D")), + (4, hash!("E")), + (5, hash!("F")), + (6, hash!("G")) + ); + let chain_tip = local_chain.tip().block_id(); + + let scenarios = [ + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and have no anchor or last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from([]), + }, + // a0 b0 c0 + Scenario { + name: "A, B and C are roots, does not spend from any other transaction, and are all have the same `last_seen`", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "B", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "C", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["A", "B", "C"]), + }, + // a0 + // \ + // b0 + // \ + // \ c0 + // \ / + // d0 + Scenario { + name: "b0 spends a0, d0 spends both b0 and c0, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0), TxInTemplate::PrevTx("c0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 c0 + // \ + // b0 + // \ + // d0 + Scenario { + name: "b0 spends a0, d0 spends b0, and a0, b0 and c0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 + // \ + // b0 + // \ + // c0 + Scenario { + name: "c0 spend a0, b0 spend a0, and a0, b0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 + // / \ + // b0 b1 + // / \ \ + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0),], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "b1", "c1", "d0"]), + }, + // a0 d0 e0 + // / / \ + // b0 f0 f1 + // / \ / + // c0 g0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 does not spend any nor is spent by, g0 spends f0, f1, and f0 and f1 spends e0, and a0, d0, and e0 are in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(2, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(4, "E")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f1", + inputs: &[TxInTemplate::PrevTx("e0", 1)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "g0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("f1", 0)], + outputs: &[TxOutTemplate::new(1000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + } + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0", "e0", "f0", "f1", "g0"]), + }, + // a0 + // / \ \ + // e0 / b1 + // / / \ + // f0 / \ + // \/ \ + // b0 \ + // / \ / + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spends both f0 and a0, f0 spend e0, e0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + // outputs: &[TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 2)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0), ], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "e0", "f0", "b0", "c0", "b1", "c1", "d0"]), + }]; + + for (_, scenario) in scenarios.iter().enumerate() { + let env = init_graph(scenario.tx_templates.iter()); + + let canonical_view = + local_chain.canonical_view(&env.tx_graph, chain_tip, env.canonicalization_params); + + let canonical_txs: Vec = canonical_view.txs().map(|tx| tx.txid).collect(); + + let exp_txs = scenario + .exp_chain_txs + .iter() + .map(|txid| *env.txid_to_name.get(txid).expect("txid must exist")) + .collect::>(); + + assert_eq!( + canonical_txs.iter().copied().collect::>(), + exp_txs, + "\n[{}] 'list_canonical_txs' failed", + scenario.name + ); + + assert!( + is_ordered_topologically(canonical_txs, env.tx_graph), + "\n[{}] 'list_canonical_txs' failed to output the txs in topological order", + scenario.name + ); + } +} diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 38f21365c3..f1f1669844 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -970,9 +970,13 @@ fn test_tx_conflict_handling() { for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let txs = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let canonical_view = local_chain.canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ); + + let txs = canonical_view .txs() .map(|tx| tx.txid) .collect::>(); @@ -987,9 +991,7 @@ fn test_tx_conflict_handling() { scenario.name ); - let txouts = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let txouts = canonical_view .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1007,9 +1009,7 @@ fn test_tx_conflict_handling() { scenario.name ); - let utxos = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let utxos = canonical_view .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1027,18 +1027,15 @@ fn test_tx_conflict_handling() { scenario.name ); - let balance = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) - .balance( - env.indexer.outpoints().iter().cloned(), - |_, txout| { - env.indexer - .index_of_spk(txout.txout.script_pubkey.as_script()) - .is_some() - }, - 0, - ); + let balance = canonical_view.balance( + env.indexer.outpoints().iter().cloned(), + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.as_script()) + .is_some() + }, + 0, + ); assert_eq!( balance, scenario.exp_balance, "\n[{}] 'balance' failed", diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs new file mode 100644 index 0000000000..660c1c74ff --- /dev/null +++ b/crates/core/src/chain_query.rs @@ -0,0 +1,41 @@ +//! 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/lib.rs b/crates/core/src/lib.rs index cf02a99b05..3d9b47aa5f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -75,3 +75,6 @@ mod merge; pub use merge::*; pub mod spk_client; + +mod chain_query; +pub use chain_query::*; diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index ef94c62f3e..c569b065da 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -3,8 +3,7 @@ use bdk_chain::{ local_chain::LocalChain, spk_client::{FullScanRequest, SyncRequest, SyncResponse}, spk_txout::SpkTxOutIndex, - Balance, CanonicalizationParams, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, - TxGraph, + Balance, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, }; use bdk_core::bitcoin::{ key::{Secp256k1, UntweakedPublicKey}, @@ -56,11 +55,14 @@ fn get_balance( recv_chain: &LocalChain, recv_graph: &IndexedTxGraph>, ) -> anyhow::Result { - let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let balance = recv_chain + .canonical_view( + recv_graph.graph(), + recv_chain.tip().block_id(), + Default::default(), + ) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -174,8 +176,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; @@ -204,8 +205,7 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(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 e6903591c7..2941e2a669 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,8 +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( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; @@ -130,8 +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( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(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 4619901cc8..8db59947fa 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,8 +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( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; @@ -127,8 +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( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + { chain.canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) } .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs new file mode 100644 index 0000000000..118483ecbb --- /dev/null +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -0,0 +1,415 @@ +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use bdk_bitcoind_rpc::{ + bitcoincore_rpc::{Auth, Client, RpcApi}, + Emitter, +}; +use bdk_chain::{bitcoin::Block, local_chain, Merge}; +use example_cli::{ + anyhow, + clap::{self, Args, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_rpc"; +const DB_PATH: &str = ".bdk_example_rpc.db"; + +/// The mpsc channel bound for emissions from [`Emitter`]. +const CHANNEL_BOUND: usize = 10; +/// Delay for printing status to stdout. +const STDOUT_PRINT_DELAY: Duration = Duration::from_secs(6); +/// Delay between mempool emissions. +const MEMPOOL_EMIT_DELAY: Duration = Duration::from_secs(30); +/// Delay for committing to persistence. +const DB_COMMIT_DELAY: Duration = Duration::from_secs(60); + +#[derive(Debug)] +enum Emission { + Block(bdk_bitcoind_rpc::BlockEvent), + Mempool(bdk_bitcoind_rpc::MempoolEvent), + Tip(u32), +} + +#[derive(Args, Debug, Clone)] +struct RpcArgs { + /// RPC URL + #[clap(env = "RPC_URL", long, default_value = "127.0.0.1:8332")] + url: String, + /// RPC auth cookie file + #[clap(env = "RPC_COOKIE", long)] + rpc_cookie: Option, + /// RPC auth username + #[clap(env = "RPC_USER", long)] + rpc_user: Option, + /// RPC auth password + #[clap(env = "RPC_PASS", long)] + rpc_password: Option, + /// Starting block height to fallback to if no point of agreement if found + #[clap(env = "FALLBACK_HEIGHT", long, default_value = "0")] + fallback_height: u32, +} + +impl From for Auth { + fn from(args: RpcArgs) -> Self { + match (args.rpc_cookie, args.rpc_user, args.rpc_password) { + (None, None, None) => Self::None, + (Some(path), _, _) => Self::CookieFile(path), + (_, Some(user), Some(pass)) => Self::UserPass(user, pass), + (_, Some(_), None) => panic!("rpc auth: missing rpc_pass"), + (_, None, Some(_)) => panic!("rpc auth: missing rpc_user"), + } + } +} + +impl RpcArgs { + fn new_client(&self) -> anyhow::Result { + Ok(Client::new( + &self.url, + match (&self.rpc_cookie, &self.rpc_user, &self.rpc_password) { + (None, None, None) => Auth::None, + (Some(path), _, _) => Auth::CookieFile(path.clone()), + (_, Some(user), Some(pass)) => Auth::UserPass(user.clone(), pass.clone()), + (_, Some(_), None) => panic!("rpc auth: missing rpc_pass"), + (_, None, Some(_)) => panic!("rpc auth: missing rpc_user"), + }, + )?) + } +} + +#[derive(Subcommand, Debug, Clone)] +enum RpcCommands { + /// Syncs local state with remote state via RPC (starting from last point of agreement) and + /// stores/indexes relevant transactions + Sync { + #[clap(flatten)] + rpc_args: RpcArgs, + }, + /// Sync by having the emitter logic in a separate thread + Live { + #[clap(flatten)] + rpc_args: RpcArgs, + }, +} + +fn main() -> anyhow::Result<()> { + let start = Instant::now(); + + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let rpc_cmd = match args.command { + example_cli::Commands::ChainSpecific(rpc_cmd) => rpc_cmd, + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |rpc_args, tx| { + let client = rpc_args.new_client()?; + client.send_raw_transaction(tx)?; + Ok(()) + }, + general_cmd, + ); + } + }; + + match rpc_cmd { + RpcCommands::Sync { rpc_args } => { + let RpcArgs { + fallback_height, .. + } = rpc_args; + + let rpc_client = rpc_args.new_client()?; + let mut emitter = { + let chain = chain.lock().unwrap(); + let graph = graph.lock().unwrap(); + Emitter::new( + &rpc_client, + chain.tip(), + fallback_height, + { + chain.canonical_view( + graph.graph(), + chain.tip().block_id(), + Default::default(), + ) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), + ) + }; + let mut db_stage = ChangeSet::default(); + + let mut last_db_commit = Instant::now(); + let mut last_print = Instant::now(); + + while let Some(emission) = emitter.next_block()? { + let height = emission.block_height(); + + let mut chain = chain.lock().unwrap(); + let mut graph = graph.lock().unwrap(); + + let chain_changeset = chain + .apply_update(emission.checkpoint) + .expect("must always apply as we receive blocks in order from emitter"); + let graph_changeset = graph.apply_block_relevant(&emission.block, height); + db_stage.merge(ChangeSet { + local_chain: chain_changeset, + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + + // commit staged db changes in intervals + if last_db_commit.elapsed() >= DB_COMMIT_DELAY { + let db = &mut *db.lock().unwrap(); + last_db_commit = Instant::now(); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + println!( + "[{:>10}s] committed to db (took {}s)", + start.elapsed().as_secs_f32(), + last_db_commit.elapsed().as_secs_f32() + ); + } + + // print synced-to height and current balance in intervals + if last_print.elapsed() >= STDOUT_PRINT_DELAY { + last_print = Instant::now(); + let synced_to = chain.tip(); + let balance = { + { + chain.canonical_view( + graph.graph(), + synced_to.block_id(), + Default::default(), + ) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) + }; + println!( + "[{:>10}s] synced to {} @ {} | total: {}", + start.elapsed().as_secs_f32(), + synced_to.hash(), + synced_to.height(), + balance.total() + ); + } + } + + let mempool_txs = emitter.mempool()?; + let graph_changeset = graph + .lock() + .unwrap() + .batch_insert_relevant_unconfirmed(mempool_txs.update); + { + let db = &mut *db.lock().unwrap(); + db_stage.merge(ChangeSet { + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + } + } + RpcCommands::Live { rpc_args } => { + let RpcArgs { + fallback_height, .. + } = rpc_args; + let sigterm_flag = start_ctrlc_handler(); + + let rpc_client = Arc::new(rpc_args.new_client()?); + let mut emitter = { + let chain = chain.lock().unwrap(); + let graph = graph.lock().unwrap(); + Emitter::new( + rpc_client.clone(), + chain.tip(), + fallback_height, + { + chain.canonical_view( + graph.graph(), + chain.tip().block_id(), + Default::default(), + ) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), + ) + }; + + println!( + "[{:>10}s] starting emitter thread...", + start.elapsed().as_secs_f32() + ); + let (tx, rx) = std::sync::mpsc::sync_channel::(CHANNEL_BOUND); + let emission_jh = std::thread::spawn(move || -> anyhow::Result<()> { + let mut block_count = rpc_client.get_block_count()? as u32; + tx.send(Emission::Tip(block_count))?; + + loop { + match emitter.next_block()? { + Some(block_emission) => { + let height = block_emission.block_height(); + if sigterm_flag.load(Ordering::Acquire) { + break; + } + if height > block_count { + block_count = rpc_client.get_block_count()? as u32; + tx.send(Emission::Tip(block_count))?; + } + tx.send(Emission::Block(block_emission))?; + } + None => { + if await_flag(&sigterm_flag, MEMPOOL_EMIT_DELAY) { + break; + } + println!("preparing mempool emission..."); + let now = Instant::now(); + tx.send(Emission::Mempool(emitter.mempool()?))?; + println!("mempool emission prepared in {}s", now.elapsed().as_secs()); + continue; + } + }; + } + + println!("emitter thread shutting down..."); + Ok(()) + }); + + let mut tip_height = 0_u32; + let mut last_db_commit = Instant::now(); + let mut last_print = Option::::None; + let mut db_stage = ChangeSet::default(); + + for emission in rx { + let mut graph = graph.lock().unwrap(); + let mut chain = chain.lock().unwrap(); + + let (chain_changeset, graph_changeset) = match emission { + Emission::Block(block_emission) => { + let height = block_emission.block_height(); + let chain_changeset = chain + .apply_update(block_emission.checkpoint) + .expect("must always apply as we receive blocks in order from emitter"); + let graph_changeset = + graph.apply_block_relevant(&block_emission.block, height); + (chain_changeset, graph_changeset) + } + Emission::Mempool(mempool_txs) => { + let mut graph_changeset = + graph.batch_insert_relevant_unconfirmed(mempool_txs.update.clone()); + graph_changeset + .merge(graph.batch_insert_relevant_evicted_at(mempool_txs.evicted)); + (local_chain::ChangeSet::default(), graph_changeset) + } + Emission::Tip(h) => { + tip_height = h; + continue; + } + }; + + db_stage.merge(ChangeSet { + local_chain: chain_changeset, + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + + if last_db_commit.elapsed() >= DB_COMMIT_DELAY { + let db = &mut *db.lock().unwrap(); + last_db_commit = Instant::now(); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + println!( + "[{:>10}s] committed to db (took {}s)", + start.elapsed().as_secs_f32(), + last_db_commit.elapsed().as_secs_f32() + ); + } + + if last_print.map_or(Duration::MAX, |i| i.elapsed()) >= STDOUT_PRINT_DELAY { + last_print = Some(Instant::now()); + let synced_to = chain.tip(); + let balance = { + { + chain.canonical_view( + graph.graph(), + synced_to.block_id(), + Default::default(), + ) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) + }; + println!( + "[{:>10}s] synced to {} @ {} / {} | total: {}", + start.elapsed().as_secs_f32(), + synced_to.hash(), + synced_to.height(), + tip_height, + balance.total() + ); + } + } + + emission_jh.join().expect("must join emitter thread")?; + } + } + + Ok(()) +} + +#[allow(dead_code)] +fn start_ctrlc_handler() -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + let cloned_flag = flag.clone(); + + ctrlc::set_handler(move || cloned_flag.store(true, Ordering::Release)); + + flag +} + +#[allow(dead_code)] +fn await_flag(flag: &AtomicBool, duration: Duration) -> bool { + let start = Instant::now(); + loop { + if flag.load(Ordering::Acquire) { + return true; + } + if start.elapsed() >= duration { + return false; + } + std::thread::sleep(Duration::from_secs(1)); + } +} diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs new file mode 100644 index 0000000000..497e6f47a5 --- /dev/null +++ b/examples/example_cli/src/lib.rs @@ -0,0 +1,940 @@ +use bdk_chain::keychain_txout::DEFAULT_LOOKAHEAD; +use serde_json::json; +use std::cmp; +use std::convert::Infallible; +use std::env; +use std::fmt; +use std::str::FromStr; +use std::sync::Mutex; + +use anyhow::bail; +use anyhow::Context; +use bdk_chain::bitcoin::{ + absolute, address::NetworkUnchecked, bip32, consensus, constants, hex::DisplayHex, relative, + secp256k1::Secp256k1, transaction, Address, Amount, Network, NetworkKind, Psbt, Sequence, + Transaction, TxIn, TxOut, +}; +use bdk_chain::miniscript::{ + plan::{Assets, Plan}, + psbt::PsbtExt, + Descriptor, DescriptorPublicKey, ForEachKey, +}; +use bdk_chain::ConfirmationBlockTime; +use bdk_chain::{ + indexer::keychain_txout::{self, KeychainTxOutIndex}, + local_chain::{self, LocalChain}, + tx_graph, CanonicalTxOut, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, +}; +use bdk_coin_select::{ + metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, + TargetFee, TargetOutputs, +}; +use bdk_file_store::Store; +use clap::{Parser, Subcommand}; +use rand::prelude::*; + +pub use anyhow; +pub use clap; + +/// Alias for a `IndexedTxGraph` with specific `Anchor` and `Indexer`. +pub type KeychainTxGraph = IndexedTxGraph>; + +/// ChangeSet +#[derive(Default, Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ChangeSet { + /// Descriptor for recipient addresses. + pub descriptor: Option>, + /// Descriptor for change addresses. + pub change_descriptor: Option>, + /// Stores the network type of the transaction data. + pub network: Option, + /// Changes to the [`LocalChain`]. + pub local_chain: local_chain::ChangeSet, + /// Changes to [`TxGraph`](tx_graph::TxGraph). + pub tx_graph: tx_graph::ChangeSet, + /// Changes to [`KeychainTxOutIndex`]. + pub indexer: keychain_txout::ChangeSet, +} + +#[derive(Parser)] +#[clap(author, version, about, long_about = None)] +#[clap(propagate_version = true)] +pub struct Args { + #[clap(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Commands { + /// Initialize a new data store. + Init { + /// Network + #[clap(long, short, default_value = "signet")] + network: Network, + /// Descriptor + #[clap(env = "DESCRIPTOR")] + descriptor: String, + /// Change descriptor + #[clap(long, short, env = "CHANGE_DESCRIPTOR")] + change_descriptor: Option, + }, + #[clap(flatten)] + ChainSpecific(CS), + /// Address generation and inspection. + Address { + #[clap(subcommand)] + addr_cmd: AddressCmd, + }, + /// Get the wallet balance. + Balance, + /// TxOut related commands. + #[clap(name = "txout")] + TxOut { + #[clap(subcommand)] + txout_cmd: TxOutCmd, + }, + /// PSBT operations + Psbt { + #[clap(subcommand)] + psbt_cmd: PsbtCmd, + }, + /// Generate new BIP86 descriptors. + Generate { + /// Network + #[clap(long, short, default_value = "signet")] + network: Network, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum AddressCmd { + /// Get the next unused address. + Next, + /// Get a new address regardless of the existing unused addresses. + New, + /// List all addresses + List { + /// List change addresses + #[clap(long)] + change: bool, + }, + /// Get last revealed address index for each keychain. + Index, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum TxOutCmd { + /// List transaction outputs. + List { + /// Return only spent outputs. + #[clap(short, long)] + spent: bool, + /// Return only unspent outputs. + #[clap(short, long)] + unspent: bool, + /// Return only confirmed outputs. + #[clap(long)] + confirmed: bool, + /// Return only unconfirmed outputs. + #[clap(long)] + unconfirmed: bool, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PsbtCmd { + /// Create a new PSBT. + New { + /// Amount to send in satoshis + #[clap(required = true)] + value: u64, + /// Recipient address + #[clap(required = true)] + address: Address, + /// Set the feerate of the tx (sat/vbyte) + #[clap(long, short, default_value = "1.0")] + feerate: Option, + /// Set max absolute timelock (from consensus value) + #[clap(long, short)] + after: Option, + /// Set max relative timelock (from consensus value) + #[clap(long, short)] + older: Option, + /// Coin selection algorithm + #[clap(long, short, default_value = "bnb")] + coin_select: CoinSelectionAlgo, + /// Debug print the PSBT + #[clap(long, short)] + debug: bool, + }, + /// Sign with a hot signer + Sign { + /// Private descriptor [env: DESCRIPTOR=] + #[clap(long, short)] + descriptor: Option, + /// PSBT + #[clap(long, short, required = true)] + psbt: String, + }, + /// Extract transaction + Extract { + /// PSBT + #[clap(long, short, required = true)] + psbt: String, + /// Whether to try broadcasting the tx + #[clap(long, short)] + broadcast: bool, + #[clap(flatten)] + chain_specific: S, + }, +} + +#[derive( + Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, serde::Deserialize, serde::Serialize, +)] +pub enum Keychain { + External, + Internal, +} + +impl fmt::Display for Keychain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Keychain::External => write!(f, "external"), + Keychain::Internal => write!(f, "internal"), + } + } +} + +#[derive(Clone, Debug, Default)] +pub enum CoinSelectionAlgo { + LargestFirst, + SmallestFirst, + OldestFirst, + NewestFirst, + #[default] + BranchAndBound, +} + +impl FromStr for CoinSelectionAlgo { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + use CoinSelectionAlgo::*; + Ok(match s { + "largest-first" => LargestFirst, + "smallest-first" => SmallestFirst, + "oldest-first" => OldestFirst, + "newest-first" => NewestFirst, + "bnb" => BranchAndBound, + unknown => bail!("unknown coin selection algorithm '{unknown}'"), + }) + } +} + +impl fmt::Display for CoinSelectionAlgo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use CoinSelectionAlgo::*; + write!( + f, + "{}", + match self { + LargestFirst => "largest-first", + SmallestFirst => "smallest-first", + OldestFirst => "oldest-first", + NewestFirst => "newest-first", + BranchAndBound => "bnb", + } + ) + } +} + +// Records changes to the internal keychain when we +// have to include a change output during tx creation. +#[derive(Debug)] +pub struct ChangeInfo { + pub change_keychain: Keychain, + pub indexer: keychain_txout::ChangeSet, + pub index: u32, +} + +pub fn create_tx( + graph: &mut KeychainTxGraph, + chain: &LocalChain, + assets: &Assets, + cs_algorithm: CoinSelectionAlgo, + address: Address, + value: u64, + feerate: f32, +) -> anyhow::Result<(Psbt, Option)> { + let mut changeset = keychain_txout::ChangeSet::default(); + + // get planned utxos + let mut plan_utxos = planned_utxos(graph, chain, assets)?; + + // sort utxos if cs-algo requires it + match cs_algorithm { + CoinSelectionAlgo::LargestFirst => { + plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.txout.value)) + } + CoinSelectionAlgo::SmallestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.txout.value), + CoinSelectionAlgo::OldestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.pos), + CoinSelectionAlgo::NewestFirst => { + plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.pos)) + } + CoinSelectionAlgo::BranchAndBound => plan_utxos.shuffle(&mut thread_rng()), + } + + // build candidate set + let candidates: Vec = plan_utxos + .iter() + .map(|(plan, utxo)| { + Candidate::new( + utxo.txout.value.to_sat(), + plan.satisfaction_weight() as u64, + plan.witness_version().is_some(), + ) + }) + .collect(); + + // create recipient output(s) + let mut outputs = vec![TxOut { + value: Amount::from_sat(value), + script_pubkey: address.script_pubkey(), + }]; + + let (change_keychain, _) = graph + .index + .keychains() + .last() + .expect("must have a keychain"); + + let ((change_index, change_script), index_changeset) = graph + .index + .next_unused_spk(change_keychain) + .expect("Must exist"); + changeset.merge(index_changeset); + + let mut change_output = TxOut { + value: Amount::ZERO, + script_pubkey: change_script, + }; + + let change_desc = graph + .index + .keychains() + .find(|(k, _)| k == &change_keychain) + .expect("must exist") + .1; + + let min_drain_value = change_desc.dust_value().to_sat(); + + let target = Target { + outputs: TargetOutputs::fund_outputs( + outputs + .iter() + .map(|output| (output.weight().to_wu(), output.value.to_sat())), + ), + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate), + ..Default::default() + }, + }; + + let change_policy = ChangePolicy { + min_value: min_drain_value, + drain_weights: DrainWeights::TR_KEYSPEND, + }; + + // run coin selection + let mut selector = CoinSelector::new(&candidates); + match cs_algorithm { + CoinSelectionAlgo::BranchAndBound => { + let metric = LowestFee { + target, + long_term_feerate: FeeRate::from_sat_per_vb(10.0), + change_policy, + }; + match selector.run_bnb(metric, 10_000) { + Ok(_) => {} + Err(_) => selector + .select_until_target_met(target) + .context("selecting coins")?, + } + } + _ => selector + .select_until_target_met(target) + .context("selecting coins")?, + } + + // get the selected plan utxos + let selected: Vec<_> = selector.apply_selection(&plan_utxos).collect(); + + // if the selection tells us to use change and the change value is sufficient, we add it as an + // output + let mut change_info = Option::::None; + let drain = selector.drain(target, change_policy); + if drain.value > min_drain_value { + change_output.value = Amount::from_sat(drain.value); + outputs.push(change_output); + change_info = Some(ChangeInfo { + change_keychain, + indexer: changeset, + index: change_index, + }); + outputs.shuffle(&mut thread_rng()); + } + + let unsigned_tx = Transaction { + version: transaction::Version::TWO, + lock_time: assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_height(chain.chain_tip().height)?), + input: selected + .iter() + .map(|(plan, utxo)| TxIn { + previous_output: utxo.outpoint, + sequence: plan + .relative_timelock + .map_or(Sequence::ENABLE_RBF_NO_LOCKTIME, Sequence::from), + ..Default::default() + }) + .collect(), + output: outputs, + }; + + // update psbt with plan + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx)?; + for (i, (plan, utxo)) in selected.iter().enumerate() { + let psbt_input = &mut psbt.inputs[i]; + plan.update_psbt_input(psbt_input); + psbt_input.witness_utxo = Some(utxo.txout.clone()); + } + + Ok((psbt, change_info)) +} + +// Alias the elements of `planned_utxos` +pub type PlanUtxo = (Plan, CanonicalTxOut>); + +pub fn planned_utxos( + graph: &KeychainTxGraph, + chain: &LocalChain, + assets: &Assets, +) -> Result, Infallible> { + let outpoints = graph.index.outpoints(); + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .filter_unspent_outpoints(outpoints.iter().cloned()) + .filter_map(|((k, i), full_txo)| -> Option> { + let desc = graph + .index + .keychains() + .find(|(keychain, _)| *keychain == k) + .expect("keychain must exist") + .1 + .at_derivation_index(i) + .expect("i can't be hardened"); + + let plan = desc.plan(assets).ok()?; + + Some(Ok((plan, full_txo))) + }) + .collect() +} + +pub fn handle_commands( + graph: &Mutex, + chain: &Mutex, + db: &Mutex>, + network: Network, + broadcast_fn: impl FnOnce(S, &Transaction) -> anyhow::Result<()>, + cmd: Commands, +) -> anyhow::Result<()> { + match cmd { + Commands::Init { .. } => unreachable!("handled by init command"), + Commands::Generate { .. } => unreachable!("handled by generate command"), + Commands::ChainSpecific(_) => unreachable!("example code should handle this!"), + Commands::Address { addr_cmd } => { + let graph = &mut *graph.lock().unwrap(); + let index = &mut graph.index; + + match addr_cmd { + AddressCmd::Next | AddressCmd::New => { + let spk_chooser = match addr_cmd { + AddressCmd::Next => KeychainTxOutIndex::next_unused_spk, + AddressCmd::New => KeychainTxOutIndex::reveal_next_spk, + _ => unreachable!("only these two variants exist in match arm"), + }; + + let ((spk_i, spk), index_changeset) = + spk_chooser(index, Keychain::External).expect("Must exist"); + let db = &mut *db.lock().unwrap(); + db.append(&ChangeSet { + indexer: index_changeset, + ..Default::default() + })?; + let addr = Address::from_script(spk.as_script(), network)?; + println!("[address @ {spk_i}] {addr}"); + Ok(()) + } + AddressCmd::Index => { + for (keychain, derivation_index) in index.last_revealed_indices() { + println!("{keychain:?}: {derivation_index}"); + } + Ok(()) + } + AddressCmd::List { change } => { + let target_keychain = match change { + true => Keychain::Internal, + false => Keychain::External, + }; + for (spk_i, spk) in index.revealed_keychain_spks(target_keychain) { + let address = Address::from_script(spk.as_script(), network) + .expect("should always be able to derive address"); + println!( + "{:?} {} used:{}", + spk_i, + address, + index.is_used(target_keychain, spk_i) + ); + } + Ok(()) + } + } + } + Commands::Balance => { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + fn print_balances<'a>( + title_str: &'a str, + items: impl IntoIterator, + ) { + println!("{title_str}:"); + for (name, amount) in items.into_iter() { + println!(" {:<10} {:>12} sats", name, amount.to_sat()) + } + } + + let balance = chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ); + + let confirmed_total = balance.confirmed + balance.immature; + let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; + + print_balances( + "confirmed", + [ + ("total", confirmed_total), + ("spendable", balance.confirmed), + ("immature", balance.immature), + ], + ); + print_balances( + "unconfirmed", + [ + ("total", unconfirmed_total), + ("trusted", balance.trusted_pending), + ("untrusted", balance.untrusted_pending), + ], + ); + + Ok(()) + } + Commands::TxOut { txout_cmd } => { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + let chain_tip = chain.chain_tip(); + let outpoints = graph.index.outpoints(); + + match txout_cmd { + TxOutCmd::List { + spent, + unspent, + confirmed, + unconfirmed, + } => { + let txouts = chain + .canonical_view(graph.graph(), chain_tip, Default::default()) + .filter_outpoints(outpoints.iter().cloned()) + .filter(|(_, full_txo)| match (spent, unspent) { + (true, false) => full_txo.spent_by.is_some(), + (false, true) => full_txo.spent_by.is_none(), + _ => true, + }) + .filter(|(_, full_txo)| match (confirmed, unconfirmed) { + (true, false) => full_txo.pos.is_confirmed(), + (false, true) => !full_txo.pos.is_confirmed(), + _ => true, + }) + .collect::>(); + + for (spk_i, full_txo) in txouts { + let addr = Address::from_script(&full_txo.txout.script_pubkey, network)?; + println!( + "{:?} {} {} {} spent:{:?}", + spk_i, full_txo.txout.value, full_txo.outpoint, addr, full_txo.spent_by + ) + } + Ok(()) + } + } + } + Commands::Psbt { psbt_cmd } => match psbt_cmd { + PsbtCmd::New { + value, + address, + feerate, + after, + older, + coin_select, + debug, + } => { + let address = address.require_network(network)?; + + let (psbt, change_info) = { + let mut graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + + // collect assets we can sign for + let mut pks = vec![]; + for (_, desc) in graph.index.keychains() { + desc.for_each_key(|k| { + pks.push(k.clone()); + true + }); + } + let mut assets = Assets::new().add(pks); + if let Some(n) = after { + assets = assets.after(absolute::LockTime::from_consensus(n)); + } + if let Some(n) = older { + assets = assets.older(relative::LockTime::from_consensus(n)?); + } + + create_tx( + &mut graph, + &chain, + &assets, + coin_select, + address, + value, + feerate.expect("must have feerate"), + )? + }; + + if let Some(ChangeInfo { + change_keychain, + indexer, + index, + }) = change_info + { + // We must first persist to disk the fact that we've got a new address from the + // change keychain so future scans will find the tx we're about to broadcast. + // If we're unable to persist this, then we don't want to broadcast. + { + let db = &mut *db.lock().unwrap(); + db.append(&ChangeSet { + indexer, + ..Default::default() + })?; + } + + // We don't want other callers/threads to use this address while we're using it + // but we also don't want to scan the tx we just created because it's not + // technically in the blockchain yet. + graph + .lock() + .unwrap() + .index + .mark_used(change_keychain, index); + } + + if debug { + dbg!(psbt); + } else { + // print base64 encoded psbt + let fee = psbt.fee()?.to_sat(); + let mut obj = serde_json::Map::new(); + obj.insert("psbt".to_string(), json!(psbt.to_string())); + obj.insert("fee".to_string(), json!(fee)); + println!("{}", serde_json::to_string_pretty(&obj)?); + }; + + Ok(()) + } + PsbtCmd::Sign { psbt, descriptor } => { + let mut psbt = Psbt::from_str(&psbt)?; + + let desc_str = match descriptor { + Some(s) => s, + None => env::var("DESCRIPTOR").context("unable to sign")?, + }; + + let secp = Secp256k1::new(); + let (_, keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + + if keymap.is_empty() { + bail!("unable to sign") + } + + let _sign_res = psbt + .sign(&keymap, &secp) + .map_err(|errors| anyhow::anyhow!("failed to sign PSBT {errors:?}"))?; + + let mut obj = serde_json::Map::new(); + obj.insert("psbt".to_string(), json!(psbt.to_string())); + println!("{}", serde_json::to_string_pretty(&obj)?); + + Ok(()) + } + PsbtCmd::Extract { + broadcast, + chain_specific, + psbt, + } => { + let mut psbt = Psbt::from_str(&psbt)?; + psbt.finalize_mut(&Secp256k1::new()) + .map_err(|errors| anyhow::anyhow!("failed to finalize PSBT {errors:?}"))?; + + let tx = psbt.extract_tx()?; + + if broadcast { + let mut graph = graph.lock().unwrap(); + + match broadcast_fn(chain_specific, &tx) { + Ok(_) => { + println!("Broadcasted Tx: {}", tx.compute_txid()); + + let changeset = graph.insert_tx(tx); + + // We know the tx is at least unconfirmed now. Note if persisting here + // fails, it's not a big deal since we can + // always find it again from the blockchain. + db.lock().unwrap().append(&ChangeSet { + tx_graph: changeset.tx_graph, + indexer: changeset.indexer, + ..Default::default() + })?; + } + Err(e) => { + // We failed to broadcast, so allow our change address to be used in the + // future + let (change_keychain, _) = graph + .index + .keychains() + .last() + .expect("must have a keychain"); + let change_index = tx.output.iter().find_map(|txout| { + let spk = txout.script_pubkey.as_script(); + match graph.index.index_of_spk(spk) { + Some(&(keychain, index)) if keychain == change_keychain => { + Some((keychain, index)) + } + _ => None, + } + }); + if let Some((keychain, index)) = change_index { + graph.index.unmark_used(keychain, index); + } + bail!(e); + } + } + } else { + // encode raw tx hex + let hex = consensus::serialize(&tx).to_lower_hex_string(); + let mut obj = serde_json::Map::new(); + obj.insert("tx".to_string(), json!(hex)); + println!("{}", serde_json::to_string_pretty(&obj)?); + } + + Ok(()) + } + }, + } +} + +/// The initial state returned by [`init_or_load`]. +pub struct Init { + /// CLI args + pub args: Args, + /// Indexed graph + pub graph: Mutex, + /// Local chain + pub chain: Mutex, + /// Database + pub db: Mutex>, + /// Network + pub network: Network, +} + +/// Loads from persistence or creates new +pub fn init_or_load( + db_magic: &[u8], + db_path: &str, +) -> anyhow::Result>> { + let args = Args::::parse(); + + match args.command { + // initialize new db + Commands::Init { .. } => initialize::(args, db_magic, db_path).map(|_| None), + // generate keys + Commands::Generate { network } => generate_bip86_helper(network).map(|_| None), + // try load + _ => { + let (mut db, changeset) = + Store::::load(db_magic, db_path).context("could not open file store")?; + + let changeset = changeset.expect("should not be empty"); + let network = changeset.network.expect("changeset network"); + + let chain = Mutex::new({ + let (mut chain, _) = + LocalChain::from_genesis(constants::genesis_block(network).block_hash()); + chain.apply_changeset(&changeset.local_chain)?; + chain + }); + + let (graph, changeset) = IndexedTxGraph::from_changeset( + (changeset.tx_graph, changeset.indexer).into(), + |c| -> anyhow::Result<_> { + let mut indexer = + KeychainTxOutIndex::from_changeset(DEFAULT_LOOKAHEAD, true, c); + if let Some(desc) = changeset.descriptor { + indexer.insert_descriptor(Keychain::External, desc)?; + } + if let Some(change_desc) = changeset.change_descriptor { + indexer.insert_descriptor(Keychain::Internal, change_desc)?; + } + Ok(indexer) + }, + )?; + db.append(&ChangeSet { + indexer: changeset.indexer, + tx_graph: changeset.tx_graph, + ..Default::default() + })?; + + let graph = Mutex::new(graph); + let db = Mutex::new(db); + + Ok(Some(Init { + args, + graph, + chain, + db, + network, + })) + } + } +} + +/// Initialize db backend. +fn initialize(args: Args, db_magic: &[u8], db_path: &str) -> anyhow::Result<()> +where + CS: clap::Subcommand, + S: clap::Args, +{ + if let Commands::Init { + network, + descriptor, + change_descriptor, + } = args.command + { + let mut changeset = ChangeSet::default(); + + // parse descriptors + let secp = Secp256k1::new(); + let mut index = KeychainTxOutIndex::default(); + let (descriptor, _) = + Descriptor::::parse_descriptor(&secp, &descriptor)?; + let _ = index.insert_descriptor(Keychain::External, descriptor.clone())?; + changeset.descriptor = Some(descriptor); + + if let Some(desc) = change_descriptor { + let (change_descriptor, _) = + Descriptor::::parse_descriptor(&secp, &desc)?; + let _ = index.insert_descriptor(Keychain::Internal, change_descriptor.clone())?; + changeset.change_descriptor = Some(change_descriptor); + } + + // create new + let (_, chain_changeset) = + LocalChain::from_genesis(constants::genesis_block(network).block_hash()); + changeset.network = Some(network); + changeset.local_chain = chain_changeset; + let mut db = Store::::create(db_magic, db_path)?; + db.append(&changeset)?; + println!("New database {db_path}"); + } + + Ok(()) +} + +/// Generate BIP86 descriptors. +fn generate_bip86_helper(network: impl Into) -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let mut seed = [0x00; 32]; + thread_rng().fill_bytes(&mut seed); + + let m = bip32::Xpriv::new_master(network, &seed)?; + let fp = m.fingerprint(&secp); + let path = if m.network.is_mainnet() { + "86h/0h/0h" + } else { + "86h/1h/0h" + }; + + let descriptors: Vec = [0, 1] + .iter() + .map(|i| format!("tr([{fp}]{m}/{path}/{i}/*)")) + .collect(); + let external_desc = &descriptors[0]; + let internal_desc = &descriptors[1]; + let (descriptor, keymap) = + >::parse_descriptor(&secp, external_desc)?; + let (internal_descriptor, internal_keymap) = + >::parse_descriptor(&secp, internal_desc)?; + println!("Public"); + println!("{descriptor}"); + println!("{internal_descriptor}"); + println!("\nPrivate"); + println!("{}", descriptor.to_string_with_secret(&keymap)); + println!( + "{}", + internal_descriptor.to_string_with_secret(&internal_keymap) + ); + + Ok(()) +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + if other.descriptor.is_some() { + self.descriptor = other.descriptor; + } + if other.change_descriptor.is_some() { + self.change_descriptor = other.change_descriptor; + } + if other.network.is_some() { + self.network = other.network; + } + Merge::merge(&mut self.local_chain, other.local_chain); + Merge::merge(&mut self.tx_graph, other.tx_graph); + Merge::merge(&mut self.indexer, other.indexer); + } + + fn is_empty(&self) -> bool { + self.descriptor.is_none() + && self.change_descriptor.is_none() + && self.network.is_none() + && self.local_chain.is_empty() + && self.tx_graph.is_empty() + && self.indexer.is_empty() + } +} diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs new file mode 100644 index 0000000000..d111a09fd1 --- /dev/null +++ b/examples/example_electrum/src/main.rs @@ -0,0 +1,294 @@ +use std::io::{self, Write}; + +use bdk_chain::{ + bitcoin::Network, + collections::BTreeSet, + indexed_tx_graph, + spk_client::{FullScanRequest, SyncRequest}, + ConfirmationBlockTime, Merge, +}; +use bdk_electrum::{ + electrum_client::{self, Client, ElectrumApi}, + BdkElectrumClient, +}; +use example_cli::{ + self, + anyhow::{self, Context}, + clap::{self, Parser, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_electrum"; +const DB_PATH: &str = ".bdk_example_electrum.db"; + +#[derive(Subcommand, Debug, Clone)] +enum ElectrumCommands { + /// Scans the addresses in the wallet using the electrum API. + Scan { + /// When a gap this large has been found for a keychain, it will stop. + #[clap(long, default_value = "5")] + stop_gap: usize, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + electrum_args: ElectrumArgs, + }, + /// Scans particular addresses using the electrum API. + Sync { + /// Scan all the unused addresses. + #[clap(long)] + unused_spks: bool, + /// Scan every address that you have derived. + #[clap(long)] + all_spks: bool, + /// Scan unspent outpoints for spends or changes to confirmation status of residing tx. + #[clap(long)] + utxos: bool, + /// Scan unconfirmed transactions for updates. + #[clap(long)] + unconfirmed: bool, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + electrum_args: ElectrumArgs, + }, +} + +impl ElectrumCommands { + fn electrum_args(&self) -> ElectrumArgs { + match self { + ElectrumCommands::Scan { electrum_args, .. } => electrum_args.clone(), + ElectrumCommands::Sync { electrum_args, .. } => electrum_args.clone(), + } + } +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ElectrumArgs { + /// The electrum url to use to connect to. If not provided it will use a default electrum + /// server for your chosen network. + electrum_url: Option, +} + +impl ElectrumArgs { + pub fn client(&self, network: Network) -> anyhow::Result { + let electrum_url = self.electrum_url.as_deref().unwrap_or(match network { + Network::Bitcoin => "ssl://electrum.blockstream.info:50002", + Network::Testnet => "ssl://electrum.blockstream.info:60002", + Network::Regtest => "tcp://localhost:60401", + Network::Signet => "tcp://signet-electrumx.wakiyamap.dev:50001", + _ => panic!("Unknown network"), + }); + let config = electrum_client::Config::builder() + .validate_domain(matches!(network, Network::Bitcoin)) + .build(); + + Ok(electrum_client::Client::from_config(electrum_url, config)?) + } +} + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ScanOptions { + /// Set batch size for each script_history call to electrum client. + #[clap(long, default_value = "25")] + pub batch_size: usize, +} + +fn main() -> anyhow::Result<()> { + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let electrum_cmd = match &args.command { + example_cli::Commands::ChainSpecific(electrum_cmd) => electrum_cmd, + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |electrum_args, tx| { + let client = electrum_args.client(network)?; + client.transaction_broadcast(tx)?; + Ok(()) + }, + general_cmd.clone(), + ); + } + }; + + let client = BdkElectrumClient::new(electrum_cmd.electrum_args().client(network)?); + + // Tell the electrum client about the txs and anchors we've already got locally so it doesn't + // re-download .them + { + let graph = graph.lock().unwrap(); + client.populate_tx_cache(graph.graph().full_txs().map(|tx_node| tx_node.tx)); + client.populate_anchor_cache(graph.graph().all_anchors().clone()); + } + + let (chain_update, tx_update, keychain_update) = match electrum_cmd.clone() { + ElectrumCommands::Scan { + stop_gap, + scan_options, + .. + } => { + let request = { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + + FullScanRequest::builder() + .chain_tip(chain.tip()) + .spks_for_keychain( + Keychain::External, + graph + .index + .unbounded_spk_iter(Keychain::External) + .into_iter() + .flatten(), + ) + .spks_for_keychain( + Keychain::Internal, + graph + .index + .unbounded_spk_iter(Keychain::Internal) + .into_iter() + .flatten(), + ) + .inspect({ + let mut once = BTreeSet::new(); + move |k, spk_i, _| { + if once.insert(k) { + eprint!("\nScanning {k}: {spk_i} "); + } else { + eprint!("{spk_i} "); + } + io::stdout().flush().expect("must flush"); + } + }) + }; + + let res = client + .full_scan::<_>(request, stop_gap, scan_options.batch_size, false) + .context("scanning the blockchain")?; + ( + res.chain_update, + res.tx_update, + Some(res.last_active_indices), + ) + } + ElectrumCommands::Sync { + mut unused_spks, + all_spks, + mut utxos, + mut unconfirmed, + scan_options, + .. + } => { + // Get a short lock on the tracker to get the spks we're interested in + let graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + + if !(all_spks || unused_spks || utxos || unconfirmed) { + unused_spks = true; + unconfirmed = true; + utxos = true; + } else if all_spks { + unused_spks = false; + } + + let chain_tip = chain.tip(); + let mut request = + SyncRequest::builder() + .chain_tip(chain_tip.clone()) + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + match item { + bdk_chain::spk_client::SyncItem::Spk((keychain, index), spk) => { + eprintln!( + "[ SCANNING {pc:3.0}% ] script {} {} {}", + keychain, index, spk + ); + } + bdk_chain::spk_client::SyncItem::Txid(txid) => { + eprintln!("[ SCANNING {pc:3.0}% ] txid {}", txid); + } + bdk_chain::spk_client::SyncItem::OutPoint(op) => { + eprintln!("[ SCANNING {pc:3.0}% ] outpoint {}", op); + } + } + let _ = io::stderr().flush(); + }); + + let canonical_view = + chain.canonical_view(graph.graph(), chain_tip.block_id(), Default::default()); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); + if all_spks { + request = request.spks_with_indexes(graph.index.revealed_spks(..)); + } + if unused_spks { + request = request.spks_with_indexes(graph.index.unused_spks()); + } + if utxos { + let init_outpoints = graph.index.outpoints(); + request = request.outpoints( + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) + .map(|(_, utxo)| utxo.outpoint), + ); + }; + if unconfirmed { + request = request.txids( + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), + ); + } + + let res = client + .sync(request, scan_options.batch_size, false) + .context("scanning the blockchain")?; + + // drop lock on graph and chain + drop((graph, chain)); + + (res.chain_update, res.tx_update, None) + } + }; + + let db_changeset = { + let mut chain = chain.lock().unwrap(); + let mut graph = graph.lock().unwrap(); + + let chain_changeset = chain.apply_update(chain_update.expect("request has chain tip"))?; + + let mut indexed_tx_graph_changeset = + indexed_tx_graph::ChangeSet::::default(); + if let Some(keychain_update) = keychain_update { + let keychain_changeset = graph.index.reveal_to_target_multi(&keychain_update); + indexed_tx_graph_changeset.merge(keychain_changeset.into()); + } + indexed_tx_graph_changeset.merge(graph.apply_update(tx_update)); + + ChangeSet { + local_chain: chain_changeset, + tx_graph: indexed_tx_graph_changeset.tx_graph, + indexer: indexed_tx_graph_changeset.indexer, + ..Default::default() + } + }; + + let mut db = db.lock().unwrap(); + db.append(&db_changeset)?; + Ok(()) +} diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs new file mode 100644 index 0000000000..320b4796d7 --- /dev/null +++ b/examples/example_esplora/src/main.rs @@ -0,0 +1,298 @@ +use core::f32; +use std::{ + collections::BTreeSet, + io::{self, Write}, +}; + +use bdk_chain::{ + bitcoin::Network, + keychain_txout::FullScanRequestBuilderExt, + spk_client::{FullScanRequest, SyncRequest}, + Merge, +}; +use bdk_esplora::{esplora_client, EsploraExt}; +use example_cli::{ + anyhow::{self, Context}, + clap::{self, Parser, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_esplora"; +const DB_PATH: &str = ".bdk_example_esplora.db"; + +#[derive(Subcommand, Debug, Clone)] +enum EsploraCommands { + /// Scans the addresses in the wallet using the esplora API. + Scan { + /// When a gap this large has been found for a keychain, it will stop. + #[clap(long, short = 'g', default_value = "10")] + stop_gap: usize, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + esplora_args: EsploraArgs, + }, + /// Scan for particular addresses and unconfirmed transactions using the esplora API. + Sync { + /// Scan all the unused addresses. + #[clap(long)] + unused_spks: bool, + /// Scan every address that you have derived. + #[clap(long)] + all_spks: bool, + /// Scan unspent outpoints for spends or changes to confirmation status of residing tx. + #[clap(long)] + utxos: bool, + /// Scan unconfirmed transactions for updates. + #[clap(long)] + unconfirmed: bool, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + esplora_args: EsploraArgs, + }, +} + +impl EsploraCommands { + fn esplora_args(&self) -> EsploraArgs { + match self { + EsploraCommands::Scan { esplora_args, .. } => esplora_args.clone(), + EsploraCommands::Sync { esplora_args, .. } => esplora_args.clone(), + } + } +} + +#[derive(clap::Args, Debug, Clone)] +pub struct EsploraArgs { + /// The esplora url endpoint to connect to. + #[clap(long, short = 'u', env = "ESPLORA_SERVER")] + esplora_url: Option, +} + +impl EsploraArgs { + pub fn client(&self, network: Network) -> anyhow::Result { + let esplora_url = self.esplora_url.as_deref().unwrap_or(match network { + Network::Bitcoin => "https://blockstream.info/api", + Network::Testnet => "https://blockstream.info/testnet/api", + Network::Regtest => "http://localhost:3002", + Network::Signet => "http://signet.bitcoindevkit.net", + _ => panic!("unsupported network"), + }); + + let client = esplora_client::Builder::new(esplora_url).build_blocking(); + Ok(client) + } +} + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ScanOptions { + /// Max number of concurrent esplora server requests. + #[clap(long, default_value = "2")] + pub parallel_requests: usize, +} + +fn main() -> anyhow::Result<()> { + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let esplora_cmd = match &args.command { + // These are commands that are handled by this example (sync, scan). + example_cli::Commands::ChainSpecific(esplora_cmd) => esplora_cmd, + // These are general commands handled by example_cli. Execute the cmd and return. + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |esplora_args, tx| { + let client = esplora_args.client(network)?; + client + .broadcast(tx) + .map(|_| ()) + .map_err(anyhow::Error::from) + }, + general_cmd.clone(), + ); + } + }; + + let client = esplora_cmd.esplora_args().client(network)?; + // Prepare the `IndexedTxGraph` and `LocalChain` updates based on whether we are scanning or + // syncing. + // + // Scanning: We are iterating through spks of all keychains and scanning for transactions for + // each spk. We start with the lowest derivation index spk and stop scanning after `stop_gap` + // number of consecutive spks have no transaction history. A Scan is done in situations of + // wallet restoration. It is a special case. Applications should use "sync" style updates + // after an initial scan. + // + // Syncing: We only check for specified spks, utxos and txids to update their confirmation + // status or fetch missing transactions. + let (local_chain_changeset, indexed_tx_graph_changeset) = match &esplora_cmd { + EsploraCommands::Scan { + stop_gap, + scan_options, + .. + } => { + let request = { + let chain_tip = chain.lock().expect("mutex must not be poisoned").tip(); + let indexed_graph = &*graph.lock().expect("mutex must not be poisoned"); + FullScanRequest::builder() + .chain_tip(chain_tip) + .spks_from_indexer(&indexed_graph.index) + .inspect({ + let mut once = BTreeSet::::new(); + move |keychain, spk_i, _| { + if once.insert(keychain) { + eprint!("\nscanning {keychain}: "); + } + eprint!("{spk_i} "); + // Flush early to ensure we print at every iteration. + let _ = io::stderr().flush(); + } + }) + .build() + }; + + // The client scans keychain spks for transaction histories, stopping after `stop_gap` + // is reached. It returns a `TxGraph` update (`tx_update`) and a structure that + // represents the last active spk derivation indices of keychains + // (`keychain_indices_update`). + let update = client + .full_scan(request, *stop_gap, scan_options.parallel_requests) + .context("scanning for transactions")?; + + let mut graph = graph.lock().expect("mutex must not be poisoned"); + let mut chain = chain.lock().expect("mutex must not be poisoned"); + // Because we did a stop gap based scan we are likely to have some updates to our + // deriviation indices. Usually before a scan you are on a fresh wallet with no + // addresses derived so we need to derive up to last active addresses the scan found + // before adding the transactions. + ( + chain.apply_update(update.chain_update.expect("request included chain tip"))?, + { + let index_changeset = graph + .index + .reveal_to_target_multi(&update.last_active_indices); + let mut indexed_tx_graph_changeset = graph.apply_update(update.tx_update); + indexed_tx_graph_changeset.merge(index_changeset.into()); + indexed_tx_graph_changeset + }, + ) + } + EsploraCommands::Sync { + mut unused_spks, + all_spks, + mut utxos, + mut unconfirmed, + scan_options, + .. + } => { + if !(*all_spks || unused_spks || utxos || unconfirmed) { + // If nothing is specifically selected, we select everything (except all spks). + unused_spks = true; + unconfirmed = true; + utxos = true; + } else if *all_spks { + // If all spks is selected, we don't need to also select unused spks (as unused spks + // is a subset of all spks). + unused_spks = false; + } + + let local_tip = chain.lock().expect("mutex must not be poisoned").tip(); + // Spks, outpoints and txids we want updates on will be accumulated here. + let mut request = + SyncRequest::builder() + .chain_tip(local_tip.clone()) + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + match item { + bdk_chain::spk_client::SyncItem::Spk((keychain, index), spk) => { + eprintln!( + "[ SCANNING {pc:3.0}% ] script {} {} {}", + keychain, index, spk + ); + } + bdk_chain::spk_client::SyncItem::Txid(txid) => { + eprintln!("[ SCANNING {pc:3.0}% ] txid {}", txid); + } + bdk_chain::spk_client::SyncItem::OutPoint(op) => { + eprintln!("[ SCANNING {pc:3.0}% ] outpoint {}", op); + } + } + let _ = io::stderr().flush(); + }); + + // Get a short lock on the structures to get spks, utxos, and txs that we are interested + // in. + { + let graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + let canonical_view = + chain.canonical_view(graph.graph(), local_tip.block_id(), Default::default()); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); + if *all_spks { + request = request.spks_with_indexes(graph.index.revealed_spks(..)); + } + if unused_spks { + request = request.spks_with_indexes(graph.index.unused_spks()); + } + if utxos { + // We want to search for whether the UTXO is spent, and spent by which + // transaction. We provide the outpoint of the UTXO to + // `EsploraExt::update_tx_graph_without_keychain`. + let init_outpoints = graph.index.outpoints(); + request = request.outpoints( + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) + .map(|(_, utxo)| utxo.outpoint), + ); + }; + if unconfirmed { + // We want to search for whether the unconfirmed transaction is now confirmed. + // We provide the unconfirmed txids to + // `EsploraExt::update_tx_graph_without_keychain`. + request = request.txids( + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), + ); + } + } + + let update = client.sync(request, scan_options.parallel_requests)?; + + ( + chain + .lock() + .unwrap() + .apply_update(update.chain_update.expect("request has chain tip"))?, + graph.lock().unwrap().apply_update(update.tx_update), + ) + } + }; + + println!(); + + // We persist the changes + let mut db = db.lock().unwrap(); + db.append(&ChangeSet { + local_chain: local_chain_changeset, + tx_graph: indexed_tx_graph_changeset.tx_graph, + indexer: indexed_tx_graph_changeset.indexer, + ..Default::default() + })?; + Ok(()) +}