use crate::error::BitcoinError;
use crate::utxo::Utxo;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UtxoPrivacyAnalysis {
pub utxo_id: String,
pub privacy_score: u32,
pub is_toxic: bool,
pub issues: Vec<PrivacyIssue>,
pub recommendation: PrivacyRecommendation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PrivacyIssue {
AddressReuse,
RoundAmount,
KnownChange,
ClusterContamination,
SmallAmount,
LargeAmount,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PrivacyRecommendation {
Safe,
UseWithCaution,
Avoid,
Consolidate,
MixFirst,
}
#[derive(Debug, Clone)]
pub struct UtxoCluster {
pub id: String,
pub utxos: Vec<Utxo>,
pub addresses: HashSet<String>,
pub privacy_score: u32,
}
#[derive(Debug)]
pub struct ConsolidationPrivacyAnalyzer {
min_privacy_score: u32,
address_usage: HashMap<String, usize>,
}
impl ConsolidationPrivacyAnalyzer {
pub fn new(min_privacy_score: u32) -> Self {
Self {
min_privacy_score,
address_usage: HashMap::new(),
}
}
pub fn analyze_consolidation(
&mut self,
utxos: &[Utxo],
) -> Result<ConsolidationPrivacyReport, BitcoinError> {
for utxo in utxos {
*self.address_usage.entry(utxo.address.clone()).or_insert(0) += 1;
}
let has_address_reuse = self.detect_address_reuse(utxos);
let has_round_amounts = self.detect_round_amounts(utxos);
let privacy_score =
self.calculate_consolidation_score(utxos, has_address_reuse, has_round_amounts);
let is_safe = privacy_score >= self.min_privacy_score;
Ok(ConsolidationPrivacyReport {
privacy_score,
is_safe,
has_address_reuse,
has_round_amounts,
unique_addresses: self.count_unique_addresses(utxos),
total_utxos: utxos.len(),
recommendation: if is_safe {
"Consolidation appears privacy-safe".to_string()
} else {
"Consolidation may reveal ownership linkage".to_string()
},
})
}
fn detect_address_reuse(&self, utxos: &[Utxo]) -> bool {
let addresses: HashSet<_> = utxos.iter().map(|u| &u.address).collect();
addresses.len() < utxos.len()
}
fn detect_round_amounts(&self, utxos: &[Utxo]) -> bool {
for utxo in utxos {
if utxo.amount_sats % 100_000_000 == 0 || utxo.amount_sats % 10_000_000 == 0 {
return true;
}
}
false
}
fn calculate_consolidation_score(
&self,
utxos: &[Utxo],
has_reuse: bool,
has_round: bool,
) -> u32 {
let mut score = 100u32;
if has_reuse {
score = score.saturating_sub(30);
}
if has_round {
score = score.saturating_sub(15);
}
if utxos.len() > 20 {
score = score.saturating_sub(10);
}
if utxos.len() < 5 {
score = score.saturating_sub(5);
}
score
}
fn count_unique_addresses(&self, utxos: &[Utxo]) -> usize {
let addresses: HashSet<_> = utxos.iter().map(|u| &u.address).collect();
addresses.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationPrivacyReport {
pub privacy_score: u32,
pub is_safe: bool,
pub has_address_reuse: bool,
pub has_round_amounts: bool,
pub unique_addresses: usize,
pub total_utxos: usize,
pub recommendation: String,
}
#[derive(Debug)]
pub struct ToxicChangeDetector {
toxic_addresses: HashSet<String>,
#[allow(dead_code)]
clusters: HashMap<String, UtxoCluster>,
}
impl ToxicChangeDetector {
pub fn new() -> Self {
Self {
toxic_addresses: HashSet::new(),
clusters: HashMap::new(),
}
}
pub fn mark_toxic(&mut self, address: String) {
self.toxic_addresses.insert(address);
}
pub fn is_toxic(&self, utxo: &Utxo) -> bool {
self.toxic_addresses.contains(&utxo.address)
}
pub fn analyze_utxo(&self, utxo: &Utxo) -> UtxoPrivacyAnalysis {
let mut issues = Vec::new();
let mut privacy_score = 100u32;
let is_toxic = self.is_toxic(utxo);
if is_toxic {
issues.push(PrivacyIssue::ClusterContamination);
privacy_score = privacy_score.saturating_sub(50);
}
if Self::is_round_amount(utxo.amount_sats) {
issues.push(PrivacyIssue::RoundAmount);
privacy_score = privacy_score.saturating_sub(10);
}
if utxo.amount_sats < 10_000 {
issues.push(PrivacyIssue::SmallAmount);
privacy_score = privacy_score.saturating_sub(5);
}
if utxo.amount_sats > 100_000_000 {
issues.push(PrivacyIssue::LargeAmount);
privacy_score = privacy_score.saturating_sub(5);
}
let recommendation = if privacy_score >= 80 {
PrivacyRecommendation::Safe
} else if privacy_score >= 60 {
PrivacyRecommendation::UseWithCaution
} else if privacy_score >= 40 {
PrivacyRecommendation::Consolidate
} else {
PrivacyRecommendation::Avoid
};
UtxoPrivacyAnalysis {
utxo_id: format!("{}:{}", utxo.txid, utxo.vout),
privacy_score,
is_toxic,
issues,
recommendation,
}
}
fn is_round_amount(sats: u64) -> bool {
sats % 100_000_000 == 0 || sats % 10_000_000 == 0 || sats % 1_000_000 == 0
}
pub fn toxic_count(&self) -> usize {
self.toxic_addresses.len()
}
pub fn cluster_utxos(&mut self, utxos: &[Utxo]) -> Vec<UtxoCluster> {
let mut address_clusters: HashMap<String, Vec<Utxo>> = HashMap::new();
for utxo in utxos {
address_clusters
.entry(utxo.address.clone())
.or_default()
.push(utxo.clone());
}
let mut clusters = Vec::new();
for (address, cluster_utxos) in address_clusters {
let mut addresses = HashSet::new();
addresses.insert(address.clone());
let cluster = UtxoCluster {
id: address.clone(),
utxos: cluster_utxos,
addresses,
privacy_score: 100, };
clusters.push(cluster);
}
clusters
}
}
impl Default for ToxicChangeDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::Txid;
use bitcoin::hashes::Hash;
fn create_test_utxo(sats: u64, address: &str) -> Utxo {
Utxo {
txid: Txid::all_zeros(),
vout: 0,
amount_sats: sats,
address: address.to_string(),
confirmations: 6,
spendable: true,
safe: true,
}
}
#[test]
fn test_consolidation_analyzer_creation() {
let analyzer = ConsolidationPrivacyAnalyzer::new(50);
assert_eq!(analyzer.min_privacy_score, 50);
}
#[test]
fn test_consolidation_privacy_analysis() {
let mut analyzer = ConsolidationPrivacyAnalyzer::new(50);
let utxos = vec![
create_test_utxo(100000, "addr1"),
create_test_utxo(200000, "addr2"),
create_test_utxo(300000, "addr3"),
];
let report = analyzer.analyze_consolidation(&utxos).unwrap();
assert!(report.privacy_score > 0);
assert_eq!(report.total_utxos, 3);
assert_eq!(report.unique_addresses, 3);
}
#[test]
fn test_address_reuse_detection() {
let mut analyzer = ConsolidationPrivacyAnalyzer::new(50);
let utxos = vec![
create_test_utxo(100000, "addr1"),
create_test_utxo(200000, "addr1"), ];
let report = analyzer.analyze_consolidation(&utxos).unwrap();
assert!(report.has_address_reuse);
assert!(report.privacy_score < 100);
}
#[test]
fn test_round_amount_detection() {
let mut analyzer = ConsolidationPrivacyAnalyzer::new(50);
let utxos = vec![create_test_utxo(100_000_000, "addr1")];
let report = analyzer.analyze_consolidation(&utxos).unwrap();
assert!(report.has_round_amounts);
}
#[test]
fn test_toxic_detector_creation() {
let detector = ToxicChangeDetector::new();
assert_eq!(detector.toxic_count(), 0);
}
#[test]
fn test_toxic_address_marking() {
let mut detector = ToxicChangeDetector::new();
detector.mark_toxic("toxic_addr".to_string());
assert_eq!(detector.toxic_count(), 1);
}
#[test]
fn test_toxic_utxo_detection() {
let mut detector = ToxicChangeDetector::new();
detector.mark_toxic("toxic_addr".to_string());
let toxic_utxo = create_test_utxo(100000, "toxic_addr");
let clean_utxo = create_test_utxo(100000, "clean_addr");
assert!(detector.is_toxic(&toxic_utxo));
assert!(!detector.is_toxic(&clean_utxo));
}
#[test]
fn test_utxo_privacy_analysis() {
let detector = ToxicChangeDetector::new();
let utxo = create_test_utxo(100000, "addr1");
let analysis = detector.analyze_utxo(&utxo);
assert!(analysis.privacy_score > 0);
assert!(!analysis.is_toxic);
}
#[test]
fn test_toxic_utxo_analysis() {
let mut detector = ToxicChangeDetector::new();
detector.mark_toxic("toxic".to_string());
let utxo = create_test_utxo(100000, "toxic");
let analysis = detector.analyze_utxo(&utxo);
assert!(analysis.is_toxic);
assert!(analysis.privacy_score < 100);
assert!(!analysis.issues.is_empty());
}
#[test]
fn test_round_amount_issue() {
let detector = ToxicChangeDetector::new();
let utxo = create_test_utxo(100_000_000, "addr1");
let analysis = detector.analyze_utxo(&utxo);
assert!(
analysis
.issues
.iter()
.any(|i| matches!(i, PrivacyIssue::RoundAmount))
);
}
#[test]
fn test_small_amount_issue() {
let detector = ToxicChangeDetector::new();
let utxo = create_test_utxo(5000, "addr1");
let analysis = detector.analyze_utxo(&utxo);
assert!(
analysis
.issues
.iter()
.any(|i| matches!(i, PrivacyIssue::SmallAmount))
);
}
#[test]
fn test_utxo_clustering() {
let mut detector = ToxicChangeDetector::new();
let utxos = vec![
create_test_utxo(100000, "addr1"),
create_test_utxo(200000, "addr1"),
create_test_utxo(300000, "addr2"),
];
let clusters = detector.cluster_utxos(&utxos);
assert_eq!(clusters.len(), 2); }
}