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 probable CoinJoin transactions based on the composite score.
///
/// Uses the `coinjoin_score` pre-calculated by the feature extractor.
/// Fires when the score exceeds the configured threshold.
///
/// Status: Experimental — CoinJoin detection has false positives in PayJoin and batch payments.
pub struct CoinJoinDetectionHeuristic {
    /// Minimum score [0.0, 1.0] to fire the event. Default: 0.7.
    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"
        );
    }
}