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::FilterVtxos;
22//!
23//! fn is_large(v: &WalletVtxo) -> Result<bool> {
24//!     Ok(v.amount() >= Amount::from_sat(50_000))
25//! }
26//!
27//! # async fn demo(mut vtxos: Vec<WalletVtxo>) -> Result<Vec<WalletVtxo>> {
28//! FilterVtxos::filter_vtxos(&is_large, &mut vtxos).await?;
29//! # Ok(vtxos) }
30//! ```
31//!
32//! Builder style with [VtxoFilter]:
33//! ```rust
34//! use bitcoin_ext::BlockHeight;
35//! use bark::vtxo::{FilterVtxos, VtxoFilter};
36//!
37//! # async 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).await?;
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;
61use std::collections::HashSet;
62
63use anyhow::Context;
64use bitcoin::FeeRate;
65use log::warn;
66
67use ark::VtxoId;
68use bitcoin_ext::{BlockHeight, P2TR_DUST};
69
70use crate::Wallet;
71use crate::exit::progress::util::estimate_exit_cost;
72use crate::vtxo::state::{VtxoStateKind, WalletVtxo};
73
74/// Trait needed to be implemented to filter wallet VTXOs.
75///
76/// See [`Wallet::vtxos_with`]. For easy filtering, see [VtxoFilter].
77///
78/// This trait is also implemented for `Fn(&WalletVtxo) -> anyhow::Result<bool>`.
79#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
80#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
81pub trait FilterVtxos: Send + Sync {
82	/// Check whether the VTXO mathes this filter
83	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool>;
84
85	/// Eliminate from the vector all non-matching VTXOs
86	async fn filter_vtxos<V: Borrow<WalletVtxo> + Send>(&self, vtxos: &mut Vec<V>) -> anyhow::Result<()> {
87		for i in (0..vtxos.len()).rev() {
88			if !self.matches(vtxos[i].borrow()).await? {
89				vtxos.swap_remove(i);
90			}
91		}
92		Ok(())
93	}
94}
95
96#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
97#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
98impl<F> FilterVtxos for F
99where
100	F: Fn(&WalletVtxo) -> anyhow::Result<bool> + Send + Sync,
101{
102	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
103	    self(vtxo)
104	}
105}
106
107/// Filter vtxos based on criteria.
108///
109/// Builder pattern is used.
110///
111/// Matching semantics:
112/// - Explicit `include` and `exclude` lists have the highest priority.
113/// - Remaining criteria (expiry, counterparty risk) are combined with OR: if any matches, the VTXO
114///   is kept.
115pub struct VtxoFilter<'a> {
116	/// Include vtxos that expire before the given height.
117	pub expires_before: Option<BlockHeight>,
118	/// If true, include vtxos that have counterparty risk.
119	pub counterparty: bool,
120	/// Exclude certain vtxos.
121	pub exclude: HashSet<VtxoId>,
122	/// Force include certain vtxos.
123	pub include: HashSet<VtxoId>,
124
125	wallet: &'a Wallet,
126}
127
128impl<'a> VtxoFilter<'a> {
129	/// Create a new [VtxoFilter] bound to a wallet context.
130	///
131	/// The wallet is used to evaluate properties such as counterparty risk.
132	/// By default, the filter matches nothing until criteria are added.
133	///
134	/// Examples
135	/// ```
136	/// # async fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
137	/// use bark::vtxo::{VtxoFilter, FilterVtxos};
138	/// use bitcoin_ext::BlockHeight;
139	///
140	/// let tip: BlockHeight = 1_000;
141	/// let filter = VtxoFilter::new(wallet)
142	///     .expires_before(tip + 144) // expiring within ~1 day
143	///     .counterparty();           // or with counterparty risk
144	/// let filtered = wallet.spendable_vtxos_with(&filter).await?;
145	/// # Ok(filtered) }
146	/// ```
147	pub fn new(wallet: &'a Wallet) -> VtxoFilter<'a> {
148		VtxoFilter {
149			expires_before: None,
150			counterparty: false,
151			exclude: HashSet::new(),
152			include: HashSet::new(),
153			wallet,
154		}
155	}
156
157	/// Include vtxos that expire before the given height.
158	///
159	/// Examples
160	/// ```
161	/// # async fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
162	/// use bark::vtxo::{VtxoFilter, FilterVtxos};
163	/// use bitcoin_ext::BlockHeight;
164	///
165	/// let h: BlockHeight = 10_000;
166	/// let filter = VtxoFilter::new(wallet)
167	///     .expires_before(h);
168	/// let filtered = wallet.spendable_vtxos_with(&filter).await?;
169	/// # Ok(filtered) }
170	/// ```
171	pub fn expires_before(mut self, expires_before: BlockHeight) -> Self {
172		self.expires_before = Some(expires_before);
173		self
174	}
175
176	/// Include vtxos that have counterparty risk.
177	///
178	/// An arkoor vtxo is considered to have some counterparty risk if it's (directly or not) based
179	/// on round VTXOs that aren't owned by the wallet.
180	pub fn counterparty(mut self) -> Self {
181		self.counterparty = true;
182		self
183	}
184
185	/// Exclude the given vtxo.
186	pub fn exclude(mut self, exclude: VtxoId) -> Self {
187		self.exclude.insert(exclude);
188		self
189	}
190
191	/// Exclude the given vtxos.
192	pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
193		self.exclude.extend(exclude);
194		self
195	}
196
197	/// Include the given vtxo.
198	pub fn include(mut self, include: VtxoId) -> Self {
199		self.include.insert(include);
200		self
201	}
202
203	/// Include the given vtxos.
204	pub fn include_many(mut self, include: impl IntoIterator<Item = VtxoId>) -> Self {
205		self.include.extend(include);
206		self
207	}
208}
209
210#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
211#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
212impl FilterVtxos for VtxoFilter<'_> {
213	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
214		let id = vtxo.id();
215
216		// First do explicit includes and excludes.
217		if self.include.contains(&id) {
218			return Ok(true);
219		}
220		if self.exclude.contains(&id) {
221			return Ok(false);
222		}
223
224		if let Some(height) = self.expires_before {
225			if (vtxo.expiry_height()) < height {
226				return Ok(true);
227			}
228		}
229
230		if self.counterparty {
231			if self.wallet.has_counterparty_risk(vtxo).await.context("db error")? {
232				return Ok(true);
233			}
234		}
235
236		Ok(false)
237	}
238}
239
240/// Determines how VTXOs get filtered when deciding whether to refresh them.
241enum InnerRefreshStrategy {
242	/// Includes a VTXO absolutely must be refreshed, for example, if it is about to expire.
243	MustRefresh,
244	/// Includes a VTXO that should be refreshed soon, for example, if it's approaching expiry, is
245	/// uneconomical to exit, or is dust. This will also include VTXOs that meet the
246	/// [InnerRefreshStrategy::MustRefresh] criteria.
247	ShouldRefreshInclusive,
248	/// Same as [InnerRefreshStrategy::ShouldRefreshInclusive], but it excludes VTXOs that meet the
249	/// [InnerRefreshStrategy::MustRefresh] criteria.
250	ShouldRefreshExclusive,
251	/// If any VTXOs _MUST_ be refreshed, then both _MUST_ and _SHOULD_ VTXOs will be included.
252	ShouldRefreshIfMustRefresh,
253}
254
255/// Strategy to select VTXOs that need proactive refreshing.
256///
257/// Refreshing is recommended when a VTXO is nearing its expiry, has reached a soft/hard
258/// out-of-round depth threshold, or is uneconomical to exit onchain at the current fee rate.
259///
260/// Variants:
261/// - [RefreshStrategy::must_refresh]: strict selection intended for mandatory refresh actions
262///   (e.g., at near expiry threshold).
263/// - [RefreshStrategy::should_refresh]: softer selection for opportunistic refreshes
264///   (e.g., approaching expiry thresholds or uneconomical unilateral exit).
265/// - [RefreshStrategy::should_refresh_exclusive]: same as [RefreshStrategy::should_refresh], but
266///   excludes VTXOs that meet the [RefreshStrategy::must_refresh] criteria.
267/// - [RefreshStrategy::should_refresh_if_must]: same as [RefreshStrategy::should_refresh], but
268///   only keeps the _SHOULD_ VTXOs if at least one VTXO meets the _MUST_ criteria.
269///
270/// Notes:
271/// - This type implements [FilterVtxos], so it can be passed directly to [`Wallet::vtxos_with`].
272/// - Calling [FilterVtxos::matches] on [RefreshStategy::should_result_if_must] is invalid.
273pub struct RefreshStrategy<'a> {
274	inner: InnerRefreshStrategy,
275	tip: BlockHeight,
276	wallet: &'a Wallet,
277	fee_rate: FeeRate,
278}
279
280impl<'a> RefreshStrategy<'a> {
281	/// Builds a strategy that matches VTXOs that must be refreshed immediately.
282	///
283	/// A [WalletVtxo] is selected when at least one of the following strict conditions holds:
284	/// - It is within `vtxo_refresh_expiry_threshold` blocks of expiry at `tip`.
285	///
286	/// Parameters:
287	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
288	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
289	/// - `fee_rate`: [FeeRate] to use for any economic checks (kept for parity with the
290	///   "should" strategy; not all checks require it in the strict mode).
291	///
292	/// Returns:
293	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
294	///   [FilterVtxos::filter_vtxos] directly.
295	///
296	/// Examples
297	/// ```
298	/// # async fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
299	/// use bark::vtxo::{FilterVtxos, RefreshStrategy};
300	/// use bitcoin::FeeRate;
301	/// use bitcoin_ext::BlockHeight;
302	///
303	/// let tip: BlockHeight = 200_000;
304	/// let fr = FeeRate::from_sat_per_vb(5).unwrap();
305	/// let must = RefreshStrategy::must_refresh(wallet, tip, fr);
306	/// must.filter_vtxos(&mut vtxos).await?;
307	/// # Ok(vtxos) }
308	/// ```
309	pub fn must_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
310		Self {
311			inner: InnerRefreshStrategy::MustRefresh,
312			tip,
313			wallet,
314			fee_rate,
315		}
316	}
317
318	/// Builds a strategy that matches VTXOs that should be refreshed soon (opportunistic).
319	///
320	/// A [WalletVtxo] is selected when at least one of the following softer conditions holds:
321	/// - It is within a softer expiry window (e.g., `vtxo_refresh_expiry_threshold + 28` blocks)
322	///   relative to `tip`.
323	/// - It is uneconomical to unilaterally exit at the provided `fee_rate` (e.g., its amount is
324	///   lower than the estimated exit cost).
325	///
326	/// Parameters:
327	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
328	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
329	/// - `fee_rate`: [FeeRate] used for economic feasibility checks.
330	///
331	/// Returns:
332	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
333	///   [FilterVtxos::filter_vtxos] directly.
334	///
335	/// Examples
336	/// ```
337	/// # async fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
338	/// use bark::vtxo::{FilterVtxos, RefreshStrategy};
339	/// use bitcoin::FeeRate;
340	/// use bitcoin_ext::BlockHeight;
341	///
342	/// let tip: BlockHeight = 200_000;
343	/// let fr = FeeRate::from_sat_per_vb(8).unwrap();
344	/// let should = RefreshStrategy::should_refresh(wallet, tip, fr);
345	/// should.filter_vtxos(&mut vtxos).await?;
346	/// # Ok(vtxos) }
347	/// ```
348	pub fn should_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
349		Self {
350			inner: InnerRefreshStrategy::ShouldRefreshInclusive,
351			tip,
352			wallet,
353			fee_rate,
354		}
355	}
356
357	/// Same as [RefreshStrategy::should_refresh] but it filters out VTXOs which meet the
358	/// [RefreshStrategy::must_refresh] criteria.
359	pub fn should_refresh_exclusive(
360		wallet: &'a Wallet,
361		tip: BlockHeight,
362		fee_rate: FeeRate,
363	) -> Self {
364		Self {
365			inner: InnerRefreshStrategy::ShouldRefreshExclusive,
366			tip,
367			wallet,
368			fee_rate,
369		}
370	}
371
372	/// Similar to calling [RefreshStrategy::must_refresh] and then
373	/// [RefreshStrategy::should_refresh_exclusive], but it only keeps the _SHOULD_ VTXOs if at
374	/// least one VTXO meets the _MUST_ criteria.
375	pub fn should_refresh_if_must(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
376		Self {
377			inner: InnerRefreshStrategy::ShouldRefreshIfMustRefresh,
378			tip,
379			wallet,
380			fee_rate,
381		}
382	}
383
384	fn check_must_refresh(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
385		let threshold = self.wallet.config().vtxo_refresh_expiry_threshold;
386		if self.tip > vtxo.expiry_height().saturating_sub(threshold) {
387			warn!("VTXO {} is about to expire soon, must be refreshed", vtxo.id());
388			return Ok(true);
389		}
390
391		Ok(false)
392	}
393
394	fn check_should_refresh(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
395		let soft_threshold = self.wallet.config().vtxo_refresh_expiry_threshold + 28;
396		if self.tip > vtxo.expiry_height().saturating_sub(soft_threshold) {
397			warn!("VTXO {} is about to expire, should be refreshed on next opportunity",
398				vtxo.id(),
399			);
400			return Ok(true);
401		}
402
403		let fr = self.fee_rate;
404		if vtxo.amount() < estimate_exit_cost(&[vtxo.vtxo.clone()], fr) {
405			warn!("VTXO {} is uneconomical to exit, should be refreshed on \
406				next opportunity", vtxo.id(),
407			);
408			return Ok(true);
409		}
410
411		if vtxo.amount() < P2TR_DUST {
412			warn!("VTXO {} is dust, should be refreshed on next opportunity", vtxo.id());
413			return Ok(true);
414		}
415
416		Ok(false)
417	}
418}
419
420#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
421#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
422impl FilterVtxos for RefreshStrategy<'_> {
423	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
424		match self.inner {
425			InnerRefreshStrategy::MustRefresh => self.check_must_refresh(vtxo),
426			InnerRefreshStrategy::ShouldRefreshInclusive =>
427				Ok(self.check_must_refresh(vtxo)? || self.check_should_refresh(vtxo)?),
428			InnerRefreshStrategy::ShouldRefreshExclusive =>
429				Ok(!self.check_must_refresh(vtxo)? && self.check_should_refresh(vtxo)?),
430			InnerRefreshStrategy::ShouldRefreshIfMustRefresh =>
431				bail!("FilterVtxos::matches called on RefreshStrategy::should_refresh_if_must"),
432		}
433	}
434
435	async fn filter_vtxos<V: Borrow<WalletVtxo> + Send>(
436		&self,
437		vtxos: &mut Vec<V>,
438	) -> anyhow::Result<()> {
439		match self.inner {
440			InnerRefreshStrategy::ShouldRefreshIfMustRefresh => {
441				let mut must_refresh = false;
442				for i in (0..vtxos.len()).rev() {
443					let keep = {
444						let vtxo = vtxos[i].borrow();
445						if self.check_must_refresh(vtxo)? {
446							must_refresh = true;
447							true
448						} else {
449							self.check_should_refresh(vtxo)?
450						}
451					};
452					if !keep {
453						vtxos.swap_remove(i);
454					}
455				}
456				// We can safely clear the container since we should only keep the should-refresh
457				// vtxos if we found at least one must-refresh vtxo.
458				if !must_refresh {
459					vtxos.clear();
460				}
461			},
462			_ => {
463				for i in (0..vtxos.len()).rev() {
464					let vtxo = vtxos[i].borrow();
465					if !self.matches(vtxo).await? {
466						vtxos.swap_remove(i);
467					}
468				}
469			},
470		}
471		Ok(())
472	}
473}
474
475#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
476#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
477impl FilterVtxos for VtxoStateKind {
478	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
479	    Ok(vtxo.state.kind() == *self)
480	}
481}