use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};
use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::{TxFeatures, UtxoAgeVariance};
pub struct HftConsolidationHeuristic;
impl Heuristic for HftConsolidationHeuristic {
fn id(&self) -> &'static str {
"hft-consolidation-v1"
}
fn version(&self) -> &'static str {
"1.0.0"
}
fn trigger_scope(&self) -> &'static str {
"block"
}
fn status(&self) -> HeuristicStatus {
HeuristicStatus::Experimental
}
fn evaluate(&self, f: &TxFeatures) -> Option<HeuristicMatch> {
if f.is_coinbase {
return None;
}
if f.input_count < 100 {
return None;
}
if f.output_count > 3 {
return None;
}
let btc_price = f.block_price_usd?;
let total_btc = f.total_input_value as f64 / 100_000_000.0;
let total_usd = total_btc * btc_price;
if total_usd < 25_000.0 {
return None;
}
if f.utxo_ages_blocks.is_empty() {
return None;
}
let max_age = f.utxo_ages_blocks.values().copied().max().unwrap_or(0);
if max_age > 1008 {
return None;
}
if matches!(
f.utxo_age_variance,
UtxoAgeVariance::Dispersed | UtxoAgeVariance::HighlyDispersed
) {
return None;
}
let summary = format!(
"HFT consolidation: {} fresh inputs (max age {} blocks, ≤1008) \
→ {} outputs (${:.0} USD) at block {}",
f.input_count, max_age, f.output_count, total_usd, f.block_height
);
Some(HeuristicMatch::new(
self.id(),
self.version(),
"hft_consolidation",
"coordinated_fresh_consolidation",
self.trigger_scope(),
summary,
serde_json::json!({
"input_count": f.input_count,
"output_count": f.output_count,
"total_input_value": f.total_input_value,
"max_utxo_age_blocks": max_age,
"utxo_age_variance": format!("{:?}", f.utxo_age_variance),
"fee_rate_sat_vb": f.fee_rate_sat_vb,
}),
))
}
fn build_evidence(&self, f: &TxFeatures) -> Option<EvidenceChain> {
if f.is_coinbase || f.input_count < 100 || f.utxo_ages_blocks.is_empty() {
return None;
}
let btc_price = f.block_price_usd?;
let total_btc = f.total_input_value as f64 / 100_000_000.0;
let total_usd = total_btc * btc_price;
if total_usd < 25_000.0 {
return None;
}
let max_age = f.utxo_ages_blocks.values().copied().max().unwrap_or(0);
if max_age > 1008 {
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::Structural,
"Tactical accumulation (over $25,000 USD)".to_string(),
txid_hex.clone(),
)
.with_metric(total_usd, "USD")
.with_threshold(25_000.0, total_usd >= 25_000.0),
);
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Structural,
format!(
"{} inputs consolidated into {} outputs (HFT pattern)",
f.input_count, f.output_count
),
txid_hex.clone(),
)
.with_metric(f.input_count as f64, "inputs")
.with_threshold(100.0, true),
);
chain.add_link(
EvidenceLink::new(
EvidenceCategory::Temporal,
format!(
"All sampled inputs fresh: max age {} blocks (≤1008 = 7 days)",
max_age
),
txid_hex,
)
.with_metric(max_age as f64, "blocks")
.with_threshold(1008.0, true),
);
chain.finalize();
Some(chain)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::collections::HashMap;
fn make_base() -> TxFeatures {
let mut ages = HashMap::new();
for i in 0u32..100 {
ages.insert((vec![i as u8; 32], 0u32), 500i64);
}
TxFeatures {
txid: vec![0xaau8; 32],
block_height: 840_000,
block_timestamp: Utc::now(),
input_count: 100,
output_count: 1,
is_coinbase: false,
total_input_value: 50_000_000,
utxo_ages_blocks: ages,
utxo_age_variance: UtxoAgeVariance::Uniform,
block_price_usd: Some(60_000.0),
..Default::default()
}
}
#[test]
fn test_sniper_detected() {
let h = HftConsolidationHeuristic;
let f = make_base();
let result = h.evaluate(&f);
assert!(result.is_some(), "HFT should fire with correct features");
let r = result.unwrap();
assert_eq!(r.event_type, "hft_consolidation");
assert_eq!(r.trigger_scope, "block");
}
#[test]
fn test_sniper_too_few_inputs() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.input_count = 50;
assert!(h.evaluate(&f).is_none(), "50 inputs < 100 should not fire");
}
#[test]
fn test_sniper_old_utxos() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.utxo_ages_blocks.insert((vec![0xffu8; 32], 0), 2000i64);
assert!(
h.evaluate(&f).is_none(),
"max_age 2000 > 1008 should not fire"
);
}
#[test]
fn test_sniper_too_many_outputs() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.output_count = 5;
assert!(
h.evaluate(&f).is_none(),
"output_count 5 > 3 should not fire"
);
}
#[test]
fn test_sniper_no_age_data() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.utxo_ages_blocks.clear();
assert!(
h.evaluate(&f).is_none(),
"empty utxo_ages_blocks should not fire"
);
}
#[test]
fn test_sniper_dispersed_variance() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.utxo_age_variance = UtxoAgeVariance::HighlyDispersed;
assert!(h.evaluate(&f).is_none(), "HighlyDispersed should not fire");
}
#[test]
fn test_sniper_build_evidence() {
let h = HftConsolidationHeuristic;
let f = make_base();
let chain = h.build_evidence(&f);
assert!(chain.is_some(), "build_evidence should return Some");
let chain = chain.unwrap();
assert!(chain.links.len() >= 2, "should have ≥2 links");
let has_structural = chain
.links
.iter()
.any(|l| l.category == EvidenceCategory::Structural);
let has_temporal = chain
.links
.iter()
.any(|l| l.category == EvidenceCategory::Temporal);
assert!(has_structural, "should have Structural link");
assert!(has_temporal, "should have Temporal link");
}
#[test]
fn test_sniper_summary_no_forbidden_words() {
let h = HftConsolidationHeuristic;
let f = make_base();
let result = h.evaluate(&f).unwrap();
let forbidden = [
"suspicious",
"malicious",
"laundering",
"illegal",
"criminal",
"fraud",
"scam",
];
for word in &forbidden {
assert!(
!result.summary.to_lowercase().contains(word),
"summary contains forbidden word '{}'",
word
);
}
}
#[test]
fn test_sniper_coinbase_skipped() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.is_coinbase = true;
assert!(h.evaluate(&f).is_none(), "coinbase should be ignored");
}
#[test]
fn test_sniper_below_min_value() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.total_input_value = 10_000_000;
assert!(
h.evaluate(&f).is_none(),
"USD value below minimum should not fire"
);
}
#[test]
fn test_sniper_no_price_skips() {
let h = HftConsolidationHeuristic;
let mut f = make_base();
f.block_price_usd = None;
assert!(h.evaluate(&f).is_none(), "no USD price should skip");
}
}