Skip to content

Commit b083dff

Browse files
committed
feat!: Persist utxo lock status
- Add `Wallet::lock_unspent` - Add `Wallet::unlock_unspent` - Add `Wallet::is_utxo_locked` - Add pub struct `UtxoInfo` - Extend ChangeSet with new member `utxo_info` with type `BTreeMap<OutPoint, UtxoInfo>` - Test `test_lock_unspent_persist`
1 parent 00dafe7 commit b083dff

File tree

3 files changed

+266
-3
lines changed

3 files changed

+266
-3
lines changed

wallet/src/wallet/changeset.rs

Lines changed: 64 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::UtxoInfo;
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 utxo info.
123+
pub utxo_info: BTreeMap<OutPoint, UtxoInfo>,
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 utxo_info we just extend the existing collection. If there's
153+
// an existing entry for a given outpoint, then it is overwritten by the
154+
// new utxo info.
155+
self.utxo_info.extend(other.utxo_info);
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.utxo_info.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 utxo info.
180+
pub const WALLET_UTXO_TABLE_NAME: &'static str = "bdk_wallet_utxo_info";
166181

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

195+
/// Get v1 sqlite [ChangeSet] schema. Schema v1 adds a table for wallet UTXO info.
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+
PRIMARY KEY(txid, vout) \
203+
) STRICT;",
204+
Self::WALLET_UTXO_TABLE_NAME,
205+
)
206+
}
207+
180208
/// Initialize sqlite tables for wallet tables.
181209
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
182210
crate::rusqlite_impl::migrate_schema(
183211
db_tx,
184212
Self::WALLET_SCHEMA_NAME,
185-
&[&Self::schema_v0()],
213+
&[&Self::schema_v0(), &Self::schema_v1()],
186214
)?;
187215

188216
bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
@@ -220,6 +248,27 @@ impl ChangeSet {
220248
changeset.network = network.map(Impl::into_inner);
221249
}
222250

251+
// Load utxo info.
252+
let mut stmt = db_tx.prepare(&format!(
253+
"SELECT txid, vout, is_locked FROM {}",
254+
Self::WALLET_UTXO_TABLE_NAME,
255+
))?;
256+
let rows = stmt.query_map([], |row| {
257+
Ok((
258+
row.get::<_, Impl<Txid>>("txid")?,
259+
row.get::<_, u32>("vout")?,
260+
row.get::<_, bool>("is_locked")?,
261+
))
262+
})?;
263+
for row in rows {
264+
let (Impl(txid), vout, is_locked) = row?;
265+
let utxo_info = UtxoInfo {
266+
outpoint: OutPoint::new(txid, vout),
267+
is_locked,
268+
};
269+
changeset.utxo_info.insert(utxo_info.outpoint, utxo_info);
270+
}
271+
223272
changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?;
224273
changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?;
225274
changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?;
@@ -268,6 +317,20 @@ impl ChangeSet {
268317
})?;
269318
}
270319

320+
// Persist utxo info.
321+
let mut stmt = db_tx.prepare_cached(&format!(
322+
"INSERT INTO {}(txid, vout, is_locked) VALUES(:txid, :vout, :is_locked) ON CONFLICT DO UPDATE SET is_locked = :is_locked",
323+
Self::WALLET_UTXO_TABLE_NAME,
324+
))?;
325+
for (&outpoint, utxo_info) in &self.utxo_info {
326+
let OutPoint { txid, vout } = outpoint;
327+
stmt.execute(named_params! {
328+
":txid": Impl(txid),
329+
":vout": vout,
330+
":is_locked": utxo_info.is_locked,
331+
})?;
332+
}
333+
271334
self.local_chain.persist_to_sqlite(db_tx)?;
272335
self.tx_graph.persist_to_sqlite(db_tx)?;
273336
self.indexer.persist_to_sqlite(db_tx)?;

wallet/src/wallet/mod.rs

Lines changed: 117 additions & 2 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+
utxo_info: BTreeMap<OutPoint, UtxoInfo>,
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 utxo_info = 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+
utxo_info,
470474
})
471475
}
472476

@@ -653,18 +657,39 @@ 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 utxo_info = BTreeMap::new();
656661

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

659-
Ok(Some(Wallet {
664+
let mut wallet = Wallet {
660665
signers,
661666
change_signers,
662667
chain,
663668
indexed_graph,
664669
stage,
665670
network,
666671
secp,
667-
}))
672+
utxo_info,
673+
};
674+
675+
// Apply lock status to wallet utxos.
676+
wallet.utxo_info = wallet
677+
.list_unspent()
678+
.map(|output| {
679+
(
680+
output.outpoint,
681+
UtxoInfo {
682+
outpoint: output.outpoint,
683+
is_locked: false,
684+
},
685+
)
686+
})
687+
.collect();
688+
for (outpoint, utxo_info) in changeset.utxo_info {
689+
wallet.utxo_info.insert(outpoint, utxo_info);
690+
}
691+
692+
Ok(Some(wallet))
668693
}
669694

