Skip to content

feat!: Persist utxo lock status #259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion wallet/src/wallet/changeset.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use bdk_chain::{
indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge,
};
use bitcoin::{OutPoint, Txid};
use miniscript::{Descriptor, DescriptorPublicKey};
use serde::{Deserialize, Serialize};

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

use crate::locked_outpoints;

/// A change set for [`Wallet`]
///
/// ## Definition
Expand Down Expand Up @@ -114,6 +117,8 @@ pub struct ChangeSet {
pub tx_graph: tx_graph::ChangeSet<ConfirmationBlockTime>,
/// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex).
pub indexer: keychain_txout::ChangeSet,
/// Changes to locked outpoints.
pub locked_outpoints: locked_outpoints::ChangeSet,
}

impl Merge for ChangeSet {
Expand Down Expand Up @@ -142,6 +147,9 @@ impl Merge for ChangeSet {
self.network = other.network;
}

// merge locked outpoints
self.locked_outpoints.merge(other.locked_outpoints);

Merge::merge(&mut self.local_chain, other.local_chain);
Merge::merge(&mut self.tx_graph, other.tx_graph);
Merge::merge(&mut self.indexer, other.indexer);
Expand All @@ -154,6 +162,7 @@ impl Merge for ChangeSet {
&& self.local_chain.is_empty()
&& self.tx_graph.is_empty()
&& self.indexer.is_empty()
&& self.locked_outpoints.is_empty()
}
}

Expand All @@ -163,6 +172,8 @@ impl ChangeSet {
pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet";
/// Name of table to store wallet descriptors and network.
pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet";
/// Name of table to store wallet locked outpoints.
pub const WALLET_OUTPOINT_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints";

/// Get v0 sqlite [ChangeSet] schema
pub fn schema_v0() -> alloc::string::String {
Expand All @@ -177,12 +188,24 @@ impl ChangeSet {
)
}

/// Get v1 sqlite [`ChangeSet`] schema. Schema v1 adds a table for locked outpoints.
pub fn schema_v1() -> alloc::string::String {
format!(
"CREATE TABLE {} ( \
txid TEXT NOT NULL, \
vout INTEGER NOT NULL, \
PRIMARY KEY(txid, vout) \
) STRICT;",
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
)
}

/// Initialize sqlite tables for wallet tables.
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
crate::rusqlite_impl::migrate_schema(
db_tx,
Self::WALLET_SCHEMA_NAME,
&[&Self::schema_v0()],
&[&Self::schema_v0(), &Self::schema_v1()],
)?;

bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
Expand Down Expand Up @@ -220,6 +243,24 @@ impl ChangeSet {
changeset.network = network.map(Impl::into_inner);
}

// Select locked outpoints.
let mut stmt = db_tx.prepare(&format!(
"SELECT txid, vout FROM {}",
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
))?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, Impl<Txid>>("txid")?,
row.get::<_, u32>("vout")?,
))
})?;
let locked_outpoints = &mut changeset.locked_outpoints.locked_outpoints;
for row in rows {
let (Impl(txid), vout) = row?;
let outpoint = OutPoint::new(txid, vout);
locked_outpoints.insert(outpoint, true);
}

changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?;
changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?;
changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?;
Expand Down Expand Up @@ -268,6 +309,31 @@ impl ChangeSet {
})?;
}

// Insert or delete locked outpoints.
let mut insert_stmt = db_tx.prepare_cached(&format!(
"REPLACE INTO {}(txid, vout) VALUES(:txid, :vout)",
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME
))?;
let mut delete_stmt = db_tx.prepare_cached(&format!(
"DELETE FROM {} WHERE txid=:txid AND vout=:vout",
Self::WALLET_OUTPOINT_LOCK_TABLE_NAME,
))?;
let locked_outpoints = &self.locked_outpoints.locked_outpoints;
for (&outpoint, &is_locked) in locked_outpoints.iter() {
let OutPoint { txid, vout } = outpoint;
if is_locked {
insert_stmt.execute(named_params! {
":txid": Impl(txid),
":vout": vout,
})?;
} else {
delete_stmt.execute(named_params! {
":txid": Impl(txid),
":vout": vout,
})?;
}
}

self.local_chain.persist_to_sqlite(db_tx)?;
self.tx_graph.persist_to_sqlite(db_tx)?;
self.indexer.persist_to_sqlite(db_tx)?;
Expand Down Expand Up @@ -311,3 +377,12 @@ impl From<keychain_txout::ChangeSet> for ChangeSet {
}
}
}

impl From<locked_outpoints::ChangeSet> for ChangeSet {
fn from(locked_outpoints: locked_outpoints::ChangeSet) -> Self {
Self {
locked_outpoints,
..Default::default()
}
}
}
26 changes: 26 additions & 0 deletions wallet/src/wallet/locked_outpoints.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//! Module containing the locked outpoints change set.
use bdk_chain::Merge;
use bitcoin::OutPoint;
use serde::{Deserialize, Serialize};

use crate::collections::BTreeMap;

/// Represents changes to locked outpoints.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChangeSet {
/// The lock status of an outpoint, `true == is_locked`.
pub locked_outpoints: BTreeMap<OutPoint, bool>,
}

impl Merge for ChangeSet {
fn merge(&mut self, other: Self) {
// Extend self with other. Any entries in `self` that share the same
// outpoint are overwritten.
self.locked_outpoints.extend(other.locked_outpoints);
}

fn is_empty(&self) -> bool {
self.locked_outpoints.is_empty()
}
}
113 changes: 113 additions & 0 deletions wallet/src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod changeset;
pub mod coin_selection;
pub mod error;
pub mod export;
pub mod locked_outpoints;
mod params;
mod persisted;
pub mod signer;
Expand Down Expand Up @@ -109,6 +110,7 @@ pub struct Wallet {
stage: ChangeSet,
network: Network,
secp: SecpCtx,
locked_outpoints: BTreeMap<OutPoint, UtxoLock>,
}

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

