use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};
use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;
pub struct UrgentExecutionHeuristic {
pub spike_threshold_sat_vb: f64,
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"
);
}
}