Skip to main content

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