diff --git a/clippy.toml b/clippy.toml index ead89212..4972822f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1 @@ -# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant -enum-variant-size-threshold = 1032 -# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#result_large_err -large-error-threshold = 993 \ No newline at end of file +msrv = "1.85.0" diff --git a/src/persist_test_utils.rs b/src/persist_test_utils.rs index 20ddf4eb..d60983ce 100644 --- a/src/persist_test_utils.rs +++ b/src/persist_test_utils.rs @@ -164,6 +164,7 @@ where local_chain: local_chain_changeset, tx_graph: tx_graph_changeset, indexer: keychain_txout_changeset, + locked_outpoints: Default::default(), }; // persist and load @@ -216,6 +217,7 @@ where local_chain: local_chain_changeset, tx_graph: tx_graph_changeset, indexer: keychain_txout_changeset, + locked_outpoints: Default::default(), }; // persist, load and check if same as merged diff --git a/src/wallet/changeset.rs b/src/wallet/changeset.rs index d3d1ba93..8e39cc14 100644 --- a/src/wallet/changeset.rs +++ b/src/wallet/changeset.rs @@ -4,14 +4,16 @@ use bdk_chain::{ use miniscript::{Descriptor, DescriptorPublicKey}; use serde::{Deserialize, Serialize}; +use crate::locked_outpoints; + type IndexedTxGraphChangeSet = indexed_tx_graph::ChangeSet; -/// A change set for [`Wallet`] +/// A change set for [`Wallet`]. /// /// ## Definition /// -/// The change set is responsible for transmiting data between the persistent storage layer and the +/// The change set is responsible for transmitting data between the persistent storage layer and the /// core library components. Specifically, it serves two primary functions: /// /// 1) Recording incremental changes to the in-memory representation that need to be persisted to @@ -114,6 +116,8 @@ pub struct ChangeSet { pub tx_graph: tx_graph::ChangeSet, /// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex). pub indexer: keychain_txout::ChangeSet, + /// Changes to locked outpoints. + pub locked_outpoints: locked_outpoints::ChangeSet, } impl Merge for ChangeSet { @@ -142,6 +146,9 @@ impl Merge for ChangeSet { self.network = other.network; } + // merge locked outpoints + self.locked_outpoints.merge(other.locked_outpoints); + 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); @@ -154,6 +161,7 @@ impl Merge for ChangeSet { && self.local_chain.is_empty() && self.tx_graph.is_empty() && self.indexer.is_empty() + && self.locked_outpoints.is_empty() } } @@ -163,6 +171,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 locked outpoints. + pub const WALLET_OUTPOINT_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints"; /// Get v0 sqlite [ChangeSet] schema pub fn schema_v0() -> alloc::string::String { @@ -177,12 +187,24 @@ impl ChangeSet { ) } + /// Get v1 sqlite [`ChangeSet`] schema. Schema v1 adds a table for locked outpoints. + pub fn schema_v1() -> alloc::string::String { + format!( + "CREATE TABLE {} ( \ + txid TEXT NOT NULL, \ + vout INTEGER NOT NULL, \ + PRIMARY KEY(txid, vout) \ + ) STRICT;", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ) + } + /// Initialize sqlite tables for wallet tables. pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { crate::rusqlite_impl::migrate_schema( db_tx, Self::WALLET_SCHEMA_NAME, - &[&Self::schema_v0()], + &[&Self::schema_v0(), &Self::schema_v1()], )?; bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?; @@ -194,6 +216,7 @@ impl ChangeSet { /// Recover a [`ChangeSet`] from sqlite database. pub fn from_sqlite(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result { + use bitcoin::{OutPoint, Txid}; use chain::rusqlite::OptionalExtension; use chain::Impl; @@ -220,6 +243,24 @@ impl ChangeSet { changeset.network = network.map(Impl::into_inner); } + // Select locked outpoints. + let mut stmt = db_tx.prepare(&format!( + "SELECT txid, vout FROM {}", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ))?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, Impl>("txid")?, + row.get::<_, u32>("vout")?, + )) + })?; + let locked_outpoints = &mut changeset.locked_outpoints.outpoints; + for row in rows { + let (Impl(txid), vout) = row?; + let outpoint = OutPoint::new(txid, vout); + locked_outpoints.insert(outpoint, true); + } + 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)?; @@ -268,6 +309,30 @@ impl ChangeSet { })?; } + // Insert or delete locked outpoints. + let mut insert_stmt = db_tx.prepare_cached(&format!( + "REPLACE INTO {}(txid, vout) VALUES(:txid, :vout)", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME + ))?; + let mut delete_stmt = db_tx.prepare_cached(&format!( + "DELETE FROM {} WHERE txid=:txid AND vout=:vout", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ))?; + for (&outpoint, &is_locked) in &self.locked_outpoints.outpoints { + let bitcoin::OutPoint { txid, vout } = outpoint; + if is_locked { + insert_stmt.execute(named_params! { + ":txid": Impl(txid), + ":vout": vout, + })?; + } else { + delete_stmt.execute(named_params! { + ":txid": Impl(txid), + ":vout": vout, + })?; + } + } + self.local_chain.persist_to_sqlite(db_tx)?; self.tx_graph.persist_to_sqlite(db_tx)?; self.indexer.persist_to_sqlite(db_tx)?; @@ -311,3 +376,12 @@ impl From for ChangeSet { } } } + +impl From for ChangeSet { + fn from(locked_outpoints: locked_outpoints::ChangeSet) -> Self { + Self { + locked_outpoints, + ..Default::default() + } + } +} diff --git a/src/wallet/locked_outpoints.rs b/src/wallet/locked_outpoints.rs new file mode 100644 index 00000000..6ab2cbc3 --- /dev/null +++ b/src/wallet/locked_outpoints.rs @@ -0,0 +1,26 @@ +//! Module containing the locked outpoints change set. + +use bdk_chain::Merge; +use bitcoin::OutPoint; +use serde::{Deserialize, Serialize}; + +use crate::collections::BTreeMap; + +/// Represents changes to locked outpoints. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ChangeSet { + /// The lock status of an outpoint, `true == is_locked`. + pub outpoints: BTreeMap, +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + // Extend self with other. Any entries in `self` that share the same + // outpoint are overwritten. + self.outpoints.extend(other.outpoints); + } + + fn is_empty(&self) -> bool { + self.outpoints.is_empty() + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 137aecc3..ac1dab0e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -53,6 +53,7 @@ mod changeset; pub mod coin_selection; pub mod error; pub mod export; +pub mod locked_outpoints; mod params; mod persisted; pub mod signer; @@ -109,6 +110,7 @@ pub struct Wallet { stage: ChangeSet, network: Network, secp: SecpCtx, + locked_outpoints: HashSet, } /// An update to [`Wallet`]. @@ -227,9 +229,9 @@ pub enum LoadMismatch { /// Keychain identifying the descriptor. keychain: KeychainKind, /// The loaded descriptor. - loaded: Option, + loaded: Option>, /// The expected descriptor. - expected: Option, + expected: Option>, }, } @@ -474,6 +476,8 @@ impl Wallet { None => (None, Arc::new(SignersContainer::new())), }; + let locked_outpoints = HashSet::new(); + let mut stage = ChangeSet { descriptor: Some(descriptor.clone()), change_descriptor: change_descriptor.clone(), @@ -500,6 +504,7 @@ impl Wallet { indexed_graph, stage, secp, + locked_outpoints, }) } @@ -599,8 +604,8 @@ impl Wallet { if descriptor.descriptor_id() != exp_desc.descriptor_id() { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::External, - loaded: Some(descriptor), - expected: Some(exp_desc), + loaded: Some(Box::new(descriptor)), + expected: Some(Box::new(exp_desc)), })); } if params.extract_keys { @@ -609,7 +614,7 @@ impl Wallet { } else { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::External, - loaded: Some(descriptor), + loaded: Some(Box::new(descriptor)), expected: None, })); } @@ -630,7 +635,7 @@ impl Wallet { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, loaded: None, - expected: Some(exp_desc), + expected: Some(Box::new(exp_desc)), })); } } @@ -644,7 +649,7 @@ impl Wallet { None => { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, - loaded: Some(desc), + loaded: Some(Box::new(desc)), expected: None, })) } @@ -656,8 +661,8 @@ impl Wallet { if desc.descriptor_id() != exp_desc.descriptor_id() { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, - loaded: Some(desc), - expected: Some(exp_desc), + loaded: Some(Box::new(desc)), + expected: Some(Box::new(exp_desc)), })); } if params.extract_keys { @@ -677,6 +682,14 @@ impl Wallet { None => Arc::new(SignersContainer::new()), }; + // Apply locked outpoints + let locked_outpoints = changeset.locked_outpoints.outpoints; + let locked_outpoints = locked_outpoints + .into_iter() + .filter(|&(_op, is_locked)| is_locked) + .map(|(op, _)| op) + .collect(); + let mut stage = ChangeSet::default(); let indexed_graph = make_indexed_graph( @@ -698,6 +711,7 @@ impl Wallet { stage, network, secp, + locked_outpoints, })) } @@ -2143,6 +2157,8 @@ impl Wallet { CanonicalizationParams::default(), self.indexed_graph.index.outpoints().iter().cloned(), ) + // Filter out locked outpoints. + .filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint)) // Only create LocalOutput if UTxO is mature. .filter_map(move |((k, i), full_txo)| { full_txo @@ -2412,6 +2428,51 @@ impl Wallet { &self.chain } + /// List the locked outpoints. + pub fn list_locked_outpoints(&self) -> impl Iterator + '_ { + self.locked_outpoints.iter().copied() + } + + /// List unspent outpoints that are currently locked. + pub fn list_locked_unspent(&self) -> impl Iterator + '_ { + self.list_unspent() + .filter(|output| self.is_outpoint_locked(output.outpoint)) + .map(|output| output.outpoint) + } + + /// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more. + pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool { + self.locked_outpoints.contains(&outpoint) + } + + /// Lock a wallet output identified by the given `outpoint`. + /// + /// A locked UTXO will not be selected as an input to fund a transaction. This is useful + /// for excluding or reserving candidate inputs during transaction creation. + /// + /// **You must persist the staged change for the lock status to be persistent**. To unlock a + /// previously locked outpoint, see [`Wallet::unlock_outpoint`]. + pub fn lock_outpoint(&mut self, outpoint: OutPoint) { + if self.locked_outpoints.insert(outpoint) { + let changeset = locked_outpoints::ChangeSet { + outpoints: [(outpoint, true)].into(), + }; + self.stage.merge(changeset.into()); + } + } + + /// Unlock the wallet output of the specified `outpoint`. + /// + /// **You must persist the staged change for the lock status to be persistent**. + pub fn unlock_outpoint(&mut self, outpoint: OutPoint) { + if self.locked_outpoints.remove(&outpoint) { + let changeset = locked_outpoints::ChangeSet { + outpoints: [(outpoint, false)].into(), + }; + self.stage.merge(changeset.into()); + } + } + /// Introduces a `block` of `height` to the wallet, and tries to connect it to the /// `prev_blockhash` of the block's header. /// diff --git a/src/wallet/persisted.rs b/src/wallet/persisted.rs index 74516e4b..84229ef7 100644 --- a/src/wallet/persisted.rs +++ b/src/wallet/persisted.rs @@ -150,7 +150,9 @@ impl PersistedWallet

