Skip to main content

bark/vtxo/selection/
inputs.rs

1//! Selection of wallet VTXOs to use as inputs to a payment.
2
3use std::collections::HashSet;
4use std::{cmp, fmt, mem, slice};
5
6use anyhow::Context;
7use bitcoin::Amount;
8use log::trace;
9
10use ark::VtxoId;
11use ark::fees::VtxoFeeInfo;
12use bitcoin_ext::BlockHeight;
13
14use crate::WalletVtxo;
15
16/// A simple way to identify the purpose of an amount is to represent a fee.
17pub type FeeAmount = Amount;
18
19/// Maximum number of iterations in the fee-scheme variant of [InputSelection::select]
20/// before we give up on the fee converging.
21const MAX_FEE_ITERATIONS: usize = 100;
22
23/// Parameters controlling which VTXOs may be selected as inputs for a payment.
24///
25/// Builder pattern is used. By default there is no limit on the number of inputs and no
26/// VTXOs are excluded.
27///
28/// ```
29/// use bitcoin::Amount;
30/// use bark::vtxo::selection::InputSelection;
31///
32/// # fn demo(poisoned: ark::VtxoId, vtxos: Vec<bark::WalletVtxo>) -> anyhow::Result<()> {
33/// let selected = InputSelection::new()
34///     .max_inputs(10)       // use at most 10 VTXOs
35///     .exclude(poisoned)    // never select this one
36///     .select(vtxos, Amount::from_sat(100_000))?;
37/// # Ok(()) }
38/// ```
39///
40/// Adding a fee scheme with [InputSelection::fee_scheme] makes [InputSelection::select]
41/// also cover the fee charged on top of the amount and return it alongside the VTXOs.
42#[derive(Debug, Clone, Default)]
43pub struct InputSelection<F = ()> {
44	/// Cap on the total number of inputs that may be selected.
45	pub max_inputs: Option<usize>,
46	/// Never select these vtxos.
47	pub exclude: HashSet<VtxoId>,
48
49	fee_scheme: F,
50}
51
52impl<F> InputSelection<F> {
53	/// Cap the total number of inputs that may be selected.
54	pub fn max_inputs(mut self, max_inputs: usize) -> Self {
55		self.max_inputs = Some(max_inputs);
56		self
57	}
58
59	/// Exclude the given vtxo from selection.
60	pub fn exclude(mut self, exclude: VtxoId) -> Self {
61		self.exclude.insert(exclude);
62		self
63	}
64
65	/// Exclude the given vtxos from selection.
66	pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
67		self.exclude.extend(exclude);
68		self
69	}
70}
71
72impl InputSelection {
73	/// Create a new [InputSelection] with no input limit, no exclusions, and no fee scheme.
74	pub fn new() -> InputSelection {
75		Default::default()
76	}
77
78	/// Make the selection also cover the fee charged on top of the amount, where the fee
79	/// itself depends on the selected VTXOs. E.g., a lightning payment, a send-onchain
80	/// payment.
81	///
82	/// `calc_fee` receives the target amount and the [VtxoFeeInfo] of each selected VTXO
83	/// (derived from the `tip` block height). [InputSelection::select] then returns the
84	/// calculated fee alongside the selected VTXOs.
85	pub fn fee_scheme<F>(self, tip: BlockHeight, calc_fee: F) -> InputSelection<FeeScheme<F>>
86	where
87		F: for<'a> Fn(Amount, SelectedFeeInfos<'a>) -> anyhow::Result<FeeAmount>,
88	{
89		InputSelection {
90			max_inputs: self.max_inputs,
91			exclude: self.exclude,
92			fee_scheme: FeeScheme { tip, calc_fee },
93		}
94	}
95
96	/// Select VTXOs from the given candidates to cover the provided amount.
97	///
98	/// Candidates are selected soonest-expiring-first; see [InputScanner::cover_amount] for
99	/// how the input limit affects this. The selection is returned
100	/// soonest-expiring-first.
101	///
102	/// Returns an error if the amount cannot be reached.
103	pub fn select(
104		&self,
105		vtxos: Vec<WalletVtxo>,
106		amount: Amount,
107	) -> anyhow::Result<Vec<WalletVtxo>> {
108		let mut scanner = InputScanner::new(self, vtxos);
109		scanner.cover_amount(amount)?;
110		Ok(scanner.into_selected())
111	}
112}
113
114impl<F> InputSelection<FeeScheme<F>>
115where
116	F: for<'a> Fn(Amount, SelectedFeeInfos<'a>) -> anyhow::Result<FeeAmount>,
117{
118	/// Select VTXOs from the given candidates to cover the provided amount plus the fee
119	/// computed by the configured [InputSelection::fee_scheme].
120	///
121	/// Candidates are selected soonest-expiring-first; see [InputScanner::cover_amount]
122	/// for how the input limit affects this. The selection is returned
123	/// soonest-expiring-first.
124	///
125	/// Returns a collection of VTXOs capable of covering the desired amount as well as the
126	/// calculated fee.
127	pub fn select(
128		&self,
129		vtxos: Vec<WalletVtxo>,
130		amount: Amount,
131	) -> anyhow::Result<(Vec<WalletVtxo>, FeeAmount)> {
132		let mut scanner = InputScanner::new(self, vtxos);
133
134		// We need to loop to find suitable inputs due to the VTXOs having a direct impact
135		// on how much we must pay in fees. The required amount never shrinks between
136		// iterations, so the scan can be resumed instead of restarted (see
137		// [InputScanner::cover_amount]).
138		let mut fee = Amount::ZERO;
139		for _ in 0..MAX_FEE_ITERATIONS {
140			let required = amount.checked_add(fee)
141				.context("Amount + fee overflow")?;
142
143			scanner.cover_amount(required)
144				.context("Could not find enough suitable VTXOs to cover payment + fees")?;
145			fee = (self.fee_scheme.calc_fee)(
146				amount, scanner.selected_fee_infos(self.fee_scheme.tip),
147			)?;
148
149			let new_required = amount.checked_add(fee)
150				.context("Amount + fee overflow")?;
151			if new_required <= scanner.total() {
152				trace!("Selected vtxos to cover amount + fee: amount = {}, fee = {}, total inputs = {}",
153					amount, fee, scanner.total(),
154				);
155				return Ok((scanner.into_selected(), fee));
156			}
157			trace!("VTXO sum of {} did not exceed amount {} and fee {}, iterating again",
158				scanner.total(), amount, fee,
159			);
160		}
161		bail!("Fee calculation did not converge after maximum iterations")
162	}
163}
164
165/// The fee configuration of an [InputSelection], set via [InputSelection::fee_scheme].
166#[derive(Clone)]
167pub struct FeeScheme<F> {
168	tip: BlockHeight,
169	calc_fee: F,
170}
171
172impl<F> fmt::Debug for FeeScheme<F> {
173	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174		f.debug_struct("FeeScheme")
175			.field("tip", &self.tip)
176			.finish_non_exhaustive()
177	}
178}
179
180/// A resumable scan over selection candidates.
181///
182/// Construction applies the [InputSelection] exclusions and sorts the candidates
183/// soonest-expiring-first. [InputScanner::cover_amount] then advances the scan; it may be
184/// called repeatedly as long as the requested amounts never shrink, which lets the
185/// fee-convergence loop grow an earlier selection instead of restarting it.
186struct InputScanner {
187	/// Eligible candidates, sorted soonest-expiring-first.
188	candidates: Vec<WalletVtxo>,
189	/// Index into `candidates` of the next candidate to consider.
190	cursor: usize,
191	/// Indices into `candidates` of the currently selected VTXOs.
192	selected: Vec<usize>,
193	/// Sum of the selected VTXO amounts.
194	total: Amount,
195	/// Cap on the size of `selected`.
196	max_inputs: usize,
197}
198
199impl InputScanner {
200	/// Takes ownership of the given candidates vector and sorts it soonest-expiring-first, ready
201	/// for [InputScanner::cover_amount].
202	fn new<F>(selection: &InputSelection<F>, mut candidates: Vec<WalletVtxo>) -> InputScanner {
203		candidates.retain(|v| !selection.exclude.contains(&v.id()));
204		candidates.sort_by_key(|v| v.expiry_height());
205
206		let max_inputs = selection.max_inputs.unwrap_or(usize::MAX);
207		let capacity = max_inputs.min(candidates.len());
208		InputScanner {
209			candidates,
210			cursor: 0,
211			selected: Vec::with_capacity(capacity),
212			total: Amount::ZERO,
213			max_inputs,
214		}
215	}
216
217	/// Advances the scan until the selection covers `amount`, at which point the
218	/// selection totals at least `amount` using at most `max_inputs` VTXOs.
219	///
220	/// Candidates are accepted soonest-expiring-first. Once the input limit is reached, a
221	/// candidate can only enter the selection by replacing the smallest selected VTXO
222	/// (the latest-expiring one on equal amounts, so that soon-expiring VTXOs stay
223	/// selected). A replaced or skipped candidate can never become useful again — the
224	/// selection always holds the largest VTXOs seen so far — so later calls with larger
225	/// amounts can safely resume where the scan left off.
226	///
227	/// Returns an error if the candidates are exhausted before the amount is covered.
228	fn cover_amount(&mut self, amount: Amount) -> anyhow::Result<()> {
229		while self.total < amount {
230			let Some(vtxo) = self.candidates.get(self.cursor) else {
231				if self.candidates.len() > self.max_inputs {
232					bail!("Insufficient money available. Needed {} but the best {} inputs \
233						only amount to {}", amount, self.max_inputs, self.total,
234					);
235				}
236				bail!("Insufficient money available. Needed {} but {} is available",
237					amount, self.total,
238				);
239			};
240
241			// We can safely add the input since we have room.
242			if self.selected.len() < self.max_inputs {
243				self.total = self.total.checked_add(vtxo.amount()).context("total overflow")?;
244				self.selected.push(self.cursor);
245			} else {
246				// We should only accept the input if it's beneficial to do so
247				if let Some(pos) = self.position_to_replace(vtxo.amount()) {
248					let evicted = mem::replace(&mut self.selected[pos], self.cursor);
249					self.total = self.total.checked_sub(self.candidates[evicted].amount())
250						.context("total deduction overflow")?;
251					self.total = self.total.checked_add(vtxo.amount())
252						.context("total addition overflow")?;
253				}
254			}
255			self.cursor += 1;
256		}
257		Ok(())
258	}
259
260	/// Returns the position in `selected` of the VTXO that a new candidate of the given
261	/// amount should replace: the smallest selected VTXO, preferring the latest-expiring
262	/// one on equal amounts. Returns `None` if the candidate doesn't improve the
263	/// selection, i.e. it is no larger than the current minimum.
264	fn position_to_replace(&self, candidate_amount: Amount) -> Option<usize> {
265		// Candidates are sorted by expiry, so on equal amounts the highest index
266		// expires last and is the preferred one to replace.
267		let (pos, &idx) = self.selected.iter().enumerate()
268			.min_by_key(|&(_, &idx)| (self.candidates[idx].amount(), cmp::Reverse(idx)))?;
269
270		if candidate_amount > self.candidates[idx].amount() {
271			Some(pos)
272		} else {
273			None
274		}
275	}
276
277	fn total(&self) -> Amount {
278		self.total
279	}
280
281	/// The [VtxoFeeInfo] of each currently selected VTXO, derived from the given chain tip.
282	fn selected_fee_infos(&self, tip: BlockHeight) -> SelectedFeeInfos<'_> {
283		SelectedFeeInfos {
284			selected: self.selected.iter(),
285			candidates: &self.candidates,
286			tip,
287		}
288	}
289
290	/// Consumes the scan, returning the selected VTXOs soonest-expiring-first.
291	fn into_selected(self) -> Vec<WalletVtxo> {
292		let InputScanner { candidates, mut selected, .. } = self;
293		selected.sort();
294		let mut selected = selected.into_iter().peekable();
295		candidates.into_iter().enumerate()
296			.filter(|(idx, _)| selected.next_if_eq(idx).is_some())
297			.map(|(_, vtxo)| vtxo)
298			.collect()
299	}
300}
301
302/// Iterator yielding the [VtxoFeeInfo] of each selected VTXO, computed on the fly so fee
303/// calculation doesn't require an allocation per fee-convergence iteration.
304///
305/// Passed to the `calc_fee` callback of [InputSelection::fee_scheme].
306pub struct SelectedFeeInfos<'a> {
307	selected: slice::Iter<'a, usize>,
308	candidates: &'a [WalletVtxo],
309	tip: BlockHeight,
310}
311
312impl Iterator for SelectedFeeInfos<'_> {
313	type Item = VtxoFeeInfo;
314
315	fn next(&mut self) -> Option<VtxoFeeInfo> {
316		let vtxo = &self.candidates[*self.selected.next()?];
317		Some(VtxoFeeInfo::from_vtxo_and_tip(vtxo, self.tip))
318	}
319
320	fn size_hint(&self) -> (usize, Option<usize>) {
321		self.selected.size_hint()
322	}
323}
324
325#[cfg(test)]
326mod test {
327	use super::*;
328
329	use bitcoin::Weight;
330
331	use ark::test_util::dummy::DummyTestVtxoSpec;
332
333	use crate::vtxo::state::VtxoState;
334
335	/// Builds a spendable [WalletVtxo] with the given amount and expiry height.
336	///
337	/// Use distinct amount/expiry combinations within a test: identical specs produce
338	/// identical VTXO ids.
339	fn dummy_wallet_vtxo(sats: u64, expiry_height: BlockHeight) -> WalletVtxo {
340		let amount = Amount::from_sat(sats);
341		let fee = Amount::from_sat(330);
342		let (_, vtxo) = DummyTestVtxoSpec {
343			amount: amount + fee,
344			fee,
345			expiry_height,
346			..Default::default()
347		}.build();
348		assert_eq!(vtxo.amount(), amount);
349		WalletVtxo {
350			vtxo: vtxo.into_bare(),
351			state: VtxoState::Spendable,
352			exit_depth: 0,
353			exit_tx_weight: Weight::ZERO,
354			registered: false,
355		}
356	}
357
358	fn amounts(vtxos: &[WalletVtxo]) -> Vec<u64> {
359		vtxos.iter().map(|v| v.amount().to_sat()).collect()
360	}
361
362	#[test]
363	fn covers_soonest_expiring_first() {
364		// Deliberately not in expiry order.
365		let vtxos = vec![
366			dummy_wallet_vtxo(30_000, 300),
367			dummy_wallet_vtxo(10_000, 100),
368			dummy_wallet_vtxo(20_000, 200),
369		];
370		let selection = InputSelection::new();
371
372		let selected = selection.select(vtxos.clone(), Amount::from_sat(25_000)).unwrap();
373		assert_eq!(amounts(&selected), [10_000, 20_000]);
374
375		let selected = selection.select(vtxos.clone(), Amount::from_sat(60_000)).unwrap();
376		assert_eq!(amounts(&selected), [10_000, 20_000, 30_000]);
377
378		let err = selection.select(vtxos, Amount::from_sat(60_001)).unwrap_err();
379		assert!(err.to_string().contains("Insufficient money"), "{}", err);
380		assert!(!err.to_string().contains("inputs"), "{}", err);
381	}
382
383	#[test]
384	fn max_inputs_replaces_smallest_selected() {
385		let vtxos = vec![
386			dummy_wallet_vtxo(10_000, 100),
387			dummy_wallet_vtxo(20_000, 200),
388			dummy_wallet_vtxo(30_000, 300),
389		];
390		let selection = InputSelection::new()
391			.max_inputs(2);
392
393		// No replacement needed: the two soonest-expiring VTXOs cover the amount.
394		let selected = selection.select(vtxos.clone(), Amount::from_sat(25_000)).unwrap();
395		assert_eq!(amounts(&selected), [10_000, 20_000]);
396
397		// The soonest-expiring pair doesn't cover the amount, so the smallest selected
398		// VTXO makes way for a bigger one.
399		let selected = selection.select(vtxos.clone(), Amount::from_sat(40_000)).unwrap();
400		assert_eq!(amounts(&selected), [20_000, 30_000]);
401
402		// Not coverable with any two inputs.
403		let err = selection.select(vtxos, Amount::from_sat(50_001)).unwrap_err();
404		assert!(err.to_string().contains("best 2 inputs"), "{}", err);
405	}
406
407	#[test]
408	fn max_inputs_evicts_latest_expiring_on_equal_amounts() {
409		let soonest = dummy_wallet_vtxo(10_000, 100);
410		let soonest_id = soonest.id();
411		let vtxos = vec![
412			soonest,
413			dummy_wallet_vtxo(10_000, 200),
414			dummy_wallet_vtxo(30_000, 300),
415		];
416
417		let selected = InputSelection::new()
418			.max_inputs(2)
419			.select(vtxos, Amount::from_sat(40_000)).unwrap();
420
421		// Of the two equal-amount VTXOs, the soonest-expiring one stays selected.
422		assert_eq!(amounts(&selected), [10_000, 30_000]);
423		assert_eq!(selected[0].id(), soonest_id);
424	}
425
426	#[test]
427	fn exclusions_are_never_selected() {
428		let vtxos = vec![
429			dummy_wallet_vtxo(10_000, 100),
430			dummy_wallet_vtxo(20_000, 200),
431			dummy_wallet_vtxo(30_000, 300),
432		];
433		let excluded = vtxos[1].id();
434
435		let selected = InputSelection::new()
436			.exclude(excluded)
437			.select(vtxos, Amount::from_sat(20_000)).unwrap();
438
439		assert_eq!(amounts(&selected), [10_000, 30_000]);
440		assert!(selected.iter().all(|v| v.id() != excluded));
441	}
442
443	#[test]
444	fn with_fee_resumes_the_scan_as_the_fee_grows() {
445		let vtxos = vec![
446			dummy_wallet_vtxo(10_000, 100),
447			dummy_wallet_vtxo(20_000, 200),
448		];
449
450		// A flat fee: the first iteration selects 10k sats for the amount alone, the
451		// second extends the selection to also cover the fee.
452		let (selected, fee) = InputSelection::new()
453			.fee_scheme(0, |_, _| Ok(Amount::from_sat(500)))
454			.select(vtxos, Amount::from_sat(9_800)).unwrap();
455
456		assert_eq!(amounts(&selected), [10_000, 20_000]);
457		assert_eq!(fee, Amount::from_sat(500));
458	}
459
460	#[test]
461	fn with_fee_respects_max_inputs() {
462		let vtxos = vec![
463			dummy_wallet_vtxo(10_000, 100),
464			dummy_wallet_vtxo(20_000, 200),
465		];
466
467		// Amount plus fee doesn't fit in the soonest-expiring VTXO, and the input limit
468		// forbids adding the second one, so the selection replaces the first.
469		let (selected, fee) = InputSelection::new()
470			.max_inputs(1)
471			.fee_scheme(0, |_, _| Ok(Amount::from_sat(500)))
472			.select(vtxos, Amount::from_sat(9_800)).unwrap();
473
474		assert_eq!(amounts(&selected), [20_000]);
475		assert_eq!(fee, Amount::from_sat(500));
476	}
477}