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 transfers of rounded values.
///
/// An output is considered "round value" if it is divisible by `round_threshold`
/// and has a USD value above `min_usd_value`.
/// Pattern indicative of institutional payments (not change outputs).
pub struct RoundValueHeuristic {
    /// Divisor to consider a value "round" (in satoshis). Default: 1_000_000 (0.01 BTC).
    pub round_threshold: i64,
    /// Minimum USD value for the output to be considered. Default: 500,000.0.
    pub min_usd_value: f64,
    /// Minimum number of outputs with round value to fire. Default: 1.
    pub min_round_outputs: usize,
    /// Minimum output value to be eligible (avoids dust outputs). Default: 546.
    pub min_value_sats: i64,
}

impl Default for RoundValueHeuristic {
    fn default() -> Self {
        Self {
            round_threshold: 1_000_000,
            min_usd_value: 500_000.0,
            min_round_outputs: 1,
            min_value_sats: 546,
        }
    }
}

impl Heuristic for RoundValueHeuristic {
    fn id(&self) -> &'static str {
        "round-value-transfer-v1"
    }

    fn version(&self) -> &'static str {
        "1.0.0"
    }

    fn status(&self) -> HeuristicStatus {
        HeuristicStatus::Experimental
    }

    fn evaluate(&self, f: &TxFeatures) -> Option<HeuristicMatch> {
        let price_usd = f.block_price_usd?;

        if f.is_coinbase || f.output_values.is_empty() {
            return None;
        }

        let round_count = f
            .output_values
            .iter()
            .filter(|&&v| {
                if v < self.min_value_sats || v % self.round_threshold != 0 {
                    return false;
                }
                let val_usd = (v as f64 / 100_000_000.0) * price_usd;
                val_usd >= self.min_usd_value
            })
            .count();

        if round_count < self.min_round_outputs {
            return None;
        }

        let summary = format!(
            "Round value transfer: {round_count} output(s) with round values \
             (threshold={} sat) at block {}",
            self.round_threshold, f.block_height
        );

        Some(HeuristicMatch::new(
            self.id(),
            self.version(),
            "round_value_transfer",
            "round_value",
            self.trigger_scope(),
            summary,
            serde_json::json!({
                "round_output_count": round_count,
                "total_output_count": f.output_count,
                "round_threshold_sats": self.round_threshold,
                "min_usd_value": self.min_usd_value,
                "output_values": f.output_values,
                "block_price_usd": price_usd,
            }),
        ))
    }

    fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
        let price_usd = f.block_price_usd?;

        if f.is_coinbase || f.output_values.is_empty() {
            return None;
        }
        let round_outputs: Vec<i64> = f
            .output_values
            .iter()
            .copied()
            .filter(|&v| {
                if v < self.min_value_sats || v % self.round_threshold != 0 {
                    return false;
                }
                let val_usd = (v as f64 / 100_000_000.0) * price_usd;
                val_usd >= self.min_usd_value
            })
            .collect();
        if round_outputs.len() < self.min_round_outputs {
            return None;
        }
        let txid_hex: String = f.txid.iter().rev().map(|b| format!("{b:02x}")).collect();
        let largest = round_outputs.iter().copied().max().unwrap_or(0);
        let mut chain = EvidenceChain::new(self.id(), self.version());

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Value,
                format!(
                    "{} output(s) divisible by {} sat (round_threshold)",
                    round_outputs.len(),
                    self.round_threshold
                ),
                txid_hex.clone(),
            )
            .with_metric(round_outputs.len() as f64, "outputs")
            .with_threshold(self.min_round_outputs as f64, true),
        );

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Value,
                format!(
                    "Largest round output: {} sat (divisible by {})",
                    largest, self.round_threshold
                ),
                txid_hex,
            )
            .with_metric(largest as f64, "satoshis"),
        );

        chain.finalize();
        Some(chain)
    }
}

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

    fn make_features_with_values(output_values: Vec<i64>) -> TxFeatures {
        let output_count = output_values.len() as i32;
        TxFeatures {
            txid: vec![0x05u8; 32],
            block_height: 840_000,
            block_timestamp: Utc::now(),
            input_count: 1,
            output_count,
            is_coinbase: false,
            total_input_value: output_values.iter().sum::<i64>() + 1_000,
            total_output_value: output_values.iter().sum(),
            fee: 1_000,
            fee_rate_sat_vb: Some(5.0),
            input_p2pkh_count: 1,
            output_p2pkh_count: output_count,
            is_simple_send: output_count == 2,
            tx_vsize_vbytes: 200,
            tx_version: 1,
            input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
            output_values,
            block_price_usd: Some(50_000.0),
            ..Default::default()
        }
    }

    #[test]
    fn test_round_value_detected() {
        let h = RoundValueHeuristic::default();
        let f = make_features_with_values(vec![1_000_000_000, 23_456_789]);
        assert!(h.evaluate(&f).is_some());
    }

    #[test]
    fn test_round_value_not_detected_due_to_price() {
        let h = RoundValueHeuristic::default();
        let f = make_features_with_values(vec![500_000_000, 87_654_321]);
        assert!(
            h.evaluate(&f).is_none(),
            "high BTC value but low USD value should not fire"
        );
    }

    #[test]
    fn test_round_value_not_detected_due_to_non_round() {
        let h = RoundValueHeuristic::default();
        let f = make_features_with_values(vec![1_000_123_456]);
        assert!(h.evaluate(&f).is_none(), "non-rounded values do not fire");
    }

    #[test]
    fn test_round_value_no_price() {
        let h = RoundValueHeuristic::default();
        let mut f = make_features_with_values(vec![1_000_000_000]);
        f.block_price_usd = None;
        assert!(h.evaluate(&f).is_none());
    }

    #[test]
    fn test_round_value_skips_coinbase() {
        let h = RoundValueHeuristic::default();
        let mut f = make_features_with_values(vec![1_000_000_000]);
        f.is_coinbase = true;
        assert!(h.evaluate(&f).is_none());
    }

    #[test]
    fn test_round_value_custom_threshold() {
        let h = RoundValueHeuristic {
            round_threshold: 50_000_000,
            min_usd_value: 1_000_000.0,
            ..RoundValueHeuristic::default()
        };
        let f = make_features_with_values(vec![2_000_000_000, 23_000_000]);
        assert!(h.evaluate(&f).is_some(), "20 BTC at 50k meets $1M");
        let f2 = make_features_with_values(vec![2_510_000_000]);
        assert!(h.evaluate(&f2).is_none(), "is not divisible by 0.5 BTC");
    }
}