Skip to main content

bitcoin_heuristics/
models.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6// --- GENOME SIGNATURE ENUMS ---
7
8/// log₂(1 + 2,100,000,000,000,000) — ceiling of ~21M BTC in satoshis.
9const LOG2_MAX_SATS: f64 = 50.897_f64;
10
11/// Encodes the volume of satoshis moved into an 8-bit tier via logarithmic scale.
12///
13/// Formula: `tier = clamp(floor(255 × log₂(1 + sats) / log₂(1 + 2.1×10¹⁵)), 0, 255)`
14///
15/// Uniformly distributes 256 bands of the total Bitcoin supply (~2.1×10¹⁵ sats).
16pub fn encode_movement_range_log(sats: i64) -> u8 {
17    if sats <= 0 {
18        return 0;
19    }
20    let log_val = (1.0 + sats as f64).log2();
21    ((255.0 * log_val / LOG2_MAX_SATS).floor() as i32).clamp(0, 255) as u8
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum FeeRateTier {
26    #[default]
27    SubMinima = 0,
28    Minima = 1,
29    UltraPatient = 2,
30    VeryLow = 3,
31    Low = 4,
32    BelowStandard = 5,
33    NormalLow = 6,
34    Normal = 7,
35    NormalMedium = 8,
36    MarketStandard = 9,
37    AboveStandard = 10,
38    High = 11,
39    Priority = 12,
40    Urgent = 13,
41    UrgentHigh = 14,
42    PanicLow = 15,
43    Panic = 16,
44    PanicHigh = 17,
45    Despair = 18,
46    ExtremeDespair = 19,
47    Outlier = 20,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
51pub enum FeePercentileTier {
52    #[default]
53    BelowP10 = 0,
54    P10ToP25 = 1,
55    P25ToP50 = 2,
56    P50ToP75 = 3,
57    P75ToP90 = 4,
58    P90ToP99 = 5,
59    AboveP99 = 6,
60    Reserved7 = 7,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub enum FeeEfficiencyRatio {
65    #[default]
66    UltraEfficient = 0,
67    Efficient = 1,
68    NormalLight = 2,
69    NormalHeavy = 3,
70    Expensive = 4,
71    Destructive = 5,
72    Reserved6 = 6,
73    Reserved7 = 7,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
77pub enum AbsoluteFeeTier {
78    #[default]
79    Tiny = 0,
80    VeryLow = 1,
81    Low = 2,
82    LowModerate = 3,
83    Moderate = 4,
84    MidHigh = 5,
85    High = 6,
86    VeryHigh = 7,
87    ExtremeUrgency = 8,
88    Critical = 9,
89    Extreme = 10,
90    Outlier = 11,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
94pub enum UtxoAgeVariance {
95    #[default]
96    Uniform = 0,
97    Mixed = 1,
98    Dispersed = 2,
99    HighlyDispersed = 3,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
103pub enum StructuralPattern {
104    #[default]
105    SimpleSend = 0,
106    Consolidation = 1,
107    Distribution = 2,
108    TotalSweep = 3,
109    SymmetricComplex = 4,
110    AsymmetricComplex = 5,
111    ExtremeFanOut = 6,
112    ExtremeFanIn = 7,
113    Reserved8 = 8,
114    Reserved9 = 9,
115    Reserved10 = 10,
116    Reserved11 = 11,
117    Reserved12 = 12,
118    Reserved13 = 13,
119    Reserved14 = 14,
120    Reserved15 = 15,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
124pub enum IoScaleTier {
125    #[default]
126    Minimal = 0,
127    Basic = 1,
128    Low = 2,
129    Medium = 3,
130    High = 4,
131    Institutional = 5,
132    Wholesale = 6,
133    Massive = 7,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub enum ChangeDetection {
138    #[default]
139    None = 0,
140    ProbableRoundNumber = 1,
141    ProbableScriptContinuity = 2,
142    ProbableValueProximity = 3,
143    HighRoundNumber = 4,
144    HighScoreContinuity = 5,
145    HighMultiMethod = 6,
146    Reserved7 = 7,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150pub enum CoinJoinScore {
151    #[default]
152    None = 0,
153    SuspectLow = 1,
154    SuspectMed = 2,
155    Probable = 3,
156    HighProb = 4,
157    Guaranteed = 5,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
161pub enum AddressHotness {
162    #[default]
163    Virgin = 0,
164    Recent = 1,
165    Recurring = 2,
166    Reserved3 = 3,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
170pub enum MultisigThreshold {
171    #[default]
172    None = 0,
173    OneOfTwo = 1,
174    TwoOfTwo = 2,
175    TwoOfThree = 3,
176    ThreeOfFive = 4,
177    MOfNComplex = 5,
178    Reserved6 = 6,
179    Reserved7 = 7,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183pub enum TaprootSpendPath {
184    #[default]
185    None = 0,
186    KeyPath = 1,
187    ScriptPath = 2,
188    Mixed = 3,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
192pub enum EmbeddedProtocol {
193    #[default]
194    None = 0,
195    OpReturnHash = 1,
196    OpReturnData = 2,
197    OrdinalsBRC20 = 3,
198    Runes = 4,
199    Stamps = 5,
200    MultiProtocol = 6,
201    LargeOpReturn = 7,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
205pub enum TimelockFingerprint {
206    #[default]
207    None = 0,
208    AntiFeeSnipingCurrent = 1,
209    AntiFeeSnipingFuzzy = 2,
210    FutureBlockLocked = 3,
211    FutureTimeLocked = 4,
212    Reserved5 = 5,
213    Reserved6 = 6,
214    Reserved7 = 7,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
218pub enum LightningIndicator {
219    #[default]
220    NoSignal = 0,
221    ChannelOpen = 1,
222    CooperativeClose = 2,
223    ForceClose = 3,
224    HtlcResolution = 4,
225    AnchorOutput = 5,
226    Reserved6 = 6,
227    Reserved7 = 7,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
231pub enum VsizeTier {
232    #[default]
233    Light = 0,
234    Medium = 1,
235    Heavy = 2,
236    Massive = 3,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
240pub enum TxVersionTier {
241    #[default]
242    V1 = 0,
243    V2 = 1,
244    V3 = 2,
245    Unknown = 3,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
249pub enum EntityPresence {
250    #[default]
251    Anonymous = 0,
252    KnownOrigin = 1,
253    KnownDestination = 2,
254    InternalTransfer = 3,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
258pub enum EntityCategory {
259    #[default]
260    None = 0,
261    ExchangeTier1 = 1,
262    ExchangeTier2 = 2,
263    DexOnChain = 3,
264    MinerPool = 4,
265    CustodyService = 5,
266    MixerPrivacy = 6,
267    DarknetIllicit = 7,
268    OfacSdnHack = 8,
269    DefiBridge = 9,
270    EtfInstitutional = 10,
271    GovSeizure = 11,
272}
273
274impl EntityCategory {
275    #[allow(clippy::should_implement_trait)]
276    pub fn from_str(s: &str) -> Self {
277        match s.to_lowercase().as_str() {
278            "exchange" => Self::ExchangeTier1,
279            "mining_pool" => Self::MinerPool,
280            "custodian" => Self::CustodyService,
281            "darknet" => Self::DarknetIllicit,
282            "seized" => Self::GovSeizure,
283            "defi" => Self::DefiBridge,
284            "mixer" => Self::MixerPrivacy,
285            _ => Self::None,
286        }
287    }
288}
289
290impl FeeRateTier {
291    pub fn from_sat_vb(rate: f64) -> Self {
292        if rate < 1.0 {
293            Self::SubMinima
294        } else if rate < 2.0 {
295            Self::Minima
296        } else if rate < 3.0 {
297            Self::UltraPatient
298        } else if rate < 4.0 {
299            Self::VeryLow
300        } else if rate < 6.0 {
301            Self::Low
302        } else if rate < 9.0 {
303            Self::BelowStandard
304        } else if rate < 12.0 {
305            Self::NormalLow
306        } else if rate < 16.0 {
307            Self::Normal
308        } else if rate < 21.0 {
309            Self::NormalMedium
310        } else if rate < 26.0 {
311            Self::MarketStandard
312        } else if rate < 34.0 {
313            Self::AboveStandard
314        } else if rate < 45.0 {
315            Self::High
316        } else if rate < 61.0 {
317            Self::Priority
318        } else if rate < 81.0 {
319            Self::Urgent
320        } else if rate < 101.0 {
321            Self::UrgentHigh
322        } else if rate < 141.0 {
323            Self::PanicLow
324        } else if rate < 201.0 {
325            Self::Panic
326        } else if rate < 301.0 {
327            Self::PanicHigh
328        } else if rate < 501.0 {
329            Self::Despair
330        } else if rate < 1001.0 {
331            Self::ExtremeDespair
332        } else {
333            Self::Outlier
334        }
335    }
336}
337
338impl FeeEfficiencyRatio {
339    pub fn from_values(fee: i64, total_input: i64) -> Self {
340        if total_input == 0 {
341            return Self::NormalLight;
342        }
343        let ratio = (fee as f64 / total_input as f64) * 100.0;
344        if ratio < 0.001 {
345            Self::UltraEfficient
346        } else if ratio < 0.01 {
347            Self::Efficient
348        } else if ratio < 0.05 {
349            Self::NormalLight
350        } else if ratio < 0.1 {
351            Self::NormalHeavy
352        } else if ratio < 1.0 {
353            Self::Expensive
354        } else {
355            Self::Destructive
356        }
357    }
358}
359
360impl AbsoluteFeeTier {
361    pub fn from_sats(sats: i64) -> Self {
362        if sats < 100 {
363            Self::Tiny
364        } else if sats < 500 {
365            Self::VeryLow
366        } else if sats < 2_000 {
367            Self::Low
368        } else if sats < 5_000 {
369            Self::LowModerate
370        } else if sats < 15_000 {
371            Self::Moderate
372        } else if sats < 50_000 {
373            Self::MidHigh
374        } else if sats < 100_000 {
375            Self::High
376        } else if sats < 200_000 {
377            Self::VeryHigh
378        } else if sats < 500_000 {
379            Self::ExtremeUrgency
380        } else if sats < 1_000_000 {
381            Self::Critical
382        } else if sats < 5_000_000 {
383            Self::Extreme
384        } else {
385            Self::Outlier
386        }
387    }
388}
389
390impl VsizeTier {
391    pub fn from_vsize(vsize: i32) -> Self {
392        if vsize < 200 {
393            Self::Light
394        } else if vsize < 1000 {
395            Self::Medium
396        } else if vsize < 5000 {
397            Self::Heavy
398        } else {
399            Self::Massive
400        }
401    }
402}
403
404impl TxVersionTier {
405    pub fn from_version(version: i32) -> Self {
406        match version {
407            1 => Self::V1,
408            2 => Self::V2,
409            3 => Self::V3,
410            _ => Self::Unknown,
411        }
412    }
413}
414
415impl TimelockFingerprint {
416    pub fn from_locktime(locktime: u32) -> Self {
417        Self::from_locktime_with_height(locktime, 0)
418    }
419
420    /// Classifies nLockTime knowing the confirmation block height.
421    pub fn from_locktime_with_height(locktime: u32, block_height: i64) -> Self {
422        if locktime == 0 {
423            return Self::None;
424        }
425        if locktime >= 500_000_000 {
426            return Self::FutureTimeLocked;
427        }
428        let lt = locktime as i64;
429        if block_height > 0 {
430            if lt > block_height {
431                return Self::FutureBlockLocked;
432            }
433            if lt == block_height {
434                return Self::AntiFeeSnipingCurrent;
435            }
436            if lt >= block_height - 100 {
437                return Self::AntiFeeSnipingFuzzy;
438            }
439            Self::None
440        } else if lt > 800_000 {
441            Self::FutureBlockLocked
442        } else {
443            Self::AntiFeeSnipingCurrent
444        }
445    }
446}
447
448impl CoinJoinScore {
449    pub fn from_f64(score: f64) -> Self {
450        if score < 0.20 {
451            Self::None
452        } else if score < 0.40 {
453            Self::SuspectLow
454        } else if score < 0.60 {
455            Self::SuspectMed
456        } else if score < 0.75 {
457            Self::Probable
458        } else if score < 0.90 {
459            Self::HighProb
460        } else {
461            Self::Guaranteed
462        }
463    }
464}
465
466/// Bitcoin script types recognized by the heuristic engine.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
468pub enum ScriptType {
469    /// Pay-to-Public-Key-Hash
470    #[default]
471    P2PKH = 0,
472    /// Pay-to-Script-Hash
473    P2SH = 1,
474    P2shP2wpkh = 2,
475    /// Pay-to-Witness-Public-Key-Hash (SegWit v0, 20-byte program)
476    P2WPKH = 3,
477    /// Pay-to-Witness-Script-Hash (SegWit v0, 32-byte program)
478    P2WSH = 4,
479    /// Pay-to-Taproot (SegWit v1, 32-byte program)
480    P2TR = 5,
481    /// Bare multisig
482    Multisig = 10,
483    /// OP_RETURN data carrier output (unspendable)
484    OpReturn = 11,
485    /// Script not recognized
486    Unknown = 12,
487    /// Multiple types in the same transaction
488    Mixed = 7,
489}
490
491impl ScriptType {
492    pub fn to_bits(self) -> u128 {
493        match self {
494            ScriptType::P2PKH => 0,
495            ScriptType::P2SH => 1,
496            ScriptType::P2shP2wpkh => 2,
497            ScriptType::P2WPKH => 3,
498            ScriptType::P2WSH => 4,
499            ScriptType::P2TR => 5,
500            ScriptType::Multisig | ScriptType::OpReturn | ScriptType::Unknown => 6,
501            ScriptType::Mixed => 7,
502        }
503    }
504}
505
506/// Features extracted from a Bitcoin transaction.
507///
508/// Pure data struct — no I/O or database dependencies.
509/// Populate the fields and call [`crate::heuristics::HeuristicRegistry::evaluate_all`] to detect patterns.
510///
511/// # Example
512///
513/// ```
514/// use bitcoin_heuristics::{TxFeatures, HeuristicRegistry};
515///
516/// let features = TxFeatures {
517///     input_count: 15,
518///     output_count: 2,
519///     is_consolidation: true,
520///     total_input_value: 200_000_000,
521///     is_input_script_homogeneous: true,
522///     output_value_max: 180_000_000,
523///     total_output_value: 199_995_000,
524///     block_price_usd: Some(60_000.0),
525///     ..TxFeatures::default()
526/// };
527///
528/// let registry = HeuristicRegistry::default_registry(0.7);
529/// let matches = registry.evaluate_all(&features);
530/// ```
531#[derive(Debug, Clone, Serialize, Deserialize, Default)]
532pub struct TxFeatures {
533    // --- Identity ---
534    pub txid: Vec<u8>,
535    pub block_height: i64,
536    pub block_timestamp: DateTime<Utc>,
537
538    // --- Structure ---
539    pub input_count: i32,
540    pub output_count: i32,
541    pub is_coinbase: bool,
542
543    // --- Values (satoshis) ---
544    pub total_input_value: i64,
545    pub total_output_value: i64,
546    pub fee: i64,
547    pub output_value_min: i64,
548    pub output_value_max: i64,
549    pub output_value_median: f64,
550    pub output_value_variance: f64,
551
552    // --- Fees ---
553    /// Fee rate in sat/vByte. None if vsize == 0.
554    pub fee_rate_sat_vb: Option<f64>,
555
556    // --- Script types in inputs ---
557    pub input_p2pkh_count: i32,
558    pub input_p2sh_count: i32,
559    pub input_p2wpkh_count: i32,
560    pub input_p2wsh_count: i32,
561    pub input_p2tr_count: i32,
562    pub input_unknown_count: i32,
563
564    // --- Script types in outputs ---
565    pub output_p2pkh_count: i32,
566    pub output_p2sh_count: i32,
567    pub output_p2wpkh_count: i32,
568    pub output_p2wsh_count: i32,
569    pub output_p2tr_count: i32,
570    pub output_op_return_count: i32,
571    pub output_unknown_count: i32,
572
573    // --- Structural patterns ---
574    /// input_count >= 10 AND output_count <= 3
575    pub is_consolidation: bool,
576    /// input_count <= 3 AND output_count >= 10
577    pub is_distribution: bool,
578    /// input_count == 1 AND output_count == 2
579    pub is_simple_send: bool,
580    /// input_count >= 2 AND output_count == 1
581    pub is_sweep: bool,
582
583    // --- Privacy ---
584    /// All non-OP_RETURN outputs have the same value.
585    pub has_equal_outputs: bool,
586    /// CoinJoin probability score [0.0, 1.0].
587    pub coinjoin_score: f64,
588
589    // --- Advanced ---
590    pub has_multisig: bool,
591    pub has_op_return: bool,
592    /// Any input with sequence < 0xFFFFFFFE.
593    pub is_rbf_enabled: bool,
594    pub has_locktime: bool,
595
596    // --- Transaction metadata ---
597    pub tx_vsize_vbytes: i32,
598    pub tx_locktime: i64,
599    pub tx_version: i32,
600
601    // --- Data for advanced heuristics ---
602    /// UTXO references for each input: (prev_txid, prev_vout).
603    pub input_utxo_refs: Vec<(Vec<u8>, u32)>,
604    /// Individual values for all outputs (including OP_RETURN).
605    pub output_values: Vec<i64>,
606
607    // --- Address Behavior ---
608    /// Number of unique output addresses. None if no decodable addresses.
609    pub unique_output_addresses: Option<u32>,
610    /// Any output address appears more than once in this transaction.
611    pub address_reuse_in_tx: bool,
612    /// List of decoded output addresses (None for unrecognized scripts).
613    pub output_addresses: Vec<Option<String>>,
614
615    // --- UTXO ages (preloaded before evaluate_all) ---
616    /// Map from (prev_txid, prev_vout) → age in blocks.
617    /// LongTermSupplyActivationHeuristic uses this; falls back to RPC if absent.
618    /// Not serialized (runtime-only field).
619    #[serde(skip)]
620    pub utxo_ages_blocks: HashMap<(Vec<u8>, u32), i64>,
621
622    // --- GENOME SIGNATURE FIELDS ---
623    /// 8-bit log-scale tier via encode_movement_range_log(total_output_value).
624    pub movement_range: u8,
625    pub fee_rate_tier: FeeRateTier,
626    pub fee_percentile_tier: FeePercentileTier,
627    pub fee_efficiency_ratio: FeeEfficiencyRatio,
628    pub absolute_fee_tier: AbsoluteFeeTier,
629    /// 8-bit log-scale tier via encode_utxo_age_log(age_blocks). 0 = unknown/fresh.
630    pub utxo_age_max: u8,
631    /// 8-bit log-scale tier via encode_utxo_age_log(age_blocks). 0 = unknown/fresh.
632    pub utxo_age_median: u8,
633    pub utxo_age_variance: UtxoAgeVariance,
634    pub structural_pattern: StructuralPattern,
635    pub io_scale_tier: IoScaleTier,
636    pub change_detection: ChangeDetection,
637    pub coinjoin_probability: CoinJoinScore,
638    pub has_dust_output: bool,
639    pub address_hotness: AddressHotness,
640    pub multisig_threshold: MultisigThreshold,
641    pub is_input_script_homogeneous: bool,
642    pub taproot_spend_path: TaprootSpendPath,
643    pub embedded_protocol: EmbeddedProtocol,
644    pub timelock_fingerprint: TimelockFingerprint,
645    pub lightning_indicator: LightningIndicator,
646    pub has_relative_timelock: bool,
647    pub vsize_tier: VsizeTier,
648    pub tx_version_tier: TxVersionTier,
649    pub is_cpfp_signal: bool,
650    pub entity_presence: EntityPresence,
651    pub entity_category: EntityCategory,
652    pub dominant_input_script: ScriptType,
653    pub dominant_output_script: ScriptType,
654
655    // --- Block context ---
656    /// Block median fee in sat/vByte. 0.0 = not available.
657    pub block_fee_median_sat_vb: f64,
658    /// true if dominant input script type differs from outputs (wallet migration signal).
659    pub script_type_evolution: bool,
660    /// Bitcoin price in the block in USD (if available).
661    pub block_price_usd: Option<f64>,
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    fn make_features() -> TxFeatures {
669        TxFeatures {
670            txid: vec![1u8; 32],
671            block_height: 840_000,
672            block_timestamp: Utc::now(),
673            input_count: 1,
674            output_count: 2,
675            is_coinbase: false,
676            total_input_value: 100_000,
677            total_output_value: 99_000,
678            fee: 1_000,
679            output_value_min: 40_000,
680            output_value_max: 59_000,
681            output_value_median: 49_500.0,
682            output_value_variance: 9_025_000.0,
683            fee_rate_sat_vb: Some(5.0),
684            input_p2pkh_count: 1,
685            is_simple_send: true,
686            output_p2pkh_count: 2,
687            tx_vsize_vbytes: 200,
688            tx_version: 1,
689            input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
690            output_values: vec![40_000, 59_000],
691            is_input_script_homogeneous: true,
692            ..Default::default()
693        }
694    }
695
696    #[test]
697    fn test_entity_category_from_str() {
698        assert_eq!(
699            EntityCategory::from_str("exchange"),
700            EntityCategory::ExchangeTier1
701        );
702        assert_eq!(
703            EntityCategory::from_str("EXCHANGE"),
704            EntityCategory::ExchangeTier1
705        );
706        assert_eq!(
707            EntityCategory::from_str("mining_pool"),
708            EntityCategory::MinerPool
709        );
710        assert_eq!(
711            EntityCategory::from_str("custodian"),
712            EntityCategory::CustodyService
713        );
714        assert_eq!(
715            EntityCategory::from_str("darknet"),
716            EntityCategory::DarknetIllicit
717        );
718        assert_eq!(
719            EntityCategory::from_str("seized"),
720            EntityCategory::GovSeizure
721        );
722        assert_eq!(EntityCategory::from_str("defi"), EntityCategory::DefiBridge);
723        assert_eq!(
724            EntityCategory::from_str("mixer"),
725            EntityCategory::MixerPrivacy
726        );
727        assert_eq!(EntityCategory::from_str("unknown"), EntityCategory::None);
728    }
729
730    #[test]
731    fn test_tx_features_clone() {
732        let f = make_features();
733        let cloned = f.clone();
734        assert_eq!(f.txid, cloned.txid);
735        assert_eq!(f.block_height, cloned.block_height);
736        assert_eq!(f.input_count, cloned.input_count);
737        assert_eq!(f.is_simple_send, cloned.is_simple_send);
738    }
739
740    #[test]
741    fn test_tx_features_serde_roundtrip() {
742        let f = make_features();
743        let json = serde_json::to_string(&f).unwrap();
744        let decoded: TxFeatures = serde_json::from_str(&json).unwrap();
745        assert_eq!(decoded.txid, f.txid);
746        assert_eq!(decoded.block_height, f.block_height);
747        assert_eq!(decoded.fee, f.fee);
748        assert_eq!(decoded.is_simple_send, f.is_simple_send);
749        assert!((decoded.coinjoin_score - f.coinjoin_score).abs() < f64::EPSILON);
750    }
751
752    #[test]
753    fn test_tx_features_default_values() {
754        let f = make_features();
755        assert!(!f.is_consolidation);
756        assert!(!f.is_distribution);
757        assert!(f.is_simple_send);
758        assert!(!f.is_sweep);
759        assert!(!f.has_equal_outputs);
760        assert!(!f.has_multisig);
761        assert!(!f.has_op_return);
762        assert!(!f.is_rbf_enabled);
763        assert!(!f.has_locktime);
764        assert!(!f.is_coinbase);
765        assert_eq!(f.coinjoin_score, 0.0);
766    }
767
768    #[test]
769    fn test_encode_movement_range_log_zero() {
770        assert_eq!(encode_movement_range_log(0), 0);
771        assert_eq!(encode_movement_range_log(-1), 0);
772    }
773
774    #[test]
775    fn test_encode_movement_range_log_monotone() {
776        let sats = [
777            1,
778            100,
779            1_000,
780            100_000,
781            1_000_000,
782            100_000_000,
783            21_000_000_000_000_i64,
784        ];
785        let tiers: Vec<u8> = sats.iter().map(|&s| encode_movement_range_log(s)).collect();
786        for i in 1..tiers.len() {
787            assert!(
788                tiers[i] >= tiers[i - 1],
789                "tier[{}]={} should be >= tier[{}]={} (sat: {} vs {})",
790                i,
791                tiers[i],
792                i - 1,
793                tiers[i - 1],
794                sats[i],
795                sats[i - 1]
796            );
797        }
798    }
799
800    #[test]
801    fn test_encode_movement_range_log_max_sats() {
802        // 21M BTC = 2.1×10^15 sat → should hit 255
803        assert_eq!(encode_movement_range_log(2_100_000_000_000_000), 255);
804    }
805
806    #[test]
807    fn test_fee_rate_tier_sat_vb() {
808        assert_eq!(FeeRateTier::from_sat_vb(0.5), FeeRateTier::SubMinima);
809        assert_eq!(FeeRateTier::from_sat_vb(5.0), FeeRateTier::Low);
810        assert_eq!(FeeRateTier::from_sat_vb(20.0), FeeRateTier::NormalMedium);
811        assert_eq!(FeeRateTier::from_sat_vb(1_500.0), FeeRateTier::Outlier);
812    }
813
814    #[test]
815    fn test_absolute_fee_tier() {
816        assert_eq!(AbsoluteFeeTier::from_sats(50), AbsoluteFeeTier::Tiny);
817        assert_eq!(AbsoluteFeeTier::from_sats(5_000), AbsoluteFeeTier::Moderate);
818        assert_eq!(
819            AbsoluteFeeTier::from_sats(10_000_000),
820            AbsoluteFeeTier::Outlier
821        );
822    }
823
824    #[test]
825    fn test_vsize_tier() {
826        assert_eq!(VsizeTier::from_vsize(100), VsizeTier::Light);
827        assert_eq!(VsizeTier::from_vsize(500), VsizeTier::Medium);
828        assert_eq!(VsizeTier::from_vsize(10_000), VsizeTier::Massive);
829    }
830
831    #[test]
832    fn test_tx_version_tier() {
833        assert_eq!(TxVersionTier::from_version(1), TxVersionTier::V1);
834        assert_eq!(TxVersionTier::from_version(2), TxVersionTier::V2);
835        assert_eq!(TxVersionTier::from_version(99), TxVersionTier::Unknown);
836    }
837
838    #[test]
839    fn test_timelock_fingerprint_no_locktime() {
840        assert_eq!(
841            TimelockFingerprint::from_locktime(0),
842            TimelockFingerprint::None
843        );
844    }
845
846    #[test]
847    fn test_timelock_fingerprint_future_time() {
848        assert_eq!(
849            TimelockFingerprint::from_locktime(500_000_001),
850            TimelockFingerprint::FutureTimeLocked
851        );
852    }
853
854    #[test]
855    fn test_coinjoin_score_enum() {
856        assert_eq!(CoinJoinScore::from_f64(0.0), CoinJoinScore::None);
857        assert_eq!(CoinJoinScore::from_f64(0.5), CoinJoinScore::SuspectMed);
858        assert_eq!(CoinJoinScore::from_f64(0.95), CoinJoinScore::Guaranteed);
859    }
860
861    #[test]
862    fn test_script_type_to_bits_unique() {
863        use std::collections::HashSet;
864        let types = [
865            ScriptType::P2PKH,
866            ScriptType::P2SH,
867            ScriptType::P2shP2wpkh,
868            ScriptType::P2WPKH,
869            ScriptType::P2WSH,
870            ScriptType::P2TR,
871            ScriptType::Mixed,
872        ];
873        let bits: HashSet<u128> = types.iter().map(|t| t.to_bits()).collect();
874        assert_eq!(
875            bits.len(),
876            types.len(),
877            "each ScriptType should map to a unique bit"
878        );
879    }
880
881    #[test]
882    fn test_fee_efficiency_ratio() {
883        assert_eq!(
884            FeeEfficiencyRatio::from_values(1, 1_000_000),
885            FeeEfficiencyRatio::UltraEfficient
886        );
887        assert_eq!(
888            FeeEfficiencyRatio::from_values(100_000, 1_000_000),
889            FeeEfficiencyRatio::Destructive
890        );
891        assert_eq!(
892            FeeEfficiencyRatio::from_values(0, 0),
893            FeeEfficiencyRatio::NormalLight
894        );
895    }
896}