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