670695
/// Get the Bitcoin network the wallet is using.
@@ -2110,6 +2135,8 @@ impl Wallet {
21102135
CanonicalizationParams::default(),
21112136
self.indexed_graph.index.outpoints().iter().cloned(),
21122137
)
2138+
// Filter out locked utxos
2139+
.filter(|(_, txo)| self.is_utxo_locked(txo.outpoint) != Some(true))
21132140
// only create LocalOutput if UTxO is mature
21142141
.filter_map(move |((k, i), full_txo)| {
21152142
full_txo
@@ -2377,6 +2404,84 @@ impl Wallet {
23772404
&self.chain
23782405
}
23792406

2407+
/// Is UTXO locked. `None` if the outpoint isn't found in `self.utxo_info`.
2408+
pub fn is_utxo_locked(&self, outpoint: OutPoint) -> Option<bool> {
2409+
Some(self.utxo_info.get(&outpoint)?.is_locked)
2410+
}
2411+
2412+
/// Lock an unspent output by `outpoint`.
2413+
///
2414+
/// Locked utxos are automatically filtered out during coin selection. You need to persist
2415+
/// the wallet in order for the lock status to persist across restarts. To unlock a previously
2416+
/// locked outpoint, see [`Wallet::unlock_unspent`].
2417+
pub fn lock_unspent(&mut self, outpoint: OutPoint) {
2418+
use alloc::collections::btree_map;
2419+
let lock_value = true;
2420+
2421+
// If the utxo is not currently locked, update the lock value
2422+
// and stage the change.
2423+
let is_changed = match self.utxo_info.entry(outpoint) {
2424+
btree_map::Entry::Occupied(mut e) => {
2425+
let mut is_changed = false;
2426+
2427+
let utxo = e.get_mut();
2428+
2429+
if !utxo.is_locked {
2430+
utxo.is_locked = lock_value;
2431+
is_changed = true;
2432+
}
2433+
2434+
is_changed
2435+
}
2436+
btree_map::Entry::Vacant(e) => {
2437+
e.insert(UtxoInfo {
2438+
outpoint,
2439+
is_locked: lock_value,
2440+
});
2441+
true
2442+
}
2443+
};
2444+
2445+
if is_changed {
2446+
let utxo_info = UtxoInfo {
2447+
outpoint,
2448+
is_locked: lock_value,
2449+
};
2450+
self.stage.merge(ChangeSet {
2451+
utxo_info: [(outpoint, utxo_info)].into(),
2452+
..Default::default()
2453+
});
2454+
}
2455+
}
2456+
2457+
/// Unlock unspent.
2458+
pub fn unlock_unspent(&mut self, outpoint: OutPoint) {
2459+
use alloc::collections::btree_map;
2460+
let lock_value = false;
2461+
2462+
match self.utxo_info.entry(outpoint) {
2463+
btree_map::Entry::Occupied(mut e) => {
2464+
let utxo = e.get_mut();
2465+
2466+
// If the utxo is currently locked, update the lock value and stage
2467+
// the change.
2468+
if utxo.is_locked {
2469+
utxo.is_locked = lock_value;
2470+
let utxo_info = UtxoInfo {
2471+
outpoint,
2472+
is_locked: lock_value,
2473+
};
2474+
self.stage.merge(ChangeSet {
2475+
utxo_info: [(outpoint, utxo_info)].into(),
2476+
..Default::default()
2477+
});
2478+
}
2479+
}
2480+
// If there is no entry, we're done because the utxo can't be locked.
2481+
btree_map::Entry::Vacant(..) => {}
2482+
}
2483+
}
2484+
23802485
/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
23812486
/// `prev_blockhash` of the block's header.
23822487
///
@@ -2580,6 +2685,16 @@ impl AsRef<bdk_chain::tx_graph::TxGraph<ConfirmationBlockTime>> for Wallet {
25802685
}
25812686
}
25822687

2688+
/// Information about a wallet UTXO.
2689+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
2690+
pub struct UtxoInfo {
2691+
/// Outpoint.
2692+
pub outpoint: bitcoin::OutPoint,
2693+
/// Whether the outpoint is locked by the user. This doesn't take into account
2694+
/// any timelocks that may be part of the actual scriptPubKey.
2695+
pub is_locked: bool,
2696+
}
2697+
25832698
/// Deterministically generate a unique name given the descriptors defining the wallet
25842699
///
25852700
/// Compatible with [`wallet_name_from_descriptor`]

0 commit comments

Comments
 (0)