Skip to content

Commit a6b0f5d

Browse files
committed
feat!: Enable persistent utxo locking
wallet: - Add pub struct `UtxoLock` - Add `Wallet::lock_outpoint` - Add `Wallet::unlock_outpoint` - Add `Wallet::list_locked_outpoints` - Add `Wallet::is_outpoint_locked` changeset: - Add member to ChangeSet `locked_outpoints: BTreeMap<OutPoint, UtxoLock>` `tests/wallet.rs`: - Add test `test_lock_outpoint_persist`
1 parent 00dafe7 commit a6b0f5d

File tree

3 files changed

+322
-1
lines changed

3 files changed

+322
-1
lines changed

wallet/src/wallet/changeset.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
use alloc::collections::BTreeMap;
2+
13
use bdk_chain::{
24
indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge,
35
};
6+
use bitcoin::{OutPoint, Txid};
47
use miniscript::{Descriptor, DescriptorPublicKey};
58
use serde::{Deserialize, Serialize};
69

710
type IndexedTxGraphChangeSet =
811
indexed_tx_graph::ChangeSet<ConfirmationBlockTime, keychain_txout::ChangeSet>;
912

13+
use crate::UtxoLock;
14+
1015
/// A change set for [`Wallet`]
1116
///
1217
/// ## Definition
@@ -114,6 +119,8 @@ pub struct ChangeSet {
114119
pub tx_graph: tx_graph::ChangeSet<ConfirmationBlockTime>,
115120
/// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex).
116121
pub indexer: keychain_txout::ChangeSet,
122+
/// Changes to locked outpoints.
123+
pub locked_outpoints: BTreeMap<OutPoint, UtxoLock>,
117124
}
118125

119126
impl Merge for ChangeSet {
@@ -142,6 +149,11 @@ impl Merge for ChangeSet {
142149
self.network = other.network;
143150
}
144151

152+
// To merge `locked_outpoints` we extend the existing collection. If there's
153+
// an existing entry for a given outpoint, it is overwritten by the
154+
// new utxo lock.
155+
self.locked_outpoints.extend(other.locked_outpoints);
156+
145157
Merge::merge(&mut self.local_chain, other.local_chain);
146158
Merge::merge(&mut self.tx_graph, other.tx_graph);
147159
Merge::merge(&mut self.indexer, other.indexer);
@@ -154,6 +166,7 @@ impl Merge for ChangeSet {
154166
&& self.local_chain.is_empty()
155167
&& self.tx_graph.is_empty()
156168
&& self.indexer.is_empty()
169+
&& self.locked_outpoints.is_empty()
157170
}
158171
}
159172

@@ -163,6 +176,8 @@ impl ChangeSet {
163176
pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet";
164177
/// Name of table to store wallet descriptors and network.
165178
pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet";
179+
/// Name of table to store wallet locked outpoints.
180+
pub const WALLET_UTXO_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints";
166181

167182
/// Get v0 sqlite [ChangeSet] schema
168183
pub fn schema_v0() -> alloc::string::String {
@@ -177,12 +192,26 @@ impl ChangeSet {
177192
)
178193
}
179194

195+
/// Get v1 sqlite [`ChangeSet`] schema. Schema v1 adds a table for locked outpoints.
196+
pub fn schema_v1() -> alloc::string::String {
197+
format!(
198+
"CREATE TABLE {} ( \
199+
txid TEXT NOT NULL, \
200+
vout INTEGER NOT NULL, \
201+
is_locked INTEGER, \
202+
expiration_height INTEGER, \
203+
PRIMARY KEY(txid, vout) \
204+
) STRICT;",
205+
Self::WALLET_UTXO_LOCK_TABLE_NAME,
206+
)
207+
}
208+
180209
/// Initialize sqlite tables for wallet tables.
181210
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
182211
crate::rusqlite_impl::migrate_schema(
183212
db_tx,
184213
Self::WALLET_SCHEMA_NAME,
185-
&[&Self::schema_v0()],
214+
&[&Self::schema_v0(), &Self::schema_v1()],
186215
)?;
187216

188217
bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
@@ -220,6 +249,31 @@ impl ChangeSet {
220249
changeset.network = network.map(Impl::into_inner);
221250
}
222251

