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}