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}