diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 7eefbc049..2c8b1e11a 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -392,7 +392,6 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { get_balance(&recv_chain, &recv_graph)?, Balance { confirmed: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64, - trusted_pending: SEND_AMOUNT * reorg_count as u64, ..Balance::default() }, "reorg_count: {}", diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 13eefd66b..b79aa434e 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -109,10 +109,11 @@ use core::{ /// [module-level documentation]: crate::tx_graph #[derive(Clone, Debug, PartialEq)] pub struct TxGraph { - // all transactions that the graph is aware of in format: `(tx_node, tx_anchors, tx_last_seen)` - txs: HashMap, u64)>, + // all transactions that the graph is aware of in format: `(tx_node, tx_anchors)` + txs: HashMap)>, spends: BTreeMap>, anchors: BTreeSet<(A, Txid)>, + last_seen: HashMap, // This atrocity exists so that `TxGraph::outspends()` can return a reference. // FIXME: This can be removed once `HashSet::new` is a const fn. @@ -125,6 +126,7 @@ impl Default for TxGraph { txs: Default::default(), spends: Default::default(), anchors: Default::default(), + last_seen: Default::default(), empty_outspends: Default::default(), } } @@ -140,7 +142,7 @@ pub struct TxNode<'a, T, A> { /// The blocks that the transaction is "anchored" in. pub anchors: &'a BTreeSet, /// The last-seen unix timestamp of the transaction as unconfirmed. - pub last_seen_unconfirmed: u64, + pub last_seen_unconfirmed: Option, } impl<'a, T, A> Deref for TxNode<'a, T, A> { @@ -210,7 +212,7 @@ impl TxGraph { /// /// This includes txouts of both full transactions as well as floating transactions. pub fn all_txouts(&self) -> impl Iterator { - self.txs.iter().flat_map(|(txid, (tx, _, _))| match tx { + self.txs.iter().flat_map(|(txid, (tx, _))| match tx { TxNodeInternal::Whole(tx) => tx .as_ref() .output @@ -232,7 +234,7 @@ impl TxGraph { pub fn floating_txouts(&self) -> impl Iterator { self.txs .iter() - .filter_map(|(txid, (tx_node, _, _))| match tx_node { + .filter_map(|(txid, (tx_node, _))| match tx_node { TxNodeInternal::Whole(_) => None, TxNodeInternal::Partial(txouts) => Some( txouts @@ -247,17 +249,30 @@ impl TxGraph { pub fn full_txs(&self) -> impl Iterator, A>> { self.txs .iter() - .filter_map(|(&txid, (tx, anchors, last_seen))| match tx { + .filter_map(|(&txid, (tx, anchors))| match tx { TxNodeInternal::Whole(tx) => Some(TxNode { txid, tx: tx.clone(), anchors, - last_seen_unconfirmed: *last_seen, + last_seen_unconfirmed: self.last_seen.get(&txid).copied(), }), TxNodeInternal::Partial(_) => None, }) } + /// Iterate over graph transactions with no anchors or last-seen. + pub fn txs_with_no_anchor_or_last_seen( + &self, + ) -> impl Iterator, A>> { + self.full_txs().filter_map(|tx| { + if tx.anchors.is_empty() && tx.last_seen_unconfirmed.is_none() { + Some(tx) + } else { + None + } + }) + } + /// Get a transaction by txid. This only returns `Some` for full transactions. /// /// Refer to [`get_txout`] for getting a specific [`TxOut`]. @@ -270,11 +285,11 @@ impl TxGraph { /// Get a transaction node by txid. This only returns `Some` for full transactions. pub fn get_tx_node(&self, txid: Txid) -> Option, A>> { match &self.txs.get(&txid)? { - (TxNodeInternal::Whole(tx), anchors, last_seen) => Some(TxNode { + (TxNodeInternal::Whole(tx), anchors) => Some(TxNode { txid, tx: tx.clone(), anchors, - last_seen_unconfirmed: *last_seen, + last_seen_unconfirmed: self.last_seen.get(&txid).copied(), }), _ => None, } @@ -504,7 +519,6 @@ impl TxGraph { ( TxNodeInternal::Partial([(outpoint.vout, txout)].into()), BTreeSet::new(), - 0, ), ); self.apply_update(update) @@ -518,7 +532,7 @@ impl TxGraph { let mut update = Self::default(); update.txs.insert( tx.compute_txid(), - (TxNodeInternal::Whole(tx), BTreeSet::new(), 0), + (TxNodeInternal::Whole(tx), BTreeSet::new()), ); self.apply_update(update) } @@ -559,8 +573,7 @@ impl TxGraph { /// [`update_last_seen_unconfirmed`]: Self::update_last_seen_unconfirmed pub fn insert_seen_at(&mut self, txid: Txid, seen_at: u64) -> ChangeSet { let mut update = Self::default(); - let (_, _, update_last_seen) = update.txs.entry(txid).or_default(); - *update_last_seen = seen_at; + update.last_seen.insert(txid, seen_at); self.apply_update(update) } @@ -607,7 +620,7 @@ impl TxGraph { .txs .iter() .filter_map( - |(&txid, (_, anchors, _))| { + |(&txid, (_, anchors))| { if anchors.is_empty() { Some(txid) } else { @@ -656,10 +669,10 @@ impl TxGraph { }); match self.txs.get_mut(&txid) { - Some((tx_node @ TxNodeInternal::Partial(_), _, _)) => { + Some((tx_node @ TxNodeInternal::Partial(_), _)) => { *tx_node = TxNodeInternal::Whole(wrapped_tx.clone()); } - Some((TxNodeInternal::Whole(tx), _, _)) => { + Some((TxNodeInternal::Whole(tx), _)) => { debug_assert_eq!( tx.as_ref().compute_txid(), txid, @@ -667,10 +680,8 @@ impl TxGraph { ); } None => { - self.txs.insert( - txid, - (TxNodeInternal::Whole(wrapped_tx), BTreeSet::new(), 0), - ); + self.txs + .insert(txid, (TxNodeInternal::Whole(wrapped_tx), BTreeSet::new())); } } } @@ -679,9 +690,8 @@ impl TxGraph { let tx_entry = self.txs.entry(outpoint.txid).or_default(); match tx_entry { - (TxNodeInternal::Whole(_), _, _) => { /* do nothing since we already have full tx */ - } - (TxNodeInternal::Partial(txouts), _, _) => { + (TxNodeInternal::Whole(_), _) => { /* do nothing since we already have full tx */ } + (TxNodeInternal::Partial(txouts), _) => { txouts.insert(outpoint.vout, txout); } } @@ -689,13 +699,13 @@ impl TxGraph { for (anchor, txid) in changeset.anchors { if self.anchors.insert((anchor.clone(), txid)) { - let (_, anchors, _) = self.txs.entry(txid).or_default(); + let (_, anchors) = self.txs.entry(txid).or_default(); anchors.insert(anchor); } } for (txid, new_last_seen) in changeset.last_seen { - let (_, _, last_seen) = self.txs.entry(txid).or_default(); + let last_seen = self.last_seen.entry(txid).or_default(); if new_last_seen > *last_seen { *last_seen = new_last_seen; } @@ -709,11 +719,10 @@ impl TxGraph { pub(crate) fn determine_changeset(&self, update: TxGraph) -> ChangeSet { let mut changeset = ChangeSet::::default(); - for (&txid, (update_tx_node, _, update_last_seen)) in &update.txs { - let prev_last_seen: u64 = match (self.txs.get(&txid), update_tx_node) { + for (&txid, (update_tx_node, _)) in &update.txs { + match (self.txs.get(&txid), update_tx_node) { (None, TxNodeInternal::Whole(update_tx)) => { changeset.txs.insert(update_tx.clone()); - 0 } (None, TxNodeInternal::Partial(update_txos)) => { changeset.txouts.extend( @@ -721,18 +730,13 @@ impl TxGraph { .iter() .map(|(&vout, txo)| (OutPoint::new(txid, vout), txo.clone())), ); - 0 } - (Some((TxNodeInternal::Whole(_), _, last_seen)), _) => *last_seen, - ( - Some((TxNodeInternal::Partial(_), _, last_seen)), - TxNodeInternal::Whole(update_tx), - ) => { + (Some((TxNodeInternal::Whole(_), _)), _) => {} + (Some((TxNodeInternal::Partial(_), _)), TxNodeInternal::Whole(update_tx)) => { changeset.txs.insert(update_tx.clone()); - *last_seen } ( - Some((TxNodeInternal::Partial(txos), _, last_seen)), + Some((TxNodeInternal::Partial(txos), _)), TxNodeInternal::Partial(update_txos), ) => { changeset.txouts.extend( @@ -741,12 +745,14 @@ impl TxGraph { .filter(|(vout, _)| !txos.contains_key(*vout)) .map(|(&vout, txo)| (OutPoint::new(txid, vout), txo.clone())), ); - *last_seen } - }; + } + } - if *update_last_seen > prev_last_seen { - changeset.last_seen.insert(txid, *update_last_seen); + for (txid, update_last_seen) in update.last_seen { + let prev_last_seen = self.last_seen.get(&txid).copied(); + if Some(update_last_seen) > prev_last_seen { + changeset.last_seen.insert(txid, update_last_seen); } } @@ -786,7 +792,7 @@ impl TxGraph { chain_tip: BlockId, txid: Txid, ) -> Result>, C::Error> { - let (tx_node, anchors, last_seen) = match self.txs.get(&txid) { + let (tx_node, anchors) = match self.txs.get(&txid) { Some(v) => v, None => return Ok(None), }; @@ -798,6 +804,13 @@ impl TxGraph { } } + // If no anchors are in best chain and we don't have a last_seen, we can return + // early because by definition the tx doesn't have a chain position. + let last_seen = match self.last_seen.get(&txid) { + Some(t) => *t, + None => return Ok(None), + }; + // The tx is not anchored to a block in the best chain, which means that it // might be in mempool, or it might have been dropped already. // Let's check conflicts to find out! @@ -884,7 +897,7 @@ impl TxGraph { if conflicting_tx.last_seen_unconfirmed > tx_last_seen { return Ok(None); } - if conflicting_tx.last_seen_unconfirmed == *last_seen + if conflicting_tx.last_seen_unconfirmed == Some(last_seen) && conflicting_tx.as_ref().compute_txid() > tx.as_ref().compute_txid() { // Conflicting tx has priority if txid of conflicting tx > txid of original tx @@ -893,7 +906,7 @@ impl TxGraph { } } - Ok(Some(ChainPosition::Unconfirmed(*last_seen))) + Ok(Some(ChainPosition::Unconfirmed(last_seen))) } /// Get the position of the transaction in `chain` with tip `chain_tip`. @@ -971,10 +984,10 @@ impl TxGraph { /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the /// returned item. /// - /// If the [`ChainOracle`] is infallible, [`list_chain_txs`] can be used instead. + /// If the [`ChainOracle`] is infallible, [`list_canonical_txs`] can be used instead. /// - /// [`list_chain_txs`]: Self::list_chain_txs - pub fn try_list_chain_txs<'a, C: ChainOracle + 'a>( + /// [`list_canonical_txs`]: Self::list_canonical_txs + pub fn try_list_canonical_txs<'a, C: ChainOracle + 'a>( &'a self, chain: &'a C, chain_tip: BlockId, @@ -993,15 +1006,15 @@ impl TxGraph { /// List graph transactions that are in `chain` with `chain_tip`. /// - /// This is the infallible version of [`try_list_chain_txs`]. + /// This is the infallible version of [`try_list_canonical_txs`]. /// - /// [`try_list_chain_txs`]: Self::try_list_chain_txs - pub fn list_chain_txs<'a, C: ChainOracle + 'a>( + /// [`try_list_canonical_txs`]: Self::try_list_canonical_txs + pub fn list_canonical_txs<'a, C: ChainOracle + 'a>( &'a self, chain: &'a C, chain_tip: BlockId, ) -> impl Iterator, A>> { - self.try_list_chain_txs(chain, chain_tip) + self.try_list_canonical_txs(chain, chain_tip) .map(|r| r.expect("oracle is infallible")) } diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 13bce01ba..3337fb436 100644 --- a/crates/chain/tests/common/tx_template.rs +++ b/crates/chain/tests/common/tx_template.rs @@ -131,9 +131,7 @@ 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()); } - if let Some(seen_at) = tx_tmp.last_seen { - let _ = graph.insert_seen_at(tx.compute_txid(), seen_at); - } + let _ = graph.insert_seen_at(tx.compute_txid(), tx_tmp.last_seen.unwrap_or(0)); } (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 4266e184a..dbd7fd302 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -116,8 +116,8 @@ fn insert_relevant_txs() { /// tx1: A Coinbase, sending 70000 sats to "trusted" address. [Block 0] /// tx2: A external Receive, sending 30000 sats to "untrusted" address. [Block 1] /// tx3: Internal Spend. Spends tx2 and returns change of 10000 to "trusted" address. [Block 2] -/// tx4: Mempool tx, sending 20000 sats to "trusted" address. -/// tx5: Mempool tx, sending 15000 sats to "untested" address. +/// tx4: Mempool tx, sending 20000 sats to "untrusted" address. +/// tx5: Mempool tx, sending 15000 sats to "trusted" address. /// tx6: Complete unrelated tx. [Block 3] /// /// Different transactions are added via `insert_relevant_txs`. @@ -160,7 +160,7 @@ fn test_list_owned_txouts() { let mut untrusted_spks: Vec = Vec::new(); { - // we need to scope here to take immutanble reference of the graph + // we need to scope here to take immutable reference of the graph for _ in 0..10 { let ((_, script), _) = graph .index @@ -226,7 +226,7 @@ fn test_list_owned_txouts() { ..common::new_tx(0) }; - // tx5 is spending tx3 and receiving change at trusted keychain, unconfirmed. + // tx5 is an external transaction receiving at trusted keychain, unconfirmed. let tx5 = Transaction { output: vec![TxOut { value: Amount::from_sat(15000), @@ -239,7 +239,7 @@ fn test_list_owned_txouts() { let tx6 = common::new_tx(0); // Insert transactions into graph with respective anchors - // For unconfirmed txs we pass in `None`. + // Insert unconfirmed txs with a last_seen timestamp let _ = graph.batch_insert_relevant([&tx1, &tx2, &tx3, &tx6].iter().enumerate().map(|(i, tx)| { @@ -291,9 +291,6 @@ fn test_list_owned_txouts() { |_, spk: &Script| trusted_spks.contains(&spk.to_owned()), ); - assert_eq!(txouts.len(), 5); - assert_eq!(utxos.len(), 4); - let confirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { @@ -359,29 +356,25 @@ fn test_list_owned_txouts() { balance, ) = fetch(0, &graph); + // tx1 is a confirmed txout and is unspent + // tx4, tx5 are unconfirmed assert_eq!(confirmed_txouts_txid, [tx1.compute_txid()].into()); assert_eq!( unconfirmed_txouts_txid, - [ - tx2.compute_txid(), - tx3.compute_txid(), - tx4.compute_txid(), - tx5.compute_txid() - ] - .into() + [tx4.compute_txid(), tx5.compute_txid()].into() ); assert_eq!(confirmed_utxos_txid, [tx1.compute_txid()].into()); assert_eq!( unconfirmed_utxos_txid, - [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() + [tx4.compute_txid(), tx5.compute_txid()].into() ); assert_eq!( balance, Balance { immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(25000), // tx3 + tx5 + trusted_pending: Amount::from_sat(15000), // tx5 untrusted_pending: Amount::from_sat(20000), // tx4 confirmed: Amount::ZERO // Nothing is confirmed yet } @@ -405,23 +398,26 @@ fn test_list_owned_txouts() { ); assert_eq!( unconfirmed_txouts_txid, - [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() + [tx4.compute_txid(), tx5.compute_txid()].into() ); - // tx2 doesn't get into confirmed utxos set - assert_eq!(confirmed_utxos_txid, [tx1.compute_txid()].into()); + // tx2 gets into confirmed utxos set + assert_eq!( + confirmed_utxos_txid, + [tx1.compute_txid(), tx2.compute_txid()].into() + ); assert_eq!( unconfirmed_utxos_txid, - [tx3.compute_txid(), tx4.compute_txid(), tx5.compute_txid()].into() + [tx4.compute_txid(), tx5.compute_txid()].into() ); assert_eq!( balance, Balance { immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(25000), // tx3 + tx5 + trusted_pending: Amount::from_sat(15000), // tx5 untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::ZERO // Nothing is confirmed yet + confirmed: Amount::from_sat(30_000) // tx2 got confirmed } ); } @@ -477,6 +473,7 @@ fn test_list_owned_txouts() { balance, ) = fetch(98, &graph); + // no change compared to block 2 assert_eq!( confirmed_txouts_txid, [tx1.compute_txid(), tx2.compute_txid(), tx3.compute_txid()].into() @@ -502,14 +499,14 @@ fn test_list_owned_txouts() { immature: Amount::from_sat(70000), // immature coinbase trusted_pending: Amount::from_sat(15000), // tx5 untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(10000) // tx1 got matured + confirmed: Amount::from_sat(10000) // tx3 is confirmed } ); } // AT Block 99 { - let (_, _, _, _, balance) = fetch(100, &graph); + let (_, _, _, _, balance) = fetch(99, &graph); // Coinbase maturity hits assert_eq!( @@ -523,3 +520,147 @@ fn test_list_owned_txouts() { ); } } + +/// Given a `LocalChain`, `IndexedTxGraph`, and a `Transaction`, when we insert some anchor +/// (possibly non-canonical) and/or a last-seen timestamp into the graph, we expect the +/// result of `get_chain_position` in these cases: +/// +/// - tx with no anchors or last_seen has no `ChainPosition` +/// - tx with any last_seen will be `Unconfirmed` +/// - tx with an anchor in best chain will be `Confirmed` +/// - tx with an anchor not in best chain (no last_seen) has no `ChainPosition` +#[test] +fn test_get_chain_position() { + use bdk_chain::local_chain::CheckPoint; + use bdk_chain::BlockId; + use bdk_chain::SpkTxOutIndex; + + struct TestCase { + name: &'static str, + tx: Transaction, + anchor: Option, + last_seen: Option, + exp_pos: Option>, + } + + // addr: bcrt1qc6fweuf4xjvz4x3gx3t9e0fh4hvqyu2qw4wvxm + let spk = ScriptBuf::from_hex("0014c692ecf13534982a9a2834565cbd37add8027140").unwrap(); + let mut graph = IndexedTxGraph::new({ + let mut index = SpkTxOutIndex::default(); + let _ = index.insert_spk(0u32, spk.clone()); + index + }); + + // Anchors to test + let blocks = vec![block_id!(0, "g"), block_id!(1, "A"), block_id!(2, "B")]; + + let cp = CheckPoint::from_block_ids(blocks.clone()).unwrap(); + let chain = LocalChain::from_tip(cp).unwrap(); + + // The test will insert a transaction into the indexed tx graph + // along with any anchors and timestamps, then check the value + // returned by `get_chain_position`. + fn run( + chain: &LocalChain, + graph: &mut IndexedTxGraph>, + test: TestCase, + ) { + let TestCase { + name, + tx, + anchor, + last_seen, + exp_pos, + } = test; + + // add data to graph + let txid = tx.compute_txid(); + let _ = graph.insert_tx(tx); + if let Some(anchor) = anchor { + let _ = graph.insert_anchor(txid, anchor); + } + if let Some(seen_at) = last_seen { + let _ = graph.insert_seen_at(txid, seen_at); + } + + // check chain position + let res = graph + .graph() + .get_chain_position(chain, chain.tip().block_id(), txid); + assert_eq!( + res.map(ChainPosition::cloned), + exp_pos, + "failed test case: {name}" + ); + } + + [ + TestCase { + name: "tx no anchors or last_seen - no chain pos", + tx: Transaction { + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: spk.clone(), + }], + ..common::new_tx(0) + }, + anchor: None, + last_seen: None, + exp_pos: None, + }, + TestCase { + name: "tx last_seen - unconfirmed", + tx: Transaction { + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: spk.clone(), + }], + ..common::new_tx(1) + }, + anchor: None, + last_seen: Some(2), + exp_pos: Some(ChainPosition::Unconfirmed(2)), + }, + TestCase { + name: "tx anchor in best chain - confirmed", + tx: Transaction { + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: spk.clone(), + }], + ..common::new_tx(2) + }, + anchor: Some(blocks[1]), + last_seen: None, + exp_pos: Some(ChainPosition::Confirmed(blocks[1])), + }, + TestCase { + name: "tx unknown anchor with last_seen - unconfirmed", + tx: Transaction { + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: spk.clone(), + }], + ..common::new_tx(3) + }, + anchor: Some(block_id!(2, "B'")), + last_seen: Some(2), + exp_pos: Some(ChainPosition::Unconfirmed(2)), + }, + TestCase { + name: "tx unknown anchor - no chain pos", + tx: Transaction { + output: vec![TxOut { + value: Amount::ONE_BTC, + script_pubkey: spk.clone(), + }], + ..common::new_tx(4) + }, + anchor: Some(block_id!(2, "B'")), + last_seen: None, + exp_pos: None, + }, + ] + .into_iter() + .for_each(|t| run(&chain, &mut graph, t)); +} diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 21b955d21..c46ab169a 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -977,16 +977,6 @@ fn test_chain_spends() { })) ); - // Even if unconfirmed tx has a last_seen of 0, it can still be part of a chain spend. - assert_eq!( - graph.get_chain_spend( - &local_chain, - tip.block_id(), - OutPoint::new(tx_0.compute_txid(), 1) - ), - Some((ChainPosition::Unconfirmed(0), tx_2.compute_txid())), - ); - // Mark the unconfirmed as seen and check correct ObservedAs status is returned. let _ = graph.insert_seen_at(tx_2.compute_txid(), 1234567); @@ -1099,10 +1089,10 @@ fn update_last_seen_unconfirmed() { let txid = tx.compute_txid(); // insert a new tx - // initially we have a last_seen of 0, and no anchors + // initially we have a last_seen of None and no anchors let _ = graph.insert_tx(tx); let tx = graph.full_txs().next().unwrap(); - assert_eq!(tx.last_seen_unconfirmed, 0); + assert_eq!(tx.last_seen_unconfirmed, None); assert!(tx.anchors.is_empty()); // higher timestamp should update last seen @@ -1117,7 +1107,56 @@ fn update_last_seen_unconfirmed() { let _ = graph.insert_anchor(txid, ()); let changeset = graph.update_last_seen_unconfirmed(4); assert!(changeset.is_empty()); - assert_eq!(graph.full_txs().next().unwrap().last_seen_unconfirmed, 2); + assert_eq!( + graph + .full_txs() + .next() + .unwrap() + .last_seen_unconfirmed + .unwrap(), + 2 + ); +} + +#[test] +fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anchor_in_best_chain() { + let txs = vec![new_tx(0), new_tx(1)]; + let txids: Vec = txs.iter().map(Transaction::compute_txid).collect(); + + // graph + let mut graph = TxGraph::::new(txs); + let full_txs: Vec<_> = graph.full_txs().collect(); + assert_eq!(full_txs.len(), 2); + let unseen_txs: Vec<_> = graph.txs_with_no_anchor_or_last_seen().collect(); + assert_eq!(unseen_txs.len(), 2); + + // chain + let blocks: BTreeMap = [(0, h!("g")), (1, h!("A")), (2, h!("B"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let canonical_txs: Vec<_> = graph + .list_canonical_txs(&chain, chain.tip().block_id()) + .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 mut canonical_txs = graph.list_canonical_txs(&chain, chain.tip().block_id()); + assert_eq!( + canonical_txs.next().map(|tx| tx.tx_node.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 + .list_canonical_txs(&chain, chain.tip().block_id()) + .map(|tx| tx.tx_node.txid) + .collect(); + assert!(canonical_txids.contains(&txids[1])); + assert!(graph.txs_with_no_anchor_or_last_seen().next().is_none()); } #[test] diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 0856eec93..512b076d6 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -15,7 +15,7 @@ struct Scenario<'a> { name: &'a str, /// Transaction templates tx_templates: &'a [TxTemplate<'a, BlockId>], - /// Names of txs that must exist in the output of `list_chain_txs` + /// Names of txs that must exist in the output of `list_canonical_txs` exp_chain_txs: HashSet<&'a str>, /// Outpoints that must exist in the output of `filter_chain_txouts` exp_chain_txouts: HashSet<(&'a str, u32)>, @@ -27,7 +27,7 @@ struct Scenario<'a> { /// This test ensures that [`TxGraph`] will reliably filter out irrelevant transactions when /// presented with multiple conflicting transaction scenarios using the [`TxTemplate`] structure. -/// This test also checks that [`TxGraph::list_chain_txs`], [`TxGraph::filter_chain_txouts`], +/// This test also checks that [`TxGraph::list_canonical_txs`], [`TxGraph::filter_chain_txouts`], /// [`TxGraph::filter_chain_unspents`], and [`TxGraph::balance`] return correct data. #[test] fn test_tx_conflict_handling() { @@ -597,7 +597,7 @@ fn test_tx_conflict_handling() { let (tx_graph, spk_index, exp_tx_ids) = init_graph(scenario.tx_templates.iter()); let txs = tx_graph - .list_chain_txs(&local_chain, chain_tip) + .list_canonical_txs(&local_chain, chain_tip) .map(|tx| tx.tx_node.txid) .collect::>(); let exp_txs = scenario @@ -607,7 +607,7 @@ fn test_tx_conflict_handling() { .collect::>(); assert_eq!( txs, exp_txs, - "\n[{}] 'list_chain_txs' failed", + "\n[{}] 'list_canonical_txs' failed", scenario.name ); diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 1d520df17..9788b2514 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -198,17 +198,14 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { .apply_update(update.chain_update) .map_err(|err| anyhow::anyhow!("LocalChain update error: {:?}", err))?; - // Check to see if a new anchor is added during current reorg. - if !initial_anchors.is_superset(update.graph_update.all_anchors()) { - println!("New anchor added at reorg depth {}", depth); - } + // Check that no new anchors are added during current reorg. + assert!(initial_anchors.is_superset(update.graph_update.all_anchors())); let _ = recv_graph.apply_update(update.graph_update); assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { confirmed: SEND_AMOUNT * (REORG_COUNT - depth) as u64, - trusted_pending: SEND_AMOUNT * depth as u64, ..Balance::default() }, "reorg_count: {}", diff --git a/crates/wallet/src/wallet/export.rs b/crates/wallet/src/wallet/export.rs index 0aa3ec887..bf86f926a 100644 --- a/crates/wallet/src/wallet/export.rs +++ b/crates/wallet/src/wallet/export.rs @@ -214,7 +214,7 @@ mod test { use core::str::FromStr; use crate::std::string::ToString; - use bdk_chain::{BlockId, ConfirmationTime}; + use bdk_chain::{BlockId, ConfirmationTimeHeightAnchor}; use bitcoin::hashes::Hash; use bitcoin::{transaction, BlockHash, Network, Transaction}; @@ -222,6 +222,8 @@ mod test { use crate::wallet::Wallet; fn get_test_wallet(descriptor: &str, change_descriptor: &str, network: Network) -> Wallet { + use crate::wallet::Update; + use bdk_chain::TxGraph; let mut wallet = Wallet::new(descriptor, change_descriptor, network).unwrap(); let transaction = Transaction { input: vec![], @@ -229,22 +231,27 @@ mod test { version: transaction::Version::non_standard(0), lock_time: bitcoin::absolute::LockTime::ZERO, }; + let txid = transaction.compute_txid(); + let block_id = BlockId { + height: 5001, + hash: BlockHash::all_zeros(), + }; + wallet.insert_checkpoint(block_id).unwrap(); + wallet.insert_tx(transaction); + let anchor = ConfirmationTimeHeightAnchor { + confirmation_height: 5000, + confirmation_time: 0, + anchor_block: block_id, + }; + let mut graph = TxGraph::default(); + let _ = graph.insert_anchor(txid, anchor); wallet - .insert_checkpoint(BlockId { - height: 5001, - hash: BlockHash::all_zeros(), + .apply_update(Update { + graph, + ..Default::default() }) .unwrap(); wallet - .insert_tx( - transaction, - ConfirmationTime::Confirmed { - height: 5000, - time: 0, - }, - ) - .unwrap(); - wallet } #[test] diff --git a/crates/wallet/src/wallet/mod.rs b/crates/wallet/src/wallet/mod.rs index 400ecde1d..105d07f83 100644 --- a/crates/wallet/src/wallet/mod.rs +++ b/crates/wallet/src/wallet/mod.rs @@ -27,7 +27,7 @@ use bdk_chain::{ self, ApplyHeaderError, CannotConnectError, CheckPoint, CheckPointIter, LocalChain, }, spk_client::{FullScanRequest, FullScanResult, SyncRequest, SyncResult}, - tx_graph::{CanonicalTx, TxGraph}, + tx_graph::{CanonicalTx, TxGraph, TxNode}, Append, BlockId, ChainPosition, ConfirmationTime, ConfirmationTimeHeightAnchor, FullTxOut, Indexed, IndexedTxGraph, }; @@ -284,35 +284,6 @@ impl fmt::Display for NewOrLoadError { #[cfg(feature = "std")] impl std::error::Error for NewOrLoadError {} -/// An error that may occur when inserting a transaction into [`Wallet`]. -#[derive(Debug)] -pub enum InsertTxError { - /// The error variant that occurs when the caller attempts to insert a transaction with a - /// confirmation height that is greater than the internal chain tip. - ConfirmationHeightCannotBeGreaterThanTip { - /// The internal chain's tip height. - tip_height: u32, - /// The introduced transaction's confirmation height. - tx_height: u32, - }, -} - -impl fmt::Display for InsertTxError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - InsertTxError::ConfirmationHeightCannotBeGreaterThanTip { - tip_height, - tx_height, - } => { - write!(f, "cannot insert tx with confirmation height ({}) higher than internal tip height ({})", tx_height, tip_height) - } - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for InsertTxError {} - /// An error that may occur when applying a block to [`Wallet`]. #[derive(Debug)] pub enum ApplyBlockError { @@ -1079,63 +1050,21 @@ impl Wallet { /// Add a transaction to the wallet's internal view of the chain. This stages the change, /// you must persist it later. /// - /// Returns whether anything changed with the transaction insertion (e.g. `false` if the - /// transaction was already inserted at the same position). - /// - /// A `tx` can be rejected if `position` has a height greater than the [`latest_checkpoint`]. - /// Therefore you should use [`insert_checkpoint`] to insert new checkpoints before manually - /// inserting new transactions. + /// This method inserts the given `tx` and returns whether anything changed after insertion, + /// which will be false if the same transaction already exists in the wallet's transaction + /// graph. Any changes are staged but not committed. /// - /// **WARNING**: If `position` is confirmed, we anchor the `tx` to the lowest checkpoint that - /// is >= the `position`'s height. The caller is responsible for ensuring the `tx` exists in our - /// local view of the best chain's history. + /// # Note /// - /// You must persist the changes resulting from one or more calls to this method if you need - /// the inserted tx to be reloaded after closing the wallet. - /// - /// [`commit`]: Self::commit - /// [`latest_checkpoint`]: Self::latest_checkpoint - /// [`insert_checkpoint`]: Self::insert_checkpoint - pub fn insert_tx( - &mut self, - tx: Transaction, - position: ConfirmationTime, - ) -> Result { - let (anchor, last_seen) = match position { - ConfirmationTime::Confirmed { height, time } => { - // anchor tx to checkpoint with lowest height that is >= position's height - let anchor = self - .chain - .range(height..) - .last() - .ok_or(InsertTxError::ConfirmationHeightCannotBeGreaterThanTip { - tip_height: self.chain.tip().height(), - tx_height: height, - }) - .map(|anchor_cp| ConfirmationTimeHeightAnchor { - anchor_block: anchor_cp.block_id(), - confirmation_height: height, - confirmation_time: time, - })?; - - (Some(anchor), None) - } - ConfirmationTime::Unconfirmed { last_seen } => (None, Some(last_seen)), - }; - + /// By default the inserted `tx` won't be considered "canonical" because it's not known + /// whether the transaction exists in the best chain. To know whether it exists, the tx + /// must be broadcast to the network and the wallet synced via a chain source. + pub fn insert_tx(&mut self, tx: Transaction) -> bool { let mut changeset = ChangeSet::default(); - let txid = tx.compute_txid(); changeset.append(self.indexed_graph.insert_tx(tx).into()); - if let Some(anchor) = anchor { - changeset.append(self.indexed_graph.insert_anchor(txid, anchor).into()); - } - if let Some(last_seen) = last_seen { - changeset.append(self.indexed_graph.insert_seen_at(txid, last_seen).into()); - } - - let changed = !changeset.is_empty(); + let ret = !changeset.is_empty(); self.stage.append(changeset); - Ok(changed) + ret } /// Iterate over the transactions in the wallet. @@ -1145,7 +1074,7 @@ impl Wallet { { self.indexed_graph .graph() - .list_chain_txs(&self.chain, self.chain.tip().block_id()) + .list_canonical_txs(&self.chain, self.chain.tip().block_id()) } /// Return the balance, separated into available, trusted-pending, untrusted-pending and immature @@ -2321,6 +2250,14 @@ impl Wallet { self.indexed_graph.graph() } + /// Iterate over transactions in the wallet that are unseen and unanchored likely + /// because they haven't been broadcast. + pub fn unbroadcast_transactions( + &self, + ) -> impl Iterator, ConfirmationTimeHeightAnchor>> { + self.tx_graph().txs_with_no_anchor_or_last_seen() + } + /// Get a reference to the inner [`KeychainTxOutIndex`]. pub fn spk_index(&self) -> &KeychainTxOutIndex { &self.indexed_graph.index @@ -2540,8 +2477,9 @@ macro_rules! floating_rate { macro_rules! doctest_wallet { () => {{ use $crate::bitcoin::{BlockHash, Transaction, absolute, TxOut, Network, hashes::Hash}; - use $crate::chain::{ConfirmationTime, BlockId}; - use $crate::{KeychainKind, wallet::Wallet}; + use $crate::chain::{ConfirmationTimeHeightAnchor, BlockId, TxGraph}; + use $crate::wallet::{Update, Wallet}; + use $crate::KeychainKind; let descriptor = "tr([73c5da0a/86'/0'/0']tprv8fMn4hSKPRC1oaCPqxDb1JWtgkpeiQvZhsr8W2xuy3GEMkzoArcAWTfJxYb6Wj8XNNDWEjfYKK4wGQXh3ZUXhDF2NcnsALpWTeSwarJt7Vc/0/*)"; let change_descriptor = "tr([73c5da0a/86'/0'/0']tprv8fMn4hSKPRC1oaCPqxDb1JWtgkpeiQvZhsr8W2xuy3GEMkzoArcAWTfJxYb6Wj8XNNDWEjfYKK4wGQXh3ZUXhDF2NcnsALpWTeSwarJt7Vc/1/*)"; @@ -2561,12 +2499,19 @@ macro_rules! doctest_wallet { script_pubkey: address.script_pubkey(), }], }; - let _ = wallet.insert_checkpoint(BlockId { height: 1_000, hash: BlockHash::all_zeros() }); - let _ = wallet.insert_tx(tx.clone(), ConfirmationTime::Confirmed { - height: 500, - time: 50_000 - }); - + let txid = tx.txid(); + let block = BlockId { height: 1_000, hash: BlockHash::all_zeros() }; + let _ = wallet.insert_checkpoint(block); + let _ = wallet.insert_tx(tx); + let anchor = ConfirmationTimeHeightAnchor { + confirmation_height: 500, + confirmation_time: 50_000, + anchor_block: block, + }; + let mut graph = TxGraph::default(); + let _ = graph.insert_anchor(txid, anchor); + let update = Update { graph, ..Default::default() }; + wallet.apply_update(update).unwrap(); wallet }} } diff --git a/crates/wallet/tests/common.rs b/crates/wallet/tests/common.rs index bab75e275..0a3e479b8 100644 --- a/crates/wallet/tests/common.rs +++ b/crates/wallet/tests/common.rs @@ -1,7 +1,8 @@ #![allow(unused)] use bdk_chain::indexed_tx_graph::Indexer; -use bdk_chain::{BlockId, ConfirmationTime}; +use bdk_chain::{BlockId, ConfirmationTime, ConfirmationTimeHeightAnchor, TxGraph}; +use bdk_wallet::wallet::Update; use bdk_wallet::{KeychainKind, LocalOutput, Wallet}; use bitcoin::hashes::Hash; use bitcoin::{ @@ -77,24 +78,26 @@ pub fn get_funded_wallet_with_change(descriptor: &str, change: &str) -> (Wallet, hash: BlockHash::all_zeros(), }) .unwrap(); - wallet - .insert_tx( - tx0, - ConfirmationTime::Confirmed { - height: 1_000, - time: 100, - }, - ) - .unwrap(); - wallet - .insert_tx( - tx1.clone(), - ConfirmationTime::Confirmed { - height: 2_000, - time: 200, - }, - ) - .unwrap(); + + wallet.insert_tx(tx0.clone()); + insert_anchor_from_conf( + &mut wallet, + tx0.compute_txid(), + ConfirmationTime::Confirmed { + height: 1_000, + time: 100, + }, + ); + + wallet.insert_tx(tx1.clone()); + insert_anchor_from_conf( + &mut wallet, + tx1.compute_txid(), + ConfirmationTime::Confirmed { + height: 2_000, + time: 200, + }, + ); (wallet, tx1.compute_txid()) } @@ -192,3 +195,31 @@ pub fn feerate_unchecked(sat_vb: f64) -> FeeRate { let sat_kwu = (sat_vb * 250.0).ceil() as u64; FeeRate::from_sat_per_kwu(sat_kwu) } + +/// Simulates confirming a tx with `txid` at the specified `position` by inserting an anchor +/// at the lowest height in local chain that is greater or equal to `position`'s height, +/// assuming the confirmation time matches `ConfirmationTime::Confirmed`. +pub fn insert_anchor_from_conf(wallet: &mut Wallet, txid: Txid, position: ConfirmationTime) { + if let ConfirmationTime::Confirmed { height, time } = position { + // anchor tx to checkpoint with lowest height that is >= position's height + let anchor = wallet + .local_chain() + .range(height..) + .last() + .map(|anchor_cp| ConfirmationTimeHeightAnchor { + anchor_block: anchor_cp.block_id(), + confirmation_height: height, + confirmation_time: time, + }) + .expect("confirmation height cannot be greater than tip"); + + let mut graph = TxGraph::default(); + let _ = graph.insert_anchor(txid, anchor); + wallet + .apply_update(Update { + graph, + ..Default::default() + }) + .unwrap(); + } +} diff --git a/crates/wallet/tests/wallet.rs b/crates/wallet/tests/wallet.rs index ef5575acb..452c839e1 100644 --- a/crates/wallet/tests/wallet.rs +++ b/crates/wallet/tests/wallet.rs @@ -42,12 +42,19 @@ fn receive_output(wallet: &mut Wallet, value: u64, height: ConfirmationTime) -> }], }; - wallet.insert_tx(tx.clone(), height).unwrap(); + let txid = tx.compute_txid(); + wallet.insert_tx(tx); - OutPoint { - txid: tx.compute_txid(), - vout: 0, + match height { + ConfirmationTime::Confirmed { .. } => { + insert_anchor_from_conf(wallet, txid, height); + } + ConfirmationTime::Unconfirmed { last_seen } => { + insert_seen_at(wallet, txid, last_seen); + } } + + OutPoint { txid, vout: 0 } } fn receive_output_in_latest_block(wallet: &mut Wallet, value: u64) -> OutPoint { @@ -61,6 +68,18 @@ fn receive_output_in_latest_block(wallet: &mut Wallet, value: u64) -> OutPoint { receive_output(wallet, value, anchor) } +fn insert_seen_at(wallet: &mut Wallet, txid: Txid, seen_at: u64) { + use bdk_wallet::wallet::Update; + let mut graph = bdk_chain::TxGraph::default(); + let _ = graph.insert_seen_at(txid, seen_at); + wallet + .apply_update(Update { + graph, + ..Default::default() + }) + .unwrap(); +} + // The satisfaction size of a P2WPKH is 112 WU = // 1 (elements in witness) + 1 (OP_PUSH) + 33 (pk) + 1 (OP_PUSH) + 72 (signature + sighash) + 1*4 (script len) // On the witness itself, we have to push once for the pk (33WU) and once for signature + sighash (72WU), for @@ -1180,12 +1199,16 @@ fn test_create_tx_add_utxo() { version: transaction::Version::non_standard(0), lock_time: absolute::LockTime::ZERO, }; - wallet - .insert_tx( - small_output_tx.clone(), - ConfirmationTime::Unconfirmed { last_seen: 0 }, - ) - .unwrap(); + let txid = small_output_tx.compute_txid(); + wallet.insert_tx(small_output_tx); + insert_anchor_from_conf( + &mut wallet, + txid, + ConfirmationTime::Confirmed { + height: 2000, + time: 200, + }, + ); let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") .unwrap() @@ -1193,10 +1216,7 @@ fn test_create_tx_add_utxo() { let mut builder = wallet.build_tx(); builder .add_recipient(addr.script_pubkey(), Amount::from_sat(30_000)) - .add_utxo(OutPoint { - txid: small_output_tx.compute_txid(), - vout: 0, - }) + .add_utxo(OutPoint { txid, vout: 0 }) .unwrap(); let psbt = builder.finish().unwrap(); let sent_received = @@ -1229,13 +1249,16 @@ fn test_create_tx_manually_selected_insufficient() { version: transaction::Version::non_standard(0), lock_time: absolute::LockTime::ZERO, }; - - wallet - .insert_tx( - small_output_tx.clone(), - ConfirmationTime::Unconfirmed { last_seen: 0 }, - ) - .unwrap(); + let txid = small_output_tx.compute_txid(); + wallet.insert_tx(small_output_tx.clone()); + insert_anchor_from_conf( + &mut wallet, + txid, + ConfirmationTime::Confirmed { + height: 2000, + time: 200, + }, + ); let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") .unwrap() @@ -1243,10 +1266,7 @@ fn test_create_tx_manually_selected_insufficient() { let mut builder = wallet.build_tx(); builder .add_recipient(addr.script_pubkey(), Amount::from_sat(30_000)) - .add_utxo(OutPoint { - txid: small_output_tx.compute_txid(), - vout: 0, - }) + .add_utxo(OutPoint { txid, vout: 0 }) .unwrap() .manually_selected_only(); builder.finish().unwrap(); @@ -1281,9 +1301,9 @@ fn test_create_tx_policy_path_no_csv() { value: Amount::from_sat(50_000), }], }; - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + let txid = tx.compute_txid(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let external_policy = wallet.policies(KeychainKind::External).unwrap().unwrap(); let root_id = external_policy.id; @@ -1674,9 +1694,8 @@ fn test_bump_fee_irreplaceable_tx() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); wallet.build_fee_bump(txid).unwrap().finish().unwrap(); } @@ -1692,15 +1711,15 @@ fn test_bump_fee_confirmed_tx() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx( - tx, - ConfirmationTime::Confirmed { - height: 42, - time: 42_000, - }, - ) - .unwrap(); + wallet.insert_tx(tx); + insert_anchor_from_conf( + &mut wallet, + txid, + ConfirmationTime::Confirmed { + height: 42, + time: 42_000, + }, + ); wallet.build_fee_bump(txid).unwrap().finish().unwrap(); } @@ -1719,9 +1738,8 @@ fn test_bump_fee_low_fee_rate() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_rate(FeeRate::BROADCAST_MIN); @@ -1752,9 +1770,8 @@ fn test_bump_fee_low_abs() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_absolute(Amount::from_sat(10)); @@ -1774,9 +1791,8 @@ fn test_bump_fee_zero_abs() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_absolute(Amount::ZERO); @@ -1800,9 +1816,8 @@ fn test_bump_fee_reduce_change() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let feerate = FeeRate::from_sat_per_kwu(625); // 2.5 sat/vb let mut builder = wallet.build_fee_bump(txid).unwrap(); @@ -1898,9 +1913,8 @@ fn test_bump_fee_reduce_single_recipient() { let original_sent_received = wallet.sent_and_received(&tx); let original_fee = check_fee!(wallet, psbt); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let feerate = FeeRate::from_sat_per_kwu(625); // 2.5 sat/vb let mut builder = wallet.build_fee_bump(txid).unwrap(); @@ -1946,9 +1960,8 @@ fn test_bump_fee_absolute_reduce_single_recipient() { let tx = psbt.extract_tx().expect("failed to extract tx"); let original_sent_received = wallet.sent_and_received(&tx); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder @@ -1991,15 +2004,18 @@ fn test_bump_fee_drain_wallet() { value: Amount::from_sat(25_000), }], }; - wallet - .insert_tx( - tx.clone(), - ConfirmationTime::Confirmed { - height: wallet.latest_checkpoint().height(), - time: 42_000, - }, - ) - .unwrap(); + let txid = tx.compute_txid(); + let tip = wallet.latest_checkpoint().height(); + wallet.insert_tx(tx.clone()); + insert_anchor_from_conf( + &mut wallet, + txid, + ConfirmationTime::Confirmed { + height: tip, + time: 42_000, + }, + ); + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") .unwrap() .assume_checked(); @@ -2019,9 +2035,8 @@ fn test_bump_fee_drain_wallet() { let original_sent_received = wallet.sent_and_received(&tx); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); assert_eq!(original_sent_received.0, Amount::from_sat(25_000)); // for the new feerate, it should be enough to reduce the output, but since we specify @@ -2056,18 +2071,17 @@ fn test_bump_fee_remove_output_manually_selected_only() { value: Amount::from_sat(25_000), }], }; - wallet - .insert_tx( - init_tx.clone(), - wallet - .transactions() - .last() - .unwrap() - .chain_position - .cloned() - .into(), - ) - .unwrap(); + let position: ConfirmationTime = wallet + .transactions() + .last() + .unwrap() + .chain_position + .cloned() + .into(); + + wallet.insert_tx(init_tx.clone()); + insert_anchor_from_conf(&mut wallet, init_tx.compute_txid(), position); + let outpoint = OutPoint { txid: init_tx.compute_txid(), vout: 0, @@ -2086,9 +2100,8 @@ fn test_bump_fee_remove_output_manually_selected_only() { let tx = psbt.extract_tx().expect("failed to extract tx"); let original_sent_received = wallet.sent_and_received(&tx); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); assert_eq!(original_sent_received.0, Amount::from_sat(25_000)); let mut builder = wallet.build_fee_bump(txid).unwrap(); @@ -2112,14 +2125,16 @@ fn test_bump_fee_add_input() { value: Amount::from_sat(25_000), }], }; - let pos = wallet + let txid = init_tx.compute_txid(); + let pos: ConfirmationTime = wallet .transactions() .last() .unwrap() .chain_position .cloned() .into(); - wallet.insert_tx(init_tx, pos).unwrap(); + wallet.insert_tx(init_tx); + insert_anchor_from_conf(&mut wallet, txid, pos); let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") .unwrap() @@ -2132,9 +2147,8 @@ fn test_bump_fee_add_input() { let tx = psbt.extract_tx().expect("failed to extract tx"); let original_details = wallet.sent_and_received(&tx); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_rate(FeeRate::from_sat_per_vb_unchecked(50)); @@ -2189,9 +2203,8 @@ fn test_bump_fee_absolute_add_input() { let tx = psbt.extract_tx().expect("failed to extract tx"); let original_sent_received = wallet.sent_and_received(&tx); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_absolute(Amount::from_sat(6_000)); @@ -2255,9 +2268,8 @@ fn test_bump_fee_no_change_add_input_and_change() { let tx = psbt.extract_tx().expect("failed to extract tx"); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); // Now bump the fees, the wallet should add an extra input and a change output, and leave // the original output untouched. @@ -2325,9 +2337,8 @@ fn test_bump_fee_add_input_change_dust() { assert_eq!(tx.input.len(), 1); assert_eq!(tx.output.len(), 2); let txid = tx.compute_txid(); - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); // We set a fee high enough that during rbf we are forced to add @@ -2396,9 +2407,8 @@ fn test_bump_fee_force_add_input() { for txin in &mut tx.input { txin.witness.push([0x00; P2WPKH_FAKE_WITNESS_SIZE]); // fake signature } - wallet - .insert_tx(tx.clone(), ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx.clone()); + insert_seen_at(&mut wallet, txid, 0); // the new fee_rate is low enough that just reducing the change would be fine, but we force // the addition of an extra input with `add_utxo()` let mut builder = wallet.build_fee_bump(txid).unwrap(); @@ -2463,9 +2473,8 @@ fn test_bump_fee_absolute_force_add_input() { for txin in &mut tx.input { txin.witness.push([0x00; P2WPKH_FAKE_WITNESS_SIZE]); // fake signature } - wallet - .insert_tx(tx.clone(), ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx.clone()); + insert_seen_at(&mut wallet, txid, 0); // the new fee_rate is low enough that just reducing the change would be fine, but we force // the addition of an extra input with `add_utxo()` @@ -2542,9 +2551,8 @@ fn test_bump_fee_unconfirmed_inputs_only() { for txin in &mut tx.input { txin.witness.push([0x00; P2WPKH_FAKE_WITNESS_SIZE]); // fake signature } - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder.fee_rate(FeeRate::from_sat_per_vb_unchecked(25)); builder.finish().unwrap(); @@ -2575,9 +2583,8 @@ fn test_bump_fee_unconfirmed_input() { for txin in &mut tx.input { txin.witness.push([0x00; P2WPKH_FAKE_WITNESS_SIZE]); // fake signature } - wallet - .insert_tx(tx, ConfirmationTime::Unconfirmed { last_seen: 0 }) - .unwrap(); + wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 0); let mut builder = wallet.build_fee_bump(txid).unwrap(); builder @@ -3774,15 +3781,16 @@ fn test_spend_coinbase() { value: Amount::from_sat(25_000), }], }; - wallet - .insert_tx( - coinbase_tx, - ConfirmationTime::Confirmed { - height: confirmation_height, - time: 30_000, - }, - ) - .unwrap(); + let txid = coinbase_tx.compute_txid(); + wallet.insert_tx(coinbase_tx); + insert_anchor_from_conf( + &mut wallet, + txid, + ConfirmationTime::Confirmed { + height: confirmation_height, + time: 30_000, + }, + ); let not_yet_mature_time = confirmation_height + COINBASE_MATURITY - 1; let maturity_time = confirmation_height + COINBASE_MATURITY; @@ -4073,3 +4081,45 @@ fn test_thread_safety() { fn thread_safe() {} thread_safe::(); // compiles only if true } + +#[test] +fn test_insert_tx_balance_and_utxos() { + // creating many txs has no effect on the wallet's available utxos + let (mut wallet, _) = get_funded_wallet(get_test_tr_single_sig_xprv()); + let addr = Address::from_str("bcrt1qc6fweuf4xjvz4x3gx3t9e0fh4hvqyu2qw4wvxm") + .unwrap() + .assume_checked(); + + let unspent: Vec<_> = wallet.list_unspent().collect(); + assert!(!unspent.is_empty()); + + let balance = wallet.balance().total(); + let fee = Amount::from_sat(143); + let amt = balance - fee; + + for _ in 0..3 { + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), amt); + let mut psbt = builder.finish().unwrap(); + assert!(wallet.sign(&mut psbt, SignOptions::default()).unwrap()); + let tx = psbt.extract_tx().unwrap(); + let _ = wallet.insert_tx(tx); + } + assert_eq!(wallet.list_unspent().collect::>(), unspent); + assert_eq!(wallet.balance().confirmed, balance); + + // manually setting a tx last_seen will consume the wallet's available utxos + let addr = Address::from_str("bcrt1qfjg5lv3dvc9az8patec8fjddrs4aqtauadnagr") + .unwrap() + .assume_checked(); + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), amt); + let mut psbt = builder.finish().unwrap(); + assert!(wallet.sign(&mut psbt, SignOptions::default()).unwrap()); + let tx = psbt.extract_tx().unwrap(); + let txid = tx.compute_txid(); + let _ = wallet.insert_tx(tx); + insert_seen_at(&mut wallet, txid, 2); + assert!(wallet.list_unspent().next().is_none()); + assert_eq!(wallet.balance().total().to_sat(), 0); +} diff --git a/example-crates/example_electrum/src/main.rs b/example-crates/example_electrum/src/main.rs index 4789269dd..79c6f79e7 100644 --- a/example-crates/example_electrum/src/main.rs +++ b/example-crates/example_electrum/src/main.rs @@ -277,7 +277,7 @@ fn main() -> anyhow::Result<()> { if unconfirmed { let unconfirmed_txids = graph .graph() - .list_chain_txs(&*chain, chain_tip.block_id()) + .list_canonical_txs(&*chain, chain_tip.block_id()) .filter(|canonical_tx| !canonical_tx.chain_position.is_confirmed()) .map(|canonical_tx| canonical_tx.tx_node.txid) .collect::>(); diff --git a/example-crates/example_esplora/src/main.rs b/example-crates/example_esplora/src/main.rs index 3f9b87210..60cf1ef3c 100644 --- a/example-crates/example_esplora/src/main.rs +++ b/example-crates/example_esplora/src/main.rs @@ -307,7 +307,7 @@ fn main() -> anyhow::Result<()> { // `EsploraExt::update_tx_graph_without_keychain`. let unconfirmed_txids = graph .graph() - .list_chain_txs(&*chain, local_tip.block_id()) + .list_canonical_txs(&*chain, local_tip.block_id()) .filter(|canonical_tx| !canonical_tx.chain_position.is_confirmed()) .map(|canonical_tx| canonical_tx.tx_node.txid) .collect::>();