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