diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 8c41efc03..14b0c9212 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -389,6 +389,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { + trusted_pending: SEND_AMOUNT * reorg_count as u64, confirmed: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64, ..Balance::default() }, diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs new file mode 100644 index 000000000..d59ca919c --- /dev/null +++ b/crates/chain/src/canonical_iter.rs @@ -0,0 +1,331 @@ +use core::cmp::Reverse; + +use crate::collections::{btree_set, hash_map, BTreeSet, HashMap, HashSet, VecDeque}; +use crate::tx_graph::{CanonicalTx, TxAncestors, TxDescendants, TxNode}; +use crate::{Anchor, ChainOracle, ChainPosition, TxGraph}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bdk_core::BlockId; +use bitcoin::{Transaction, Txid}; + +/// A set of canonical transactions. +pub type CanonicalSet = HashMap, CanonicalReason)>; + +type ToProcess = btree_set::IntoIter>; + +/// Iterates over canonical txs. +pub struct CanonicalIter<'g, A, C> { + tx_graph: &'g TxGraph, + chain: &'g C, + chain_tip: BlockId, + + to_process: ToProcess, + canonical: CanonicalSet, + not_canonical: HashSet, + 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) -> Self { + let to_process = tx_graph + .full_txs() + .filter_map(|tx_node| { + Some(Reverse(( + tx_graph.last_seen_in(tx_node.txid)?, + tx_node.txid, + ))) + }) + .collect::>() + .into_iter(); + Self { + tx_graph, + chain, + chain_tip, + to_process, + canonical: HashMap::default(), + not_canonical: HashSet::default(), + queue: VecDeque::default(), + } + } + + fn canonicalize_by_traversing_backwards( + &mut self, + txid: Txid, + last_seen: Option, + ) -> Result<(), C::Error> { + type TxWithId = (Txid, Arc); + let tx = match self.tx_graph.get_tx(txid) { + Some(tx) => tx, + None => return Ok(()), + }; + let maybe_canonical = TxAncestors::new_include_root( + self.tx_graph, + tx, + |_: usize, tx: Arc| -> Option> { + let txid = tx.compute_txid(); + match self.is_canonical(txid, tx.is_coinbase()) { + // Break when we know if something is definitely canonical or definitely not + // canonical. + Ok(Some(_)) => None, + Ok(None) => Some(Ok((txid, tx))), + Err(err) => Some(Err(err)), + } + }, + ) + .collect::, C::Error>>()?; + + // TODO: Check if this is correct. This assumes that `last_seen` values are fully + // transitive. I.e. if A is an ancestor of B, then the most recent timestamp between A & B + // also applies to A. + let starting_txid = txid; + if let Some(last_seen) = last_seen { + for (txid, tx) in maybe_canonical { + if !self.not_canonical.contains(&txid) { + self.mark_canonical( + tx, + CanonicalReason::from_descendant_last_seen(starting_txid, last_seen), + ); + } + } + } + Ok(()) + } + fn is_canonical(&mut self, txid: Txid, is_coinbase: bool) -> Result, C::Error> { + if self.canonical.contains_key(&txid) { + return Ok(Some(true)); + } + if self.not_canonical.contains(&txid) { + return Ok(Some(false)); + } + let tx = match self.tx_graph.get_tx(txid) { + Some(tx) => tx, + None => return Ok(None), + }; + for anchor in self + .tx_graph + .all_anchors() + .get(&txid) + .unwrap_or(&BTreeSet::new()) + { + if self + .chain + .is_block_in_chain(anchor.anchor_block(), self.chain_tip)? + == Some(true) + { + self.mark_canonical(tx, CanonicalReason::from_anchor(anchor.clone())); + return Ok(Some(true)); + } + } + if is_coinbase { + // Coinbase transactions cannot exist in mempool. + return Ok(Some(false)); + } + for (_, conflicting_txid) in self.tx_graph.direct_conflicts(&tx) { + if self.canonical.contains_key(&conflicting_txid) { + self.mark_not_canonical(txid); + return Ok(Some(false)); + } + } + Ok(None) + } + + fn mark_not_canonical(&mut self, txid: Txid) { + TxDescendants::new_include_root(self.tx_graph, txid, |_: usize, txid: Txid| -> Option<()> { + if self.not_canonical.insert(txid) { + Some(()) + } else { + None + } + }) + .for_each(|_| {}) + } + + fn mark_canonical(&mut self, tx: Arc, reason: CanonicalReason) { + let starting_txid = tx.compute_txid(); + if !self.insert_canonical(starting_txid, tx.clone(), reason.clone()) { + return; + } + TxAncestors::new_exclude_root( + self.tx_graph, + tx, + |_: usize, tx: Arc| -> Option<()> { + let this_reason = reason.clone().with_descendant(starting_txid); + if self.insert_canonical(tx.compute_txid(), tx, this_reason) { + Some(()) + } else { + None + } + }, + ) + .for_each(|_| {}) + } + + fn insert_canonical( + &mut self, + txid: Txid, + tx: Arc, + reason: CanonicalReason, + ) -> bool { + match self.canonical.entry(txid) { + hash_map::Entry::Occupied(_) => false, + hash_map::Entry::Vacant(entry) => { + entry.insert((tx, reason)); + self.queue.push_back(txid); + true + } + } + } +} + +impl<'g, A: Anchor, C: ChainOracle> Iterator for CanonicalIter<'g, 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))); + } + + let Reverse((last_seen, txid)) = self.to_process.next()?; + if let Err(err) = self.canonicalize_by_traversing_backwards(txid, Some(last_seen)) { + return Some(Err(err)); + } + } + } +} + +/// Represents when and where a given transaction is last seen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, core::hash::Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum LastSeen { + /// The transaction was last seen in the mempool at the given unix timestamp. + Mempool(u64), + /// The transaction was last seen in a block of height. + Block(u32), +} + +/// The reason why a transaction is canonical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanonicalReason { + /// 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 `last_seen` + /// value or one that is anchored in the best chain. + LastSeen { + /// The [`LastSeen`] value of the transaction. + last_seen: LastSeen, + /// Whether the [`LastSeen`] value is of the transaction's descendant. + descendant: Option, + }, +} + +impl CanonicalReason { + /// Constructs a [`CanonicalReason`] from an `anchor`. + pub fn from_anchor(anchor: A) -> Self { + Self::Anchor { + anchor, + descendant: None, + } + } + + /// Constructs a [`CanonicalReason`] from a `descendant`'s `anchor`. + pub fn from_descendant_anchor(descendant: Txid, anchor: A) -> Self { + Self::Anchor { + anchor, + descendant: Some(descendant), + } + } + + /// Constructs a [`CanonicalReason`] from a `last_seen` value. + pub fn from_last_seen(last_seen: LastSeen) -> Self { + Self::LastSeen { + last_seen, + descendant: None, + } + } + + /// Constructs a [`CanonicalReason`] from a `descendant`'s `last_seen` value. + pub fn from_descendant_last_seen(descendant: Txid, last_seen: LastSeen) -> Self { + Self::LastSeen { + last_seen, + descendant: Some(descendant), + } + } + + /// Adds a `descendant` to the [`CanonicalReason`]. + /// + /// This signals that either the [`LastSeen`] or [`Anchor`] value belongs to the transaction's + /// descendant. + #[must_use] + pub fn with_descendant(self, descendant: Txid) -> Self { + match self { + CanonicalReason::Anchor { anchor, .. } => Self::Anchor { + anchor, + descendant: Some(descendant), + }, + CanonicalReason::LastSeen { last_seen, .. } => Self::LastSeen { + last_seen, + descendant: Some(descendant), + }, + } + } + + /// This signals that either the [`LastSeen`] or [`Anchor`] value belongs to the transaction's + /// descendant. + pub fn descendant(&self) -> &Option { + match self { + CanonicalReason::Anchor { descendant, .. } => descendant, + CanonicalReason::LastSeen { descendant, .. } => descendant, + } + } +} + +/// Helper to create canonical tx. +pub fn make_canonical_tx<'a, A: Anchor, C: ChainOracle>( + chain: &C, + chain_tip: BlockId, + tx_node: TxNode<'a, Arc, A>, + canonical_reason: CanonicalReason, +) -> Result, A>, C::Error> { + let chain_position = match canonical_reason { + CanonicalReason::Anchor { anchor, descendant } => match descendant { + Some(desc_txid) => { + let direct_anchor = 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()?; + match direct_anchor { + Some(anchor) => ChainPosition::Confirmed(anchor), + None => ChainPosition::ConfirmedByTransitivity(desc_txid, anchor), + } + } + None => ChainPosition::Confirmed(anchor), + }, + CanonicalReason::LastSeen { last_seen, .. } => match last_seen { + LastSeen::Mempool(last_seen) => ChainPosition::Unconfirmed(last_seen), + LastSeen::Block(_) => ChainPosition::UnconfirmedAndNotSeen, + }, + }; + Ok(CanonicalTx { + chain_position, + tx_node, + }) +} diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index e0202e1af..960591e38 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -15,16 +15,24 @@ use crate::{Anchor, COINBASE_MATURITY}; )) )] pub enum ChainPosition { - /// The chain data is seen as confirmed, and in anchored by `A`. + /// The chain data is confirmed because it is anchored by `A`. Confirmed(A), - /// The chain data is not confirmed and last seen in the mempool at this timestamp. + /// The chain data is confirmed because it has a descendant that is anchored by `A`. + ConfirmedByTransitivity(Txid, A), + /// The chain data is not confirmed and is last seen in the mempool (or has a descendant that + /// is last seen in the mempool) at this timestamp. Unconfirmed(u64), + /// The chain data is not confirmed and we have never seen it in the mempool. + UnconfirmedAndNotSeen, } impl ChainPosition { /// Returns whether [`ChainPosition`] is confirmed or not. pub fn is_confirmed(&self) -> bool { - matches!(self, Self::Confirmed(_)) + matches!( + self, + Self::Confirmed(_) | Self::ConfirmedByTransitivity(_, _) + ) } } @@ -33,7 +41,11 @@ impl ChainPosition<&A> { pub fn cloned(self) -> ChainPosition { match self { ChainPosition::Confirmed(a) => ChainPosition::Confirmed(a.clone()), + ChainPosition::ConfirmedByTransitivity(txid, a) => { + ChainPosition::ConfirmedByTransitivity(txid, a.clone()) + } ChainPosition::Unconfirmed(last_seen) => ChainPosition::Unconfirmed(last_seen), + ChainPosition::UnconfirmedAndNotSeen => ChainPosition::UnconfirmedAndNotSeen, } } } @@ -42,8 +54,10 @@ impl ChainPosition { /// Determines the upper bound of the confirmation height. pub fn confirmation_height_upper_bound(&self) -> Option { match self { - ChainPosition::Confirmed(a) => Some(a.confirmation_height_upper_bound()), - ChainPosition::Unconfirmed(_) => None, + ChainPosition::Confirmed(a) | ChainPosition::ConfirmedByTransitivity(_, a) => { + Some(a.confirmation_height_upper_bound()) + } + ChainPosition::Unconfirmed(_) | ChainPosition::UnconfirmedAndNotSeen => None, } } } @@ -73,9 +87,10 @@ impl FullTxOut { /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound pub fn is_mature(&self, tip: u32) -> bool { if self.is_on_coinbase { - let tx_height = match &self.chain_position { - ChainPosition::Confirmed(anchor) => anchor.confirmation_height_upper_bound(), - ChainPosition::Unconfirmed(_) => { + let tx_height = self.chain_position.confirmation_height_upper_bound(); + let tx_height = match tx_height { + Some(tx_height) => tx_height, + None => { debug_assert!(false, "coinbase tx can never be unconfirmed"); return false; } @@ -103,9 +118,9 @@ impl FullTxOut { return false; } - let confirmation_height = match &self.chain_position { - ChainPosition::Confirmed(anchor) => anchor.confirmation_height_upper_bound(), - ChainPosition::Unconfirmed(_) => return false, + let confirmation_height = match self.chain_position.confirmation_height_upper_bound() { + Some(h) => h, + None => return false, }; if confirmation_height > tip { return false; diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 9667bb549..fc471a951 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -43,6 +43,8 @@ pub mod tx_graph; pub use tx_graph::TxGraph; mod chain_oracle; pub use chain_oracle::*; +mod canonical_iter; +pub use canonical_iter::*; #[doc(hidden)] pub mod example_utils; diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 37c00a23a..24480110d 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -93,7 +93,11 @@ //! [`insert_txout`]: TxGraph::insert_txout use crate::collections::*; +use crate::make_canonical_tx; use crate::BlockId; +use crate::CanonicalIter; +use crate::CanonicalSet; +use crate::LastSeen; use crate::{Anchor, Balance, ChainOracle, ChainPosition, FullTxOut, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; @@ -204,7 +208,7 @@ impl Default for TxNodeInternal { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct CanonicalTx<'a, T, A> { /// How the transaction is observed as (confirmed or unconfirmed). - pub chain_position: ChainPosition<&'a A>, + pub chain_position: ChainPosition, /// The transaction node (as part of the graph). pub tx_node: TxNode<'a, T, A>, } @@ -964,15 +968,15 @@ impl TxGraph { chain: &'a C, chain_tip: BlockId, ) -> impl Iterator, A>, C::Error>> { - self.full_txs().filter_map(move |tx| { - self.try_get_chain_position(chain, chain_tip, tx.txid) - .map(|v| { - v.map(|observed_in| CanonicalTx { - chain_position: observed_in, - tx_node: tx, - }) - }) - .transpose() + self.canonical_iter(chain, chain_tip).flat_map(move |res| { + res.map(|(txid, _, canonical_reason)| { + make_canonical_tx( + chain, + chain_tip, + self.get_tx_node(txid).expect("must contain tx"), + canonical_reason, + ) + }) }) } @@ -987,7 +991,7 @@ impl TxGraph { chain_tip: BlockId, ) -> impl Iterator, A>> { self.try_list_canonical_txs(chain, chain_tip) - .map(|r| r.expect("oracle is infallible")) + .map(|res| res.expect("infallible")) } /// Get a filtered list of outputs from the given `outpoints` that are in `chain` with @@ -1014,173 +1018,88 @@ impl TxGraph { chain: &'a C, chain_tip: BlockId, outpoints: impl IntoIterator + 'a, - ) -> impl Iterator), C::Error>> + 'a { - outpoints - .into_iter() - .map( - move |(spk_i, op)| -> Result)>, C::Error> { - let tx_node = match self.get_tx_node(op.txid) { - Some(n) => n, - None => return Ok(None), - }; - - let txout = match tx_node.tx.as_ref().output.get(op.vout as usize) { - Some(txout) => txout.clone(), - None => return Ok(None), - }; - - let chain_position = - match self.try_get_chain_position(chain, chain_tip, op.txid)? { - Some(pos) => pos.cloned(), - None => return Ok(None), - }; - - let spent_by = self - .try_get_chain_spend(chain, chain_tip, op)? - .map(|(a, txid)| (a.cloned(), txid)); - - Ok(Some(( - spk_i, - FullTxOut { - outpoint: op, - txout, - chain_position, - spent_by, - is_on_coinbase: tx_node.tx.is_coinbase(), - }, - ))) + ) -> Result)> + 'a, C::Error> { + let mut canon_txs = HashMap::, A>>::new(); + let mut canon_spends = HashMap::::new(); + for r in self.try_list_canonical_txs(chain, chain_tip) { + let canonical_tx = r?; + let txid = canonical_tx.tx_node.txid; + + if !canonical_tx.tx_node.tx.is_coinbase() { + for txin in &canonical_tx.tx_node.tx.input { + let _res = canon_spends.insert(txin.previous_output, txid); + assert!( + _res.is_none(), + "tried to replace {:?} with {:?}", + _res, + txid + ); + } + } + canon_txs.insert(txid, canonical_tx); + } + Ok(outpoints.into_iter().filter_map(move |(spk_i, outpoint)| { + let canon_tx = canon_txs.get(&outpoint.txid)?; + let txout = canon_tx + .tx_node + .tx + .output + .get(outpoint.vout as usize) + .cloned()?; + let chain_position = canon_tx.chain_position.clone(); + let spent_by = canon_spends.get(&outpoint).map(|spend_txid| { + let spend_tx = canon_txs + .get(spend_txid) + .cloned() + .expect("must be canonical"); + (spend_tx.chain_position, *spend_txid) + }); + let is_on_coinbase = canon_tx.tx_node.is_coinbase(); + Some(( + spk_i, + FullTxOut { + outpoint, + txout, + chain_position, + spent_by, + is_on_coinbase, }, - ) - .filter_map(Result::transpose) + )) + })) } - /// Get a canonical set of txids. - pub fn canoncial_set( - &self, - chain: &C, - chain_tip: BlockId, - ) -> Result, C::Error> { - let mut definitely_canonical = HashSet::::new(); - let mut definitely_not_canonical = HashSet::::new(); - let txid_by_last_seen = self - .last_seen - .iter() - .map(|(&txid, &last_seen)| (last_seen, txid)) - .collect::>(); - for (_, txid) in txid_by_last_seen { - self.canonicalize_by_traversing_up( - chain, - chain_tip, - &mut definitely_canonical, - &mut definitely_not_canonical, - txid, - )?; - } - Ok(definitely_canonical) + pub(crate) fn last_seen_in(&self, txid: Txid) -> Option { + self.last_seen + .get(&txid) + .map(|&last_seen| LastSeen::Mempool(last_seen)) + .or_else(|| { + self.anchors.get(&txid).and_then(|anchors| { + anchors + .iter() + .last() + .map(|a| LastSeen::Block(a.anchor_block().height)) + }) + }) } - fn canonicalize_by_traversing_up( - &self, - chain: &C, + /// Returns a [`CanonicalIter`]. + pub fn canonical_iter<'a, C: ChainOracle>( + &'a self, + chain: &'a C, chain_tip: BlockId, - definitely_canonical: &mut HashSet, - definitely_not_canonical: &mut HashSet, - txid: Txid, - ) -> Result<(), C::Error> { - type TxWithId = (Txid, Arc); - let tx = match self.get_tx(txid) { - Some(tx) => tx, - None => return Ok(()), - }; - let maybe_canonical = TxAncestors::new_include_root( - self, - tx, - |_: usize, tx: Arc| -> Option> { - let txid = tx.compute_txid(); - match self.is_definitely_canonical( - chain, - chain_tip, - definitely_canonical, - definitely_not_canonical, - txid, - ) { - Ok(None) => Some(Ok((txid, tx))), - Ok(Some(_)) => None, - Err(err) => Some(Err(err)), - } - }, - ) - .collect::, C::Error>>()?; - // TODO: Check if this is correct. - for (txid, tx) in maybe_canonical { - if !definitely_not_canonical.contains(&txid) { - self.mark_definitely_canonical(definitely_canonical, tx); - } - } - Ok(()) + ) -> CanonicalIter<'a, A, C> { + CanonicalIter::new(self, chain, chain_tip) } - fn is_definitely_canonical( + /// Get a canonical set of txids. + pub fn canonical_set( &self, chain: &C, chain_tip: BlockId, - definitely_canonical: &mut HashSet, - definitely_not_canonical: &mut HashSet, - txid: Txid, - ) -> Result, C::Error> { - if definitely_canonical.contains(&txid) { - return Ok(Some(true)); - } - if definitely_not_canonical.contains(&txid) { - return Ok(Some(false)); - } - let tx = match self.get_tx(txid) { - Some(tx) => tx, - None => return Ok(None), - }; - for anchor in self.anchors.get(&txid).unwrap_or(&self.empty_anchors) { - if chain.is_block_in_chain(anchor.anchor_block(), chain_tip)? == Some(true) { - self.mark_definitely_canonical(definitely_canonical, tx); - return Ok(Some(true)); - } - } - for (_, conflicting_txid) in self.direct_conflicts(&tx) { - if definitely_canonical.contains(&conflicting_txid) { - self.mark_definitely_not_canonical(definitely_not_canonical, txid); - return Ok(Some(false)); - } - } - Ok(None) - } - - fn mark_definitely_not_canonical( - &self, - definitely_not_canonical: &mut HashSet, - txid: Txid, - ) { - TxDescendants::new_include_root(self, txid, |_: usize, txid: Txid| -> Option<()> { - if definitely_not_canonical.insert(txid) { - Some(()) - } else { - None - } - }) - .for_each(|_| {}) - } - - fn mark_definitely_canonical( - &self, - definitely_canonical: &mut HashSet, - tx: Arc, - ) { - TxAncestors::new_include_root(self, tx, |_: usize, tx: Arc| -> Option<()> { - if definitely_canonical.insert(tx.compute_txid()) { - Some(()) - } else { - None - } - }) - .for_each(|_| {}) + ) -> Result, C::Error> { + self.canonical_iter(chain, chain_tip) + .map(|res| res.map(|(txid, tx, reason)| (txid, (tx, reason)))) + .collect() } /// Get a filtered list of outputs from the given `outpoints` that are in `chain` with @@ -1196,7 +1115,7 @@ impl TxGraph { outpoints: impl IntoIterator + 'a, ) -> impl Iterator)> + 'a { self.try_filter_chain_txouts(chain, chain_tip, outpoints) - .map(|r| r.expect("oracle is infallible")) + .expect("oracle is infallible") } /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` that are in @@ -1222,14 +1141,10 @@ impl TxGraph { chain: &'a C, chain_tip: BlockId, outpoints: impl IntoIterator + 'a, - ) -> impl Iterator), C::Error>> + 'a { - self.try_filter_chain_txouts(chain, chain_tip, outpoints) - .filter(|r| match r { - // keep unspents, drop spents - Ok((_, full_txo)) => full_txo.spent_by.is_none(), - // keep errors - Err(_) => true, - }) + ) -> Result)> + 'a, C::Error> { + Ok(self + .try_filter_chain_txouts(chain, chain_tip, outpoints)? + .filter(|(_, full_txo)| full_txo.spent_by.is_none())) } /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` that are in @@ -1245,7 +1160,7 @@ impl TxGraph { txouts: impl IntoIterator + 'a, ) -> impl Iterator)> + 'a { self.try_filter_chain_unspents(chain, chain_tip, txouts) - .map(|r| r.expect("oracle is infallible")) + .expect("oracle is infallible") } /// Get the total balance of `outpoints` that are in `chain` of `chain_tip`. @@ -1272,18 +1187,16 @@ impl TxGraph { let mut untrusted_pending = Amount::ZERO; let mut confirmed = Amount::ZERO; - for res in self.try_filter_chain_unspents(chain, chain_tip, outpoints) { - let (spk_i, txout) = res?; - + for (spk_i, txout) in self.try_filter_chain_unspents(chain, chain_tip, outpoints)? { match &txout.chain_position { - ChainPosition::Confirmed(_) => { + ChainPosition::Confirmed(_) | ChainPosition::ConfirmedByTransitivity(_, _) => { if txout.is_confirmed_and_spendable(chain_tip.height) { confirmed += txout.txout.value; } else if !txout.is_mature(chain_tip.height) { immature += txout.txout.value; } } - ChainPosition::Unconfirmed(_) => { + ChainPosition::Unconfirmed(_) | ChainPosition::UnconfirmedAndNotSeen => { if trust_predicate(&spk_i, txout.txout.script_pubkey) { trusted_pending += txout.txout.value; } else { diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 6ece64cbb..0b0e2fd9e 100644 --- a/crates/chain/tests/common/tx_template.rs +++ b/crates/chain/tests/common/tx_template.rs @@ -132,7 +132,9 @@ pub fn init_graph<'a, A: Anchor + Clone + 'a>( for anchor in tx_tmp.anchors.iter() { let _ = graph.insert_anchor(tx.compute_txid(), anchor.clone()); } - let _ = graph.insert_seen_at(tx.compute_txid(), tx_tmp.last_seen.unwrap_or(0)); + if let Some(last_seen) = tx_tmp.last_seen { + let _ = graph.insert_seen_at(tx.compute_txid(), last_seen); + } } (graph, spk_index, tx_ids) } diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index a8d17ca91..3236d2b2a 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -191,7 +191,7 @@ fn test_list_owned_txouts() { value: Amount::from_sat(70000), script_pubkey: trusted_spks[0].to_owned(), }], - ..new_tx(0) + ..new_tx(1) }; // tx2 is an incoming transaction received at untrusted keychain at block 1. @@ -200,7 +200,7 @@ fn test_list_owned_txouts() { value: Amount::from_sat(30000), script_pubkey: untrusted_spks[0].to_owned(), }], - ..new_tx(0) + ..new_tx(2) }; // tx3 spends tx2 and gives a change back in trusted keychain. Confirmed at Block 2. @@ -213,7 +213,7 @@ fn test_list_owned_txouts() { value: Amount::from_sat(10000), script_pubkey: trusted_spks[1].to_owned(), }], - ..new_tx(0) + ..new_tx(3) }; // tx4 is an external transaction receiving at untrusted keychain, unconfirmed. @@ -222,7 +222,7 @@ fn test_list_owned_txouts() { value: Amount::from_sat(20000), script_pubkey: untrusted_spks[1].to_owned(), }], - ..new_tx(0) + ..new_tx(4) }; // tx5 is an external transaction receiving at trusted keychain, unconfirmed. @@ -231,11 +231,12 @@ fn test_list_owned_txouts() { value: Amount::from_sat(15000), script_pubkey: trusted_spks[2].to_owned(), }], - ..new_tx(0) + ..new_tx(5) }; // tx6 is an unrelated transaction confirmed at 3. - let tx6 = new_tx(0); + // This won't be inserted because it is not relevant. + let tx6 = new_tx(6); // Insert transactions into graph with respective anchors // Insert unconfirmed txs with a last_seen timestamp @@ -293,7 +294,7 @@ fn test_list_owned_txouts() { let confirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if matches!(full_txout.chain_position, ChainPosition::Confirmed(_)) { + if full_txout.chain_position.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -304,7 +305,7 @@ fn test_list_owned_txouts() { let unconfirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if matches!(full_txout.chain_position, ChainPosition::Unconfirmed(_)) { + if !full_txout.chain_position.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -315,7 +316,7 @@ fn test_list_owned_txouts() { let confirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if matches!(full_txout.chain_position, ChainPosition::Confirmed(_)) { + if full_txout.chain_position.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -326,7 +327,7 @@ fn test_list_owned_txouts() { let unconfirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if matches!(full_txout.chain_position, ChainPosition::Unconfirmed(_)) { + if !full_txout.chain_position.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -360,20 +361,26 @@ fn test_list_owned_txouts() { assert_eq!(confirmed_txouts_txid, [tx1.compute_txid()].into()); assert_eq!( unconfirmed_txouts_txid, - [tx4.compute_txid(), tx5.compute_txid()].into() + [ + tx2.compute_txid(), + tx3.compute_txid(), + tx4.compute_txid(), + tx5.compute_txid() + ] + .into() ); assert_eq!(confirmed_utxos_txid, [tx1.compute_txid()].into()); assert_eq!( unconfirmed_utxos_txid, - [tx4.compute_txid(), tx5.compute_txid()].into() + [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() ); assert_eq!( balance, Balance { immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(15000), // tx5 + trusted_pending: Amount::from_sat(25000), // tx3, tx5 untrusted_pending: Amount::from_sat(20000), // tx4 confirmed: Amount::ZERO // Nothing is confirmed yet } @@ -397,26 +404,23 @@ fn test_list_owned_txouts() { ); assert_eq!( unconfirmed_txouts_txid, - [tx4.compute_txid(), tx5.compute_txid()].into() + [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() ); // tx2 gets into confirmed utxos set - assert_eq!( - confirmed_utxos_txid, - [tx1.compute_txid(), tx2.compute_txid()].into() - ); + assert_eq!(confirmed_utxos_txid, [tx1.compute_txid()].into()); assert_eq!( unconfirmed_utxos_txid, - [tx4.compute_txid(), tx5.compute_txid()].into() + [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() ); assert_eq!( balance, Balance { immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(15000), // tx5 + trusted_pending: Amount::from_sat(25000), // tx3, tx5 untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(30_000) // tx2 got confirmed + confirmed: Amount::from_sat(0) // tx2 got confirmed (but spent by 3) } ); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 1f54c4b82..2e64e3912 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -413,12 +413,13 @@ fn test_tx_conflict_handling() { inputs: &[TxInTemplate::Bogus], outputs: &[TxOutTemplate::new(10000, Some(0))], anchors: &[block_id!(1, "B")], - last_seen: None, + ..Default::default() }, TxTemplate { tx_name: "B", inputs: &[TxInTemplate::PrevTx("A", 0)], outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(2), ..Default::default() }, TxTemplate { @@ -432,6 +433,7 @@ fn test_tx_conflict_handling() { tx_name: "C", inputs: &[TxInTemplate::PrevTx("B'", 0)], outputs: &[TxOutTemplate::new(30000, Some(3))], + last_seen: Some(1), ..Default::default() }, ], diff --git a/crates/wallet/src/test_utils.rs b/crates/wallet/src/test_utils.rs index 050b9fb19..7ad93e0c3 100644 --- a/crates/wallet/src/test_utils.rs +++ b/crates/wallet/src/test_utils.rs @@ -4,7 +4,7 @@ use alloc::string::ToString; use alloc::sync::Arc; use core::str::FromStr; -use bdk_chain::{tx_graph, BlockId, ChainPosition, ConfirmationBlockTime}; +use bdk_chain::{tx_graph, BlockId, ConfirmationBlockTime}; use bitcoin::{ absolute, hashes::Hash, transaction, Address, Amount, BlockHash, FeeRate, Network, OutPoint, Transaction, TxIn, TxOut, Txid, @@ -224,29 +224,43 @@ pub fn feerate_unchecked(sat_vb: f64) -> FeeRate { FeeRate::from_sat_per_kwu(sat_kwu) } +/// Input parameter for [`receive_output`]. +pub enum ReceiveTo { + /// Receive tx to mempool at this `last_seen` timestamp. + Mempool(u64), + /// Receive tx to block with this anchor. + Block(ConfirmationBlockTime), +} + +impl From for ReceiveTo { + fn from(value: ConfirmationBlockTime) -> Self { + Self::Block(value) + } +} + /// Receive a tx output with the given value in the latest block pub fn receive_output_in_latest_block(wallet: &mut Wallet, value: u64) -> OutPoint { let latest_cp = wallet.latest_checkpoint(); let height = latest_cp.height(); - let anchor = if height == 0 { - ChainPosition::Unconfirmed(0) - } else { - ChainPosition::Confirmed(ConfirmationBlockTime { + assert!(height > 0, "cannot receive tx into genesis block"); + receive_output( + wallet, + value, + ConfirmationBlockTime { block_id: latest_cp.block_id(), confirmation_time: 0, - }) - }; - receive_output(wallet, value, anchor) + }, + ) } /// Receive a tx output with the given value and chain position pub fn receive_output( wallet: &mut Wallet, value: u64, - pos: ChainPosition, + receive_to: impl Into, ) -> OutPoint { let addr = wallet.next_unused_address(KeychainKind::External).address; - receive_output_to_address(wallet, addr, value, pos) + receive_output_to_address(wallet, addr, value, receive_to) } /// Receive a tx output to an address with the given value and chain position @@ -254,7 +268,7 @@ pub fn receive_output_to_address( wallet: &mut Wallet, addr: Address, value: u64, - pos: ChainPosition, + receive_to: impl Into, ) -> OutPoint { let tx = Transaction { version: transaction::Version::ONE, @@ -269,13 +283,9 @@ pub fn receive_output_to_address( let txid = tx.compute_txid(); insert_tx(wallet, tx); - match pos { - ChainPosition::Confirmed(anchor) => { - insert_anchor(wallet, txid, anchor); - } - ChainPosition::Unconfirmed(last_seen) => { - insert_seen_at(wallet, txid, last_seen); - } + match receive_to.into() { + ReceiveTo::Block(anchor) => insert_anchor(wallet, txid, anchor), + ReceiveTo::Mempool(last_seen) => insert_seen_at(wallet, txid, last_seen), } OutPoint { txid, vout: 0 } diff --git a/crates/wallet/src/wallet/export.rs b/crates/wallet/src/wallet/export.rs index 6441d3b58..62039bee3 100644 --- a/crates/wallet/src/wallet/export.rs +++ b/crates/wallet/src/wallet/export.rs @@ -130,8 +130,10 @@ impl FullyNodedExport { let blockheight = if include_blockheight { wallet.transactions().next().map_or(0, |canonical_tx| { match canonical_tx.chain_position { - bdk_chain::ChainPosition::Confirmed(a) => a.block_id.height, - bdk_chain::ChainPosition::Unconfirmed(_) => 0, + bdk_chain::ChainPosition::Confirmed(a) + | bdk_chain::ChainPosition::ConfirmedByTransitivity(_, a) => a.block_id.height, + bdk_chain::ChainPosition::Unconfirmed(_) + | bdk_chain::ChainPosition::UnconfirmedAndNotSeen => 0, } }) } else { diff --git a/crates/wallet/src/wallet/mod.rs b/crates/wallet/src/wallet/mod.rs index 68d5e6bec..5cc469f28 100644 --- a/crates/wallet/src/wallet/mod.rs +++ b/crates/wallet/src/wallet/mod.rs @@ -1043,10 +1043,17 @@ impl Wallet { /// "tx is confirmed at height {}, we know this since {}:{} is in the best chain", /// anchor.block_id.height, anchor.block_id.height, anchor.block_id.hash, /// ), + /// ChainPosition::ConfirmedByTransitivity(txid, anchor) => println!( + /// "tx is confirmed because a descendant '{}' is in the best chain at {}:{}", + /// txid, anchor.block_id.height, anchor.block_id.hash, + /// ), /// ChainPosition::Unconfirmed(last_seen) => println!( /// "tx is last seen at {}, it is unconfirmed as it is not anchored in the best chain", /// last_seen, /// ), + /// ChainPosition::UnconfirmedAndNotSeen => println!( + /// "tx does not conflict with anything canonical, but we never seen it in mempool", + /// ), /// } /// ``` /// @@ -1055,11 +1062,9 @@ impl Wallet { let graph = self.indexed_graph.graph(); Some(WalletTx { - chain_position: graph.get_chain_position( - &self.chain, - self.chain.tip().block_id(), - txid, - )?, + chain_position: graph + .get_chain_position(&self.chain, self.chain.tip().block_id(), txid)? + .cloned(), tx_node: graph.get_tx_node(txid)?, }) } @@ -1843,9 +1848,11 @@ impl Wallet { .graph() .get_chain_position(&self.chain, chain_tip, input.previous_output.txid) .map(|chain_position| match chain_position { - ChainPosition::Confirmed(a) => a.block_id.height, - ChainPosition::Unconfirmed(_) => u32::MAX, - }); + ChainPosition::Confirmed(a) + // TODO: This is the upper-bound of the confirmation height. Is this okay? + | ChainPosition::ConfirmedByTransitivity(_, a) => a.block_id.height, + ChainPosition::Unconfirmed(_) | ChainPosition::UnconfirmedAndNotSeen => u32::MAX, + }); let current_height = sign_options .assume_height .unwrap_or_else(|| self.chain.tip().height()); @@ -2034,12 +2041,14 @@ impl Wallet { ); if let Some(current_height) = current_height { match chain_position { - ChainPosition::Confirmed(a) => { + ChainPosition::Confirmed(a) + | ChainPosition::ConfirmedByTransitivity(_, a) => { // https://github.com/bitcoin/bitcoin/blob/c5e67be03bb06a5d7885c55db1f016fbf2333fe3/src/validation.cpp#L373-L375 spendable &= (current_height.saturating_sub(a.block_id.height)) >= COINBASE_MATURITY; } - ChainPosition::Unconfirmed { .. } => spendable = false, + ChainPosition::Unconfirmed(_) + | ChainPosition::UnconfirmedAndNotSeen => spendable = false, } } } diff --git a/crates/wallet/tests/wallet.rs b/crates/wallet/tests/wallet.rs index 6bfae2ec7..02a915701 100644 --- a/crates/wallet/tests/wallet.rs +++ b/crates/wallet/tests/wallet.rs @@ -1167,7 +1167,7 @@ fn test_create_tx_add_utxo() { let txid = small_output_tx.compute_txid(); insert_tx(&mut wallet, small_output_tx); let anchor = ConfirmationBlockTime { - block_id: wallet.latest_checkpoint().block_id(), + block_id: wallet.latest_checkpoint().get(2000).unwrap().block_id(), confirmation_time: 200, }; insert_anchor(&mut wallet, txid, anchor); @@ -1214,7 +1214,7 @@ fn test_create_tx_manually_selected_insufficient() { let txid = small_output_tx.compute_txid(); insert_tx(&mut wallet, small_output_tx.clone()); let anchor = ConfirmationBlockTime { - block_id: wallet.latest_checkpoint().block_id(), + block_id: wallet.latest_checkpoint().get(2000).unwrap().block_id(), confirmation_time: 200, }; insert_anchor(&mut wallet, txid, anchor); @@ -1440,7 +1440,7 @@ fn test_create_tx_increment_change_index() { .create_wallet_no_persist() .unwrap(); // fund wallet - receive_output(&mut wallet, amount, ChainPosition::Unconfirmed(0)); + receive_output(&mut wallet, amount, ReceiveTo::Mempool(0)); // create tx let mut builder = wallet.build_tx(); builder.add_recipient(recipient.clone(), Amount::from_sat(test.to_send)); @@ -2101,12 +2101,15 @@ fn test_bump_fee_remove_output_manually_selected_only() { }], }; + let position: ChainPosition = + wallet.transactions().last().unwrap().chain_position; insert_tx(&mut wallet, init_tx.clone()); - let anchor = ConfirmationBlockTime { - block_id: wallet.latest_checkpoint().block_id(), - confirmation_time: 200, - }; - insert_anchor(&mut wallet, init_tx.compute_txid(), anchor); + match position { + ChainPosition::Confirmed(anchor) => { + insert_anchor(&mut wallet, init_tx.compute_txid(), anchor) + } + other => panic!("all wallet txs must be confirmed: {:?}", other), + } let outpoint = OutPoint { txid: init_tx.compute_txid(), @@ -2150,12 +2153,13 @@ fn test_bump_fee_add_input() { }], }; let txid = init_tx.compute_txid(); + let pos: ChainPosition = + wallet.transactions().last().unwrap().chain_position; insert_tx(&mut wallet, init_tx); - let anchor = ConfirmationBlockTime { - block_id: wallet.latest_checkpoint().block_id(), - confirmation_time: 200, - }; - insert_anchor(&mut wallet, txid, anchor); + match pos { + ChainPosition::Confirmed(anchor) => insert_anchor(&mut wallet, txid, anchor), + other => panic!("all wallet txs must be confirmed: {:?}", other), + } let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") .unwrap() @@ -2542,7 +2546,7 @@ fn test_bump_fee_unconfirmed_inputs_only() { let psbt = builder.finish().unwrap(); // Now we receive one transaction with 0 confirmations. We won't be able to use that for // fee bumping, as it's still unconfirmed! - receive_output(&mut wallet, 25_000, ChainPosition::Unconfirmed(0)); + receive_output(&mut wallet, 25_000, ReceiveTo::Mempool(0)); let mut tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); for txin in &mut tx.input { @@ -2567,7 +2571,7 @@ fn test_bump_fee_unconfirmed_input() { .assume_checked(); // We receive a tx with 0 confirmations, which will be used as an input // in the drain tx. - receive_output(&mut wallet, 25_000, ChainPosition::Unconfirmed(0)); + receive_output(&mut wallet, 25_000, ReceiveTo::Mempool(0)); let mut builder = wallet.build_tx(); builder.drain_wallet().drain_to(addr.script_pubkey()); let psbt = builder.finish().unwrap(); @@ -2977,7 +2981,7 @@ fn test_next_unused_address() { assert_eq!(next_unused_addr.index, 0); // use the above address - receive_output_in_latest_block(&mut wallet, 25_000); + receive_output(&mut wallet, 25_000, ReceiveTo::Mempool(0)); assert_eq!( wallet @@ -4037,14 +4041,14 @@ fn test_keychains_with_overlapping_spks() { .last() .unwrap() .address; - let chain_position = ChainPosition::Confirmed(ConfirmationBlockTime { + let anchor = ConfirmationBlockTime { block_id: BlockId { height: 2000, hash: BlockHash::all_zeros(), }, confirmation_time: 0, - }); - let _outpoint = receive_output_to_address(&mut wallet, addr, 8000, chain_position); + }; + let _outpoint = receive_output_to_address(&mut wallet, addr, 8000, anchor); assert_eq!(wallet.balance().confirmed, Amount::from_sat(58000)); } @@ -4133,7 +4137,7 @@ fn single_descriptor_wallet_can_create_tx_and_receive_change() { .unwrap(); assert_eq!(wallet.keychains().count(), 1); let amt = Amount::from_sat(5_000); - receive_output(&mut wallet, 2 * amt.to_sat(), ChainPosition::Unconfirmed(2)); + receive_output(&mut wallet, 2 * amt.to_sat(), ReceiveTo::Mempool(2)); // create spend tx that produces a change output let addr = Address::from_str("bcrt1qc6fweuf4xjvz4x3gx3t9e0fh4hvqyu2qw4wvxm") .unwrap() @@ -4159,7 +4163,7 @@ fn single_descriptor_wallet_can_create_tx_and_receive_change() { #[test] fn test_transactions_sort_by() { let (mut wallet, _txid) = get_funded_wallet_wpkh(); - receive_output(&mut wallet, 25_000, ChainPosition::Unconfirmed(0)); + receive_output(&mut wallet, 25_000, ReceiveTo::Mempool(0)); // sort by chain position, unconfirmed then confirmed by descending block height let sorted_txs: Vec = diff --git a/example-crates/example_cli/src/lib.rs b/example-crates/example_cli/src/lib.rs index 6a97252fc..8c17348b3 100644 --- a/example-crates/example_cli/src/lib.rs +++ b/example-crates/example_cli/src/lib.rs @@ -421,12 +421,8 @@ pub fn planned_utxos( let outpoints = graph.index.outpoints(); graph .graph() - .try_filter_chain_unspents(chain, chain_tip, outpoints.iter().cloned()) - .filter_map(|r| -> Option> { - let (k, i, full_txo) = match r { - Err(err) => return Some(Err(err)), - Ok(((k, i), full_txo)) => (k, i, full_txo), - }; + .try_filter_chain_unspents(chain, chain_tip, outpoints.iter().cloned())? + .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph .index .keychains() @@ -560,26 +556,18 @@ pub fn handle_commands( } => { let txouts = graph .graph() - .try_filter_chain_txouts(chain, chain_tip, outpoints.iter().cloned()) - .filter(|r| match r { - Ok((_, full_txo)) => match (spent, unspent) { - (true, false) => full_txo.spent_by.is_some(), - (false, true) => full_txo.spent_by.is_none(), - _ => true, - }, - // always keep errored items - Err(_) => true, + .try_filter_chain_txouts(chain, chain_tip, 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(|r| match r { - Ok((_, full_txo)) => match (confirmed, unconfirmed) { - (true, false) => full_txo.chain_position.is_confirmed(), - (false, true) => !full_txo.chain_position.is_confirmed(), - _ => true, - }, - // always keep errored items - Err(_) => true, + .filter(|(_, full_txo)| match (confirmed, unconfirmed) { + (true, false) => full_txo.chain_position.is_confirmed(), + (false, true) => !full_txo.chain_position.is_confirmed(), + _ => true, }) - .collect::, _>>()?; + .collect::>(); for (spk_i, full_txo) in txouts { let addr = Address::from_script(&full_txo.txout.script_pubkey, network)?;