use bitcoin::Txid;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::client::BitcoinClient;
use crate::error::{BitcoinError, Result};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Utxo {
pub txid: Txid,
pub vout: u32,
pub address: String,
pub amount_sats: u64,
pub confirmations: u32,
pub spendable: bool,
pub safe: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SelectionStrategy {
LargestFirst,
SmallestFirst,
BranchAndBound,
FirstFit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationConfig {
pub min_utxo_count: usize,
pub max_fee_rate: f64,
pub target_utxo_count: usize,
pub min_utxo_value_sats: u64,
pub consolidation_address: Option<String>,
}
impl Default for ConsolidationConfig {
fn default() -> Self {
Self {
min_utxo_count: 50,
max_fee_rate: 5.0, target_utxo_count: 10,
min_utxo_value_sats: 10_000, consolidation_address: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UtxoSelection {
pub selected: Vec<Utxo>,
pub total_input_sats: u64,
pub estimated_fee_sats: u64,
pub change_sats: u64,
}
pub struct UtxoManager {
client: Arc<BitcoinClient>,
config: ConsolidationConfig,
}
impl UtxoManager {
pub fn new(client: Arc<BitcoinClient>, config: ConsolidationConfig) -> Self {
Self { client, config }
}
pub fn list_utxos(&self) -> Result<Vec<Utxo>> {
let unspent = self.client.list_unspent(None, None, None)?;
Ok(unspent
.into_iter()
.map(|u| Utxo {
txid: u.txid,
vout: u.vout,
address: u
.address
.map(|a| a.assume_checked().to_string())
.unwrap_or_default(),
amount_sats: u.amount.to_sat(),
confirmations: u.confirmations,
spendable: u.spendable,
safe: u.safe,
})
.collect())
}
pub fn select_utxos(
&self,
target_amount_sats: u64,
fee_rate: f64,
strategy: SelectionStrategy,
) -> Result<UtxoSelection> {
let mut utxos = self.list_utxos()?;
utxos.retain(|u| u.spendable && u.safe && u.confirmations > 0);
if utxos.is_empty() {
return Err(BitcoinError::InsufficientFunds(
"No spendable UTXOs available".to_string(),
));
}
match strategy {
SelectionStrategy::LargestFirst => {
utxos.sort_by(|a, b| b.amount_sats.cmp(&a.amount_sats));
}
SelectionStrategy::SmallestFirst => {
utxos.sort_by(|a, b| a.amount_sats.cmp(&b.amount_sats));
}
SelectionStrategy::FirstFit => {
}
SelectionStrategy::BranchAndBound => {
utxos.sort_by(|a, b| b.amount_sats.cmp(&a.amount_sats));
}
}
let mut selected = Vec::new();
let mut total_input_sats = 0u64;
for utxo in utxos {
selected.push(utxo.clone());
total_input_sats += utxo.amount_sats;
let estimated_fee = self.estimate_tx_fee(selected.len(), 2, fee_rate);
if total_input_sats >= target_amount_sats + estimated_fee {
let change_sats =
total_input_sats.saturating_sub(target_amount_sats + estimated_fee);
return Ok(UtxoSelection {
selected,
total_input_sats,
estimated_fee_sats: estimated_fee,
change_sats,
});
}
}
Err(BitcoinError::InsufficientFunds(format!(
"Insufficient funds: need {} sats, have {} sats",
target_amount_sats, total_input_sats
)))
}
pub fn should_consolidate(&self) -> Result<bool> {
let utxos = self.list_utxos()?;
let consolidatable: Vec<_> = utxos
.into_iter()
.filter(|u| {
u.spendable
&& u.safe
&& u.confirmations > 0
&& u.amount_sats >= self.config.min_utxo_value_sats
})
.collect();
Ok(consolidatable.len() >= self.config.min_utxo_count)
}
pub fn get_consolidation_plan(&self) -> Result<Option<ConsolidationPlan>> {
if !self.should_consolidate()? {
return Ok(None);
}
let current_fee_rate = self
.client
.estimate_smart_fee(6)?
.unwrap_or(self.config.max_fee_rate);
if current_fee_rate > self.config.max_fee_rate {
return Ok(None); }
let utxos = self.list_utxos()?;
let mut consolidatable: Vec<_> = utxos
.into_iter()
.filter(|u| {
u.spendable
&& u.safe
&& u.confirmations > 0
&& u.amount_sats >= self.config.min_utxo_value_sats
})
.collect();
consolidatable.sort_by(|a, b| a.amount_sats.cmp(&b.amount_sats));
if consolidatable.len() < self.config.min_utxo_count {
return Ok(None);
}
let total_value: u64 = consolidatable.iter().map(|u| u.amount_sats).sum();
let estimated_fee = self.estimate_tx_fee(consolidatable.len(), 1, current_fee_rate);
if total_value <= estimated_fee {
return Ok(None); }
let output_value = total_value - estimated_fee;
Ok(Some(ConsolidationPlan {
utxos: consolidatable,
estimated_fee_sats: estimated_fee,
output_value_sats: output_value,
recommended_fee_rate: current_fee_rate,
}))
}
fn estimate_tx_fee(&self, num_inputs: usize, num_outputs: usize, fee_rate: f64) -> u64 {
let input_size = num_inputs as f64 * 68.0; let output_size = num_outputs as f64 * 31.0;
let overhead = 10.5;
let total_vsize = (input_size + output_size + overhead).ceil();
(total_vsize * fee_rate).ceil() as u64
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationPlan {
pub utxos: Vec<Utxo>,
pub estimated_fee_sats: u64,
pub output_value_sats: u64,
pub recommended_fee_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_consolidation_config_defaults() {
let config = ConsolidationConfig::default();
assert_eq!(config.min_utxo_count, 50);
assert_eq!(config.max_fee_rate, 5.0);
assert_eq!(config.target_utxo_count, 10);
assert_eq!(config.min_utxo_value_sats, 10_000);
}
#[test]
fn test_selection_strategy() {
assert_eq!(
SelectionStrategy::LargestFirst,
SelectionStrategy::LargestFirst
);
assert_ne!(
SelectionStrategy::LargestFirst,
SelectionStrategy::SmallestFirst
);
}
#[test]
fn test_utxo_selection_result() {
let selection = UtxoSelection {
selected: vec![],
total_input_sats: 100_000,
estimated_fee_sats: 1_000,
change_sats: 9_000,
};
assert_eq!(selection.total_input_sats, 100_000);
assert_eq!(selection.estimated_fee_sats, 1_000);
assert_eq!(selection.change_sats, 9_000);
}
}