Skip to content

Commit 7c28c5f

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 b0c1b72 commit 7c28c5f

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
@@ -4,14 +4,16 @@ use bdk_chain::{
44
use miniscript::{Descriptor, DescriptorPublicKey};
55
use serde::{Deserialize, Serialize};
66

7+
use crate::locked_outpoints;
8+
79
type IndexedTxGraphChangeSet =
810
indexed_tx_graph::ChangeSet<ConfirmationBlockTime, keychain_txout::ChangeSet>;
911

10-
/// A change set for [`Wallet`]
12+
/// A change set for [`Wallet`].
1113
///
1214
/// ## Definition
1315
///
14-
/// The change set is responsible for transmiting data between the persistent storage layer and the
16+
/// The change set is responsible for transmitting data between the persistent storage layer and the
1517
/// core library components. Specifically, it serves two primary functions:
1618
///
1719
/// 1) Recording incremental changes to the in-memory representation that need to be persisted to
@@ -114,6 +116,8 @@ pub struct ChangeSet {
114116
pub tx_graph: tx_graph::ChangeSet<ConfirmationBlockTime>,
115117
/// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex).
116118
pub indexer: keychain_txout::ChangeSet,
119+
/// Changes to locked outpoints.
120+
pub locked_outpoints: locked_outpoints::ChangeSet,
117121
}
118122

119123
impl Merge for ChangeSet {
@@ -142,6 +146,9 @@ impl Merge for ChangeSet {
142146
self.network = other.network;
143147
}
144148

149+
// merge locked outpoints
150+
self.locked_outpoints.merge(other.locked_outpoints);
151+
145152
Merge::merge(&mut self.local_chain, other.local_chain);
146153
Merge::merge(&mut self.tx_graph, other.tx_graph);
147154
Merge::merge(&mut self.indexer, other.indexer);
@@ -154,6 +161,7 @@ impl Merge for ChangeSet {
154161
&& self.local_chain.is_empty()
155162
&& self.tx_graph.is_empty()
156163
&& self.indexer.is_empty()
164+
&& self.locked_outpoints.is_empty()
157165
}
158166
}
159167

@@ -163,6 +171,8 @@ impl ChangeSet {
163171
pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet";
164172
/// Name of table to store wallet descriptors and network.
165173
pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet";
174+
/// Name of table to store wallet locked outpoints.
175+
pub const WALLET_OUTPOINT_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints";
166176

167177
/// Get v0 sqlite [ChangeSet] schema
168178
pub fn schema_v0() -> alloc::string::String {
@@ -177,12 +187,24 @@ impl ChangeSet {
177187
)
178188
}
179189

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

188210
bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
@@ -194,6 +216,7 @@ impl ChangeSet {
194216

195217
/// Recover a [`ChangeSet`] from sqlite database.
196218
pub fn from_sqlite(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<Self> {
219+
use bitcoin::{OutPoint, Txid};
197220
use chain::rusqlite::OptionalExtension;
198221
use chain::Impl;
199222

@@ -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+
"INSERT OR IGNORE 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 bitcoin::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`].
@@ -474,6 +476,8 @@ impl Wallet {
474476
None => (None, Arc::new(SignersContainer::new())),
475477
};
476478

479+
let locked_outpoints = HashSet::new();
480+
477481
let mut stage = ChangeSet {
478482
descriptor: Some(descriptor.clone()),
479483
change_descriptor: change_descriptor.clone(),
@@ -500,6 +504,7 @@ impl Wallet {
500504
indexed_graph,
501505
stage,
502506
secp,
507+
locked_outpoints,
503508
})
504509
}
505510

@@ -677,6 +682,14 @@ impl Wallet {
677682
None => Arc::new(SignersContainer::new()),
678683
};
679684

685+
// Apply locked outpoints
686+
let locked_outpoints = changeset.locked_outpoints.outpoints;
687+
let locked_outpoints = locked_outpoints
688+
.into_iter()
689+
.filter(|&(_op, is_locked)| is_locked)
690+
.map(|(op, _)| op)
691+
.collect();
692+
680693
let mut stage = ChangeSet::default();
681694

682695
let indexed_graph = make_indexed_graph(
@@ -698,6 +711,7 @@ impl Wallet {
698711
stage,
699712
network,
700713
secp,
714+
locked_outpoints,
701715
}))
702716
}
703717

@@ -2143,6 +2157,8 @@ impl Wallet {
21432157
CanonicalizationParams::default(),
21442158
self.indexed_graph.index.outpoints().iter().cloned(),
21452159
)
2160+
// Filter out locked outpoints.
2161+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21462162
// Only create LocalOutput if UTxO is mature.
21472163
.filter_map(move |((k, i), full_txo)| {
21482164
full_txo
@@ -2412,6 +2428,51 @@ impl Wallet {
24122428
&self.chain
24132429
}
24142430

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

0 commit comments

Comments
 (0)