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, 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>`.
78#[async_trait]
79pub trait FilterVtxos: Send + Sync {
80	/// Check whether the VTXO mathes this filter
81	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool>;
82
83	/// Eliminate from the vector all non-matching VTXOs
84	async fn filter_vtxos<V: Borrow<WalletVtxo> + Send>(&self, vtxos: &mut Vec<V>) -> anyhow::Result<()> {
85		for i in (0..vtxos.len()).rev() {
86			if !self.matches(vtxos[i].borrow()).await? {
87				vtxos.swap_remove(i);
88			}
89		}
90		Ok(())
91	}
92}
93
94#[async_trait]
95impl<F> FilterVtxos for F
96where
97	F: Fn(&WalletVtxo) -> anyhow::Result<bool> + Send + Sync,
98{
99	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
100	    self(vtxo)
101	}
102}
103
104/// Filter vtxos based on criteria.
105///
106/// Builder pattern is used.
107///
108/// Matching semantics:
109/// - Explicit `include` and `exclude` lists have the highest priority.
110/// - Remaining criteria (expiry, counterparty risk) are combined with OR: if any matches, the VTXO
111///   is kept.
112pub struct VtxoFilter<'a> {
113	/// Include vtxos that expire before the given height.
114	pub expires_before: Option<BlockHeight>,
115	/// If true, include vtxos that have counterparty risk.
116	pub counterparty: bool,
117	/// Exclude certain vtxos.
118	pub exclude: HashSet<VtxoId>,
119	/// Force include certain vtxos.
120	pub include: HashSet<VtxoId>,
121
122	wallet: &'a Wallet,
123}
124
125impl<'a> VtxoFilter<'a> {
126	/// Create a new [VtxoFilter] bound to a wallet context.
127	///
128	/// The wallet is used to evaluate properties such as counterparty risk.
129	/// By default, the filter matches nothing until criteria are added.
130	///
131	/// Examples
132	/// ```
133	/// # async fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
134	/// use bark::vtxo::{VtxoFilter, FilterVtxos};
135	/// use bitcoin_ext::BlockHeight;
136	///
137	/// let tip: BlockHeight = 1_000;
138	/// let filter = VtxoFilter::new(wallet)
139	///     .expires_before(tip + 144) // expiring within ~1 day
140	///     .counterparty();           // or with counterparty risk
141	/// let filtered = wallet.spendable_vtxos_with(&filter).await?;
142	/// # Ok(filtered) }
143	/// ```
144	pub fn new(wallet: &'a Wallet) -> VtxoFilter<'a> {
145		VtxoFilter {
146			expires_before: None,
147			counterparty: false,
148			exclude: HashSet::new(),
149			include: HashSet::new(),
150			wallet,
151		}
152	}
153
154	/// Include vtxos that expire before the given height.
155	///
156	/// Examples
157	/// ```
158	/// # async fn demo(wallet: &bark::Wallet) -> anyhow::Result<Vec<bark::WalletVtxo>> {
159	/// use bark::vtxo::{VtxoFilter, FilterVtxos};
160	/// use bitcoin_ext::BlockHeight;
161	///
162	/// let h: BlockHeight = 10_000;
163	/// let filter = VtxoFilter::new(wallet)
164	///     .expires_before(h);
165	/// let filtered = wallet.spendable_vtxos_with(&filter).await?;
166	/// # Ok(filtered) }
167	/// ```
168	pub fn expires_before(mut self, expires_before: BlockHeight) -> Self {
169		self.expires_before = Some(expires_before);
170		self
171	}
172
173	/// Include vtxos that have counterparty risk.
174	///
175	/// An arkoor vtxo is considered to have some counterparty risk if it's (directly or not) based
176	/// on round VTXOs that aren't owned by the wallet.
177	pub fn counterparty(mut self) -> Self {
178		self.counterparty = true;
179		self
180	}
181
182	/// Exclude the given vtxo.
183	pub fn exclude(mut self, exclude: VtxoId) -> Self {
184		self.exclude.insert(exclude);
185		self
186	}
187
188	/// Exclude the given vtxos.
189	pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
190		self.exclude.extend(exclude);
191		self
192	}
193
194	/// Include the given vtxo.
195	pub fn include(mut self, include: VtxoId) -> Self {
196		self.include.insert(include);
197		self
198	}
199
200	/// Include the given vtxos.
201	pub fn include_many(mut self, include: impl IntoIterator<Item = VtxoId>) -> Self {
202		self.include.extend(include);
203		self
204	}
205}
206
207#[async_trait]
208impl FilterVtxos for VtxoFilter<'_> {
209	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
210		let id = vtxo.id();
211
212		// First do explicit includes and excludes.
213		if self.include.contains(&id) {
214			return Ok(true);
215		}
216		if self.exclude.contains(&id) {
217			return Ok(false);
218		}
219
220		if let Some(height) = self.expires_before {
221			if (vtxo.expiry_height()) < height {
222				return Ok(true);
223			}
224		}
225
226		if self.counterparty {
227			if self.wallet.has_counterparty_risk(vtxo).await.context("db error")? {
228				return Ok(true);
229			}
230		}
231
232		Ok(false)
233	}
234}
235
236enum InnerRefreshStrategy {
237	MustRefresh,
238	ShouldRefresh,
239}
240
241/// Strategy to select VTXOs that need proactive refreshing.
242///
243/// Refreshing is recommended when a VTXO is nearing its expiry, has reached a soft/hard
244/// out-of-round depth threshold, or is uneconomical to exit onchain at the current fee rate.
245///
246/// Variants:
247/// - [RefreshStrategy::must_refresh]: strict selection intended for mandatory refresh actions
248///   (e.g., at or beyond maximum depth or near-hard expiry threshold).
249/// - [RefreshStrategy::should_refresh]: softer selection for opportunistic refreshes
250///   (e.g., approaching soft thresholds or uneconomical unilateral exit).
251///
252/// This type implements [FilterVtxos], so it can be passed directly to
253/// [`Wallet::vtxos_with`].
254pub struct RefreshStrategy<'a> {
255	inner: InnerRefreshStrategy,
256	tip: BlockHeight,
257	wallet: &'a Wallet,
258	fee_rate: FeeRate,
259}
260
261impl<'a> RefreshStrategy<'a> {
262	/// Builds a strategy that matches VTXOs that must be refreshed immediately.
263	///
264	/// A [WalletVtxo] is selected when at least one of the following strict conditions holds:
265	/// - It reached or exceeded the maximum allowed out-of-round (OOR) depth (if configured by the
266	///   Ark server info in the wallet).
267	/// - It is within `vtxo_refresh_expiry_threshold` blocks of expiry at `tip`.
268	///
269	/// Parameters:
270	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
271	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
272	/// - `fee_rate`: [FeeRate] to use for any economic checks (kept for parity with the
273	///   "should" strategy; not all checks require it in the strict mode).
274	///
275	/// Returns:
276	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
277	///   [FilterVtxos::filter_vtxos] directly.
278	///
279	/// Examples
280	/// ```
281	/// # async fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
282	/// use bark::vtxo::{FilterVtxos, RefreshStrategy};
283	/// use bitcoin::FeeRate;
284	/// use bitcoin_ext::BlockHeight;
285	///
286	/// let tip: BlockHeight = 200_000;
287	/// let fr = FeeRate::from_sat_per_vb(5).unwrap();
288	/// let must = RefreshStrategy::must_refresh(wallet, tip, fr);
289	/// must.filter_vtxos(&mut vtxos).await?;
290	/// # Ok(vtxos) }
291	/// ```
292	pub fn must_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
293		Self {
294			inner: InnerRefreshStrategy::MustRefresh,
295			tip,
296			wallet,
297			fee_rate,
298		}
299	}
300
301	/// Builds a strategy that matches VTXOs that should be refreshed soon (opportunistic).
302	///
303	/// A [WalletVtxo] is selected when at least one of the following softer conditions holds:
304	/// - It is at or beyond a soft OOR depth threshold (typically one less than the maximum, if
305	///   configured by the Ark server info in the wallet).
306	/// - It is within a softer expiry window (e.g., `vtxo_refresh_expiry_threshold + 28` blocks)
307	///   relative to `tip`.
308	/// - It is uneconomical to unilaterally exit at the provided `fee_rate` (e.g., its amount is
309	///   lower than the estimated exit cost).
310	///
311	/// Parameters:
312	/// - `wallet`: [Wallet] context used to read configuration and Ark parameters.
313	/// - `tip`: Current chain tip height used to evaluate expiry proximity.
314	/// - `fee_rate`: [FeeRate] used for economic feasibility checks.
315	///
316	/// Returns:
317	/// - A [RefreshStrategy] implementing [FilterVtxos]. Pass it to [Wallet::vtxos_with] or call
318	///   [FilterVtxos::filter_vtxos] directly.
319	///
320	/// Examples
321	/// ```
322	/// # async fn demo(wallet: &bark::Wallet, mut vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<Vec<bark::WalletVtxo>> {
323	/// use bark::vtxo::{FilterVtxos, RefreshStrategy};
324	/// use bitcoin::FeeRate;
325	/// use bitcoin_ext::BlockHeight;
326	///
327	/// let tip: BlockHeight = 200_000;
328	/// let fr = FeeRate::from_sat_per_vb(8).unwrap();
329	/// let should = RefreshStrategy::should_refresh(wallet, tip, fr);
330	/// should.filter_vtxos(&mut vtxos).await?;
331	/// # Ok(vtxos) }
332	/// ```
333	pub fn should_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
334		Self {
335			inner: InnerRefreshStrategy::ShouldRefresh,
336			tip,
337			wallet,
338			fee_rate,
339		}
340	}
341}
342
343#[async_trait]
344impl FilterVtxos for RefreshStrategy<'_> {
345	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
346		match self.inner {
347			InnerRefreshStrategy::MustRefresh => {
348				let threshold = self.wallet.config().vtxo_refresh_expiry_threshold;
349				if self.tip > vtxo.spec().expiry_height.saturating_sub(threshold) {
350					warn!("VTXO {} is about to expire soon, must be refreshed", vtxo.id());
351					return Ok(true);
352				}
353
354				Ok(false)
355			},
356			InnerRefreshStrategy::ShouldRefresh => {
357				let soft_threshold = self.wallet.config().vtxo_refresh_expiry_threshold + 28;
358				if self.tip > vtxo.spec().expiry_height.saturating_sub(soft_threshold) {
359					warn!("VTXO {} is about to expire, should be refreshed on next opportunity",
360						vtxo.id(),
361					);
362					return Ok(true);
363				}
364
365				let fr = self.fee_rate;
366				if vtxo.amount() < estimate_exit_cost(&[vtxo.vtxo.clone()], fr) {
367					warn!("VTXO {} is uneconomical to exit, should be refreshed on \
368						next opportunity", vtxo.id(),
369					);
370					return Ok(true);
371				}
372
373				Ok(false)
374			}
375		}
376	}
377}
378
379#[async_trait]
380impl FilterVtxos for VtxoStateKind {
381	async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
382	    Ok(vtxo.state.kind() == *self)
383	}
384}