Skip to content

Commit a260a33

Browse files
committed
Merge bitcoindevkit#259: feat!: Persist utxo lock status
3132c69 feat(persist-test-utils): Update `persist_wallet_changeset` to persist locked outpoints (valued mammal) 2105d03 feat!: Support persistent UTXO locking (valued mammal) 4d2ea50 clippy!: fix large enum variants (valued mammal) Pull request description: ### Description This PR adds methods to `Wallet` to lock and unlock a UTXO by outpoint, to query the locked outpoints, and updates the wallet `ChangeSet` to persist the lock status of an outpoint. fixes bitcoindevkit#166 fixes bitcoindevkit#245 #### Considerations for broadcast scenarios: When a UTXO is locked, it won't be selected for a transaction. This is useful in cases where the user needs to permanently exclude an output from being spent, or reserve it until some time in the future and continue making transactions in the meantime. To eventually spend the coin the user has to explicitly unlock it. This process could perhaps be facilitated by implementing an automatic lock expiry based on block height. If you submit a transaction to the network and then lock the inputs, then they won't be re-selected in subsequent transactions. This prevents us from inadvertently "double-spending" ourselves. Once broadcast, the lock effect depends on the fate of the transaction: If it confirms and the outpoint is still locked, then the lock is no longer useful, because the input can't be double spent anyway by virtue of consensus. If a conflict confirms which spends the same output, the lock is also irrelevant. If a conflict confirms but doesn't spend the already locked output then the lock remains in effect. If the transaction is somehow dropped from the mempool but otherwise still valid, we can either rebroadcast the tx or create a replacement. This implementation doesn't prevent us from creating a Replace-By-Fee transaction regardless of the lock status, since the inputs to the RBF are considered "manually selected". ### Changelog notice Added these methods to `Wallet` - `lock_outpoint` - `unlock_outpoint` - `list_locked_outpoints` - `list_locked_unspent` - `is_outpoint_locked` Added - New type `wallet::locked_outpoints::ChangeSet` - BREAKING: New member field `wallet::ChangeSet::locked_outpoints` ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `just p` before pushing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature * [x] This pull request breaks the existing API * [x] I'm linking the issue being fixed by this PR Top commit has no ACKs. Tree-SHA512: 9cc23c7b80677a3783506949d3d3e2a5dd86a17036b4a8845257845464b432fa06ba2898285765eb47cf441d99eaa46cb642bacb2cf8340cec8a50462e3d8f55
2 parents 791a22f + 3132c69 commit a260a33

7 files changed

Lines changed: 286 additions & 22 deletions

File tree

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+
# Clippy configuration file. For more see https://doc.rust-lang.org/stable/clippy/configuration.html

src/persist_test_utils.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::{
99
keychain_txout::{self},
1010
local_chain, tx_graph, ConfirmationBlockTime, DescriptorExt, Merge, SpkIterator,
1111
},
12+
locked_outpoints,
1213
miniscript::descriptor::{Descriptor, DescriptorPublicKey},
1314
ChangeSet, WalletPersister,
1415
};
@@ -113,11 +114,13 @@ where
113114
confirmation_time: 1755317160,
114115
};
115116

117+
let outpoint = OutPoint::new(hash!("Rust"), 0);
118+
116119
let tx_graph_changeset = tx_graph::ChangeSet::<ConfirmationBlockTime> {
117120
txs: [tx1.clone()].into(),
118121
txouts: [
119122
(
120-
OutPoint::new(hash!("Rust"), 0),
123+
outpoint,
121124
TxOut {
122125
value: Amount::from_sat(1300),
123126
script_pubkey: spk_at_index(&descriptor, 4),
@@ -157,13 +160,18 @@ where
157160
.into(),
158161
};
159162

163+
let locked_outpoints_changeset = locked_outpoints::ChangeSet {
164+
outpoints: [(outpoint, true)].into(),
165+
};
166+
160167
let mut changeset = ChangeSet {
161168
descriptor: Some(descriptor.clone()),
162169
change_descriptor: Some(change_descriptor.clone()),
163170
network: Some(Network::Testnet),
164171
local_chain: local_chain_changeset,
165172
tx_graph: tx_graph_changeset,
166173
indexer: keychain_txout_changeset,
174+
locked_outpoints: locked_outpoints_changeset,
167175
};
168176

169177
// persist and load
@@ -184,10 +192,12 @@ where
184192
confirmation_time: 1755317760,
185193
};
186194

195+
let outpoint = OutPoint::new(hash!("Bitcoin_fixes_things"), 1);
196+
187197
let tx_graph_changeset = tx_graph::ChangeSet::<ConfirmationBlockTime> {
188198
txs: [tx2.clone()].into(),
189199
txouts: [(
190-
OutPoint::new(hash!("Bitcoin_fixes_things"), 0),
200+
outpoint,
191201
TxOut {
192202
value: Amount::from_sat(10000),
193203
script_pubkey: spk_at_index(&descriptor, 21),
@@ -209,13 +219,18 @@ where
209219
.into(),
210220
};
211221

222+
let locked_outpoints_changeset = locked_outpoints::ChangeSet {
223+
outpoints: [(outpoint, true)].into(),
224+
};
225+
212226
let changeset_new = ChangeSet {
213227
descriptor: None,
214228
change_descriptor: None,
215229
network: None,
216230
local_chain: local_chain_changeset,
217231
tx_graph: tx_graph_changeset,
218232
indexer: keychain_txout_changeset,
233+
locked_outpoints: locked_outpoints_changeset,
219234
};
220235

221236
// 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+
}

0 commit comments

Comments
 (0)