Skip to content

Commit ff74e59

Browse files
committed
feat!: Support persistent UTXO locking
New APIs added for locking and unlocking a UTXO by outpoint and to query the locked outpoints. Locking an outpoint means that it is excluded from coin selection. - Add `Wallet::lock_outpoint` - Add `Wallet::unlock_outpoint` - Add `Wallet::is_outpoint_locked` - Add `Wallet::list_locked_outpoints` - Add `Wallet::list_locked_unspent` `test_lock_outpoint_persist` tests the lock/unlock functionality and that the lock status is persistent. BREAKING: Added `locked_outpoints` member field to ChangeSet. A SQLite migration is included for adding the locked outpoints table.
1 parent 6905189 commit ff74e59

File tree

6 files changed

+284
-7
lines changed

6 files changed

+284
-7
lines changed

clippy.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant
2-
enum-variant-size-threshold = 1032
3-
# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#result_large_err
4-
large-error-threshold = 993
1+
msrv = "1.63.0"

wallet/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
#![no_std]
99
#![warn(missing_docs)]
1010
#![allow(clippy::uninlined_format_args)]
11+
// TODO: these can be removed after <https://github.com/bitcoindevkit/bdk_wallet/issues/245>
12+
#![allow(clippy::result_large_err)]
13+
#![allow(clippy::large_enum_variant)]
1114

1215
#[cfg(feature = "std")]
1316
#[macro_use]

wallet/src/wallet/changeset.rs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
use bdk_chain::{
22
indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge,
33
};
4+
use bitcoin::{OutPoint, Txid};
45
use miniscript::{Descriptor, DescriptorPublicKey};
56
use serde::{Deserialize, Serialize};
67

78
type IndexedTxGraphChangeSet =
89
indexed_tx_graph::ChangeSet<ConfirmationBlockTime, keychain_txout::ChangeSet>;
910

10-
/// A change set for [`Wallet`]
11+
use crate::locked_outpoints;
12+
13+
/// A change set for [`Wallet`].
1114
///
1215
/// ## Definition
1316
///
14-
/// The change set is responsible for transmiting data between the persistent storage layer and the
17+
/// The change set is responsible for transmitting data between the persistent storage layer and the
1518
/// core library components. Specifically, it serves two primary functions:
1619
///
1720
/// 1) Recording incremental changes to the in-memory representation that need to be persisted to
@@ -114,6 +117,8 @@ pub struct ChangeSet {
114117
pub tx_graph: tx_graph::ChangeSet<ConfirmationBlockTime>,
115118
/// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex).
116119
pub indexer: keychain_txout::ChangeSet,
120+
/// Changes to locked outpoints.
121+
pub locked_outpoints: locked_outpoints::ChangeSet,
117122
}
118123

119124
impl Merge for ChangeSet {
@@ -142,6 +147,9 @@ impl Merge for ChangeSet {
142147
self.network = other.network;
143148
}
144149

150+
// merge locked outpoints
151+
self.locked_outpoints.merge(other.locked_outpoints);
152+
145153
Merge::merge(&mut self.local_chain, other.local_chain);
146154
Merge::merge(&mut self.tx_graph, other.tx_graph);
147155
Merge::merge(&mut self.indexer, other.indexer);
@@ -154,6 +162,7 @@ impl Merge for ChangeSet {
154162
&& self.local_chain.is_empty()
155163
&& self.tx_graph.is_empty()
156164
&& self.indexer.is_empty()
165+
&& self.locked_outpoints.is_empty()
157166
}
158167
}
159168

@@ -163,6 +172,8 @@ impl ChangeSet {
163172
pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet";
164173
/// Name of table to store wallet descriptors and network.
165174
pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet";
175+
/// Name of table to store wallet locked outpoints.
176+
pub const WALLET_OUTPOINT_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints";
166177

167178
/// Get v0 sqlite [ChangeSet] schema
168179
pub fn schema_v0() -> alloc::string::String {
@@ -177,12 +188,24 @@ impl ChangeSet {
177188
)
178189
}
179190

191+
/// Get v1 sqlite [`ChangeSet`] schema. Schema v1 adds a table for locked outpoints.
192+
pub fn schema_v1() -> alloc::string::String {
193+
format!(
194+
"CREATE TABLE {} ( \
195+
txid TEXT NOT NULL, \
196+
vout INTEGER NOT NULL, \
197+
PRIMARY KEY(txid, vout) \
198+
) STRICT;",
199+
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
200+
)
201+
}
202+
180203
/// Initialize sqlite tables for wallet tables.
181204
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
182205
crate::rusqlite_impl::migrate_schema(
183206
db_tx,
184207
Self::WALLET_SCHEMA_NAME,
185-
&[&Self::schema_v0()],
208+
&[&Self::schema_v0(), &Self::schema_v1()],
186209
)?;
187210