{ ) -> Result> { let existing = P::initialize(persister).map_err(CreateWithPersistError::Persist)?; if !existing.is_empty() { - return Err(CreateWithPersistError::DataAlreadyExists(existing)); + return Err(CreateWithPersistError::DataAlreadyExists(Box::new( + existing, + ))); } let mut inner = Wallet::create_with_params(params).map_err(CreateWithPersistError::Descriptor)?; @@ -207,7 +209,9 @@ impl PersistedWallet

{ .await .map_err(CreateWithPersistError::Persist)?; if !existing.is_empty() { - return Err(CreateWithPersistError::DataAlreadyExists(existing)); + return Err(CreateWithPersistError::DataAlreadyExists(Box::new( + existing, + ))); } let mut inner = Wallet::create_with_params(params).map_err(CreateWithPersistError::Descriptor)?; @@ -293,6 +297,7 @@ impl WalletPersister for bdk_chain::rusqlite::Connection { /// Error for [`bdk_file_store`]'s implementation of [`WalletPersister`]. #[cfg(feature = "file_store")] #[derive(Debug)] +#[allow(clippy::large_enum_variant)] pub enum FileStoreError { /// Error when loading from the store. Load(bdk_file_store::StoreErrorWithDump), @@ -357,7 +362,7 @@ pub enum CreateWithPersistError { /// Error from persistence. Persist(E), /// Persister already has wallet data. - DataAlreadyExists(ChangeSet), + DataAlreadyExists(Box), /// Occurs when the loaded changeset cannot construct [`Wallet`]. Descriptor(DescriptorError), } diff --git a/tests/persisted_wallet.rs b/tests/persisted_wallet.rs index 6814f187..0b9076bc 100644 --- a/tests/persisted_wallet.rs +++ b/tests/persisted_wallet.rs @@ -7,7 +7,9 @@ use bdk_chain::DescriptorId; use bdk_chain::{ keychain_txout::DEFAULT_LOOKAHEAD, ChainPosition, ConfirmationBlockTime, DescriptorExt, }; +use bdk_wallet::coin_selection::InsufficientFunds; use bdk_wallet::descriptor::IntoWalletDescriptor; +use bdk_wallet::error::CreateTxError; use bdk_wallet::test_utils::*; use bdk_wallet::{ ChangeSet, KeychainKind, LoadError, LoadMismatch, LoadWithPersistError, Wallet, WalletPersister, @@ -420,7 +422,7 @@ fn single_descriptor_wallet_persist_and_recover() { assert_matches!( err, Err(LoadWithPersistError::InvalidChangeSet(LoadError::Mismatch(LoadMismatch::Descriptor { keychain, loaded, expected }))) - if keychain == KeychainKind::Internal && loaded.is_none() && expected == Some(exp_desc), + if keychain == KeychainKind::Internal && loaded.is_none() && expected == Some(Box::new(exp_desc)), "single descriptor wallet should refuse change descriptor param" ); } @@ -464,3 +466,87 @@ fn network_is_persisted() { Ok(bdk_chain::rusqlite::Connection::open(path)?) }); } + +#[test] +fn test_lock_outpoint_persist() -> anyhow::Result<()> { + use bdk_chain::rusqlite; + let mut conn = rusqlite::Connection::open_in_memory()?; + + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Signet) + .create_wallet(&mut conn)?; + + // Receive coins. + let mut outpoints = vec![]; + for i in 0..3 { + let op = receive_output(&mut wallet, Amount::from_sat(10_000), ReceiveTo::Mempool(i)); + outpoints.push(op); + } + + // Test: lock outpoints + let unspent = wallet.list_unspent().collect::>(); + assert!(!unspent.is_empty()); + for utxo in unspent { + wallet.lock_outpoint(utxo.outpoint); + assert!( + wallet.is_outpoint_locked(utxo.outpoint), + "Expect outpoint is locked" + ); + } + wallet.persist(&mut conn)?; + + // Test: The lock value is persistent + { + wallet = Wallet::load() + .load_wallet(&mut conn)? + .expect("wallet is persisted"); + + let unspent = wallet.list_unspent().collect::>(); + assert!(!unspent.is_empty()); + for utxo in unspent { + assert!( + wallet.is_outpoint_locked(utxo.outpoint), + "Expect recover lock value" + ); + } + let locked_unspent = wallet.list_locked_unspent().collect::>(); + assert_eq!(locked_unspent, outpoints); + + // Test: Locked outpoints are excluded from coin selection + let addr = wallet.next_unused_address(KeychainKind::External).address; + let mut tx_builder = wallet.build_tx(); + tx_builder.add_recipient(addr, Amount::from_sat(10_000)); + let res = tx_builder.finish(); + assert!( + matches!( + res, + Err(CreateTxError::CoinSelection(InsufficientFunds { + available: Amount::ZERO, + .. + })), + ), + "Locked outpoints should not be selected", + ); + } + + // Test: Unlock outpoints + { + wallet = Wallet::load() + .load_wallet(&mut conn)? + .expect("wallet is persisted"); + + let unspent = wallet.list_unspent().collect::>(); + for utxo in &unspent { + wallet.unlock_outpoint(utxo.outpoint); + assert!( + !wallet.is_outpoint_locked(utxo.outpoint), + "Expect outpoint is not locked" + ); + } + assert!(wallet.list_locked_outpoints().next().is_none()); + assert!(wallet.list_locked_unspent().next().is_none()); + } + + Ok(()) +}