use evidence_chain::{EvidenceCategory, EvidenceChain, EvidenceLink};
use crate::heuristics::{Heuristic, HeuristicStatus};
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;
pub struct RoundValueHeuristic {
pub round_threshold: i64,
pub min_usd_value: f64,
pub min_round_outputs: usize,
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");
}
}