bark/vtxo/
selection.rs

1//! VTXO selection and filtering utilities.
2//!
3//! This module provides reusable filters to select subsets of wallet VTXOs for various workflows.
4//! The primary interface to facilitate this is the [FilterVtxos] trait, which is accepted by
5//! methods such as [Wallet::vtxos_with] and [Wallet::inround_vtxos_with] to filter VTXOs based on
6//! custom logic or ready-made builders.
7//!
8//! Provided filters:
9//! - [VtxoFilter]: A builder to match VTXOs by criteria such as expiry height, counterparty risk,
10//!   and explicit include/exclude lists.
11//! - [RefreshStrategy]: Selects VTXOs that must or should be refreshed preemptively based on
12//!   depth, expiry proximity, and economic viability.
13//!
14//! Usage examples
15//!
16//! Custom predicate via [FilterVtxos]:
17//! ```rust
18//! use anyhow::Result;
19//! use bitcoin::Amount;
20//! use bark::WalletVtxo;
21//! use bark::vtxo::selection::FilterVtxos;
22//!
23//! fn is_large(v: &WalletVtxo) -> Result<bool> {
24//!     Ok(v.amount() >= Amount::from_sat(50_000))
25//! }
26//!
27//! # fn demo(mut vtxos: Vec<WalletVtxo>) -> Result<Vec<WalletVtxo>> {
28//! FilterVtxos::filter_vtxos(&is_large, &mut vtxos)?;
29//! # Ok(vtxos) }
30//! ```
31//!
32//! Builder style with [VtxoFilter]:
33//! ```rust
34//! use bitcoin_ext::BlockHeight;
35//! use bark::vtxo::selection::{FilterVtxos, VtxoFilter};
36//!
37//! # fn example(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
38//! let tip: BlockHeight = 1_000;
39//! let filter = VtxoFilter::new(wallet)
40//!     .expires_before(tip + 144) // expiring within ~1 day
41//!     .counterparty();           // and/or with counterparty risk
42//! filter.filter_vtxos(&mut vtxos)?;
43//! # Ok(vtxos) }
44//! ```
45//!
46//! Notes on semantics
47//! - Include/exclude precedence: an ID in `include` always matches; an ID in `exclude` never
48//!   matches. These take precedence over other criteria.
49//! - Criteria are OR'ed together: a [WalletVtxo] matches if any enabled criterion matches (after applying
50//!   include/exclude).
51//! - “Counterparty risk” is wallet-defined and indicates a [WalletVtxo] may be invalidated by another
52//!   party; see [VtxoFilter::counterparty].
53//!
54//! See also:
55//! - [Wallet::vtxos_with]
56//! - [Wallet::inround_vtxos_with]
57//!
58//! The intent is to allow users to filter VTXOs based on different parameters.
59
60use std::{borrow::Borrow, collections::HashSet};
61
62use anyhow::Context;
63use bitcoin::FeeRate;
64use bitcoin_ext::BlockHeight;
65
66use ark::VtxoId;
67use log::warn;
68
69use crate::Wallet;
70use crate::exit::progress::util::estimate_exit_cost;
71use crate::vtxo::state::{VtxoStateKind, WalletVtxo};
72
73/// Trait needed to be implemented to filter wallet VTXOs.
74///
75/// See [`Wallet::vtxos_with`]. For easy filtering, see [VtxoFilter].
76///
77/// This trait is also implemented for `Fn(&WalletVtxo) -> anyhow::Result<bool>`.
78pub trait FilterVtxos {
79	/// Check whether the VTXO mathes this filter
80	fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool>;
81
82	/// Eliminate from the vector all non-matching VTXOs
83	fn filter_vtxos<V: Borrow<WalletVtxo>>(&self, vtxos: &mut Vec<V>) -> anyhow::Result<()> {
84		for i in (0..vtxos.len()).rev() {
85			if !self.matches(vtxos[i].borrow())? {
86				vtxos.swap_remove(i);
87			}
88		}
89		Ok(())
90	}
91}
92
93impl<F> FilterVtxos for F
94where
95	F: Fn(&WalletVtxo) -> anyhow::Result<bool>,
96{
97	fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
98	    self(vtxo)
99	}
100}
101
102/// Filter vtxos based on criteria.
103///
104/// Builder pattern is used.
105///
106/// Matching semantics:
107/// - Explicit `include` and `exclude` lists have the highest priority.
108/// - Remaining criteria (expiry, counterparty risk) are combined with OR: if any matches, the VTXO
109///   is kept.
110pub struct VtxoFilter<'a> {
111	/// Include vtxos that expire before the given height.
112	pub expires_before: Option<BlockHeight>,
113	/// If true, include vtxos that have counterparty risk.
114	pub counterparty: bool,
115	/// Exclude certain vtxos.
116	pub exclude: HashSet<VtxoId>,
117	/// Force include certain vtxos.
118	pub include: HashSet<VtxoId>,
119
120	wallet: &'a Wallet,
121}
122
123impl<'a> VtxoFilter<'a> {
124	/// Create a new [VtxoFilter] bound to a wallet context.
125	///
126	/// The wallet is used to evaluate properties such as counterparty risk.
127	/// By default, the filter matches nothing until criteria are added.
128	///
129	/// Examples
130	/// ```
131	/// # fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
132	/// use bark::vtxo::selection::{VtxoFilter, FilterVtxos};
133	/// use bitcoin_ext::BlockHeight;
134	///
135	/// let tip: BlockHeight = 1_000;
136	/// let filter = VtxoFilter::new(wallet)
137	///     .expires_before(tip + 144) // expiring within ~1 day
138	///     .counterparty();           // or with counterparty risk
139	/// let filtered = wallet.spendable_vtxos_with(&filter)?;
140	/// # Ok(filtered) }
141	/// ```
142	pub fn new(wallet: &'a Wallet) -> VtxoFilter<'a> {
143		VtxoFilter {
144			expires_before: None,
145			counterparty: false,
146			exclude: HashSet::new(),
147			include: HashSet::new(),
148			wallet,
149		}
150	}
151
152	/// Include vtxos that expire before the given height.
153	///
154	/// Examples
155	/// ```
156	/// # fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
157	/// use bark::vtxo::selection::{VtxoFilter, FilterVtxos};
158	/// use bitcoin_ext::BlockHeight;
159	///
160	/// let h: BlockHeight = 10_000;
161	/// let filter = VtxoFilter::new(wallet)
162	///     .expires_before(h);
163	/// let filtered = wallet.spendable_vtxos_with(&filter)?;
164	/// # Ok(filtered) }
165	/// ```
166	pub fn expires_before(mut self, expires_before: BlockHeight) -> Self {
167		self.expires_before = Some(expires_before);
168		self
169	}
170
171	/// Include vtxos that have counterparty risk.
172	///
173	/// An arkoor vtxo is considered to have some counterparty risk if it's (directly or not) based
174	/// on round VTXOs that aren't owned by the wallet.
175	pub fn counterparty(mut self) -> Self {
176		self.counterparty = true;
177		self
178	}
179
180	/// Exclude the given vtxo.
181	pub fn exclude(mut self, exclude: VtxoId) -> Self {
182		self.exclude.insert(exclude);
183		self
184	}
185
186	/// Exclude the given vtxos.
187	pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
188		self.exclude.extend(exclude);
189		self
190	}
191
192	/// Include the given vtxo.
193	pub fn include(mut self, include: VtxoId) -> Self {
194		self.include.insert(include);
195		self
196	}
197
198	/// Include the given vtxos.
199	pub fn include_many(mut self, include: impl IntoIterator<Item = VtxoId>) -> Self {
200		self.include.extend(include);
201		self
202	}
203}
204
205impl FilterVtxos for VtxoFilter<'_> {
206	fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
207		let id = vtxo.id();
208
209		// First do explicit includes and excludes.
210		if self.include.contains(&id) {
211			return Ok(true);
212		}
213		if self.exclude.contains(&id) {
214			return Ok(false);
215		}
216
217		if let Some(height) = self.expires_before {
218			if (vtxo.expiry_height()) < height {
219				return Ok(true);
220			}
221		}
222
223		if self.counterparty {
224			if self.wallet.has_counterparty_risk(vtxo).context("db error")? {
225				return Ok(true);
226			}
227		}
228
229		Ok(false)
230	}
231}
232
233enum InnerRefreshStrategy {
234	MustRefresh,
235	ShouldRefresh,
236}
237
238/// Strategy to select VTXOs that need proactive refreshing.
239///
240/// Refreshing is recommended when a VTXO is nearing its expiry, has reached a soft/hard
241/// out-of-round depth threshold, or is uneconomical to exit onchain at the current fee rate.
242///
243/// Variants:
244/// - [RefreshStrategy::must_refresh]: strict selection intended for mandatory refresh actions
245///   (e.g., at or beyond maximum depth or near-hard expiry threshold).
246/// - [RefreshStrategy::should_refresh]: softer selection for opportunistic refreshes
247///   (e.g., approaching soft thresholds or uneconomical unilateral exit).
248///
249/// This type implements [FilterVtxos], so it can be passed directly to
250/// [`Wallet::vtxos_with`] or [`Wallet::inround_vtxos_with`].
251pub struct RefreshStrategy<'a> {
252	inner: InnerRefreshStrategy,
253	tip: BlockHeight,
254	wallet: &'a Wallet,
255	fee_rate: FeeRate,
256}
257
258impl<'a> RefreshStrategy<'a> {
259	/// Builds a strategy that matches VTXOs that must be refreshed immediately.
260	///
261	/// A [WalletVtxo] is selected when at least one of the following strict conditions holds:
262	/// - It reached or exceeded the maximum allowed out-of-round (OOR) depth (if configured by the
263	///   Ark server info in the wallet).
264	/// - It is within `vtxo_refresh_expiry_threshold` blocks of expiry at `tip`.
265	///
266	/// Parameters:
267	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
268	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
269	/// - `fee_rate`: [FeeRate] to use for any economic checks (kept for parity with the
270	///   "should" strategy; not all checks require it in the strict mode).
271	///
272	/// Returns:
273	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
274	///   [FilterVtxos::filter_vtxos] directly.
275	///
276	/// Examples
277	/// ```
278	/// # fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
279	/// use bark::vtxo::selection::{FilterVtxos, RefreshStrategy};
280	/// use bitcoin::FeeRate;
281	/// use bitcoin_ext::BlockHeight;
282	///
283	/// let tip: BlockHeight = 200_000;
284	/// let fr = FeeRate::from_sat_per_vb(5).unwrap();
285	/// let must = RefreshStrategy::must_refresh(wallet, tip, fr);
286	/// must.filter_vtxos(&mut vtxos)?;
287	/// # Ok(vtxos) }
288	/// ```
289	pub fn must_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
290		Self {
291			inner: InnerRefreshStrategy::MustRefresh,
292			tip,
293			wallet,
294			fee_rate,
295		}
296	}
297
298	/// Builds a strategy that matches VTXOs that should be refreshed soon (opportunistic).
299	///
300	/// A [WalletVtxo] is selected when at least one of the following softer conditions holds:
301	/// - It is at or beyond a soft OOR depth threshold (typically one less than the maximum, if
302	///   configured by the Ark server info in the wallet).
303	/// - It is within a softer expiry window (e.g., `vtxo_refresh_expiry_threshold + 28` blocks)
304	///   relative to `tip`.
305	/// - It is uneconomical to unilaterally exit at the provided `fee_rate` (e.g., its amount is
306	///   lower than the estimated exit cost).
307	///
308	/// Parameters:
309	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
310	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
311	/// - `fee_rate`: [FeeRate] used for economic feasibility checks.
312	///
313	/// Returns:
314	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
315	///   [FilterVtxos::filter_vtxos] directly.
316	///
317	/// Examples
318	/// ```
319	/// # fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
320	/// use bark::vtxo::selection::{FilterVtxos, RefreshStrategy};
321	/// use bitcoin::FeeRate;
322	/// use bitcoin_ext::BlockHeight;
323	///
324	/// let tip: BlockHeight = 200_000;
325	/// let fr = FeeRate::from_sat_per_vb(8).unwrap();
326	/// let should = RefreshStrategy::should_refresh(wallet, tip, fr);
327	/// should.filter_vtxos(&mut vtxos)?;
328	/// # Ok(vtxos) }
329	/// ```
330	pub fn should_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
331		Self {
332			inner: InnerRefreshStrategy::ShouldRefresh,
333			tip,
334			wallet,
335			fee_rate,
336		}
337	}
338}
339
340impl FilterVtxos for RefreshStrategy<'_> {
341	fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
342		match self.inner {
343			InnerRefreshStrategy::MustRefresh => {
344				let threshold = self.wallet.config().vtxo_refresh_expiry_threshold;
345				if self.tip > vtxo.spec().expiry_height.saturating_sub(threshold) {
346					warn!("VTXO {} is about to expire soon, must be refreshed", vtxo.id());
347					return Ok(true);
348				}
349
350				Ok(false)
351			},
352			InnerRefreshStrategy::ShouldRefresh => {
353				let soft_threshold = self.wallet.config().vtxo_refresh_expiry_threshold + 28;
354				if self.tip > vtxo.spec().expiry_height.saturating_sub(soft_threshold) {
355					warn!("VTXO {} is about to expire, should be refreshed on next opportunity",
356						vtxo.id(),
357					);
358					return Ok(true);
359				}
360
361				let fr = self.fee_rate;
362				if vtxo.amount() < estimate_exit_cost(&[vtxo.vtxo.clone()], fr) {
363					warn!("VTXO {} is uneconomical to exit, should be refreshed on \
364						next opportunity", vtxo.id(),
365					);
366					return Ok(true);
367				}
368
369				Ok(false)
370			}
371		}
372	}
373}
374
375impl FilterVtxos for VtxoStateKind {
376	fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
377	    Ok(vtxo.state.kind() == *self)
378	}
379}