use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const LOG2_MAX_SATS: f64 = 50.897_f64;
pub fn encode_movement_range_log(sats: i64) -> u8 {
if sats <= 0 {
return 0;
}
let log_val = (1.0 + sats as f64).log2();
((255.0 * log_val / LOG2_MAX_SATS).floor() as i32).clamp(0, 255) as u8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FeeRateTier {
#[default]
SubMinima = 0,
Minima = 1,
UltraPatient = 2,
VeryLow = 3,
Low = 4,
BelowStandard = 5,
NormalLow = 6,
Normal = 7,
NormalMedium = 8,
MarketStandard = 9,
AboveStandard = 10,
High = 11,
Priority = 12,
Urgent = 13,
UrgentHigh = 14,
PanicLow = 15,
Panic = 16,
PanicHigh = 17,
Despair = 18,
ExtremeDespair = 19,
Outlier = 20,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FeePercentileTier {
#[default]
BelowP10 = 0,
P10ToP25 = 1,
P25ToP50 = 2,
P50ToP75 = 3,
P75ToP90 = 4,
P90ToP99 = 5,
AboveP99 = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FeeEfficiencyRatio {
#[default]
UltraEfficient = 0,
Efficient = 1,
NormalLight = 2,
NormalHeavy = 3,
Expensive = 4,
Destructive = 5,
Reserved6 = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AbsoluteFeeTier {
#[default]
Tiny = 0,
VeryLow = 1,
Low = 2,
LowModerate = 3,
Moderate = 4,
MidHigh = 5,
High = 6,
VeryHigh = 7,
ExtremeUrgency = 8,
Critical = 9,
Extreme = 10,
Outlier = 11,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum UtxoAgeVariance {
#[default]
Uniform = 0,
Mixed = 1,
Dispersed = 2,
HighlyDispersed = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StructuralPattern {
#[default]
SimpleSend = 0,
Consolidation = 1,
Distribution = 2,
TotalSweep = 3,
SymmetricComplex = 4,
AsymmetricComplex = 5,
ExtremeFanOut = 6,
ExtremeFanIn = 7,
Reserved8 = 8,
Reserved9 = 9,
Reserved10 = 10,
Reserved11 = 11,
Reserved12 = 12,
Reserved13 = 13,
Reserved14 = 14,
Reserved15 = 15,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum IoScaleTier {
#[default]
Minimal = 0,
Basic = 1,
Low = 2,
Medium = 3,
High = 4,
Institutional = 5,
Wholesale = 6,
Massive = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ChangeDetection {
#[default]
None = 0,
ProbableRoundNumber = 1,
ProbableScriptContinuity = 2,
ProbableValueProximity = 3,
HighRoundNumber = 4,
HighScoreContinuity = 5,
HighMultiMethod = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CoinJoinScore {
#[default]
None = 0,
SuspectLow = 1,
SuspectMed = 2,
Probable = 3,
HighProb = 4,
Guaranteed = 5,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AddressHotness {
#[default]
Virgin = 0,
Recent = 1,
Recurring = 2,
Reserved3 = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MultisigThreshold {
#[default]
None = 0,
OneOfTwo = 1,
TwoOfTwo = 2,
TwoOfThree = 3,
ThreeOfFive = 4,
MOfNComplex = 5,
Reserved6 = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TaprootSpendPath {
#[default]
None = 0,
KeyPath = 1,
ScriptPath = 2,
Mixed = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EmbeddedProtocol {
#[default]
None = 0,
OpReturnHash = 1,
OpReturnData = 2,
OrdinalsBRC20 = 3,
Runes = 4,
Stamps = 5,
MultiProtocol = 6,
LargeOpReturn = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TimelockFingerprint {
#[default]
None = 0,
AntiFeeSnipingCurrent = 1,
AntiFeeSnipingFuzzy = 2,
FutureBlockLocked = 3,
FutureTimeLocked = 4,
Reserved5 = 5,
Reserved6 = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum LightningIndicator {
#[default]
NoSignal = 0,
ChannelOpen = 1,
CooperativeClose = 2,
ForceClose = 3,
HtlcResolution = 4,
AnchorOutput = 5,
Reserved6 = 6,
Reserved7 = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VsizeTier {
#[default]
Light = 0,
Medium = 1,
Heavy = 2,
Massive = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TxVersionTier {
#[default]
V1 = 0,
V2 = 1,
V3 = 2,
Unknown = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EntityPresence {
#[default]
Anonymous = 0,
KnownOrigin = 1,
KnownDestination = 2,
InternalTransfer = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EntityCategory {
#[default]
None = 0,
ExchangeTier1 = 1,
ExchangeTier2 = 2,
DexOnChain = 3,
MinerPool = 4,
CustodyService = 5,
MixerPrivacy = 6,
DarknetIllicit = 7,
OfacSdnHack = 8,
DefiBridge = 9,
EtfInstitutional = 10,
GovSeizure = 11,
}
impl EntityCategory {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"exchange" => Self::ExchangeTier1,
"mining_pool" => Self::MinerPool,
"custodian" => Self::CustodyService,
"darknet" => Self::DarknetIllicit,
"seized" => Self::GovSeizure,
"defi" => Self::DefiBridge,
"mixer" => Self::MixerPrivacy,
_ => Self::None,
}
}
}
impl FeeRateTier {
pub fn from_sat_vb(rate: f64) -> Self {
if rate < 1.0 {
Self::SubMinima
} else if rate < 2.0 {
Self::Minima
} else if rate < 3.0 {
Self::UltraPatient
} else if rate < 4.0 {
Self::VeryLow
} else if rate < 6.0 {
Self::Low
} else if rate < 9.0 {
Self::BelowStandard
} else if rate < 12.0 {
Self::NormalLow
} else if rate < 16.0 {
Self::Normal
} else if rate < 21.0 {
Self::NormalMedium
} else if rate < 26.0 {
Self::MarketStandard
} else if rate < 34.0 {
Self::AboveStandard
} else if rate < 45.0 {
Self::High
} else if rate < 61.0 {
Self::Priority
} else if rate < 81.0 {
Self::Urgent
} else if rate < 101.0 {
Self::UrgentHigh
} else if rate < 141.0 {
Self::PanicLow
} else if rate < 201.0 {
Self::Panic
} else if rate < 301.0 {
Self::PanicHigh
} else if rate < 501.0 {
Self::Despair
} else if rate < 1001.0 {
Self::ExtremeDespair
} else {
Self::Outlier
}
}
}
impl FeeEfficiencyRatio {
pub fn from_values(fee: i64, total_input: i64) -> Self {
if total_input == 0 {
return Self::NormalLight;
}
let ratio = (fee as f64 / total_input as f64) * 100.0;
if ratio < 0.001 {
Self::UltraEfficient
} else if ratio < 0.01 {
Self::Efficient
} else if ratio < 0.05 {
Self::NormalLight
} else if ratio < 0.1 {
Self::NormalHeavy
} else if ratio < 1.0 {
Self::Expensive
} else {
Self::Destructive
}
}
}
impl AbsoluteFeeTier {
pub fn from_sats(sats: i64) -> Self {
if sats < 100 {
Self::Tiny
} else if sats < 500 {
Self::VeryLow
} else if sats < 2_000 {
Self::Low
} else if sats < 5_000 {
Self::LowModerate
} else if sats < 15_000 {
Self::Moderate
} else if sats < 50_000 {
Self::MidHigh
} else if sats < 100_000 {
Self::High
} else if sats < 200_000 {
Self::VeryHigh
} else if sats < 500_000 {
Self::ExtremeUrgency
} else if sats < 1_000_000 {
Self::Critical
} else if sats < 5_000_000 {
Self::Extreme
} else {
Self::Outlier
}
}
}
impl VsizeTier {
pub fn from_vsize(vsize: i32) -> Self {
if vsize < 200 {
Self::Light
} else if vsize < 1000 {
Self::Medium
} else if vsize < 5000 {
Self::Heavy
} else {
Self::Massive
}
}
}
impl TxVersionTier {
pub fn from_version(version: i32) -> Self {
match version {
1 => Self::V1,
2 => Self::V2,
3 => Self::V3,
_ => Self::Unknown,
}
}
}
impl TimelockFingerprint {
pub fn from_locktime(locktime: u32) -> Self {
Self::from_locktime_with_height(locktime, 0)
}
pub fn from_locktime_with_height(locktime: u32, block_height: i64) -> Self {
if locktime == 0 {
return Self::None;
}
if locktime >= 500_000_000 {
return Self::FutureTimeLocked;
}
let lt = locktime as i64;
if block_height > 0 {
if lt > block_height {
return Self::FutureBlockLocked;
}
if lt == block_height {
return Self::AntiFeeSnipingCurrent;
}
if lt >= block_height - 100 {
return Self::AntiFeeSnipingFuzzy;
}
Self::None
} else if lt > 800_000 {
Self::FutureBlockLocked
} else {
Self::AntiFeeSnipingCurrent
}
}
}
impl CoinJoinScore {
pub fn from_f64(score: f64) -> Self {
if score < 0.20 {
Self::None
} else if score < 0.40 {
Self::SuspectLow
} else if score < 0.60 {
Self::SuspectMed
} else if score < 0.75 {
Self::Probable
} else if score < 0.90 {
Self::HighProb
} else {
Self::Guaranteed
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ScriptType {
#[default]
P2PKH = 0,
P2SH = 1,
P2shP2wpkh = 2,
P2WPKH = 3,
P2WSH = 4,
P2TR = 5,
Multisig = 10,
OpReturn = 11,
Unknown = 12,
Mixed = 7,
}
impl ScriptType {
pub fn to_bits(self) -> u128 {
match self {
ScriptType::P2PKH => 0,
ScriptType::P2SH => 1,
ScriptType::P2shP2wpkh => 2,
ScriptType::P2WPKH => 3,
ScriptType::P2WSH => 4,
ScriptType::P2TR => 5,
ScriptType::Multisig | ScriptType::OpReturn | ScriptType::Unknown => 6,
ScriptType::Mixed => 7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TxFeatures {
pub txid: Vec<u8>,
pub block_height: i64,
pub block_timestamp: DateTime<Utc>,
pub input_count: i32,
pub output_count: i32,
pub is_coinbase: bool,
pub total_input_value: i64,
pub total_output_value: i64,
pub fee: i64,
pub output_value_min: i64,
pub output_value_max: i64,
pub output_value_median: f64,
pub output_value_variance: f64,
pub fee_rate_sat_vb: Option<f64>,
pub input_p2pkh_count: i32,
pub input_p2sh_count: i32,
pub input_p2wpkh_count: i32,
pub input_p2wsh_count: i32,
pub input_p2tr_count: i32,
pub input_unknown_count: i32,
pub output_p2pkh_count: i32,
pub output_p2sh_count: i32,
pub output_p2wpkh_count: i32,
pub output_p2wsh_count: i32,
pub output_p2tr_count: i32,
pub output_op_return_count: i32,
pub output_unknown_count: i32,
pub is_consolidation: bool,
pub is_distribution: bool,
pub is_simple_send: bool,
pub is_sweep: bool,
pub has_equal_outputs: bool,
pub coinjoin_score: f64,
pub has_multisig: bool,
pub has_op_return: bool,
pub is_rbf_enabled: bool,
pub has_locktime: bool,
pub tx_vsize_vbytes: i32,
pub tx_locktime: i64,
pub tx_version: i32,
pub input_utxo_refs: Vec<(Vec<u8>, u32)>,
pub output_values: Vec<i64>,
pub unique_output_addresses: Option<u32>,
pub address_reuse_in_tx: bool,
pub output_addresses: Vec<Option<String>>,
#[serde(skip)]
pub utxo_ages_blocks: HashMap<(Vec<u8>, u32), i64>,
pub movement_range: u8,
pub fee_rate_tier: FeeRateTier,
pub fee_percentile_tier: FeePercentileTier,
pub fee_efficiency_ratio: FeeEfficiencyRatio,
pub absolute_fee_tier: AbsoluteFeeTier,
pub utxo_age_max: u8,
pub utxo_age_median: u8,
pub utxo_age_variance: UtxoAgeVariance,
pub structural_pattern: StructuralPattern,
pub io_scale_tier: IoScaleTier,
pub change_detection: ChangeDetection,
pub coinjoin_probability: CoinJoinScore,
pub has_dust_output: bool,
pub address_hotness: AddressHotness,
pub multisig_threshold: MultisigThreshold,
pub is_input_script_homogeneous: bool,
pub taproot_spend_path: TaprootSpendPath,
pub embedded_protocol: EmbeddedProtocol,
pub timelock_fingerprint: TimelockFingerprint,
pub lightning_indicator: LightningIndicator,
pub has_relative_timelock: bool,
pub vsize_tier: VsizeTier,
pub tx_version_tier: TxVersionTier,
pub is_cpfp_signal: bool,
pub entity_presence: EntityPresence,
pub entity_category: EntityCategory,
pub dominant_input_script: ScriptType,
pub dominant_output_script: ScriptType,
pub block_fee_median_sat_vb: f64,
pub script_type_evolution: bool,
pub block_price_usd: Option<f64>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_features() -> TxFeatures {
TxFeatures {
txid: vec![1u8; 32],
block_height: 840_000,
block_timestamp: Utc::now(),
input_count: 1,
output_count: 2,
is_coinbase: false,
total_input_value: 100_000,
total_output_value: 99_000,
fee: 1_000,
output_value_min: 40_000,
output_value_max: 59_000,
output_value_median: 49_500.0,
output_value_variance: 9_025_000.0,
fee_rate_sat_vb: Some(5.0),
input_p2pkh_count: 1,
is_simple_send: true,
output_p2pkh_count: 2,
tx_vsize_vbytes: 200,
tx_version: 1,
input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
output_values: vec![40_000, 59_000],
is_input_script_homogeneous: true,
..Default::default()
}
}
#[test]
fn test_entity_category_from_str() {
assert_eq!(
EntityCategory::from_str("exchange"),
EntityCategory::ExchangeTier1
);
assert_eq!(
EntityCategory::from_str("EXCHANGE"),
EntityCategory::ExchangeTier1
);
assert_eq!(
EntityCategory::from_str("mining_pool"),
EntityCategory::MinerPool
);
assert_eq!(
EntityCategory::from_str("custodian"),
EntityCategory::CustodyService
);
assert_eq!(
EntityCategory::from_str("darknet"),
EntityCategory::DarknetIllicit
);
assert_eq!(
EntityCategory::from_str("seized"),
EntityCategory::GovSeizure
);
assert_eq!(EntityCategory::from_str("defi"), EntityCategory::DefiBridge);
assert_eq!(
EntityCategory::from_str("mixer"),
EntityCategory::MixerPrivacy
);
assert_eq!(EntityCategory::from_str("unknown"), EntityCategory::None);
}
#[test]
fn test_tx_features_clone() {
let f = make_features();
let cloned = f.clone();
assert_eq!(f.txid, cloned.txid);
assert_eq!(f.block_height, cloned.block_height);
assert_eq!(f.input_count, cloned.input_count);
assert_eq!(f.is_simple_send, cloned.is_simple_send);
}
#[test]
fn test_tx_features_serde_roundtrip() {
let f = make_features();
let json = serde_json::to_string(&f).unwrap();
let decoded: TxFeatures = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.txid, f.txid);
assert_eq!(decoded.block_height, f.block_height);
assert_eq!(decoded.fee, f.fee);
assert_eq!(decoded.is_simple_send, f.is_simple_send);
assert!((decoded.coinjoin_score - f.coinjoin_score).abs() < f64::EPSILON);
}
#[test]
fn test_tx_features_default_values() {
let f = make_features();
assert!(!f.is_consolidation);
assert!(!f.is_distribution);
assert!(f.is_simple_send);
assert!(!f.is_sweep);
assert!(!f.has_equal_outputs);
assert!(!f.has_multisig);
assert!(!f.has_op_return);
assert!(!f.is_rbf_enabled);
assert!(!f.has_locktime);
assert!(!f.is_coinbase);
assert_eq!(f.coinjoin_score, 0.0);
}
#[test]
fn test_encode_movement_range_log_zero() {
assert_eq!(encode_movement_range_log(0), 0);
assert_eq!(encode_movement_range_log(-1), 0);
}
#[test]
fn test_encode_movement_range_log_monotone() {
let sats = [
1,
100,
1_000,
100_000,
1_000_000,
100_000_000,
21_000_000_000_000_i64,
];
let tiers: Vec<u8> = sats.iter().map(|&s| encode_movement_range_log(s)).collect();
for i in 1..tiers.len() {
assert!(
tiers[i] >= tiers[i - 1],
"tier[{}]={} should be >= tier[{}]={} (sat: {} vs {})",
i,
tiers[i],
i - 1,
tiers[i - 1],
sats[i],
sats[i - 1]
);
}
}
#[test]
fn test_encode_movement_range_log_max_sats() {
assert_eq!(encode_movement_range_log(2_100_000_000_000_000), 255);
}
#[test]
fn test_fee_rate_tier_sat_vb() {
assert_eq!(FeeRateTier::from_sat_vb(0.5), FeeRateTier::SubMinima);
assert_eq!(FeeRateTier::from_sat_vb(5.0), FeeRateTier::Low);
assert_eq!(FeeRateTier::from_sat_vb(20.0), FeeRateTier::NormalMedium);
assert_eq!(FeeRateTier::from_sat_vb(1_500.0), FeeRateTier::Outlier);
}
#[test]
fn test_absolute_fee_tier() {
assert_eq!(AbsoluteFeeTier::from_sats(50), AbsoluteFeeTier::Tiny);
assert_eq!(AbsoluteFeeTier::from_sats(5_000), AbsoluteFeeTier::Moderate);
assert_eq!(
AbsoluteFeeTier::from_sats(10_000_000),
AbsoluteFeeTier::Outlier
);
}
#[test]
fn test_vsize_tier() {
assert_eq!(VsizeTier::from_vsize(100), VsizeTier::Light);
assert_eq!(VsizeTier::from_vsize(500), VsizeTier::Medium);
assert_eq!(VsizeTier::from_vsize(10_000), VsizeTier::Massive);
}
#[test]
fn test_tx_version_tier() {
assert_eq!(TxVersionTier::from_version(1), TxVersionTier::V1);
assert_eq!(TxVersionTier::from_version(2), TxVersionTier::V2);
assert_eq!(TxVersionTier::from_version(99), TxVersionTier::Unknown);
}
#[test]
fn test_timelock_fingerprint_no_locktime() {
assert_eq!(
TimelockFingerprint::from_locktime(0),
TimelockFingerprint::None
);
}
#[test]
fn test_timelock_fingerprint_future_time() {
assert_eq!(
TimelockFingerprint::from_locktime(500_000_001),
TimelockFingerprint::FutureTimeLocked
);
}
#[test]
fn test_coinjoin_score_enum() {
assert_eq!(CoinJoinScore::from_f64(0.0), CoinJoinScore::None);
assert_eq!(CoinJoinScore::from_f64(0.5), CoinJoinScore::SuspectMed);
assert_eq!(CoinJoinScore::from_f64(0.95), CoinJoinScore::Guaranteed);
}
#[test]
fn test_script_type_to_bits_unique() {
use std::collections::HashSet;
let types = [
ScriptType::P2PKH,
ScriptType::P2SH,
ScriptType::P2shP2wpkh,
ScriptType::P2WPKH,
ScriptType::P2WSH,
ScriptType::P2TR,
ScriptType::Mixed,
];
let bits: HashSet<u128> = types.iter().map(|t| t.to_bits()).collect();
assert_eq!(
bits.len(),
types.len(),
"each ScriptType should map to a unique bit"
);
}
#[test]
fn test_fee_efficiency_ratio() {
assert_eq!(
FeeEfficiencyRatio::from_values(1, 1_000_000),
FeeEfficiencyRatio::UltraEfficient
);
assert_eq!(
FeeEfficiencyRatio::from_values(100_000, 1_000_000),
FeeEfficiencyRatio::Destructive
);
assert_eq!(
FeeEfficiencyRatio::from_values(0, 0),
FeeEfficiencyRatio::NormalLight
);
}
}