let stage = ChangeSet {
descriptor,
Expand All @@ -457,6 +460,7 @@ impl Wallet {
tx_graph: indexed_graph_changeset.tx_graph,
indexer: indexed_graph_changeset.indexer,
network: Some(network),
..Default::default()
};

Ok(Wallet {
Expand All @@ -467,6 +471,7 @@ impl Wallet {
indexed_graph,
stage,
secp,
locked_outpoints,
})
}

Expand Down Expand Up @@ -654,6 +659,21 @@ impl Wallet {
indexed_graph.apply_changeset(changeset.indexer.into());
indexed_graph.apply_changeset(changeset.tx_graph.into());

// Apply locked outpoints
let locked_outpoints = changeset.locked_outpoints.locked_outpoints;
let locked_outpoints = locked_outpoints
.into_iter()
.map(|(outpoint, is_locked)| {
(
outpoint,
UtxoLock {
outpoint,
is_locked,
},
)
})
.collect();

let stage = ChangeSet::default();

Ok(Some(Wallet {
Expand All @@ -664,6 +684,7 @@ impl Wallet {
stage,
network,
secp,
locked_outpoints,
}))
}

Expand Down Expand Up @@ -2108,6 +2129,8 @@ impl Wallet {
CanonicalizationParams::default(),
self.indexed_graph.index.outpoints().iter().cloned(),
)
// Filter out locked outpoints
.filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint))
// only create LocalOutput if UTxO is mature
.filter_map(move |((k, i), full_txo)| {
full_txo
Expand Down Expand Up @@ -2376,6 +2399,82 @@ impl Wallet {
&self.chain
}

/// Get a reference to the locked outpoints.
pub fn locked_outpoints(&self) -> &BTreeMap<OutPoint, UtxoLock> {
&self.locked_outpoints
}

/// List unspent outpoints that are currently locked.
pub fn list_locked_unspent(&self) -> impl Iterator<Item = OutPoint> + '_ {
Copy link
Member

@notmandatory notmandatory Jun 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ a nit, it's a little confusing to refer to the outpoints as "unspent" here and "outpoints" in the other new functions. Since we already have the "list_unspent" function would it make sense to use "unspent" for all the related functions (ie. locked_unspent, is_unspent_locked, lock_unspent, unlock_unspent)? Even though we all know what "outpoints" mean for those unfamiliar with bitcoin internals it's not a very descriptive term.

self.list_unspent()
.filter(|output| self.is_outpoint_locked(output.outpoint))
.map(|output| output.outpoint)
}

/// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more.
pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool {
self.locked_outpoints
.get(&outpoint)
.map_or(false, |u| u.is_locked)
}

/// Lock a wallet output identified by the given `outpoint`.
///
/// A locked UTXO will not be selected as an input to fund a transaction. This is useful
/// for excluding or reserving candidate inputs during transaction creation.
///
/// **You must persist the staged change for the lock status to be persistent**. To unlock a
/// previously locked outpoint, see [`Wallet::unlock_outpoint`].
pub fn lock_outpoint(&mut self, outpoint: OutPoint) {
use crate::collections::btree_map;
let lock_value = true;
let mut changeset = locked_outpoints::ChangeSet::default();

// If the lock status changed, update the entry and record the change
// in the changeset.
match self.locked_outpoints.entry(outpoint) {
btree_map::Entry::Occupied(mut e) => {
let utxo = e.get_mut();
if !utxo.is_locked {
utxo.is_locked = lock_value;
changeset.locked_outpoints.insert(outpoint, lock_value);
}
}
btree_map::Entry::Vacant(e) => {
e.insert(UtxoLock {
outpoint,
is_locked: lock_value,
});
changeset.locked_outpoints.insert(outpoint, lock_value);
}
};

self.stage.merge(changeset.into());
}

/// Unlock the wallet output of the specified `outpoint`.
///
/// **You must persist the staged changes.**
pub fn unlock_outpoint(&mut self, outpoint: OutPoint) {
use crate::collections::btree_map;
let lock_value = false;
let mut changeset = locked_outpoints::ChangeSet::default();

match self.locked_outpoints.entry(outpoint) {
btree_map::Entry::Vacant(..) => {}
btree_map::Entry::Occupied(mut e) => {
// If the utxo is currently locked, update the lock value and stage
// the change.
let utxo = e.get_mut();
if utxo.is_locked {
utxo.is_locked = lock_value;
changeset.locked_outpoints.insert(outpoint, lock_value);
self.stage.merge(changeset.into());
}
}
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about an unlock_all_outpoints method? Just in case the caller needs to reset their state.

/// Introduces a `block` of `height` to the wallet, and tries to connect it to the
/// `prev_blockhash` of the block's header.
///
Expand Down Expand Up @@ -2579,6 +2678,20 @@ impl AsRef<bdk_chain::tx_graph::TxGraph<ConfirmationBlockTime>> for Wallet {
}
}

/// Records the lock status of a wallet UTXO.
///
/// Only a single [`UtxoLock`] may be assigned to a particular outpoint at a time. Refer to the docs
/// for [`Wallet::lock_outpoint`]. Note that the lock status is user-defined and does not take
/// into account any timelocks encoded by the descriptor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub struct UtxoLock {
/// Outpoint.
pub outpoint: OutPoint,
/// Whether the outpoint is locked.
pub is_locked: bool,
}

/// Deterministically generate a unique name given the descriptors defining the wallet
///
/// Compatible with [`wallet_name_from_descriptor`]
Expand Down
Loading
Loading