use crate::btc_utils::{calculate_fee_from_rate, estimate_simple_transaction_vsize, is_dust};
use crate::utxo::Utxo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HealthScore(
) and 100 (best)
pub u8,
);
impl HealthScore {
pub fn new(score: u8) -> Self {
Self(score.min(100))
}
pub fn value(&self) -> u8 {
self.0
}
pub fn is_critical(&self) -> bool {
self.0 < 30
}
pub fn is_poor(&self) -> bool {
self.0 < 50
}
pub fn is_good(&self) -> bool {
self.0 >= 70
}
pub fn is_excellent(&self) -> bool {
self.0 >= 90
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UtxoSetHealth {
pub overall_score: HealthScore,
pub total_utxos: usize,
pub dust_utxos: usize,
pub economical_utxos: usize,
pub small_utxos: usize,
pub medium_utxos: usize,
pub large_utxos: usize,
pub average_value: u64,
pub total_value: u64,
pub consolidation_cost_sats: u64,
pub recommendation: HealthRecommendation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthRecommendation {
Healthy,
ConsiderConsolidation,
ConsolidationRecommended,
UrgentConsolidation,
ExcessiveDust,
}
#[allow(dead_code)]
pub struct UtxoOptimizer {
low_fee_threshold: u64,
medium_fee_threshold: u64,
high_fee_threshold: u64,
max_healthy_utxos: usize,
max_acceptable_utxos: usize,
dust_threshold: u64,
small_utxo_threshold: u64,
medium_utxo_threshold: u64,
}
impl UtxoOptimizer {
pub fn new() -> Self {
Self {
low_fee_threshold: 5, medium_fee_threshold: 20, high_fee_threshold: 50, max_healthy_utxos: 20, max_acceptable_utxos: 50, dust_threshold: 546, small_utxo_threshold: 10_000,
medium_utxo_threshold: 100_000,
}
}
pub fn analyze_health(&self, utxos: &[Utxo], current_fee_rate: u64) -> UtxoSetHealth {
let total_utxos = utxos.len();
let mut dust_count = 0;
let mut economical_count = 0;
let mut small_count = 0;
let mut medium_count = 0;
let mut large_count = 0;
let mut total_value = 0u64;
for utxo in utxos {
total_value += utxo.amount_sats;
if is_dust(utxo.amount_sats, current_fee_rate) {
dust_count += 1;
continue;
}
let input_size = 68; let cost_to_spend = calculate_fee_from_rate(input_size, current_fee_rate);
if utxo.amount_sats > cost_to_spend * 2 {
economical_count += 1;
}
if utxo.amount_sats < self.small_utxo_threshold {
small_count += 1;
} else if utxo.amount_sats < self.medium_utxo_threshold {
medium_count += 1;
} else {
large_count += 1;
}
}
let average_value = if total_utxos > 0 {
total_value / total_utxos as u64
} else {
0
};
let consolidation_vsize = estimate_simple_transaction_vsize(
total_utxos as u64,
1,
68, 31, );
let consolidation_cost = calculate_fee_from_rate(consolidation_vsize, current_fee_rate);
let overall_score = self.calculate_health_score(
total_utxos,
dust_count,
economical_count,
total_value,
consolidation_cost,
);
let recommendation =
self.generate_recommendation(&overall_score, total_utxos, dust_count, current_fee_rate);
UtxoSetHealth {
overall_score,
total_utxos,
dust_utxos: dust_count,
economical_utxos: economical_count,
small_utxos: small_count,
medium_utxos: medium_count,
large_utxos: large_count,
average_value,
total_value,
consolidation_cost_sats: consolidation_cost,
recommendation,
}
}
fn calculate_health_score(
&self,
total_utxos: usize,
dust_count: usize,
economical_count: usize,
total_value: u64,
consolidation_cost: u64,
) -> HealthScore {
let mut score = 100u8;
if total_utxos > self.max_acceptable_utxos {
score = score.saturating_sub(30);
} else if total_utxos > self.max_healthy_utxos {
let excess = total_utxos - self.max_healthy_utxos;
let penalty = (excess * 2).min(20) as u8;
score = score.saturating_sub(penalty);
}
if dust_count > 0 {
let dust_ratio = (dust_count * 100) / total_utxos.max(1);
let penalty = (dust_ratio / 2).min(25) as u8;
score = score.saturating_sub(penalty);
}
if total_utxos > 0 {
let economical_ratio = (economical_count * 100) / total_utxos;
if economical_ratio < 50 {
score = score.saturating_sub(15);
} else if economical_ratio < 75 {
score = score.saturating_sub(5);
}
}
if total_value > 0 {
let cost_ratio = (consolidation_cost * 100) / total_value;
if cost_ratio > 10 {
score = score.saturating_sub(20);
} else if cost_ratio > 5 {
score = score.saturating_sub(10);
}
}
HealthScore::new(score)
}
fn generate_recommendation(
&self,
score: &HealthScore,
total_utxos: usize,
dust_count: usize,
current_fee_rate: u64,
) -> HealthRecommendation {
if dust_count > 10 || (total_utxos > 0 && (dust_count * 100) / total_utxos > 20) {
return HealthRecommendation::ExcessiveDust;
}
if score.is_critical() || total_utxos > self.max_acceptable_utxos * 2 {
return HealthRecommendation::UrgentConsolidation;
}
if score.is_poor() || total_utxos > self.max_acceptable_utxos {
return HealthRecommendation::ConsolidationRecommended;
}
if total_utxos > self.max_healthy_utxos && current_fee_rate < self.low_fee_threshold {
return HealthRecommendation::ConsiderConsolidation;
}
HealthRecommendation::Healthy
}
pub fn should_consolidate(&self, health: &UtxoSetHealth, current_fee_rate: u64) -> bool {
match health.recommendation {
HealthRecommendation::UrgentConsolidation => true,
HealthRecommendation::ConsolidationRecommended => {
current_fee_rate < self.medium_fee_threshold
}
HealthRecommendation::ConsiderConsolidation | HealthRecommendation::ExcessiveDust => {
current_fee_rate < self.low_fee_threshold
}
HealthRecommendation::Healthy => false,
}
}
pub fn optimal_target_count(&self, current_count: usize) -> usize {
if current_count <= self.max_healthy_utxos {
current_count
} else if current_count <= self.max_acceptable_utxos {
self.max_healthy_utxos
} else {
(self.max_healthy_utxos / 2).max(5)
}
}
pub fn create_health_trend(&self, history: &[UtxoSetHealth]) -> HealthTrend {
if history.is_empty() {
return HealthTrend {
trend: TrendDirection::Stable,
score_change: 0,
utxo_count_change: 0,
};
}
let latest = &history[history.len() - 1];
let earliest = &history[0];
let score_change =
latest.overall_score.value() as i16 - earliest.overall_score.value() as i16;
let utxo_count_change = latest.total_utxos as i64 - earliest.total_utxos as i64;
let trend = if score_change > 10 {
TrendDirection::Improving
} else if score_change < -10 {
TrendDirection::Degrading
} else {
TrendDirection::Stable
};
HealthTrend {
trend,
score_change,
utxo_count_change,
}
}
}
impl Default for UtxoOptimizer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthTrend {
pub trend: TrendDirection,
pub score_change: i16,
pub utxo_count_change: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendDirection {
Improving,
Stable,
Degrading,
}
pub struct ConsolidationScheduler {
optimizer: UtxoOptimizer,
last_check: Option<u64>,
check_interval_secs: u64,
}
impl ConsolidationScheduler {
pub fn new() -> Self {
Self {
optimizer: UtxoOptimizer::new(),
last_check: None,
check_interval_secs: 3600, }
}
pub fn should_check(&mut self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if let Some(last) = self.last_check {
if now - last >= self.check_interval_secs {
self.last_check = Some(now);
true
} else {
false
}
} else {
self.last_check = Some(now);
true
}
}
pub fn evaluate(&self, utxos: &[Utxo], current_fee_rate: u64) -> ConsolidationDecision {
let health = self.optimizer.analyze_health(utxos, current_fee_rate);
let should_consolidate = self.optimizer.should_consolidate(&health, current_fee_rate);
let reason = self.get_decision_reason(&health, current_fee_rate);
ConsolidationDecision {
should_consolidate,
health,
reason,
}
}
fn get_decision_reason(&self, health: &UtxoSetHealth, current_fee_rate: u64) -> String {
match &health.recommendation {
HealthRecommendation::Healthy => "UTXO set is healthy".to_string(),
HealthRecommendation::ConsiderConsolidation => {
format!(
"Low fees ({} sat/vB), good time to consolidate {} UTXOs",
current_fee_rate, health.total_utxos
)
}
HealthRecommendation::ConsolidationRecommended => {
format!(
"UTXO count ({}) exceeds healthy threshold, consolidation recommended",
health.total_utxos
)
}
HealthRecommendation::UrgentConsolidation => {
format!(
"Critical: {} UTXOs with health score {}, urgent consolidation needed",
health.total_utxos,
health.overall_score.value()
)
}
HealthRecommendation::ExcessiveDust => {
format!(
"Excessive dust: {} dust UTXOs out of {}",
health.dust_utxos, health.total_utxos
)
}
}
}
}
impl Default for ConsolidationScheduler {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationDecision {
pub should_consolidate: bool,
pub health: UtxoSetHealth,
pub reason: String,
}
pub struct FeeMarketPredictor {
fee_history: Vec<FeeDataPoint>,
max_history: usize,
}
impl FeeMarketPredictor {
pub fn new() -> Self {
Self {
fee_history: Vec::new(),
max_history: 168, }
}
pub fn record_fee_rate(&mut self, fee_rate: u64) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
self.fee_history.push(FeeDataPoint {
timestamp: now,
fee_rate,
});
if self.fee_history.len() > self.max_history {
self.fee_history.remove(0);
}
}
pub fn predict_fee_spike(&self) -> bool {
if self.fee_history.len() < 24 {
return false;
}
let recent_avg = self.average_recent_fees(6);
let earlier_avg = self.average_earlier_fees(6, 18);
recent_avg > earlier_avg * 15 / 10
}
fn average_recent_fees(&self, hours: usize) -> u64 {
let count = hours.min(self.fee_history.len());
if count == 0 {
return 0;
}
let recent: Vec<_> = self.fee_history.iter().rev().take(count).collect();
let sum: u64 = recent.iter().map(|d| d.fee_rate).sum();
sum / count as u64
}
fn average_earlier_fees(&self, hours: usize, offset: usize) -> u64 {
if self.fee_history.len() < offset + hours {
return 0;
}
let start = self.fee_history.len() - offset - hours;
let end = self.fee_history.len() - offset;
let period: Vec<_> = self.fee_history[start..end].to_vec();
if period.is_empty() {
return 0;
}
let sum: u64 = period.iter().map(|d| d.fee_rate).sum();
sum / period.len() as u64
}
pub fn current_fee_percentile(&self) -> u8 {
if self.fee_history.is_empty() {
return 50;
}
let latest = self.fee_history.last().unwrap().fee_rate;
let mut sorted: Vec<_> = self.fee_history.iter().map(|d| d.fee_rate).collect();
sorted.sort_unstable();
let position = sorted
.iter()
.position(|&r| r >= latest)
.unwrap_or(sorted.len() - 1);
((position * 100) / sorted.len().max(1)).min(100) as u8
}
}
impl Default for FeeMarketPredictor {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct FeeDataPoint {
timestamp: u64,
fee_rate: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_health_score() {
let score = HealthScore::new(75);
assert_eq!(score.value(), 75);
assert!(!score.is_critical());
assert!(!score.is_poor());
assert!(score.is_good());
assert!(!score.is_excellent());
let critical = HealthScore::new(25);
assert!(critical.is_critical());
assert!(critical.is_poor());
}
#[test]
fn test_health_score_clamping() {
let score = HealthScore::new(150);
assert_eq!(score.value(), 100);
}
#[test]
fn test_optimizer_creation() {
let optimizer = UtxoOptimizer::new();
assert_eq!(optimizer.low_fee_threshold, 5);
assert_eq!(optimizer.max_healthy_utxos, 20);
}
#[test]
fn test_health_analysis() {
use bitcoin::Txid;
use std::str::FromStr;
let optimizer = UtxoOptimizer::new();
let utxos = vec![
Utxo {
txid: Txid::from_str(
"0000000000000000000000000000000000000000000000000000000000000001",
)
.unwrap(),
vout: 0,
address: "bc1qtest".to_string(),
amount_sats: 100_000,
confirmations: 6,
spendable: true,
safe: true,
},
Utxo {
txid: Txid::from_str(
"0000000000000000000000000000000000000000000000000000000000000002",
)
.unwrap(),
vout: 0,
address: "bc1qtest2".to_string(),
amount_sats: 50_000,
confirmations: 6,
spendable: true,
safe: true,
},
];
let health = optimizer.analyze_health(&utxos, 10);
assert_eq!(health.total_utxos, 2);
assert_eq!(health.total_value, 150_000);
assert!(health.overall_score.is_excellent());
}
#[test]
fn test_optimal_target_count() {
let optimizer = UtxoOptimizer::new();
assert_eq!(optimizer.optimal_target_count(15), 15);
assert_eq!(optimizer.optimal_target_count(25), 20);
assert_eq!(optimizer.optimal_target_count(100), 10);
}
#[test]
fn test_consolidation_scheduler() {
let mut scheduler = ConsolidationScheduler::new();
assert!(scheduler.should_check()); }
#[test]
fn test_fee_market_predictor() {
let mut predictor = FeeMarketPredictor::new();
for rate in [5, 6, 7, 10, 15, 20] {
predictor.record_fee_rate(rate);
}
assert_eq!(predictor.fee_history.len(), 6);
}
#[test]
fn test_fee_percentile() {
let mut predictor = FeeMarketPredictor::new();
for rate in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] {
predictor.record_fee_rate(rate);
}
let percentile = predictor.current_fee_percentile();
assert!(percentile > 80);
}
#[test]
fn test_health_trend() {
let optimizer = UtxoOptimizer::new();
let history = vec![];
let trend = optimizer.create_health_trend(&history);
assert_eq!(trend.trend, TrendDirection::Stable);
}
}