Skip to content

Commit 223377e

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 e2cf34d commit 223377e

File tree

7 files changed

+256
-7
lines changed

7 files changed

+256
-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.85.0"

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]

src/persist_test_utils.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ where
164164
local_chain: local_chain_changeset,
165165
tx_graph: tx_graph_changeset,
166166
indexer: keychain_txout_changeset,
167+
locked_outpoints: Default::default(),
167168
};
168169

169170
// persist and load
@@ -216,6 +217,7 @@ where
216217
local_chain: local_chain_changeset,
217218
tx_graph: tx_graph_changeset,
218219
indexer: keychain_txout_changeset,
220+
locked_outpoints: Default::default(),
219221
};
220222

221223
// persist, load and check if same as merged

src/wallet/changeset.rs

Lines changed: 77 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.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,30 @@ 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+
for (&outpoint, &is_locked) in &self.locked_outpoints.outpoints {
322+
let OutPoint { txid, vout } = outpoint;
323+
if is_locked {
324+
insert_stmt.execute(named_params! {
325+
":txid": Impl(txid),
326+
":vout": vout,
327+
})?;
328+
} else {
329+
delete_stmt.execute(named_params! {
330+
":txid": Impl(txid),
331+
":vout": vout,
332+
})?;
333+
}
334+
}
335+
271336
self.local_chain.persist_to_sqlite(db_tx)?;
272337
self.tx_graph.persist_to_sqlite(db_tx)?;
273338
self.indexer.persist_to_sqlite(db_tx)?;
@@ -311,3 +376,12 @@ impl From<keychain_txout::ChangeSet> for ChangeSet {
311376
}
312377
}
313378
}
379+
380+
impl From<locked_outpoints::ChangeSet> for ChangeSet {
381+
fn from(locked_outpoints: locked_outpoints::ChangeSet) -> Self {
382+
Self {
383+
locked_outpoints,
384+
..Default::default()
385+
}
386+
}
387+
}

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 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.outpoints.extend(other.outpoints);
21+
}
22+
23+
fn is_empty(&self) -> bool {
24+
self.outpoints.is_empty()
25+
}
26+
}

src/wallet/mod.rs

Lines changed: 61 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: HashSet<OutPoint>,
112114
}
113115

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

478+
let locked_outpoints = HashSet::new();
479+
476480
let mut stage = ChangeSet {
477481
descriptor: Some(descriptor.clone()),
478482
change_descriptor: change_descriptor.clone(),
@@ -499,6 +503,7 @@ impl Wallet {
499503
indexed_graph,
500504
stage,
501505
secp,
506+
locked_outpoints,
502507
})
503508
}
504509

@@ -674,6 +679,14 @@ impl Wallet {
674679
None => Arc::new(SignersContainer::new()),
675680
};
676681

682+
// Apply locked outpoints
683+
let locked_outpoints = changeset.locked_outpoints.outpoints;
684+
let locked_outpoints = locked_outpoints
685+
.into_iter()
686+
.filter(|&(_op, is_locked)| is_locked)
687+
.map(|(op, _)| op)
688+
.collect();
689+
677690
let mut stage = ChangeSet::default();
678691

679692
let indexed_graph = make_indexed_graph(
@@ -695,6 +708,7 @@ impl Wallet {
695708
stage,
696709
network,
697710
secp,
711+
locked_outpoints,
698712
}))
699713
}
700714

@@ -2140,6 +2154,8 @@ impl Wallet {
21402154
CanonicalizationParams::default(),
21412155
self.indexed_graph.index.outpoints().iter().cloned(),
21422156
)
2157+
// Filter out locked outpoints
2158+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21432159
// only create LocalOutput if UTxO is mature
21442160
.filter_map(move |((k, i), full_txo)| {
21452161
full_txo
@@ -2408,6 +2424,51 @@ impl Wallet {
24082424
&self.chain
24092425
}
24102426

2427+
/// List the locked outpoints.
2428+
pub fn list_locked_outpoints(&self) -> impl Iterator<Item = OutPoint> + '_ {
2429+
self.locked_outpoints.iter().copied()
2430+
}
2431+
2432+
/// List unspent outpoints that are currently locked.
2433+
pub fn list_locked_unspent(&self) -> impl Iterator<Item = OutPoint> + '_ {
2434+
self.list_unspent()
2435+
.filter(|output| self.is_outpoint_locked(output.outpoint))
2436+
.map(|output| output.outpoint)
2437+
}
2438+
2439+
/// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more.
2440+
pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool {
2441+
self.locked_outpoints.contains(&outpoint)
2442+
}
2443+
2444+
/// Lock a wallet output identified by the given `outpoint`.
2445+
///
2446+
/// A locked UTXO will not be selected as an input to fund a transaction. This is useful
2447+
/// for excluding or reserving candidate inputs during transaction creation.
2448+
///
2449+
/// **You must persist the staged change for the lock status to be persistent**. To unlock a
2450+
/// previously locked outpoint, see [`Wallet::unlock_outpoint`].
2451+
pub fn lock_outpoint(&mut self, outpoint: OutPoint) {
2452+
if self.locked_outpoints.insert(outpoint) {
2453+
let changeset = locked_outpoints::ChangeSet {
2454+
outpoints: [(outpoint, true)].into(),
2455+
};
2456+
self.stage.merge(changeset.into());
2457+
}
2458+
}
2459+
2460+
/// Unlock the wallet output of the specified `outpoint`.
2461+
///
2462+
/// **You must persist the staged change for the lock status to be persistent**.
2463+
pub fn unlock_outpoint(&mut self, outpoint: OutPoint) {
2464+
if self.locked_outpoints.remove(&outpoint) {
2465+
let changeset = locked_outpoints::ChangeSet {
2466+
outpoints: [(outpoint, false)].into(),
2467+
};
2468+
self.stage.merge(changeset.into());
2469+
}
2470+
}
2471+
24112472
/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
24122473
/// `prev_blockhash` of the block's header.
24132474
///

0 commit comments

Comments
 (0)