use crate::error::BitcoinError;
use crate::utxo::Utxo;
use rand::RngExt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectionConfig {
pub fee_rate: f64,
pub dust_threshold: u64,
pub max_inputs: usize,
pub minimize_change: bool,
pub exact_match_tolerance: u64,
}
impl Default for SelectionConfig {
fn default() -> Self {
Self {
fee_rate: 1.0,
dust_threshold: 546,
max_inputs: 100,
minimize_change: true,
exact_match_tolerance: 1000, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectionResult {
pub selected_utxos: Vec<Utxo>,
pub total_input: u64,
pub total_output: u64,
pub change: u64,
pub fee: u64,
pub is_exact_match: bool,
pub quality_score: u8,
}
pub struct KnapsackSelector {
config: SelectionConfig,
}
impl KnapsackSelector {
pub fn new(config: SelectionConfig) -> Self {
Self { config }
}
pub fn select(
&self,
utxos: &[Utxo],
target: u64,
fee_rate: f64,
) -> Result<SelectionResult, BitcoinError> {
if utxos.is_empty() {
return Err(BitcoinError::InsufficientFunds(
"No UTXOs available".to_string(),
));
}
let estimated_fee = self.estimate_fee(1, 2, fee_rate);
let total_target = target + estimated_fee;
if let Some(exact) = self.find_exact_match(utxos, total_target) {
return Ok(exact);
}
let selected = self.knapsack_select(utxos, total_target)?;
let num_inputs = selected.len();
let num_outputs = 2; let actual_fee = self.estimate_fee(num_inputs, num_outputs, fee_rate);
let total_input: u64 = selected.iter().map(|u| u.amount_sats).sum();
let total_output = target + actual_fee;
let change = total_input.saturating_sub(total_output);
let (final_change, final_fee) = if change < self.config.dust_threshold {
(0, actual_fee + change)
} else {
(change, actual_fee)
};
let is_exact_match = final_change < self.config.exact_match_tolerance;
let quality_score = self.calculate_quality_score(&selected, total_input, final_change);
Ok(SelectionResult {
selected_utxos: selected,
total_input,
total_output: target + final_fee,
change: final_change,
fee: final_fee,
is_exact_match,
quality_score,
})
}
fn find_exact_match(&self, utxos: &[Utxo], target: u64) -> Option<SelectionResult> {
for utxo in utxos {
if utxo.amount_sats >= target
&& utxo.amount_sats <= target + self.config.exact_match_tolerance
{
return Some(SelectionResult {
selected_utxos: vec![utxo.clone()],
total_input: utxo.amount_sats,
total_output: target,
change: utxo.amount_sats - target,
fee: utxo.amount_sats - target,
is_exact_match: true,
quality_score: 100,
});
}
}
None
}
fn knapsack_select(&self, utxos: &[Utxo], target: u64) -> Result<Vec<Utxo>, BitcoinError> {
let mut sorted_utxos = utxos.to_vec();
sorted_utxos.sort_by_key(|u| std::cmp::Reverse(u.amount_sats));
let mut best_selection = Vec::new();
let mut best_sum = 0u64;
let mut best_waste = u64::MAX;
for i in 0..sorted_utxos.len().min(self.config.max_inputs) {
let mut current_selection = vec![sorted_utxos[i].clone()];
let mut current_sum = sorted_utxos[i].amount_sats;
if current_sum >= target {
let waste = current_sum - target;
if waste < best_waste {
best_selection = current_selection;
best_sum = current_sum;
best_waste = waste;
}
} else {
for utxo in sorted_utxos.iter().skip(i + 1) {
if current_selection.len() >= self.config.max_inputs {
break;
}
current_selection.push(utxo.clone());
current_sum += utxo.amount_sats;
if current_sum >= target {
let waste = current_sum - target;
if waste < best_waste {
best_selection = current_selection.clone();
best_sum = current_sum;
best_waste = waste;
}
break;
}
}
}
}
if best_sum >= target {
Ok(best_selection)
} else {
Err(BitcoinError::InsufficientFunds(format!(
"Cannot reach target {} with available UTXOs (max found: {})",
target, best_sum
)))
}
}
fn estimate_fee(&self, num_inputs: usize, num_outputs: usize, fee_rate: f64) -> u64 {
let vsize = 10 + (num_inputs * 68) + (num_outputs * 31);
(vsize as f64 * fee_rate).ceil() as u64
}
fn calculate_quality_score(&self, selected: &[Utxo], total_input: u64, change: u64) -> u8 {
let mut score = 100u8;
if selected.len() > 3 {
score = score.saturating_sub((selected.len() - 3) as u8 * 5);
}
if change > 0 {
let change_ratio = (change as f64 / total_input as f64 * 100.0) as u8;
score = score.saturating_sub(change_ratio / 4);
}
score
}
}
pub struct SimulatedAnnealingSelector {
config: SelectionConfig,
max_iterations: usize,
initial_temperature: f64,
cooling_rate: f64,
}
impl SimulatedAnnealingSelector {
pub fn new(config: SelectionConfig) -> Self {
Self {
config,
max_iterations: 1000,
initial_temperature: 100.0,
cooling_rate: 0.95,
}
}
pub fn select(
&self,
utxos: &[Utxo],
target: u64,
fee_rate: f64,
) -> Result<SelectionResult, BitcoinError> {
if utxos.is_empty() {
return Err(BitcoinError::InsufficientFunds(
"No UTXOs available".to_string(),
));
}
let mut rng = rand::rng();
let mut current_solution = self.initial_solution(utxos, target)?;
let mut best_solution = current_solution.clone();
let mut temperature = self.initial_temperature;
for _ in 0..self.max_iterations {
let neighbor = self.neighbor_solution(utxos, ¤t_solution, target);
if let Ok(neighbor) = neighbor {
let current_cost = self.cost(¤t_solution, target, fee_rate);
let neighbor_cost = self.cost(&neighbor, target, fee_rate);
if neighbor_cost < current_cost
|| rng.random::<f64>() < ((current_cost - neighbor_cost) / temperature).exp()
{
current_solution = neighbor.clone();
if self.cost(¤t_solution, target, fee_rate)
< self.cost(&best_solution, target, fee_rate)
{
best_solution = current_solution.clone();
}
}
}
temperature *= self.cooling_rate;
}
self.to_selection_result(&best_solution, target, fee_rate)
}
fn initial_solution(&self, utxos: &[Utxo], target: u64) -> Result<Vec<Utxo>, BitcoinError> {
let mut sorted = utxos.to_vec();
sorted.sort_by_key(|u| std::cmp::Reverse(u.amount_sats));
let mut selected = Vec::new();
let mut sum = 0u64;
for utxo in sorted {
selected.push(utxo.clone());
sum += utxo.amount_sats;
if sum >= target {
break;
}
}
if sum >= target {
Ok(selected)
} else {
Err(BitcoinError::InsufficientFunds(
"Cannot reach target with available UTXOs".to_string(),
))
}
}
fn neighbor_solution(
&self,
all_utxos: &[Utxo],
current: &[Utxo],
target: u64,
) -> Result<Vec<Utxo>, BitcoinError> {
let mut rng = rand::rng();
let mut neighbor = current.to_vec();
if !neighbor.is_empty() && rng.random::<bool>() {
let idx = rng.random_range(0..neighbor.len());
neighbor.remove(idx);
} else {
let available: Vec<_> = all_utxos
.iter()
.filter(|u| !current.iter().any(|c| c.txid == u.txid && c.vout == u.vout))
.collect();
if !available.is_empty() {
let idx = rng.random_range(0..available.len());
neighbor.push(available[idx].clone());
}
}
let sum: u64 = neighbor.iter().map(|u| u.amount_sats).sum();
if sum >= target {
Ok(neighbor)
} else {
Err(BitcoinError::InsufficientFunds(
"Invalid neighbor".to_string(),
))
}
}
fn cost(&self, solution: &[Utxo], target: u64, fee_rate: f64) -> f64 {
let total: u64 = solution.iter().map(|u| u.amount_sats).sum();
let fee = self.estimate_fee(solution.len(), 2, fee_rate);
let change = total.saturating_sub(target + fee);
change as f64 + (solution.len() as f64 * 1000.0)
}
fn estimate_fee(&self, num_inputs: usize, num_outputs: usize, fee_rate: f64) -> u64 {
let vsize = 10 + (num_inputs * 68) + (num_outputs * 31);
(vsize as f64 * fee_rate).ceil() as u64
}
fn to_selection_result(
&self,
selected: &[Utxo],
target: u64,
fee_rate: f64,
) -> Result<SelectionResult, BitcoinError> {
let total_input: u64 = selected.iter().map(|u| u.amount_sats).sum();
let fee = self.estimate_fee(selected.len(), 2, fee_rate);
let total_output = target + fee;
let change = total_input.saturating_sub(total_output);
let is_exact_match = change < self.config.exact_match_tolerance;
let quality_score = if selected.len() <= 2 && change < 10000 {
95
} else if selected.len() <= 5 {
80
} else {
60
};
Ok(SelectionResult {
selected_utxos: selected.to_vec(),
total_input,
total_output,
change,
fee,
is_exact_match,
quality_score,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::Txid;
use std::str::FromStr;
fn create_test_utxo(amount: u64, index: u32) -> Utxo {
Utxo {
txid: Txid::from_str(&format!("{:064x}", index)).unwrap(),
vout: 0,
address: format!("bc1qtest{}", index),
amount_sats: amount,
confirmations: 6,
spendable: true,
safe: true,
}
}
#[test]
fn test_knapsack_selector_exact_match() {
let utxos = vec![
create_test_utxo(50000, 1),
create_test_utxo(30000, 2),
create_test_utxo(20000, 3),
];
let config = SelectionConfig::default();
let selector = KnapsackSelector::new(config);
let result = selector.select(&utxos, 48000, 1.0).unwrap();
assert!(!result.selected_utxos.is_empty());
assert!(result.total_input >= 48000);
}
#[test]
fn test_knapsack_selector_multiple_utxos() {
let utxos = vec![
create_test_utxo(10000, 1),
create_test_utxo(15000, 2),
create_test_utxo(20000, 3),
create_test_utxo(25000, 4),
];
let config = SelectionConfig::default();
let selector = KnapsackSelector::new(config);
let result = selector.select(&utxos, 40000, 1.0).unwrap();
assert!(!result.selected_utxos.is_empty());
assert!(result.total_input >= 40000);
}
#[test]
fn test_knapsack_insufficient_funds() {
let utxos = vec![create_test_utxo(10000, 1)];
let config = SelectionConfig::default();
let selector = KnapsackSelector::new(config);
let result = selector.select(&utxos, 50000, 1.0);
assert!(result.is_err());
}
#[test]
fn test_selection_config_default() {
let config = SelectionConfig::default();
assert_eq!(config.fee_rate, 1.0);
assert_eq!(config.dust_threshold, 546);
assert_eq!(config.max_inputs, 100);
}
#[test]
fn test_simulated_annealing_selector() {
let utxos = vec![
create_test_utxo(10000, 1),
create_test_utxo(20000, 2),
create_test_utxo(30000, 3),
];
let config = SelectionConfig::default();
let selector = SimulatedAnnealingSelector::new(config);
let result = selector.select(&utxos, 25000, 1.0).unwrap();
assert!(!result.selected_utxos.is_empty());
assert!(result.total_input >= 25000);
}
#[test]
fn test_quality_score_calculation() {
let utxos = vec![create_test_utxo(50000, 1)];
let config = SelectionConfig::default();
let selector = KnapsackSelector::new(config);
let result = selector.select(&utxos, 48000, 1.0).unwrap();
assert!(result.quality_score > 0);
assert!(result.quality_score <= 100);
}
}