diff --git a/crates/wallet/src/wallet/changeset.rs b/crates/wallet/src/wallet/changeset.rs index 9c75a289d..247b91dce 100644 --- a/crates/wallet/src/wallet/changeset.rs +++ b/crates/wallet/src/wallet/changeset.rs @@ -1,8 +1,11 @@ use bdk_chain::{ indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge, }; +use bitcoin::Txid; use miniscript::{Descriptor, DescriptorPublicKey}; +use crate::wallet::tx_details; + type IndexedTxGraphChangeSet = indexed_tx_graph::ChangeSet; @@ -15,6 +18,9 @@ pub struct ChangeSet { pub change_descriptor: Option>, /// Stores the network type of the transaction data. pub network: Option, + /// Details and metadata of wallet transactions + pub tx_details: Option, + /// Changes to the [`LocalChain`](local_chain::LocalChain). pub local_chain: local_chain::ChangeSet, /// Changes to [`TxGraph`](tx_graph::TxGraph). @@ -49,6 +55,12 @@ impl Merge for ChangeSet { self.network = other.network; } + match (&mut self.tx_details, other.tx_details) { + (None, b) => self.tx_details = b, + (Some(a), Some(b)) => Merge::merge(a, b), + _ => {} + } + Merge::merge(&mut self.local_chain, other.local_chain); Merge::merge(&mut self.tx_graph, other.tx_graph); Merge::merge(&mut self.indexer, other.indexer); @@ -58,6 +70,7 @@ impl Merge for ChangeSet { self.descriptor.is_none() && self.change_descriptor.is_none() && self.network.is_none() + && (self.tx_details.is_none() || self.tx_details.as_ref().unwrap().is_empty()) && self.local_chain.is_empty() && self.tx_graph.is_empty() && self.indexer.is_empty() @@ -70,6 +83,8 @@ impl ChangeSet { pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet"; /// Name of table to store wallet descriptors and network. pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet"; + /// Name of table to store wallet tx details + pub const TX_DETAILS_TABLE_NAME: &'static str = "bdk_tx_details"; /// Initialize sqlite tables for wallet tables. pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { @@ -82,7 +97,19 @@ impl ChangeSet { ) STRICT;", Self::WALLET_TABLE_NAME, )]; - crate::rusqlite_impl::migrate_schema(db_tx, Self::WALLET_SCHEMA_NAME, &[schema_v0])?; + // schema_v1 adds a table for tx-details + let schema_v1: &[&str] = &[&format!( + "CREATE TABLE {} ( \ + txid TEXT PRIMARY KEY NOT NULL, \ + is_canceled INTEGER DEFAULT 0 \ + ) STRICT;", + Self::TX_DETAILS_TABLE_NAME, + )]; + crate::rusqlite_impl::migrate_schema( + db_tx, + Self::WALLET_SCHEMA_NAME, + &[schema_v0, schema_v1], + )?; bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?; bdk_chain::tx_graph::ChangeSet::::init_sqlite_tables(db_tx)?; @@ -119,6 +146,30 @@ impl ChangeSet { changeset.network = network.map(Impl::into_inner); } + // select tx details + let mut change = tx_details::ChangeSet::default(); + let mut stmt = db_tx.prepare_cached(&format!( + "SELECT txid, is_canceled FROM {}", + Self::TX_DETAILS_TABLE_NAME, + ))?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, Impl>("txid")?, + row.get::<_, bool>("is_canceled")?, + )) + })?; + for res in rows { + let (Impl(txid), is_canceled) = res?; + let det = tx_details::Details { is_canceled }; + let record = tx_details::Record::Details(det); + change.records.push((txid, record)); + } + changeset.tx_details = if change.is_empty() { + None + } else { + Some(change) + }; + changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?; changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?; changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?; @@ -167,6 +218,21 @@ impl ChangeSet { })?; } + // persist tx details + let mut cancel_tx_stmt = db_tx.prepare_cached(&format!( + "REPLACE INTO {}(txid, is_canceled) VALUES(:txid, 1)", + Self::TX_DETAILS_TABLE_NAME, + ))?; + if let Some(change) = &self.tx_details { + for (txid, record) in &change.records { + if record == &tx_details::Record::Canceled { + cancel_tx_stmt.execute(named_params! { + ":txid": Impl(*txid), + })?; + } + } + } + self.local_chain.persist_to_sqlite(db_tx)?; self.tx_graph.persist_to_sqlite(db_tx)?; self.indexer.persist_to_sqlite(db_tx)?; @@ -210,3 +276,12 @@ impl From for ChangeSet { } } } + +impl From for ChangeSet { + fn from(value: tx_details::ChangeSet) -> Self { + Self { + tx_details: Some(value), + ..Default::default() + } + } +} diff --git a/crates/wallet/src/wallet/mod.rs b/crates/wallet/src/wallet/mod.rs index e2e905020..e9a741be8 100644 --- a/crates/wallet/src/wallet/mod.rs +++ b/crates/wallet/src/wallet/mod.rs @@ -57,6 +57,7 @@ mod params; mod persisted; pub mod signer; pub mod tx_builder; +mod tx_details; pub(crate) mod utils; use crate::collections::{BTreeMap, HashMap}; @@ -76,6 +77,7 @@ use crate::wallet::{ error::{BuildFeeBumpError, CreateTxError, MiniscriptPsbtError}, signer::{SignOptions, SignerError, SignerOrdering, SignersContainer, TransactionSigner}, tx_builder::{FeePolicy, TxBuilder, TxParams}, + tx_details::TxDetails, utils::{check_nsequence_rbf, After, Older, SecpCtx}, }; @@ -112,6 +114,7 @@ pub struct Wallet { stage: ChangeSet, network: Network, secp: SecpCtx, + tx_details: TxDetails, } /// An update to [`Wallet`]. @@ -409,6 +412,7 @@ impl Wallet { let change_descriptor = index.get_descriptor(KeychainKind::Internal).cloned(); let indexed_graph = IndexedTxGraph::new(index); let indexed_graph_changeset = indexed_graph.initial_changeset(); + let tx_details = TxDetails::default(); let stage = ChangeSet { descriptor, @@ -417,6 +421,7 @@ impl Wallet { tx_graph: indexed_graph_changeset.tx_graph, indexer: indexed_graph_changeset.indexer, network: Some(network), + ..Default::default() }; Ok(Wallet { @@ -427,6 +432,7 @@ impl Wallet { indexed_graph, stage, secp, + tx_details, }) } @@ -609,6 +615,11 @@ impl Wallet { indexed_graph.apply_changeset(changeset.indexer.into()); indexed_graph.apply_changeset(changeset.tx_graph.into()); + let mut tx_details = TxDetails::default(); + if let Some(change) = changeset.tx_details { + tx_details.apply_changeset(change); + } + let stage = ChangeSet::default(); Ok(Some(Wallet { @@ -619,6 +630,7 @@ impl Wallet { stage, network, secp, + tx_details, })) } @@ -815,14 +827,34 @@ impl Wallet { /// Return the list of unspent outputs of this wallet pub fn list_unspent(&self) -> impl Iterator + '_ { + self._list_unspent() + .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) + } + + /// `list_unspent` that accounts for canceled txs + fn _list_unspent( + &self, + ) -> impl Iterator)> + '_ { self.indexed_graph .graph() - .filter_chain_unspents( + .filter_chain_txouts( &self.chain, self.chain.tip().block_id(), self.indexed_graph.index.outpoints().iter().cloned(), ) - .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) + .filter(|(_, txo)| { + txo.chain_position.is_confirmed() || !self.is_canceled_tx(&txo.outpoint.txid) + }) + .filter(|(_, txo)| { + match txo.spent_by { + // keep unspent + None => true, + // keep if spent by a canceled tx + Some((pos, spend_txid)) => { + self.is_canceled_tx(&spend_txid) && !pos.is_confirmed() + } + } + }) } /// List all relevant outputs (includes both spent and unspent, confirmed and unconfirmed). @@ -1103,12 +1135,38 @@ impl Wallet { /// Return the balance, separated into available, trusted-pending, untrusted-pending and immature /// values. pub fn balance(&self) -> Balance { - self.indexed_graph.graph().balance( - &self.chain, - self.chain.tip().block_id(), - self.indexed_graph.index.outpoints().iter().cloned(), - |&(k, _), _| k == KeychainKind::Internal, - ) + let mut immature = Amount::ZERO; + let mut trusted_pending = Amount::ZERO; + let mut untrusted_pending = Amount::ZERO; + let mut confirmed = Amount::ZERO; + + let chain_tip = self.latest_checkpoint().height(); + + for (indexed, txo) in self._list_unspent() { + match &txo.chain_position { + ChainPosition::Confirmed { .. } => { + if txo.is_confirmed_and_spendable(chain_tip) { + confirmed += txo.txout.value; + } else if !txo.is_mature(chain_tip) { + immature += txo.txout.value; + } + } + ChainPosition::Unconfirmed { .. } => { + if let (KeychainKind::Internal, _) = indexed { + trusted_pending += txo.txout.value; + } else { + untrusted_pending += txo.txout.value; + } + } + } + } + + Balance { + immature, + trusted_pending, + untrusted_pending, + confirmed, + } } /// Add an external signer @@ -1937,10 +1995,18 @@ impl Wallet { .0 } + /// Whether the transaction with `txid` was canceled + fn is_canceled_tx(&self, txid: &Txid) -> bool { + self.tx_details + .map + .get(txid) + .map(|v| v.is_canceled) + .unwrap_or(false) + } + /// Informs the wallet that you no longer intend to broadcast a tx that was built from it. /// /// This frees up the change address used when creating the tx for use in future transactions. - // TODO: Make this free up reserved utxos when that's implemented pub fn cancel_tx(&mut self, tx: &Transaction) { let txout_index = &mut self.indexed_graph.index; for txout in &tx.output { @@ -1950,6 +2016,8 @@ impl Wallet { txout_index.unmark_used(*keychain, *index); } } + self.stage + .merge(self.tx_details.cancel_tx(tx.compute_txid()).into()); } fn get_descriptor_for_txout(&self, txout: &TxOut) -> Option { diff --git a/crates/wallet/src/wallet/tx_details.rs b/crates/wallet/src/wallet/tx_details.rs new file mode 100644 index 000000000..51fd4220e --- /dev/null +++ b/crates/wallet/src/wallet/tx_details.rs @@ -0,0 +1,83 @@ +//! `TxDetails` + +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; + +use bitcoin::Txid; + +use crate::collections::HashMap; + +/// Kind of change that can be applied to [`TxDetails`] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Record { + /// tx canceled + Canceled, + /// new details added. note, when applied this will overwrite existing + Details(Details), +} + +/// Communicates changes to [`TxDetails`] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangeSet { + /// records + pub records: Vec<(Txid, Record)>, +} + +impl bdk_chain::Merge for ChangeSet { + fn merge(&mut self, other: Self) { + self.records.extend(other.records); + } + + fn is_empty(&self) -> bool { + self.records.is_empty() + } +} + +/// The details of a wallet transaction +/// +/// In the future this may be extended to include more metadata. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Details { + /// Cancellation status, `true` if the tx was canceled + pub is_canceled: bool, +} + +/// Object that stores transaction metadata +#[derive(Debug, Clone, Default)] +pub(crate) struct TxDetails { + /// maps txid to details + pub map: HashMap, +} + +impl TxDetails { + /// Marks a tx canceled + pub fn cancel_tx(&mut self, txid: Txid) -> ChangeSet { + let mut change = ChangeSet::default(); + + let val = self.map.entry(txid).or_default(); + + if val.is_canceled { + return change; + } + + val.is_canceled = true; + + change.records = vec![(txid, Record::Canceled)]; + + change + } + + /// Applies the given `changeset` to `self` + pub fn apply_changeset(&mut self, changeset: ChangeSet) { + for (txid, record) in changeset.records { + match record { + Record::Details(det) => { + let _ = self.map.insert(txid, det); + } + Record::Canceled => { + let _ = self.cancel_tx(txid); + } + } + } + } +} diff --git a/crates/wallet/tests/wallet.rs b/crates/wallet/tests/wallet.rs index 05baa5c33..bee1b9328 100644 --- a/crates/wallet/tests/wallet.rs +++ b/crates/wallet/tests/wallet.rs @@ -44,6 +44,95 @@ const P2WPKH_FAKE_WITNESS_SIZE: usize = 106; const DB_MAGIC: &[u8] = &[0x21, 0x24, 0x48]; +#[test] +fn cancel_tx_frees_spent_inputs() -> anyhow::Result<()> { + use bdk_wallet::TxOrdering; + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut alice = Wallet::create(desc, change_desc) + .network(Network::Regtest) + .create_wallet_no_persist()?; + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let (mut bob, _) = get_funded_wallet(desc, change_desc); + + // Bob sends a tx to alice at time 1 + let recip = alice + .next_unused_address(KeychainKind::External) + .script_pubkey(); + let mut b = bob.build_tx(); + b.add_recipient(recip, Amount::from_sat(10_000)); + b.ordering(TxOrdering::Untouched); + let mut psbt = b.finish()?; + bob.sign(&mut psbt, SignOptions::default())?; + let tx = Arc::new(psbt.extract_tx()?); + let txid = tx.compute_txid(); + let outpoint_1 = OutPoint::new(txid, 0); + alice.apply_unconfirmed_txs([(tx.clone(), 1)]); + + // Alice sees the pending tx + assert_eq!(alice.balance().total().to_sat(), 10_000); + assert_eq!(alice.balance().untrusted_pending.to_sat(), 10_000); + + // `cancel_tx` should abandon the outputs + alice.cancel_tx(&tx); + assert_eq!(alice.balance().total(), Amount::ZERO); + + // the tx confirms anyway + let new_tip = alice.latest_checkpoint().height() + 1; + let block = BlockId { + height: new_tip, + hash: BlockHash::all_zeros(), + }; + insert_checkpoint(&mut alice, block); + let anchor = ConfirmationBlockTime { + block_id: block, + confirmation_time: 1, + }; + insert_anchor(&mut alice, txid, anchor); + assert_eq!(alice.balance().confirmed.to_sat(), 10_000); + + // Now alice creates tx2 to herself + let recip = alice + .next_unused_address(KeychainKind::External) + .script_pubkey(); + let mut b = alice.build_tx(); + b.add_recipient(recip, Amount::from_sat(9800)); + b.ordering(TxOrdering::Untouched); + let mut psbt = b.finish()?; + alice.sign(&mut psbt, SignOptions::default())?; + let tx2 = psbt.extract_tx()?; + let outpoint_2 = OutPoint::new(tx2.compute_txid(), 0); + insert_tx(&mut alice, tx2.clone()); + assert_eq!(alice.balance().total().to_sat(), 9800); + assert_eq!(alice.balance().untrusted_pending.to_sat(), 9800); + assert_eq!(alice.list_unspent().collect::>().len(), 1); + assert_eq!(alice.list_unspent().next().unwrap().outpoint, outpoint_2); + + // Alice cancels tx2 to free the spent input (outpoint 1) + alice.cancel_tx(&tx2); + assert_eq!(alice.balance().total().to_sat(), 10_000); + assert_eq!(alice.balance().confirmed.to_sat(), 10_000); + assert_eq!(alice.list_unspent().collect::>().len(), 1); + assert_eq!(alice.list_unspent().next().unwrap().outpoint, outpoint_1); + + // tx2 confirms anyway, alice should see outpoint 2 + let new_tip = alice.latest_checkpoint().height() + 1; + let block = BlockId { + height: new_tip, + hash: BlockHash::all_zeros(), + }; + insert_checkpoint(&mut alice, block); + let anchor = ConfirmationBlockTime { + block_id: block, + confirmation_time: 2, + }; + insert_anchor(&mut alice, tx2.compute_txid(), anchor); + assert_eq!(alice.balance().total().to_sat(), 9800); + assert_eq!(alice.list_unspent().collect::>().len(), 1); + assert_eq!(alice.list_unspent().next().unwrap().outpoint, outpoint_2); + + Ok(()) +} + #[test] fn wallet_is_persisted() -> anyhow::Result<()> { fn run( diff --git a/example-crates/example_wallet_esplora_async/Cargo.toml b/example-crates/example_wallet_esplora_async/Cargo.toml index 38458b782..593be6f85 100644 --- a/example-crates/example_wallet_esplora_async/Cargo.toml +++ b/example-crates/example_wallet_esplora_async/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bdk_wallet = { path = "../../crates/wallet", features = ["rusqlite"] } +bdk_wallet = { path = "../../crates/wallet", features = ["rusqlite", "test-utils"] } bdk_esplora = { path = "../../crates/esplora", features = ["async-https", "tokio"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } anyhow = "1" diff --git a/example-crates/example_wallet_esplora_async/src/main.rs b/example-crates/example_wallet_esplora_async/src/main.rs index b6ab5d6dc..6783d20b0 100644 --- a/example-crates/example_wallet_esplora_async/src/main.rs +++ b/example-crates/example_wallet_esplora_async/src/main.rs @@ -1,21 +1,25 @@ +#![allow(unused)] use std::{collections::BTreeSet, io::Write}; use anyhow::Ok; use bdk_esplora::{esplora_client, EsploraAsyncExt}; use bdk_wallet::{ - bitcoin::{Amount, Network}, + bitcoin::{Amount, Network, ScriptBuf}, rusqlite::Connection, KeychainKind, SignOptions, Wallet, }; +use bdk_wallet::test_utils::*; + const SEND_AMOUNT: Amount = Amount::from_sat(5000); const STOP_GAP: usize = 5; -const PARALLEL_REQUESTS: usize = 5; +const PARALLEL_REQUESTS: usize = 1; -const DB_PATH: &str = "bdk-example-esplora-async.sqlite"; +const DB_PATH: &str = ".bdk-example-esplora-async.sqlite"; const NETWORK: Network = Network::Signet; -const EXTERNAL_DESC: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; -const INTERNAL_DESC: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; +const EXTERNAL_DESC: &str = "tr(tprv8ZgxMBicQKsPczwfSDDHGpmNeWzKaMajLtkkNUdBoisixK3sW3YTC8subMCsTJB7sM4kaJJ7K1cNVM37aZoJ7dMBt2HRYLQzoFPqPMC8cTr/86'/1'/0'/0/*)"; +const INTERNAL_DESC: &str = "tr(tprv8ZgxMBicQKsPczwfSDDHGpmNeWzKaMajLtkkNUdBoisixK3sW3YTC8subMCsTJB7sM4kaJJ7K1cNVM37aZoJ7dMBt2HRYLQzoFPqPMC8cTr/86'/1'/0'/1/*)"; + const ESPLORA_URL: &str = "http://signet.bitcoindevkit.net"; #[tokio::main] @@ -35,16 +39,8 @@ async fn main() -> Result<(), anyhow::Error> { .create_wallet(&mut conn)?, }; - let address = wallet.next_unused_address(KeychainKind::External); - wallet.persist(&mut conn)?; - println!("Next unused address: ({}) {}", address.index, address); - - let balance = wallet.balance(); - println!("Wallet balance before syncing: {}", balance.total()); - - print!("Syncing..."); + // Sync let client = esplora_client::Builder::new(ESPLORA_URL).build_async()?; - let request = wallet.start_full_scan().inspect({ let mut stdout = std::io::stdout(); let mut once = BTreeSet::::new(); @@ -53,39 +49,48 @@ async fn main() -> Result<(), anyhow::Error> { print!("\nScanning keychain [{:?}]", keychain); } print!(" {:<3}", spk_i); - stdout.flush().expect("must flush") + stdout.flush().unwrap(); } }); - let update = client .full_scan(request, STOP_GAP, PARALLEL_REQUESTS) .await?; - wallet.apply_update(update)?; wallet.persist(&mut conn)?; println!(); + let old_balance = wallet.balance(); + println!("Balance after sync: {}", old_balance.total()); + + // Build tx that sends all to a foreign recipient + let recip = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa")?; + let mut b = wallet.build_tx(); + b.drain_to(recip).drain_wallet(); + let mut psbt = b.finish()?; + assert!(wallet.sign(&mut psbt, SignOptions::default())?); + let tx = psbt.extract_tx()?; + let txid = tx.compute_txid(); + insert_tx(&mut wallet, tx.clone()); + wallet.persist(&mut conn)?; - let balance = wallet.balance(); - println!("Wallet balance after syncing: {}", balance.total()); - - if balance.total() < SEND_AMOUNT { - println!( - "Please send at least {} to the receiving address", - SEND_AMOUNT - ); - std::process::exit(0); - } + println!("Balance after send: {}", wallet.balance().total()); + assert_eq!(wallet.balance().total(), Amount::ZERO); - let mut tx_builder = wallet.build_tx(); - tx_builder.add_recipient(address.script_pubkey(), SEND_AMOUNT); + // Cancel spend + wallet.cancel_tx(&tx); + assert_eq!(wallet.balance(), old_balance); + println!("Balance after cancel: {}", wallet.balance().total()); + wallet.persist(&mut conn)?; - let mut psbt = tx_builder.finish()?; - let finalized = wallet.sign(&mut psbt, SignOptions::default())?; - assert!(finalized); + // Load one more time + wallet = Wallet::load() + .load_wallet(&mut conn)? + .expect("must load existing"); - let tx = psbt.extract_tx()?; - client.broadcast(&tx).await?; - println!("Tx broadcasted! Txid: {}", tx.compute_txid()); + assert_eq!( + wallet.balance(), + old_balance, + "balance did not match original" + ); Ok(()) }