Skip to content

Commit 37fac7d

Browse files
committed
feat!: Support persistent UTXO locking
wallet: - Add pub struct `UtxoLock` - Add `Wallet::lock_outpoint` - Add `Wallet::unlock_outpoint` - Add `Wallet::is_outpoint_locked` - Add `Wallet::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 4b625c0 commit 37fac7d

File tree

4 files changed

+302
-1
lines changed

4 files changed

+302
-1
lines changed

wallet/src/wallet/changeset.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
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

11+
use crate::locked_outpoints;
12+
1013
/// A change set for [`Wallet`]
1114
///
1215
/// ## Definition
@@ -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: 113 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, UtxoLock>,
112114
}
113115

114116
/// An update to [`Wallet`].
@@ -449,6 +451,7 @@ impl Wallet {
449451
let change_descriptor = index.get_descriptor(KeychainKind::Internal).cloned();
450452
let indexed_graph = IndexedTxGraph::new(index);
451453
let indexed_graph_changeset = indexed_graph.initial_changeset();
454+
let locked_outpoints = BTreeMap::new();
452455

453456
let stage = ChangeSet {
454457
descriptor,
@@ -457,6 +460,7 @@ impl Wallet {
457460
tx_graph: indexed_graph_changeset.tx_graph,
458461
indexer: indexed_graph_changeset.indexer,
459462
network: Some(network),
463+
..Default::default()
460464
};
461465

462466
Ok(Wallet {
@@ -467,6 +471,7 @@ impl Wallet {
467471
indexed_graph,
468472
stage,
469473
secp,
474+
locked_outpoints,
470475
})
471476
}
472477

@@ -654,6 +659,21 @@ impl Wallet {
654659
indexed_graph.apply_changeset(changeset.indexer.into());
655660
indexed_graph.apply_changeset(changeset.tx_graph.into());
656661

662+
// Apply locked outpoints
663+
let locked_outpoints = changeset.locked_outpoints.locked_outpoints;
664+
let locked_outpoints = locked_outpoints
665+
.into_iter()
666+
.map(|(outpoint, is_locked)| {
667+
(
668+
outpoint,
669+
UtxoLock {
670+
outpoint,
671+
is_locked,
672+
},
673+
)
674+
})
675+
.collect();
676+
657677
let stage = ChangeSet::default();
658678

659679
Ok(Some(Wallet {
@@ -664,6 +684,7 @@ impl Wallet {
664684
stage,
665685
network,
666686
secp,
687+
locked_outpoints,
667688
}))
668689
}
669690

@@ -2108,6 +2129,8 @@ impl Wallet {
21082129
CanonicalizationParams::default(),
21092130
self.indexed_graph.index.outpoints().iter().cloned(),
21102131
)
2132+
// Filter out locked outpoints
2133+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21112134
// only create LocalOutput if UTxO is mature
21122135
.filter_map(move |((k, i), full_txo)| {
21132136
full_txo
@@ -2376,6 +2399,82 @@ impl Wallet {
23762399
&self.chain
23772400
}
23782401

2402+
/// Get a reference to the locked outpoints.
2403+
pub fn locked_outpoints(&self) -> &BTreeMap<OutPoint, UtxoLock> {
2404+
&self.locked_outpoints
2405+
}
2406+
2407+
/// List unspent outpoints that are currently locked.
2408+
pub fn list_locked_unspent(&self) -> impl Iterator<Item = OutPoint> + '_ {
2409+
self.list_unspent()
2410+
.filter(|output| self.is_outpoint_locked(output.outpoint))
2411+
.map(|output| output.outpoint)
2412+
}
2413+
2414+
/// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more.
2415+
pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool {
2416+
self.locked_outpoints
2417+
.get(&outpoint)
2418+
.map_or(false, |u| u.is_locked)
2419+
}
2420+
2421+
/// Lock a wallet output identified by the given `outpoint`.
2422+
///
2423+
/// A locked UTXO will not be selected as an input to fund a transaction. This is useful
2424+
/// for excluding or reserving candidate inputs during transaction creation.
2425+
///
2426+
/// **You must persist the staged change for the lock status to be persistent**. To unlock a
2427+
/// previously locked outpoint, see [`Wallet::unlock_outpoint`].
2428+
pub fn lock_outpoint(&mut self, outpoint: OutPoint) {
2429+
use crate::collections::btree_map;
2430+
let lock_value = true;
2431+
let mut changeset = locked_outpoints::ChangeSet::default();
2432+
2433+
// If the lock status changed, update the entry and record the change
2434+
// in the changeset.
2435+
match self.locked_outpoints.entry(outpoint) {
2436+
btree_map::Entry::Occupied(mut e) => {
2437+
let utxo = e.get_mut();
2438+
if !utxo.is_locked {
2439+
utxo.is_locked = lock_value;
2440+
changeset.locked_outpoints.insert(outpoint, lock_value);
2441+
}
2442+
}
2443+
btree_map::Entry::Vacant(e) => {
2444+
e.insert(UtxoLock {
2445+
outpoint,
2446+
is_locked: lock_value,
2447+
});
2448+
changeset.locked_outpoints.insert(outpoint, lock_value);
2449+
}
2450+
};
2451+
2452+
self.stage.merge(changeset.into());
2453+
}
2454+
2455+
/// Unlock the wallet output of the specified `outpoint`.
2456+
///
2457+
/// **You must persist the staged changes.**
2458+
pub fn unlock_outpoint(&mut self, outpoint: OutPoint) {
2459+
use crate::collections::btree_map;
2460+
let lock_value = false;
2461+
let mut changeset = locked_outpoints::ChangeSet::default();
2462+
2463+
match self.locked_outpoints.entry(outpoint) {
2464+
btree_map::Entry::Vacant(..) => {}
2465+
btree_map::Entry::Occupied(mut e) => {
2466+
// If the utxo is currently locked, update the lock value and stage
2467+
// the change.
2468+
let utxo = e.get_mut();
2469+
if utxo.is_locked {
2470+
utxo.is_locked = lock_value;
2471+
changeset.locked_outpoints.insert(outpoint, lock_value);
2472+
self.stage.merge(changeset.into());
2473+
}
2474+
}
2475+
}
2476+
}
2477+
23792478
/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
23802479
/// `prev_blockhash` of the block's header.
23812480
///
@@ -2579,6 +2678,20 @@ impl AsRef<bdk_chain::tx_graph::TxGraph<ConfirmationBlockTime>> for Wallet {
25792678
}
25802679
}
25812680

2681+
/// Records the lock status of a wallet UTXO.
2682+
///
2683+
/// Only a single [`UtxoLock`] may be assigned to a particular outpoint at a time. Refer to the docs
2684+
/// for [`Wallet::lock_outpoint`]. Note that the lock status is user-defined and does not take
2685+
/// into account any timelocks encoded by the descriptor.
2686+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2687+
#[non_exhaustive]
2688+
pub struct UtxoLock {
2689+
/// Outpoint.
2690+
pub outpoint: OutPoint,
2691+
/// Whether the outpoint is locked.
2692+
pub is_locked: bool,
2693+
}
2694+
25822695
/// Deterministically generate a unique name given the descriptors defining the wallet
25832696
///
25842697
/// Compatible with [`wallet_name_from_descriptor`]

0 commit comments

Comments
 (0)