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}