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 crate::models::{ChangeDetection, ScriptType};

/// Analyzes transaction outputs to detect the likely change output.
///
/// Implements classical heuristics:
/// 1. Round Number: Payments are usually round numbers, change is the residue.
/// 2. Script Continuity: Change usually maintains the same script type as the inputs.
/// 3. Uniqueness: If there is only one output that is not the obvious payment.
pub fn detect_change(
    output_values: &[i64],
    output_scripts: &[ScriptType],
    dominant_input_script: ScriptType,
) -> ChangeDetection {
    if output_values.len() <= 1 {
        return ChangeDetection::None;
    }

    let mut has_round = false;
    let mut has_continuity = false;

    for &val in output_values {
        if val > 0 && (val % 10_000 == 0 || val % 100_000 == 0 || val % 1_000_000 == 0) {
            has_round = true;
            break;
        }
    }

    for &script in output_scripts {
        if script == dominant_input_script
            && script != ScriptType::Unknown
            && script != ScriptType::Mixed
        {
            has_continuity = true;
            break;
        }
    }

    match (has_round, has_continuity) {
        (true, true) => ChangeDetection::HighMultiMethod,
        (true, false) => ChangeDetection::HighRoundNumber,
        (false, true) => ChangeDetection::HighScoreContinuity,
        (false, false) => ChangeDetection::None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_single_output_returns_none() {
        let result = detect_change(&[100_000], &[ScriptType::P2WPKH], ScriptType::P2WPKH);
        assert_eq!(result, ChangeDetection::None);
    }

    #[test]
    fn test_round_and_continuity_returns_high_multi() {
        let result = detect_change(
            &[1_000_000, 234_567],
            &[ScriptType::P2WPKH, ScriptType::P2WPKH],
            ScriptType::P2WPKH,
        );
        assert_eq!(result, ChangeDetection::HighMultiMethod);
    }

    #[test]
    fn test_round_no_continuity() {
        let result = detect_change(
            &[1_000_000, 234_567],
            &[ScriptType::P2TR, ScriptType::P2PKH],
            ScriptType::P2WPKH,
        );
        assert_eq!(result, ChangeDetection::HighRoundNumber);
    }

    #[test]
    fn test_continuity_no_round() {
        let result = detect_change(
            &[1_234_567, 987_654],
            &[ScriptType::P2WPKH, ScriptType::P2WPKH],
            ScriptType::P2WPKH,
        );
        assert_eq!(result, ChangeDetection::HighScoreContinuity);
    }

    #[test]
    fn test_empty_outputs_returns_none() {
        let result = detect_change(&[], &[], ScriptType::P2WPKH);
        assert_eq!(result, ChangeDetection::None);
    }
}