use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};
use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;
pub struct AddressReuseDetectionHeuristic {
pub min_reuse_count: u32,
pub min_reuse_ratio: f64,
pub min_output_count: u32,
}
impl Default for AddressReuseDetectionHeuristic {
fn default() -> Self {
Self {
min_reuse_count: 2,
min_reuse_ratio: 0.6,
min_output_count: 3,
}
}
}
impl Heuristic for AddressReuseDetectionHeuristic {
fn id(&self) -> &'static str {
"address-reuse-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 {
return None;
}
if (f.output_count as u32) < self.min_output_count {
return None;
}
if !f.address_reuse_in_tx {
return None;
}
let unique = f.unique_output_addresses.unwrap_or(f.output_count as u32);
let reuse_ratio = 1.0 - (unique as f64 / f.output_count as f64);
if reuse_ratio < self.min_reuse_ratio {
return None;
}
let reused_count = f.output_count as u32 - unique;
if reused_count < self.min_reuse_count {
return None;
}
let summary = format!(
"Address reuse detected: {} outputs share {} unique addresses out of {} \
total in block {}.",
reused_count, unique, f.output_count, f.block_height
);
Some(HeuristicMatch::new(
self.id(),
self.version(),
"address_reuse_detection",
"address_reuse",
self.trigger_scope(),
summary,
serde_json::json!({
"output_count": f.output_count,
"unique_output_addresses": unique,
"reused_count": reused_count,
"reuse_ratio": reuse_ratio,
"address_reuse_in_tx": f.address_reuse_in_tx,
}),
))
}
fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
if f.is_coinbase
|| (f.output_count as u32) < self.min_output_count
|| !f.address_reuse_in_tx
{
return None;
}
let unique = f.unique_output_addresses.unwrap_or(f.output_count as u32);
let reuse_ratio = 1.0 - (unique as f64 / f.output_count as f64);
if reuse_ratio < self.min_reuse_ratio {
return None;
}
let reused_count = f.output_count as u32 - unique;
if reused_count < self.min_reuse_count {
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,
"address_reuse_in_tx flag: multiple outputs share the same destination address"
.to_string(),
txid_hex.clone(),
)
.with_threshold(1.0, f.address_reuse_in_tx),
);
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Behavioral,
format!(
"Reuse ratio {reuse_ratio:.2} (threshold {:.2}): {} of {} outputs non-unique",
self.min_reuse_ratio, reused_count, f.output_count
),
txid_hex.clone(),
)
.with_metric(reuse_ratio, "ratio")
.with_threshold(self.min_reuse_ratio, reuse_ratio >= self.min_reuse_ratio),
);
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Structural,
format!(
"{unique} unique address(es) vs {output} total outputs",
output = f.output_count
),
txid_hex,
)
.with_metric(unique as f64, "unique addresses"),
);
chain.finalize();
Some(chain)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn make_features(
output_count: i32,
unique_addresses: Option<u32>,
reuse_in_tx: bool,
) -> TxFeatures {
TxFeatures {
txid: vec![0x07u8; 32],
block_height: 840_000,
block_timestamp: Utc::now(),
input_count: 1,
output_count,
is_coinbase: false,
total_input_value: 1_000_000,
total_output_value: 999_000,
fee: 1_000,
fee_rate_sat_vb: Some(5.0),
input_p2pkh_count: 1,
output_p2pkh_count: output_count,
tx_vsize_vbytes: 300,
tx_version: 1,
input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
output_values: vec![200_000; output_count as usize],
unique_output_addresses: unique_addresses,
address_reuse_in_tx: reuse_in_tx,
..Default::default()
}
}
#[test]
fn test_address_reuse_detected() {
let h = AddressReuseDetectionHeuristic::default();
let f = make_features(5, Some(2), true);
let result = h.evaluate(&f);
assert!(result.is_some(), "reuse_ratio=0.6 should fire");
assert_eq!(result.unwrap().event_type, "address_reuse_detection");
}
#[test]
fn test_address_reuse_below_threshold() {
let h = AddressReuseDetectionHeuristic::default();
let f = make_features(5, Some(4), true);
assert!(
h.evaluate(&f).is_none(),
"ratio=0.2 below the threshold 0.6"
);
}
#[test]
fn test_address_reuse_no_reuse_flag() {
let h = AddressReuseDetectionHeuristic::default();
let f = make_features(5, Some(2), false);
assert!(
h.evaluate(&f).is_none(),
"address_reuse_in_tx=false should return None"
);
}
#[test]
fn test_address_reuse_too_few_outputs() {
let h = AddressReuseDetectionHeuristic::default();
let f = make_features(2, Some(1), true);
assert!(
h.evaluate(&f).is_none(),
"output_count=2 below the minimum 3"
);
}
#[test]
fn test_address_reuse_evidence_chain() {
let h = AddressReuseDetectionHeuristic::default();
let f = make_features(5, Some(2), true);
let chain = h.build_evidence(&f);
assert!(chain.is_some(), "should return EvidenceChain");
let chain = chain.unwrap();
let behavioral_count = chain
.links
.iter()
.filter(|l| l.category == EvidenceCategory::Behavioral)
.count();
assert!(
behavioral_count >= 2,
"should have ≥2 Behavioral links, has {behavioral_count}"
);
}
#[test]
fn test_address_reuse_skips_coinbase() {
let h = AddressReuseDetectionHeuristic::default();
let mut f = make_features(5, Some(2), true);
f.is_coinbase = true;
assert!(h.evaluate(&f).is_none(), "coinbase ignored");
}
}