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