Skip to main content

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