252+
// Select locked outpoints.
253+
let mut stmt = db_tx.prepare(&format!(
254+
"SELECT txid, vout, is_locked, expiration_height FROM {}",
255+
Self::WALLET_UTXO_LOCK_TABLE_NAME,
256+
))?;
257+
let rows = stmt.query_map([], |row| {
258+
Ok((
259+
row.get::<_, Impl<Txid>>("txid")?,
260+
row.get::<_, u32>("vout")?,
261+
row.get::<_, bool>("is_locked")?,
262+
row.get::<_, Option<u32>>("expiration_height")?,
263+
))
264+
})?;
265+
for row in rows {
266+
let (Impl(txid), vout, is_locked, expiration_height) = row?;
267+
let utxo_lock = UtxoLock {
268+
outpoint: OutPoint::new(txid, vout),
269+
is_locked,
270+
expiration_height,
271+
};
272+
changeset
273+
.locked_outpoints
274+
.insert(utxo_lock.outpoint, utxo_lock);
275+
}
276+
223277
changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?;
224278
changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?;
225279
changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?;
@@ -268,6 +322,21 @@ impl ChangeSet {
268322
})?;
269323
}
270324

325+
// Insert locked outpoints.
326+
let mut stmt = db_tx.prepare_cached(&format!(
327+
"INSERT INTO {}(txid, vout, is_locked, expiration_height) VALUES(:txid, :vout, :is_locked, :expiration_height) ON CONFLICT DO UPDATE SET is_locked=:is_locked, expiration_height=:expiration_height",
328+
Self::WALLET_UTXO_LOCK_TABLE_NAME,
329+
))?;
330+
for (&outpoint, utxo_lock) in &self.locked_outpoints {
331+
let OutPoint { txid, vout } = outpoint;
332+
stmt.execute(named_params! {
333+
":txid": Impl(txid),
334+
":vout": vout,
335+
":is_locked": utxo_lock.is_locked,
336+
":expiration_height": utxo_lock.expiration_height,
337+
})?;
338+
}
339+
271340
self.local_chain.persist_to_sqlite(db_tx)?;
272341
self.tx_graph.persist_to_sqlite(db_tx)?;
273342
self.indexer.persist_to_sqlite(db_tx)?;

wallet/src/wallet/mod.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ pub struct Wallet {
109109
stage: ChangeSet,
110110
network: Network,
111111
secp: SecpCtx,
112+
locked_outpoints: BTreeMap<OutPoint, UtxoLock>,
112113
}
113114

114115
/// An update to [`Wallet`].
@@ -449,6 +450,7 @@ impl Wallet {
449450
let change_descriptor = index.get_descriptor(KeychainKind::Internal).cloned();
450451
let indexed_graph = IndexedTxGraph::new(index);
451452
let indexed_graph_changeset = indexed_graph.initial_changeset();
453+
let locked_outpoints = BTreeMap::new();
452454

453455
let stage = ChangeSet {
454456
descriptor,
@@ -457,6 +459,7 @@ impl Wallet {
457459
tx_graph: indexed_graph_changeset.tx_graph,
458460
indexer: indexed_graph_changeset.indexer,
459461
network: Some(network),
462+
..Default::default()
460463
};
461464

462465
Ok(Wallet {
@@ -467,6 +470,7 @@ impl Wallet {
467470
indexed_graph,
468471
stage,
469472
secp,
473+
locked_outpoints,
470474
})
471475
}
472476

@@ -653,6 +657,7 @@ impl Wallet {
653657
let mut indexed_graph = IndexedTxGraph::new(index);
654658
indexed_graph.apply_changeset(changeset.indexer.into());
655659
indexed_graph.apply_changeset(changeset.tx_graph.into());
660+
let locked_outpoints = changeset.locked_outpoints;
656661

657662
let stage = ChangeSet::default();
658663

@@ -664,6 +669,7 @@ impl Wallet {
664669
stage,
665670
network,
666671
secp,
672+
locked_outpoints,
667673
}))
668674
}
669675

