use crate::error::BitcoinError;
use crate::utxo::Utxo;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PrivacyScore(pub u32);
impl PrivacyScore {
pub const PERFECT: Self = Self(100);
pub const GOOD: Self = Self(75);
pub const FAIR: Self = Self(50);
pub const POOR: Self = Self(25);
pub const VERY_POOR: Self = Self(0);
pub fn new(score: u32) -> Self {
Self(score.min(100))
}
pub fn value(&self) -> u32 {
self.0
}
pub fn is_good(&self) -> bool {
self.0 >= 75
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PrivacyConcerns {
pub address_reuse: bool,
pub round_amounts: bool,
pub common_ownership: bool,
pub change_linkage: bool,
pub missed_exact_payment: bool,
}
#[derive(Debug, Clone)]
pub struct PrivateSelection {
pub utxos: Vec<Utxo>,
pub change_sats: Option<u64>,
pub privacy_score: PrivacyScore,
pub concerns: PrivacyConcerns,
pub fee_sats: u64,
}
#[derive(Debug)]
pub struct PrivacyCoinSelector {
min_privacy_score: PrivacyScore,
prefer_exact: bool,
#[allow(dead_code)]
avoid_address_reuse: bool,
randomize: bool,
}
impl PrivacyCoinSelector {
pub fn new() -> Self {
Self {
min_privacy_score: PrivacyScore::FAIR,
prefer_exact: true,
avoid_address_reuse: true,
randomize: true,
}
}
pub fn with_min_score(mut self, score: PrivacyScore) -> Self {
self.min_privacy_score = score;
self
}
pub fn with_exact_preference(mut self, prefer: bool) -> Self {
self.prefer_exact = prefer;
self
}
pub fn select_coins(
&self,
available_utxos: &[Utxo],
target_sats: u64,
fee_rate: u64,
) -> Result<PrivateSelection, BitcoinError> {
if self.prefer_exact {
if let Ok(exact) = self.find_exact_match(available_utxos, target_sats, fee_rate) {
return Ok(exact);
}
}
let clustered = self.anti_clustering_selection(available_utxos, target_sats, fee_rate)?;
let random = if self.randomize {
self.randomized_selection(available_utxos, target_sats, fee_rate)
.ok()
} else {
None
};
let best = match random {
Some(r) if r.privacy_score > clustered.privacy_score => r,
_ => clustered,
};
if best.privacy_score < self.min_privacy_score {
return Err(BitcoinError::InvalidAddress(format!(
"Privacy score {} below minimum {}",
best.privacy_score.0, self.min_privacy_score.0
)));
}
Ok(best)
}
fn find_exact_match(
&self,
available_utxos: &[Utxo],
target_sats: u64,
fee_rate: u64,
) -> Result<PrivateSelection, BitcoinError> {
for utxo in available_utxos {
let estimated_fee = self.estimate_fee_sats(std::slice::from_ref(utxo), false, fee_rate);
let required = target_sats + estimated_fee;
if utxo.amount_sats == required {
return Ok(PrivateSelection {
utxos: vec![utxo.clone()],
change_sats: None,
privacy_score: PrivacyScore::PERFECT,
concerns: PrivacyConcerns::default(),
fee_sats: estimated_fee,
});
}
}
for i in 0..available_utxos.len() {
for j in (i + 1)..available_utxos.len() {
let utxos = vec![available_utxos[i].clone(), available_utxos[j].clone()];
let estimated_fee = self.estimate_fee_sats(&utxos, false, fee_rate);
let total: u64 = utxos.iter().map(|u| u.amount_sats).sum();
let required = target_sats + estimated_fee;
if total == required {
return Ok(PrivateSelection {
utxos,
change_sats: None,
privacy_score: PrivacyScore::new(95),
concerns: PrivacyConcerns::default(),
fee_sats: estimated_fee,
});
}
}
}
Err(BitcoinError::InvalidAddress(
"No exact match found".to_string(),
))
}
fn anti_clustering_selection(
&self,
available_utxos: &[Utxo],
target_sats: u64,
fee_rate: u64,
) -> Result<PrivateSelection, BitcoinError> {
let mut address_groups: HashMap<String, Vec<Utxo>> = HashMap::new();
for utxo in available_utxos {
address_groups
.entry(utxo.address.clone())
.or_default()
.push(utxo.clone());
}
let mut selected = Vec::new();
let mut used_addresses = HashSet::new();
let mut total = 0u64;
let mut sorted_groups: Vec<_> = address_groups.iter().collect();
sorted_groups.sort_by_key(|(_, utxos)| utxos.len());
for (addr, utxos) in sorted_groups {
if used_addresses.contains(addr) {
continue;
}
if let Some(utxo) = utxos.iter().max_by_key(|u| u.amount_sats) {
selected.push(utxo.clone());
total += utxo.amount_sats;
used_addresses.insert(addr.clone());
let estimated_fee = self.estimate_fee_sats(&selected, true, fee_rate);
if total >= target_sats + estimated_fee {
break;
}
}
}
let final_fee = self.estimate_fee_sats(&selected, true, fee_rate);
let total_amount: u64 = selected.iter().map(|u| u.amount_sats).sum();
if total_amount < target_sats + final_fee {
return Err(BitcoinError::InvalidAddress(
"Insufficient funds".to_string(),
));
}
let change = total_amount - target_sats - final_fee;
let privacy_score = self.calculate_privacy_score(&selected, change);
let concerns =
self.analyze_concerns(&selected.iter().map(|u| &u.address).collect::<Vec<_>>());
Ok(PrivateSelection {
utxos: selected,
change_sats: if change > 0 { Some(change) } else { None },
privacy_score,
concerns,
fee_sats: final_fee,
})
}
fn randomized_selection(
&self,
available_utxos: &[Utxo],
target_sats: u64,
fee_rate: u64,
) -> Result<PrivateSelection, BitcoinError> {
use rand::seq::SliceRandom;
let mut rng = rand::rng();
let mut shuffled = available_utxos.to_vec();
shuffled.shuffle(&mut rng);
let mut selected = Vec::new();
let mut total = 0u64;
for utxo in shuffled {
total += utxo.amount_sats;
selected.push(utxo);
let estimated_fee = self.estimate_fee_sats(&selected, true, fee_rate);
if total >= target_sats + estimated_fee {
break;
}
}
let final_fee = self.estimate_fee_sats(&selected, true, fee_rate);
let total_amount: u64 = selected.iter().map(|u| u.amount_sats).sum();
if total_amount < target_sats + final_fee {
return Err(BitcoinError::InvalidAddress(
"Insufficient funds".to_string(),
));
}
let change = total_amount - target_sats - final_fee;
let privacy_score = self.calculate_privacy_score(&selected, change);
let concerns =
self.analyze_concerns(&selected.iter().map(|u| &u.address).collect::<Vec<_>>());
Ok(PrivateSelection {
utxos: selected,
change_sats: if change > 0 { Some(change) } else { None },
privacy_score,
concerns,
fee_sats: final_fee,
})
}
fn calculate_privacy_score(&self, selected: &[Utxo], change_sats: u64) -> PrivacyScore {
let mut score = 100u32;
let addresses: HashSet<_> = selected.iter().map(|u| &u.address).collect();
if addresses.len() < selected.len() {
score = score.saturating_sub(20);
}
if self.has_round_amount(selected) {
score = score.saturating_sub(10);
}
if change_sats > 0 {
score = score.saturating_sub(5);
}
if addresses.len() > 1 {
score = score.saturating_add(10);
}
PrivacyScore::new(score)
}
fn has_round_amount(&self, utxos: &[Utxo]) -> bool {
for utxo in utxos {
if utxo.amount_sats % 100_000_000 == 0 {
return true; }
if utxo.amount_sats % 10_000_000 == 0 {
return true; }
if utxo.amount_sats % 1_000_000 == 0 {
return true; }
if utxo.amount_sats % 100_000 == 0 && utxo.amount_sats >= 1_000_000 {
return true; }
}
false
}
fn analyze_concerns(&self, addresses: &[&String]) -> PrivacyConcerns {
let mut concerns = PrivacyConcerns::default();
let unique_addresses: HashSet<_> = addresses.iter().collect();
if unique_addresses.len() < addresses.len() {
concerns.address_reuse = true;
}
if addresses.len() > 1 {
concerns.common_ownership = true;
}
concerns
}
fn estimate_fee_sats(&self, utxos: &[Utxo], has_change: bool, fee_rate: u64) -> u64 {
let input_vbytes = utxos.len() as u64 * 148;
let output_vbytes = if has_change { 68 } else { 34 }; let overhead_vbytes = 10;
let total_vbytes = overhead_vbytes + input_vbytes + output_vbytes;
total_vbytes * fee_rate
}
}
impl Default for PrivacyCoinSelector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::hashes::Hash;
#[allow(dead_code)]
fn create_test_utxo(sats: u64, addr_suffix: &str) -> Utxo {
Utxo {
txid: bitcoin::Txid::all_zeros(),
vout: 0,
amount_sats: sats,
address: format!("tb1q{}xy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", addr_suffix),
confirmations: 6,
spendable: true,
safe: true,
}
}
#[test]
fn test_privacy_score_creation() {
let score = PrivacyScore::new(75);
assert_eq!(score.value(), 75);
assert!(score.is_good());
}
#[test]
fn test_privacy_score_capping() {
let score = PrivacyScore::new(150);
assert_eq!(score.value(), 100);
}
#[test]
fn test_selector_creation() {
let selector = PrivacyCoinSelector::new();
assert!(selector.prefer_exact);
assert!(selector.avoid_address_reuse);
}
#[test]
fn test_selector_configuration() {
let selector = PrivacyCoinSelector::new()
.with_min_score(PrivacyScore::GOOD)
.with_exact_preference(false);
assert!(!selector.prefer_exact);
assert_eq!(selector.min_privacy_score.value(), 75);
}
#[test]
fn test_exact_match_single_utxo() {
let selector = PrivacyCoinSelector::new();
let target = 10000;
let fee_rate = 10;
let exact_amount = 10000 + 1920;
let utxos = vec![create_test_utxo(exact_amount, "1")];
let result = selector.find_exact_match(&utxos, target, fee_rate);
assert!(result.is_ok());
let selection = result.unwrap();
assert_eq!(selection.utxos.len(), 1);
assert!(selection.change_sats.is_none());
assert_eq!(selection.privacy_score, PrivacyScore::PERFECT);
}
#[test]
fn test_privacy_concerns_default() {
let concerns = PrivacyConcerns::default();
assert!(!concerns.address_reuse);
assert!(!concerns.round_amounts);
}
#[test]
fn test_anti_clustering_selection() {
let selector = PrivacyCoinSelector::new();
let utxos = vec![
create_test_utxo(50000, "1"),
create_test_utxo(30000, "2"),
create_test_utxo(20000, "3"),
];
let result = selector.anti_clustering_selection(&utxos, 60000, 10);
assert!(result.is_ok());
}
#[test]
fn test_randomized_selection() {
let selector = PrivacyCoinSelector::new();
let utxos = vec![
create_test_utxo(50000, "1"),
create_test_utxo(30000, "2"),
create_test_utxo(20000, "3"),
];
let result = selector.randomized_selection(&utxos, 60000, 10);
assert!(result.is_ok());
}
#[test]
fn test_insufficient_funds() {
let selector = PrivacyCoinSelector::new();
let utxos = vec![create_test_utxo(1000, "1")];
let result = selector.select_coins(&utxos, 100000, 10);
assert!(result.is_err());
}
#[test]
fn test_round_amount_detection() {
let selector = PrivacyCoinSelector::new();
let utxos_exact_btc = vec![create_test_utxo(100_000_000, "1")]; assert!(selector.has_round_amount(&utxos_exact_btc));
let utxos_tenth_btc = vec![create_test_utxo(10_000_000, "1")]; assert!(selector.has_round_amount(&utxos_tenth_btc));
let utxos_hundredth_btc = vec![create_test_utxo(1_000_000, "1")]; assert!(selector.has_round_amount(&utxos_hundredth_btc));
let utxos_non_round = vec![create_test_utxo(12_345_678, "1")];
assert!(!selector.has_round_amount(&utxos_non_round));
}
#[test]
fn test_privacy_concerns_analysis() {
let selector = PrivacyCoinSelector::new();
let addr1 = "addr1".to_string();
let addr1_dup = "addr1".to_string();
let addr2 = "addr2".to_string();
let addresses_with_reuse = vec![&addr1, &addr1_dup, &addr2];
let concerns_reuse = selector.analyze_concerns(&addresses_with_reuse);
assert!(concerns_reuse.address_reuse);
assert!(concerns_reuse.common_ownership);
let addr_a = "addr1".to_string();
let addr_b = "addr2".to_string();
let addr_c = "addr3".to_string();
let addresses_no_reuse = vec![&addr_a, &addr_b, &addr_c];
let concerns_ownership = selector.analyze_concerns(&addresses_no_reuse);
assert!(!concerns_ownership.address_reuse);
assert!(concerns_ownership.common_ownership);
let single_addr = "addr1".to_string();
let addresses_single = vec![&single_addr];
let concerns_single = selector.analyze_concerns(&addresses_single);
assert!(!concerns_single.address_reuse);
assert!(!concerns_single.common_ownership); }
#[test]
fn test_round_amount_penalties() {
let selector = PrivacyCoinSelector::new();
let round_utxos = vec![create_test_utxo(100_000_000, "1")]; let round_score = selector.calculate_privacy_score(&round_utxos, 0);
let non_round_utxos = vec![create_test_utxo(99_876_543, "2")]; let non_round_score = selector.calculate_privacy_score(&non_round_utxos, 0);
assert!(non_round_score.value() >= round_score.value());
}
}