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 transactions with disproportionate fees (fee spike).
///
/// Fires when `fee_rate_sat_vb` exceeds the configured threshold.
/// May indicate extreme urgency, fee estimator error, or out-of-band acceleration.
pub struct UrgentExecutionHeuristic {
    /// Minimum rate in sat/vByte to be considered suspicious. Default: 500.0.
    pub spike_threshold_sat_vb: f64,
    /// Minimum absolute fee in satoshis to avoid false positives. Default: 10_000.
    pub min_absolute_fee_sats: i64,
}

impl Default for UrgentExecutionHeuristic {
    fn default() -> Self {
        Self {
            spike_threshold_sat_vb: 500.0,
            min_absolute_fee_sats: 10_000,
        }
    }
}

impl Heuristic for UrgentExecutionHeuristic {
    fn id(&self) -> &'static str {
        "urgent-execution-v1"
    }

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

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

    fn evaluate(&self, f: &TxFeatures) -> Option<HeuristicMatch> {
        if f.is_coinbase {
            return None;
        }

        let fee_rate = f.fee_rate_sat_vb?;

        let effective_threshold = if f.block_fee_median_sat_vb > 0.0 {
            self.spike_threshold_sat_vb
                .max(f.block_fee_median_sat_vb * 3.0)
        } else {
            self.spike_threshold_sat_vb
        };

        if fee_rate < effective_threshold || f.fee < self.min_absolute_fee_sats {
            return None;
        }

        let btc_price = f.block_price_usd?;
        let total_btc = f.total_output_value as f64 / 100_000_000.0;
        let total_usd = total_btc * btc_price;
        if total_usd < 10_000.0 {
            return None;
        }

        let summary = format!(
            "Urgent execution: {:.1} sat/vB ({} sats total) at block {}",
            fee_rate, f.fee, f.block_height
        );

        Some(HeuristicMatch::new(
            self.id(),
            self.version(),
            "urgent_execution",
            "fee_anomaly",
            self.trigger_scope(),
            summary,
            serde_json::json!({
                "fee_rate_sat_vb": fee_rate,
                "effective_threshold_sat_vb": effective_threshold,
                "block_fee_median_sat_vb": f.block_fee_median_sat_vb,
                "fee_sats": f.fee,
                "tx_vsize_vbytes": f.tx_vsize_vbytes,
                "input_count": f.input_count,
                "output_count": f.output_count,
                "total_output_value": f.total_output_value,
                "is_cpfp_signal": f.is_cpfp_signal,
            }),
        ))
    }

    fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
        if f.is_coinbase {
            return None;
        }
        let fee_rate = f.fee_rate_sat_vb?;
        let effective_threshold = if f.block_fee_median_sat_vb > 0.0 {
            self.spike_threshold_sat_vb
                .max(f.block_fee_median_sat_vb * 3.0)
        } else {
            self.spike_threshold_sat_vb
        };
        let btc_price = f.block_price_usd?;
        let total_btc = f.total_output_value as f64 / 100_000_000.0;
        let total_usd = total_btc * btc_price;

        if fee_rate < effective_threshold
            || f.fee < self.min_absolute_fee_sats
            || total_usd < 10_000.0
        {
            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::Value,
                format!(
                    "Fee rate {fee_rate:.1} sat/vB exceeds effective threshold {effective_threshold:.1} \
                     (block_median={:.1}, abs_threshold={})",
                    f.block_fee_median_sat_vb, self.spike_threshold_sat_vb
                ),
                txid_hex.clone(),
            )
            .with_metric(fee_rate, "sat/vB")
            .with_threshold(effective_threshold, fee_rate >= effective_threshold),
        );

        chain.add_link(
            EvidenceLink::new(
                EvidenceCategory::Value,
                format!(
                    "Absolute fee {} sat exceeds minimum {}",
                    f.fee, self.min_absolute_fee_sats
                ),
                txid_hex,
            )
            .with_metric(f.fee as f64, "satoshis")
            .with_threshold(
                self.min_absolute_fee_sats as f64,
                f.fee >= self.min_absolute_fee_sats,
            ),
        );

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

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

    fn make_features(fee_rate: Option<f64>, fee: i64) -> TxFeatures {
        let vsize = if let Some(rate) = fee_rate {
            if rate > 0.0 {
                (fee as f64 / rate).round() as i32
            } else {
                0
            }
        } else {
            0
        };
        TxFeatures {
            txid: vec![0x04u8; 32],
            block_height: 840_000,
            block_timestamp: Utc::now(),
            input_count: 1,
            output_count: 2,
            is_coinbase: false,
            total_input_value: 10_000_000 + fee,
            total_output_value: 10_000_000,
            fee,
            output_value_min: 400_000,
            output_value_max: 600_000,
            output_value_median: 500_000.0,
            fee_rate_sat_vb: fee_rate,
            input_p2pkh_count: 1,
            output_p2pkh_count: 2,
            is_simple_send: true,
            is_rbf_enabled: true,
            tx_vsize_vbytes: vsize,
            tx_version: 2,
            input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
            output_values: vec![400_000, 600_000],
            block_price_usd: Some(60_000.0),
            ..Default::default()
        }
    }

    #[test]
    fn test_urgent_execution_detected() {
        let h = UrgentExecutionHeuristic::default();
        let mut f = make_features(Some(1_000.0), 100_000);
        f.total_output_value = 20_000_000;
        let result = h.evaluate(&f);
        assert!(result.is_some(), "1000 sat/vB should fire");
        assert_eq!(result.unwrap().event_type, "urgent_execution");
    }

    #[test]
    fn test_urgent_execution_below_threshold() {
        let h = UrgentExecutionHeuristic::default();
        let f = make_features(Some(50.0), 10_000);
        assert!(h.evaluate(&f).is_none(), "50 sat/vB is not a spike");
    }

    #[test]
    fn test_urgent_execution_above_threshold_but_low_absolute() {
        let h = UrgentExecutionHeuristic::default();
        let f = make_features(Some(600.0), 5_000);
        assert!(
            h.evaluate(&f).is_none(),
            "absolute fee < 10,000 should not fire"
        );
    }

    #[test]
    fn test_urgent_execution_none_fee_rate() {
        let h = UrgentExecutionHeuristic::default();
        let f = make_features(None, 100_000);
        assert!(h.evaluate(&f).is_none(), "None fee_rate should not fire");
    }

    #[test]
    fn test_urgent_execution_skips_coinbase() {
        let h = UrgentExecutionHeuristic::default();
        let mut f = make_features(Some(2_000.0), 200_000);
        f.is_coinbase = true;
        assert!(h.evaluate(&f).is_none(), "coinbase ignored");
    }

    #[test]
    fn test_urgent_execution_exact_threshold() {
        let h = UrgentExecutionHeuristic::default();
        let mut f = make_features(Some(500.0), 50_000);
        f.total_output_value = 20_000_000;
        assert!(
            h.evaluate(&f).is_some(),
            "500 sat/vB == threshold should fire"
        );
    }

    #[test]
    fn test_urgent_execution_no_price_skips() {
        let h = UrgentExecutionHeuristic::default();
        let mut f = make_features(Some(1000.0), 100_000);
        f.block_price_usd = None;
        assert!(h.evaluate(&f).is_none(), "no USD price should skip");
    }

    #[test]
    fn test_urgent_execution_below_usd() {
        let h = UrgentExecutionHeuristic::default();
        let mut f = make_features(Some(1000.0), 100_000);
        f.total_output_value = 1_000_000;
        assert!(
            h.evaluate(&f).is_none(),
            "transaction < $10k USD should be ignored"
        );
    }
}