@@ -2110,6 +2116,8 @@ impl Wallet {
21102116
CanonicalizationParams::default(),
21112117
self.indexed_graph.index.outpoints().iter().cloned(),
21122118
)
2119+
// Filter out locked outpoints
2120+
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
21132121
// only create LocalOutput if UTxO is mature
21142122
.filter_map(move |((k, i), full_txo)| {
21152123
full_txo
@@ -2377,6 +2385,102 @@ impl Wallet {
23772385
&self.chain
23782386
}
23792387

2388+
/// List locked outpoints.
2389+
pub fn list_locked_outpoints(&self) -> impl Iterator<Item = UtxoLock> + '_ {
2390+
self.locked_outpoints.values().copied()
2391+
}
2392+
2393+
/// Whether the `outpoint` is currently locked. See [`Wallet::lock_outpoint`] for more.
2394+
pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool {
2395+
if let Some(utxo_lock) = self.locked_outpoints.get(&outpoint) {
2396+
if utxo_lock.is_locked {
2397+
return utxo_lock
2398+
.expiration_height
2399+
.map_or(true, |height| self.chain.tip().height() < height);
2400+
}
2401+
}
2402+
false
2403+
}
2404+
2405+
/// Lock a wallet output identified by the given `outpoint`.
2406+
///
2407+
/// A locked UTXO will not be selected as an input to fund a transaction. This is useful
2408+
/// for excluding or reserving candidate inputs during transaction creation. You can optionally
2409+
/// specify the `expiration_height` of the lock that defines the height of the local chain at
2410+
/// which the outpoint becomes spendable.
2411+
///
2412+
/// You must persist the staged change for the lock status to be persistent. To unlock a
2413+
/// previously locked outpoint, see [`Wallet::unlock_outpoint`].
2414+
pub fn lock_outpoint(&mut self, outpoint: OutPoint, expiration_height: Option<u32>) {
2415+
use alloc::collections::btree_map;
2416+
let lock_value = true;
2417+
2418+
// We only stage a change if the lock status changed. Here that means
2419+
// checking if the outpoint is not currently locked, or if the expiration
2420+
// height changed.
2421+
let is_changed = match self.locked_outpoints.entry(outpoint) {
2422+
btree_map::Entry::Occupied(mut e) => {
2423+
let mut is_changed = false;
2424+
let utxo = e.get_mut();
2425+
if !utxo.is_locked {
2426+
utxo.is_locked = lock_value;
2427+
is_changed = true;
2428+
}
2429+
if utxo.expiration_height != expiration_height {
2430+
utxo.expiration_height = expiration_height;
2431+
is_changed = true;
2432+
}
2433+
is_changed
2434+
}
2435+
btree_map::Entry::Vacant(e) => {
2436+
e.insert(UtxoLock {
2437+
outpoint,
2438+
is_locked: lock_value,
2439+
expiration_height,
2440+
});
2441+
true
2442+
}
2443+
};
2444+
2445+
if is_changed {
2446+
let utxo_lock = UtxoLock {
2447+
outpoint,
2448+
is_locked: lock_value,
2449+
expiration_height,
2450+
};
2451+
self.stage.merge(ChangeSet {
2452+
locked_outpoints: [(outpoint, utxo_lock)].into(),
2453+
..Default::default()
2454+
});
2455+
}
2456+
}
2457+
2458+
/// Unlock the wallet output of the specified `outpoint`.
2459+
pub fn unlock_outpoint(&mut self, outpoint: OutPoint) {
2460+
use alloc::collections::btree_map;
2461+
let lock_value = false;
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+
let utxo_lock = UtxoLock {
2472+
is_locked: lock_value,
2473+
..*utxo
2474+
};
2475+
self.stage.merge(ChangeSet {
2476+
locked_outpoints: [(outpoint, utxo_lock)].into(),
2477+
..Default::default()
2478+
});
2479+
}
2480+
}
2481+
}
2482+
}
2483+
23802484
/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
23812485
/// `prev_blockhash` of the block's header.
23822486
///
@@ -2580,6 +2684,25 @@ impl AsRef<bdk_chain::tx_graph::TxGraph<ConfirmationBlockTime>> for Wallet {
25802684
}
25812685
}
25822686

2687+
/// Records the lock status of a wallet UTXO (outpoint).
2688+
///
2689+
/// Only 1 [`UtxoLock`] may be applied to a particular outpoint at a time. Refer to the docs
2690+
/// for [`Wallet::lock_outpoint`]. Note that the lock status is user-defined and does not take
2691+
/// into account any timelocks directly encoded by the descriptor.
2692+
#[derive(
2693+
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
2694+
)]
2695+
pub struct UtxoLock {
2696+
/// Outpoint.
2697+
pub outpoint: OutPoint,
2698+
/// Whether the outpoint is currently locked.
2699+
pub is_locked: bool,
2700+
/// Height at which the UTXO lock expires. The outpoint may be selected when the
2701+
/// wallet chain tip is at or above this height. If `None`, the lock remains in
2702+
/// effect unless explicitly unlocked.
2703+
pub expiration_height: Option<u32>,
2704+
}
2705+
25832706
/// Deterministically generate a unique name given the descriptors defining the wallet
25842707
///
25852708
/// Compatible with [`wallet_name_from_descriptor`]

0 commit comments

Comments
 (0)