use std::collections::HashSet;
use std::{cmp, fmt, mem, slice};
use anyhow::Context;
use bitcoin::Amount;
use log::trace;
use ark::VtxoId;
use ark::fees::VtxoFeeInfo;
use bitcoin_ext::BlockHeight;
use crate::WalletVtxo;
pub type FeeAmount = Amount;
const MAX_FEE_ITERATIONS: usize = 100;
#[derive(Debug, Clone, Default)]
pub struct InputSelection<F = ()> {
pub max_inputs: Option<usize>,
pub exclude: HashSet<VtxoId>,
fee_scheme: F,
}
impl<F> InputSelection<F> {
pub fn max_inputs(mut self, max_inputs: usize) -> Self {
self.max_inputs = Some(max_inputs);
self
}
pub fn exclude(mut self, exclude: VtxoId) -> Self {
self.exclude.insert(exclude);
self
}
pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
self.exclude.extend(exclude);
self
}
}
impl InputSelection {
pub fn new() -> InputSelection {
Default::default()
}
pub fn fee_scheme<F>(self, tip: BlockHeight, calc_fee: F) -> InputSelection<FeeScheme<F>>
where
F: for<'a> Fn(Amount, SelectedFeeInfos<'a>) -> anyhow::Result<FeeAmount>,
{
InputSelection {
max_inputs: self.max_inputs,
exclude: self.exclude,
fee_scheme: FeeScheme { tip, calc_fee },
}
}
pub fn select(
&self,
vtxos: Vec<WalletVtxo>,
amount: Amount,
) -> anyhow::Result<Vec<WalletVtxo>> {
let mut scanner = InputScanner::new(self, vtxos);
scanner.cover_amount(amount)?;
Ok(scanner.into_selected())
}
}
impl<F> InputSelection<FeeScheme<F>>
where
F: for<'a> Fn(Amount, SelectedFeeInfos<'a>) -> anyhow::Result<FeeAmount>,
{
pub fn select(
&self,
vtxos: Vec<WalletVtxo>,
amount: Amount,
) -> anyhow::Result<(Vec<WalletVtxo>, FeeAmount)> {
let mut scanner = InputScanner::new(self, vtxos);
let mut fee = Amount::ZERO;
for _ in 0..MAX_FEE_ITERATIONS {
let required = amount.checked_add(fee)
.context("Amount + fee overflow")?;
scanner.cover_amount(required)
.context("Could not find enough suitable VTXOs to cover payment + fees")?;
fee = (self.fee_scheme.calc_fee)(
amount, scanner.selected_fee_infos(self.fee_scheme.tip),
)?;
let new_required = amount.checked_add(fee)
.context("Amount + fee overflow")?;
if new_required <= scanner.total() {
trace!("Selected vtxos to cover amount + fee: amount = {}, fee = {}, total inputs = {}",
amount, fee, scanner.total(),
);
return Ok((scanner.into_selected(), fee));
}
trace!("VTXO sum of {} did not exceed amount {} and fee {}, iterating again",
scanner.total(), amount, fee,
);
}
bail!("Fee calculation did not converge after maximum iterations")
}
}
#[derive(Clone)]
pub struct FeeScheme<F> {
tip: BlockHeight,
calc_fee: F,
}
impl<F> fmt::Debug for FeeScheme<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FeeScheme")
.field("tip", &self.tip)
.finish_non_exhaustive()
}
}
struct InputScanner {
candidates: Vec<WalletVtxo>,
cursor: usize,
selected: Vec<usize>,
total: Amount,
max_inputs: usize,
}
impl InputScanner {
fn new<F>(selection: &InputSelection<F>, mut candidates: Vec<WalletVtxo>) -> InputScanner {
candidates.retain(|v| !selection.exclude.contains(&v.id()));
candidates.sort_by_key(|v| v.expiry_height());
let max_inputs = selection.max_inputs.unwrap_or(usize::MAX);
let capacity = max_inputs.min(candidates.len());
InputScanner {
candidates,
cursor: 0,
selected: Vec::with_capacity(capacity),
total: Amount::ZERO,
max_inputs,
}
}
fn cover_amount(&mut self, amount: Amount) -> anyhow::Result<()> {
while self.total < amount {
let Some(vtxo) = self.candidates.get(self.cursor) else {
if self.candidates.len() > self.max_inputs {
bail!("Insufficient money available. Needed {} but the best {} inputs \
only amount to {}", amount, self.max_inputs, self.total,
);
}
bail!("Insufficient money available. Needed {} but {} is available",
amount, self.total,
);
};
if self.selected.len() < self.max_inputs {
self.total = self.total.checked_add(vtxo.amount()).context("total overflow")?;
self.selected.push(self.cursor);
} else {
if let Some(pos) = self.position_to_replace(vtxo.amount()) {
let evicted = mem::replace(&mut self.selected[pos], self.cursor);
self.total = self.total.checked_sub(self.candidates[evicted].amount())
.context("total deduction overflow")?;
self.total = self.total.checked_add(vtxo.amount())
.context("total addition overflow")?;
}
}
self.cursor += 1;
}
Ok(())
}
fn position_to_replace(&self, candidate_amount: Amount) -> Option<usize> {
let (pos, &idx) = self.selected.iter().enumerate()
.min_by_key(|&(_, &idx)| (self.candidates[idx].amount(), cmp::Reverse(idx)))?;
if candidate_amount > self.candidates[idx].amount() {
Some(pos)
} else {
None
}
}
fn total(&self) -> Amount {
self.total
}
fn selected_fee_infos(&self, tip: BlockHeight) -> SelectedFeeInfos<'_> {
SelectedFeeInfos {
selected: self.selected.iter(),
candidates: &self.candidates,
tip,
}
}
fn into_selected(self) -> Vec<WalletVtxo> {
let InputScanner { candidates, mut selected, .. } = self;
selected.sort();
let mut selected = selected.into_iter().peekable();
candidates.into_iter().enumerate()
.filter(|(idx, _)| selected.next_if_eq(idx).is_some())
.map(|(_, vtxo)| vtxo)
.collect()
}
}
pub struct SelectedFeeInfos<'a> {
selected: slice::Iter<'a, usize>,
candidates: &'a [WalletVtxo],
tip: BlockHeight,
}
impl Iterator for SelectedFeeInfos<'_> {
type Item = VtxoFeeInfo;
fn next(&mut self) -> Option<VtxoFeeInfo> {
let vtxo = &self.candidates[*self.selected.next()?];
Some(VtxoFeeInfo::from_vtxo_and_tip(vtxo, self.tip))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.selected.size_hint()
}
}
#[cfg(test)]
mod test {
use super::*;
use bitcoin::Weight;
use ark::test_util::dummy::DummyTestVtxoSpec;
use crate::vtxo::state::VtxoState;
fn dummy_wallet_vtxo(sats: u64, expiry_height: BlockHeight) -> WalletVtxo {
let amount = Amount::from_sat(sats);
let fee = Amount::from_sat(330);
let (_, vtxo) = DummyTestVtxoSpec {
amount: amount + fee,
fee,
expiry_height,
..Default::default()
}.build();
assert_eq!(vtxo.amount(), amount);
WalletVtxo {
vtxo: vtxo.into_bare(),
state: VtxoState::Spendable,
exit_depth: 0,
exit_tx_weight: Weight::ZERO,
registered: false,
}
}
fn amounts(vtxos: &[WalletVtxo]) -> Vec<u64> {
vtxos.iter().map(|v| v.amount().to_sat()).collect()
}
#[test]
fn covers_soonest_expiring_first() {
let vtxos = vec![
dummy_wallet_vtxo(30_000, 300),
dummy_wallet_vtxo(10_000, 100),
dummy_wallet_vtxo(20_000, 200),
];
let selection = InputSelection::new();
let selected = selection.select(vtxos.clone(), Amount::from_sat(25_000)).unwrap();
assert_eq!(amounts(&selected), [10_000, 20_000]);
let selected = selection.select(vtxos.clone(), Amount::from_sat(60_000)).unwrap();
assert_eq!(amounts(&selected), [10_000, 20_000, 30_000]);
let err = selection.select(vtxos, Amount::from_sat(60_001)).unwrap_err();
assert!(err.to_string().contains("Insufficient money"), "{}", err);
assert!(!err.to_string().contains("inputs"), "{}", err);
}
#[test]
fn max_inputs_replaces_smallest_selected() {
let vtxos = vec![
dummy_wallet_vtxo(10_000, 100),
dummy_wallet_vtxo(20_000, 200),
dummy_wallet_vtxo(30_000, 300),
];
let selection = InputSelection::new()
.max_inputs(2);
let selected = selection.select(vtxos.clone(), Amount::from_sat(25_000)).unwrap();
assert_eq!(amounts(&selected), [10_000, 20_000]);
let selected = selection.select(vtxos.clone(), Amount::from_sat(40_000)).unwrap();
assert_eq!(amounts(&selected), [20_000, 30_000]);
let err = selection.select(vtxos, Amount::from_sat(50_001)).unwrap_err();
assert!(err.to_string().contains("best 2 inputs"), "{}", err);
}
#[test]
fn max_inputs_evicts_latest_expiring_on_equal_amounts() {
let soonest = dummy_wallet_vtxo(10_000, 100);
let soonest_id = soonest.id();
let vtxos = vec![
soonest,
dummy_wallet_vtxo(10_000, 200),
dummy_wallet_vtxo(30_000, 300),
];
let selected = InputSelection::new()
.max_inputs(2)
.select(vtxos, Amount::from_sat(40_000)).unwrap();
assert_eq!(amounts(&selected), [10_000, 30_000]);
assert_eq!(selected[0].id(), soonest_id);
}
#[test]
fn exclusions_are_never_selected() {
let vtxos = vec![
dummy_wallet_vtxo(10_000, 100),
dummy_wallet_vtxo(20_000, 200),
dummy_wallet_vtxo(30_000, 300),
];
let excluded = vtxos[1].id();
let selected = InputSelection::new()
.exclude(excluded)
.select(vtxos, Amount::from_sat(20_000)).unwrap();
assert_eq!(amounts(&selected), [10_000, 30_000]);
assert!(selected.iter().all(|v| v.id() != excluded));
}
#[test]
fn with_fee_resumes_the_scan_as_the_fee_grows() {
let vtxos = vec![
dummy_wallet_vtxo(10_000, 100),
dummy_wallet_vtxo(20_000, 200),
];
let (selected, fee) = InputSelection::new()
.fee_scheme(0, |_, _| Ok(Amount::from_sat(500)))
.select(vtxos, Amount::from_sat(9_800)).unwrap();
assert_eq!(amounts(&selected), [10_000, 20_000]);
assert_eq!(fee, Amount::from_sat(500));
}
#[test]
fn with_fee_respects_max_inputs() {
let vtxos = vec![
dummy_wallet_vtxo(10_000, 100),
dummy_wallet_vtxo(20_000, 200),
];
let (selected, fee) = InputSelection::new()
.max_inputs(1)
.fee_scheme(0, |_, _| Ok(Amount::from_sat(500)))
.select(vtxos, Amount::from_sat(9_800)).unwrap();
assert_eq!(amounts(&selected), [20_000]);
assert_eq!(fee, Amount::from_sat(500));
}
}