188211
bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
@@ -220,6 +243,24 @@ impl ChangeSet {
220243
changeset.network = network.map(Impl::into_inner);
221244
}
222245

246+
// Select locked outpoints.
247+
let mut stmt = db_tx.prepare(&format!(
248+
"SELECT txid, vout FROM {}",
249+
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
250+
))?;
251+
let rows = stmt.query_map([], |row| {
252+
Ok((
253+
row.get::<_, Impl<Txid>>("txid")?,
254+
row.get::<_, u32>("vout")?,
255+
))
256+
})?;
257+
let locked_outpoints = &mut changeset.locked_outpoints.locked_outpoints;
258+
for row in rows {
259+
let (Impl(txid), vout) = row?;
260+
let outpoint = OutPoint::new(txid, vout);
261+
locked_outpoints.insert(outpoint, true);
262+
}
263+
223264
changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?;
224265
changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?;
225266
changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?;
@@ -268,6 +309,31 @@ impl ChangeSet {
268309
})?;
269310
}
270311

312+
// Insert or delete locked outpoints.
313+
let mut insert_stmt = db_tx.prepare_cached(&format!(
314+
"REPLACE INTO {}(txid, vout) VALUES(:txid, :vout)",
315+
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME
316+
))?;
317+
let mut delete_stmt = db_tx.prepare_cached(&format!(
318+
"DELETE FROM {} WHERE txid=:txid AND vout=:vout",
319+
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
320+
))?;
321+
let locked_outpoints = &self.locked_outpoints.locked_outpoints;
322+
for (&outpoint, &is_locked) in locked_outpoints.iter() {
323+
let OutPoint { txid, vout } = outpoint;
324+
if is_locked {
325+
insert_stmt.execute(named_params! {
326+
":txid": Impl(txid),
327+
":vout": vout,
328+
})?;
329+
} else {
330+
delete_stmt.execute(named_params! {
331+
":txid": Impl(txid),
332+
":vout": vout,
333+
})?;
334+
}
335+
}
336+
271337
self.local_chain.persist_to_sqlite(db_tx)?;
272338
self.tx_graph.persist_to_sqlite(db_tx)?;
273339
self.indexer.persist_to_sqlite(db_tx)?;
@@ -311,3 +377,12 @@ impl From<keychain_txout::ChangeSet> for ChangeSet {
311377
}
312378
}
313379
}
380+
381+
impl From<locked_outpoints::ChangeSet> for ChangeSet {
382+
fn from(locked_outpoints: locked_outpoints::ChangeSet) -> Self {
383+
Self {
384+
locked_outpoints,
385+
..Default::default()
386+
}
387+
}
388+
}

wallet/src/wallet/locked_outpoints.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! Module containing the locked outpoints change set.
2+
3+
use bdk_chain::Merge;
4+
use bitcoin::OutPoint;
5+
use serde::{Deserialize, Serialize};
6+
7+
use crate::collections::BTreeMap;
8+
9+
/// Represents changes to locked outpoints.
10+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
11+
pub struct ChangeSet {
12+
/// The lock status of an outpoint, `true == is_locked`.
13+
pub locked_outpoints: BTreeMap<OutPoint, bool>,
14+
}
15+
16+
impl Merge for ChangeSet {
17+
fn merge(&mut self, other: Self) {
18+
// Extend self with other. Any entries in `self` that share the same
19+
// outpoint are overwritten.
20+
self.locked_outpoints.extend(other.locked_outpoints);
21+
}
22+
23+
fn is_empty(&self) -> bool {
24+
self.locked_outpoints.is_empty()
25+
}
26+
}

wallet/src/wallet/mod.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ mod changeset;
5353
pub mod coin_selection;
5454
pub mod error;
5555
pub mod export;
56+
pub mod locked_outpoints;
5657
mod params;
5758
mod persisted;
5859
pub mod signer;
@@ -109,6 +110,7 @@ pub struct Wallet {
109110
stage: ChangeSet,
110111
network: Network,
111112
secp: SecpCtx,
113+
locked_outpoints: BTreeMap<OutPoint, Option<bool>>,
112114
}
113115

114116
/// An update to [`Wallet`].
@@ -471,6 +473,8 @@ impl Wallet {
471473
None => (None, Arc::new(SignersContainer::new())),
472474
};
473475

476+
let locked_outpoints = BTreeMap::new();
477+
474478
let mut stage = ChangeSet {
475479
descriptor: Some(descriptor.clone()),
476480
change_descriptor: change_descriptor.clone(),
@@ -497,6 +501,7 @@ impl Wallet {
497501
indexed_graph,
498502
stage,
499503
secp,
504+
locked_outpoints,
500505
})
501506
}
502507

