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