Skip to content

Commit 892ffd0

Browse files
committed
feat!: Support persistent UTXO locking
wallet: - Add `Wallet::lock_outpoint` - Add `Wallet::unlock_outpoint` - Add `Wallet::is_outpoint_locked` - Add `Wallet::list_locked_outpoints` - Add `Wallet::list_locked_unspent` changeset: - Add member `locked_outpoints` to ChangeSet `tests/persisted_wallet.rs`: - Add test `test_lock_outpoint_persist`
1 parent c39ce79 commit 892ffd0

File tree

6 files changed

+273
-7
lines changed

6 files changed

+273
-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: 79 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`].
@@ -482,6 +484,7 @@ impl Wallet {
482484
let change_descriptor = index.get_descriptor(KeychainKind::Internal).cloned();
483485
let indexed_graph = IndexedTxGraph::new(index);
484486
let indexed_graph_changeset = indexed_graph.initial_changeset();
487+
let locked_outpoints = BTreeMap::new();
485488

486489
let stage = ChangeSet {
487490
descriptor,
@@ -490,6 +493,7 @@ impl Wallet {
490493
tx_graph: indexed_graph_changeset.tx_graph,
491494
indexer: indexed_graph_changeset.indexer,
492495
network: Some(network),
496+
..Default::default()
493497
};
494498

495499
Ok(Wallet {
@@ -500,6 +504,7 @@ impl Wallet {
500504
indexed_graph,
501505
stage,
502506
secp,
507+
locked_outpoints,
503508
})
504509
}
505510

@@ -687,6 +692,13 @@ impl Wallet {
687692
indexed_graph.apply_changeset(changeset.indexer.into());
688693
indexed_graph.apply_changeset(changeset.tx_graph.into());
689694

695+
// Apply locked outpoints
696+
let locked_outpoints = changeset.locked_outpoints.locked_outpoints;
697+
let locked_outpoints = locked_outpoints
698+
.into_iter()
699+
.map(|(op, is_locked)| (op, if is_locked { Some(true) } else { None }))
700+
.collect();
701+
690702
let stage = ChangeSet::default();
691703

692704
Ok(Some(Wallet {
@@ -697,6 +709,7 @@ impl Wallet {
697709
stage,
698710
network,
699711
secp,
712+
locked_outpoints,
700713
}))
701714
}
702715

@@ -2141,6 +2154,8 @@ impl Wallet {
21412154
CanonicalizationParams::default(),
21422155
self.indexed_graph.index.outpoints().iter().cloned(),
21432156
)
2157+
// Filter out locked outpoints
2158+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21442159
// only create LocalOutput if UTxO is mature
21452160
.filter_map(move |((k, i), full_txo)| {
21462161
full_txo
@@ -2409,6 +2424,70 @@ impl Wallet {
24092424
&self.chain
24102425
}
24112426

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

0 commit comments

Comments
 (0)