@@ -672,6 +677,13 @@ impl Wallet {
672677
None => Arc::new(SignersContainer::new()),
673678
};
674679

680+
// Apply locked outpoints
681+
let locked_outpoints = changeset.locked_outpoints.locked_outpoints;
682+
let locked_outpoints = locked_outpoints
683+
.into_iter()
684+
.map(|(op, is_locked)| (op, if is_locked { Some(true) } else { None }))
685+
.collect();
686+
675687
let mut stage = ChangeSet::default();
676688

677689
let indexed_graph = make_indexed_graph(
@@ -693,6 +705,7 @@ impl Wallet {
693705
stage,
694706
network,
695707
secp,
708+
locked_outpoints,
696709
}))
697710
}
698711

@@ -2137,6 +2150,8 @@ impl Wallet {
21372150
CanonicalizationParams::default(),
21382151
self.indexed_graph.index.outpoints().iter().cloned(),
21392152
)
2153+
// Filter out locked outpoints
2154+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21402155
// only create LocalOutput if UTxO is mature
21412156
.filter_map(move |((k, i), full_txo)| {
21422157
full_txo
@@ -2405,6 +2420,81 @@ impl Wallet {
24052420
&self.chain
24062421
}
24072422

2423+
/// List the locked outpoints.
2424+
pub fn list_locked_outpoints(&self) -> impl Iterator<Item = OutPoint> + '_ {
2425+
self.locked_outpoints
2426+
.iter()
2427+
.filter(|(_, lock)| matches!(lock, Some(true)))
2428+
.map(|(op, _)| *op)
2429+
}
2430+
2431+
/// List unspent outpoints that are currently locked.
2432+
pub fn list_locked_unspent(&self) -> impl Iterator<Item = OutPoint> + '_ {
2433+
self.list_unspent()
2434+
.filter(|output| self.is_outpoint_locked(output.outpoint))
2435+
.map(|output| output.outpoint)
2436+
}
2437+
2438+
/// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more.
2439+
pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool {
2440+
self.locked_outpoints
2441+
.get(&outpoint)
2442+
.map_or(false, |lock| matches!(lock, Some(true)))
2443+
}
2444+
2445+
/// Lock a wallet output identified by the given `outpoint`.
2446+
///
2447+
/// A locked UTXO will not be selected as an input to fund a transaction. This is useful
2448+
/// for excluding or reserving candidate inputs during transaction creation.
2449+
///
2450+
/// **You must persist the staged change for the lock status to be persistent**. To unlock a
2451+
/// previously locked outpoint, see [`Wallet::unlock_outpoint`].
2452+
pub fn lock_outpoint(&mut self, outpoint: OutPoint) {
2453+
use crate::collections::btree_map;
2454+
let lock_value = true;
2455+
let mut changeset = locked_outpoints::ChangeSet::default();
2456+
2457+
// If the lock status changed, update the entry and record the change
2458+
// in the changeset.
2459+
match self.locked_outpoints.entry(outpoint) {
2460+
btree_map::Entry::Occupied(mut e) => {
2461+
let is_locked = e.get().unwrap_or(false);
2462+
if !is_locked {
2463+
e.insert(Some(lock_value));
2464+
changeset.locked_outpoints.insert(outpoint, lock_value);
2465+
}
2466+
}
2467+
btree_map::Entry::Vacant(e) => {
2468+
e.insert(Some(lock_value));
2469+
changeset.locked_outpoints.insert(outpoint, lock_value);
2470+
}
2471+
}
2472+
2473+
self.stage.merge(changeset.into());
2474+
}
2475+
2476+
/// Unlock the wallet output of the specified `outpoint`.
2477+
///
2478+
/// **You must persist the staged change for the lock status to be persistent**.
2479+
pub fn unlock_outpoint(&mut self, outpoint: OutPoint) {
2480+
use crate::collections::btree_map;
2481+
let mut changeset = locked_outpoints::ChangeSet::default();
2482+
2483+
// If the outpoint is currently locked, remove the value from the entry
2484+
// and stage the change.
2485+
match self.locked_outpoints.entry(outpoint) {
2486+
btree_map::Entry::Vacant(..) => {}
2487+
btree_map::Entry::Occupied(entry) => {
2488+
let is_locked = entry.get().unwrap_or(false);
2489+
if is_locked {
2490+
entry.remove();
2491+
changeset.locked_outpoints.insert(outpoint, false);
2492+
self.stage.merge(changeset.into());
2493+
}
2494+
}
2495+
}
2496+
}
2497+
24082498
/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
24092499
/// `prev_blockhash` of the block's header.
24102500
///

0 commit comments

Comments
 (0)