use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};
use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;
pub struct CoinJoinDetectionHeuristic {
pub score_threshold: f64,
}
impl Default for CoinJoinDetectionHeuristic {
fn default() -> Self {
Self {
score_threshold: 0.7,
}
}
}
impl Heuristic for CoinJoinDetectionHeuristic {
fn id(&self) -> &'static str {
"coinjoin-detection-v1"
}
fn version(&self) -> &'static str {
"1.0.0"
}
fn status(&self) -> HeuristicStatus {
HeuristicStatus::Experimental
}
fn evaluate(&self, f: &TxFeatures) -> Option<HeuristicMatch> {
if f.is_coinbase || f.coinjoin_score < self.score_threshold {
return None;
}
if f.input_count < 5 || f.output_count < 5 {
return None;
}
if !f.has_equal_outputs {
return None;
}
let summary = format!(
"Probable CoinJoin detected (score={:.3}): {} inputs, {} outputs at block {}",
f.coinjoin_score, f.input_count, f.output_count, f.block_height
);
Some(HeuristicMatch::new(
self.id(),
self.version(),
"coinjoin_detected",
"coinjoin",
self.trigger_scope(),
summary,
serde_json::json!({
"coinjoin_score": f.coinjoin_score,
"input_count": f.input_count,
"output_count": f.output_count,
"has_equal_outputs": f.has_equal_outputs,
"output_value_variance": f.output_value_variance,
}),
))
}
fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
if f.is_coinbase || f.coinjoin_score < self.score_threshold {
return None;
}
if f.input_count < 5 || f.output_count < 5 || !f.has_equal_outputs {
return None;
}
let txid_hex: String = f.txid.iter().rev().map(|b| format!("{b:02x}")).collect();
let mut chain = EvidenceChain::new(self.id(), self.version());
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Behavioral,
format!(
"CoinJoin score {:.3} (threshold {:.3})",
f.coinjoin_score, self.score_threshold
),
txid_hex.clone(),
)
.with_metric(f.coinjoin_score, "ratio")
.with_threshold(
self.score_threshold,
f.coinjoin_score >= self.score_threshold,
),
);
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Structural,
format!(
"Equal-value outputs: {} ({} inputs / {} outputs)",
f.has_equal_outputs, f.input_count, f.output_count
),
txid_hex,
)
.with_threshold(1.0, f.has_equal_outputs),
);
chain.finalize();
Some(chain)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn make_features(coinjoin_score: f64, input_count: i32, output_count: i32) -> TxFeatures {
TxFeatures {
txid: vec![0x02u8; 32],
block_height: 840_000,
block_timestamp: Utc::now(),
input_count,
output_count,
is_coinbase: false,
total_input_value: 1_000_000,
total_output_value: 999_000,
fee: 1_000,
output_value_min: 100_000,
output_value_max: 100_000,
output_value_median: 100_000.0,
fee_rate_sat_vb: Some(2.0),
input_p2wpkh_count: input_count,
output_p2wpkh_count: output_count,
has_equal_outputs: coinjoin_score >= 0.7,
coinjoin_score,
tx_vsize_vbytes: 1_000,
tx_version: 2,
input_utxo_refs: (0..input_count as u32)
.map(|i| (vec![0xeeu8; 32], i))
.collect(),
output_values: vec![100_000; output_count as usize],
..Default::default()
}
}
#[test]
fn test_coinjoin_detected_above_threshold() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.85, 10, 10);
let result = h.evaluate(&f);
assert!(result.is_some(), "score=0.85 >= 0.7 should fire");
let r = result.unwrap();
assert_eq!(r.event_type, "coinjoin_detected");
assert_eq!(r.pattern, "coinjoin");
}
#[test]
fn test_coinjoin_not_detected_below_threshold() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.5, 5, 5);
assert!(h.evaluate(&f).is_none(), "score=0.5 < 0.7 should not fire");
}
#[test]
fn test_coinjoin_exact_threshold() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.7, 7, 7);
assert!(
h.evaluate(&f).is_some(),
"score==0.7 should fire (>= threshold)"
);
}
#[test]
fn test_coinjoin_skips_coinbase() {
let h = CoinJoinDetectionHeuristic::default();
let mut f = make_features(0.99, 10, 10);
f.is_coinbase = true;
assert!(h.evaluate(&f).is_none(), "coinbase is never CoinJoin");
}
#[test]
fn test_coinjoin_custom_threshold() {
let h = CoinJoinDetectionHeuristic {
score_threshold: 0.9,
};
let f = make_features(0.85, 10, 10);
assert!(
h.evaluate(&f).is_none(),
"score=0.85 < threshold=0.9 should not fire"
);
}
#[test]
fn test_event_includes_score_in_summary() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.80, 8, 8);
let result = h.evaluate(&f).unwrap();
assert!(
result.summary.contains("0.800"),
"summary should include score: {}",
result.summary
);
}
#[test]
fn test_coinjoin_too_few_participants() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.99, 4, 10);
assert!(
h.evaluate(&f).is_none(),
"input_count < 5 should be blocked"
);
}
#[test]
fn test_coinjoin_too_few_outputs() {
let h = CoinJoinDetectionHeuristic::default();
let f = make_features(0.99, 10, 4);
assert!(
h.evaluate(&f).is_none(),
"output_count < 5 should be blocked"
);
}
#[test]
fn test_coinjoin_not_equal_outputs() {
let h = CoinJoinDetectionHeuristic::default();
let mut f = make_features(0.99, 10, 10);
f.has_equal_outputs = false;
assert!(
h.evaluate(&f).is_none(),
"without equal outputs should be blocked"
);
}
}