bitcoin-heuristics 0.1.0

Pattern detection heuristics for Bitcoin on-chain analytics — consolidations, distributions, CoinJoin, fee spikes, dormant supply reactivation and more
Documentation
use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};

use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;

/// Detects Bitcoin address reuse within a single transaction.
///
/// Fires when multiple outputs share the same destination address,
/// with a reuse ratio above the configured threshold.
///
/// Status: Experimental — legitimate reuse occurs in payments to a single recipient.
pub struct AddressReuseDetectionHeuristic {
    /// Minimum number of reused outputs to fire. Default: 2.
    pub min_reuse_count: u32,
    /// Minimum ratio: 1 - (unique/total) >= min_reuse_ratio. Default: 0.6.
    pub min_reuse_ratio: f64,
    /// Minimum total outputs to activate. Default: 3.
    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");
    }
}