Skip to main content

scope/compliance/
risk.rs

1//! Risk Scoring Engine for Scope
2//!
3//! Provides compliance-grade risk analysis for blockchain addresses.
4//! Aggregates data from multiple sources to produce comprehensive risk scores.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Risk level classification
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum RiskLevel {
12    Low,      // 0-3
13    Medium,   // 4-6
14    High,     // 7-8
15    Critical, // 9-10
16}
17
18impl RiskLevel {
19    pub fn from_score(score: f32) -> Self {
20        match score {
21            s if s <= 3.0 => RiskLevel::Low,
22            s if s <= 6.0 => RiskLevel::Medium,
23            s if s <= 8.0 => RiskLevel::High,
24            _ => RiskLevel::Critical,
25        }
26    }
27
28    pub fn emoji(&self) -> &'static str {
29        match self {
30            RiskLevel::Low => "🟢",
31            RiskLevel::Medium => "🟡",
32            RiskLevel::High => "🔴",
33            RiskLevel::Critical => "âš«",
34        }
35    }
36}
37
38/// Individual risk factor with weight and score
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RiskFactor {
41    pub name: String,
42    pub category: RiskCategory,
43    pub score: f32,  // 0-10
44    pub weight: f32, // 0-1, contribution to final score
45    pub description: String,
46    pub evidence: Vec<String>,
47}
48
49/// Risk category for organizing factors
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub enum RiskCategory {
52    Behavioral,  // Transaction patterns, velocity
53    Association, // Connected to known bad addresses
54    Source,      // Funds from suspicious sources
55    Destination, // Funds to suspicious destinations
56    Entity,      // Known entity (exchange, mixer, etc.)
57    Sanctions,   // OFAC, sanctions lists
58    Reputation,  // Community reports, scam databases
59}
60
61/// Comprehensive risk assessment for an address
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RiskAssessment {
64    pub address: String,
65    pub chain: String,
66    pub overall_score: f32, // 0-10
67    pub risk_level: RiskLevel,
68    pub factors: Vec<RiskFactor>,
69    pub assessed_at: DateTime<Utc>,
70    pub recommendations: Vec<String>,
71}
72
73use super::datasource::{BlockchainDataClient, analyze_patterns};
74
75/// Risk scoring engine configuration
76#[derive(Debug)]
77pub struct RiskEngine {
78    /// Data client for fetching blockchain data
79    data_client: Option<BlockchainDataClient>,
80}
81
82impl Default for RiskEngine {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl RiskEngine {
89    /// Create new risk engine without data sources (basic scoring only)
90    pub fn new() -> Self {
91        Self { data_client: None }
92    }
93
94    /// Create new risk engine with data sources for enhanced analysis
95    pub fn with_data_client(client: BlockchainDataClient) -> Self {
96        Self {
97            data_client: Some(client),
98        }
99    }
100
101    /// Assess risk for a single address
102    pub async fn assess_address(
103        &self,
104        address: &str,
105        chain: &str,
106    ) -> anyhow::Result<RiskAssessment> {
107        let mut factors = Vec::new();
108
109        // 1. Behavioral Analysis (Transaction Patterns)
110        if let Ok(factor) = self.analyze_behavior(address, chain).await {
111            factors.push(factor);
112        }
113
114        // 2. Association Analysis (Connected Addresses)
115        if let Ok(factor) = self.analyze_associations(address, chain).await {
116            factors.push(factor);
117        }
118
119        // 3. Source Analysis (Where funds came from)
120        if let Ok(factor) = self.analyze_sources(address, chain).await {
121            factors.push(factor);
122        }
123
124        // 4. Entity Recognition (Known services)
125        if let Ok(factor) = self.identify_entity(address, chain).await {
126            factors.push(factor);
127        }
128
129        // Calculate weighted score
130        let overall_score = self.calculate_weighted_score(&factors);
131        let risk_level = RiskLevel::from_score(overall_score);
132
133        // Generate recommendations
134        let recommendations = self.generate_recommendations(&factors, risk_level);
135
136        Ok(RiskAssessment {
137            address: address.to_string(),
138            chain: chain.to_string(),
139            overall_score,
140            risk_level,
141            factors,
142            assessed_at: Utc::now(),
143            recommendations,
144        })
145    }
146
147    /// Analyze transaction behavior patterns
148    async fn analyze_behavior(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
149        let mut evidence = Vec::new();
150        let mut score: f32 = 2.0; // Default low score
151
152        // Fetch real transaction data if available
153        if let Some(client) = &self.data_client {
154            match client.get_transactions(address, chain).await {
155                Ok(txs) => {
156                    let analysis = analyze_patterns(&txs);
157
158                    // Adjust score based on patterns
159                    if analysis.structuring_detected {
160                        score += 3.0;
161                        evidence.push(
162                            "Structuring pattern detected (amounts just under thresholds)"
163                                .to_string(),
164                        );
165                    }
166
167                    if analysis.round_number_pattern {
168                        score += 1.5;
169                        evidence.push("Round number pattern suggests automation".to_string());
170                    }
171
172                    if analysis.velocity_score > 10.0 {
173                        score += 2.0;
174                        evidence.push(format!(
175                            "High transaction velocity: {:.1} tx/day",
176                            analysis.velocity_score
177                        ));
178                    }
179
180                    if analysis.unusual_hours > 0 {
181                        score += 1.0;
182                        evidence.push(format!(
183                            "{} transactions during unusual hours",
184                            analysis.unusual_hours
185                        ));
186                    }
187
188                    evidence.push(format!(
189                        "Analyzed {} transactions",
190                        analysis.total_transactions
191                    ));
192                }
193                Err(e) => {
194                    evidence.push(format!("Could not fetch transaction data: {}", e));
195                }
196            }
197        } else {
198            evidence.push("No data client configured - using default scores".to_string());
199        }
200
201        // Ensure score stays in bounds
202        score = score.clamp(0.0, 10.0);
203
204        Ok(RiskFactor {
205            name: "Behavioral Patterns".to_string(),
206            category: RiskCategory::Behavioral,
207            score,
208            weight: 0.25,
209            description: "Transaction velocity and pattern analysis".to_string(),
210            evidence,
211        })
212    }
213
214    /// Analyze associations with known addresses
215    async fn analyze_associations(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
216        let mut evidence = Vec::new();
217        let mut score: f32 = 1.5; // Default low score
218
219        // Fetch transaction data to analyze connections
220        if let Some(client) = &self.data_client {
221            match client.get_transactions(address, chain).await {
222                Ok(txs) => {
223                    // Count unique counterparties
224                    let mut counterparties = std::collections::HashSet::new();
225                    for tx in &txs {
226                        counterparties.insert(tx.from.clone());
227                        counterparties.insert(tx.to.clone());
228                    }
229                    counterparties.remove(address);
230
231                    evidence.push(format!(
232                        "Found {} unique counterparties",
233                        counterparties.len()
234                    ));
235
236                    // High number of counterparties can indicate mixing
237                    if counterparties.len() > 100 {
238                        score += 2.0;
239                        evidence.push(
240                            "High number of counterparties may indicate mixing service".to_string(),
241                        );
242                    }
243
244                    // Check for self-transfers (looping)
245                    let self_transfers = txs.iter().filter(|tx| tx.from == tx.to).count();
246                    if self_transfers > 0 {
247                        score += 1.0;
248                        evidence.push(format!("{} self-transfers detected", self_transfers));
249                    }
250                }
251                Err(e) => {
252                    evidence.push(format!("Could not analyze associations: {}", e));
253                }
254            }
255        } else {
256            evidence.push("No data client configured - using default scores".to_string());
257        }
258
259        score = score.clamp(0.0, 10.0);
260
261        Ok(RiskFactor {
262            name: "Address Associations".to_string(),
263            category: RiskCategory::Association,
264            score,
265            weight: 0.30,
266            description: "Connections to known high-risk addresses".to_string(),
267            evidence,
268        })
269    }
270
271    /// Analyze source of funds
272    async fn analyze_sources(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
273        let mut evidence = Vec::new();
274        let mut score: f32 = 2.0; // Default low-medium score
275
276        if let Some(client) = &self.data_client {
277            match client.get_transactions(address, chain).await {
278                Ok(txs) => {
279                    // Analyze incoming transactions (where this address is the recipient)
280                    let incoming: Vec<_> = txs
281                        .iter()
282                        .filter(|tx| tx.to.to_lowercase() == address.to_lowercase())
283                        .collect();
284
285                    evidence.push(format!("Analyzed {} incoming transactions", incoming.len()));
286
287                    // Check for failed transactions
288                    let failed = txs.iter().filter(|tx| tx.is_error == "1").count();
289                    if failed > 0 {
290                        score += 1.0;
291                        evidence.push(format!("{} failed transactions detected", failed));
292                    }
293
294                    // Check for contract interactions (more complex, higher risk)
295                    let contract_calls = txs
296                        .iter()
297                        .filter(|tx| !tx.contract_address.is_empty())
298                        .count();
299                    if contract_calls > 0 {
300                        evidence.push(format!("{} contract interactions", contract_calls));
301                    }
302                }
303                Err(e) => {
304                    evidence.push(format!("Could not analyze sources: {}", e));
305                }
306            }
307        } else {
308            evidence.push("No data client configured - using default scores".to_string());
309        }
310
311        score = score.clamp(0.0, 10.0);
312
313        Ok(RiskFactor {
314            name: "Source of Funds".to_string(),
315            category: RiskCategory::Source,
316            score,
317            weight: 0.25,
318            description: "Origin analysis of incoming funds".to_string(),
319            evidence,
320        })
321    }
322
323    /// Identify if address belongs to known entity
324    async fn identify_entity(&self, address: &str, _chain: &str) -> anyhow::Result<RiskFactor> {
325        let mut evidence = Vec::new();
326        let mut score: f32 = 2.0;
327
328        // Check for known entity patterns
329        // This would typically integrate with a database of known addresses
330
331        // Placeholder: Check if address has code (is a contract)
332        if let Some(client) = &self.data_client {
333            // Try to get internal transactions - contracts often have these
334            match client.get_internal_transactions(address).await {
335                Ok(internal_txs) => {
336                    if !internal_txs.is_empty() {
337                        evidence.push(format!(
338                            "Contract interactions detected: {} internal transactions",
339                            internal_txs.len()
340                        ));
341                        score += 0.5; // Slight increase for being a contract
342                    }
343                }
344                Err(_) => {
345                    // Not necessarily an error - EOAs don't have internal transactions
346                }
347            }
348        }
349
350        // Known exchange addresses would be checked here
351        evidence.push("Address not in known entity database (implement integration)".to_string());
352
353        score = score.clamp(0.0, 10.0);
354
355        Ok(RiskFactor {
356            name: "Entity Identification".to_string(),
357            category: RiskCategory::Entity,
358            score,
359            weight: 0.20,
360            description: "Known entity classification".to_string(),
361            evidence,
362        })
363    }
364
365    /// Calculate weighted score from factors
366    fn calculate_weighted_score(&self, factors: &[RiskFactor]) -> f32 {
367        if factors.is_empty() {
368            return 0.0;
369        }
370
371        let weighted_sum: f32 = factors.iter().map(|f| f.score * f.weight).sum();
372
373        let total_weight: f32 = factors.iter().map(|f| f.weight).sum();
374
375        if total_weight == 0.0 {
376            return 0.0;
377        }
378
379        (weighted_sum / total_weight).clamp(0.0, 10.0)
380    }
381
382    /// Generate recommendations based on risk factors
383    fn generate_recommendations(&self, factors: &[RiskFactor], level: RiskLevel) -> Vec<String> {
384        let mut recommendations = Vec::new();
385
386        match level {
387            RiskLevel::Critical => {
388                recommendations.push("Immediate investigation required".to_string());
389                recommendations.push("Consider suspending transactions".to_string());
390                recommendations.push("File SAR if applicable".to_string());
391            }
392            RiskLevel::High => {
393                recommendations.push("Enhanced due diligence recommended".to_string());
394                recommendations.push("Monitor transactions closely".to_string());
395                recommendations.push("Verify source of funds".to_string());
396            }
397            RiskLevel::Medium => {
398                recommendations.push("Standard due diligence".to_string());
399                recommendations.push("Periodic re-assessment".to_string());
400            }
401            RiskLevel::Low => {
402                recommendations.push("Standard monitoring".to_string());
403            }
404        }
405
406        // Add factor-specific recommendations
407        for factor in factors {
408            if factor.score > 7.0 {
409                recommendations.push(format!("Address {} concerns immediately", factor.name));
410            }
411        }
412
413        recommendations
414    }
415}
416
417// ============================================================================
418// Enhanced Risk Detection (Contract-Aware)
419// ============================================================================
420
421/// Enhanced holder concentration analysis with Gini coefficient.
422pub fn compute_gini_coefficient(percentages: &[f64]) -> f64 {
423    if percentages.is_empty() {
424        return 0.0;
425    }
426    let n = percentages.len() as f64;
427    let mut sorted = percentages.to_vec();
428    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
429
430    let sum: f64 = sorted.iter().sum();
431    if sum == 0.0 {
432        return 0.0;
433    }
434
435    let mut numerator = 0.0;
436    for (i, &val) in sorted.iter().enumerate() {
437        numerator += (2.0 * (i as f64 + 1.0) - n - 1.0) * val;
438    }
439
440    (numerator / (n * sum)).clamp(0.0, 1.0)
441}
442
443/// Detect rugpull indicators from contract analysis.
444///
445/// Returns a list of rugpull risk indicators with severity (0-10).
446pub fn detect_rugpull_indicators(
447    contract_analysis: Option<&crate::contract::ContractAnalysis>,
448    token_analytics: Option<&crate::chains::TokenAnalytics>,
449) -> Vec<RiskFactor> {
450    let mut factors = Vec::new();
451
452    if let Some(ca) = contract_analysis {
453        let mut evidence = Vec::new();
454        let mut score: f32 = 0.0;
455
456        // Check for owner can mint
457        if let Some(ac) = &ca.access_control {
458            for pf in &ac.privileged_functions {
459                match pf.name.to_lowercase().as_str() {
460                    n if n.contains("mint") => {
461                        score += 3.0;
462                        evidence.push(format!("Owner can mint tokens: {}", pf.name));
463                    }
464                    n if n.contains("pause") => {
465                        score += 2.0;
466                        evidence.push(format!("Owner can pause transfers: {}", pf.name));
467                    }
468                    n if n.contains("blacklist") => {
469                        score += 2.5;
470                        evidence.push(format!("Owner can blacklist addresses: {}", pf.name));
471                    }
472                    n if n.contains("setfee") || n.contains("settax") => {
473                        score += 2.0;
474                        evidence.push(format!("Owner can change fees: {}", pf.name));
475                    }
476                    _ => {}
477                }
478            }
479
480            // Renounced ownership reduces risk
481            if ac.has_renounced_ownership {
482                score -= 3.0;
483                evidence.push("Ownership has been renounced".to_string());
484            }
485
486            // tx.origin usage is a red flag
487            if ac.uses_tx_origin {
488                score += 2.0;
489                evidence.push("Uses tx.origin for authorization".to_string());
490            }
491        }
492
493        // Unverified source is a major red flag
494        if !ca.is_verified {
495            score += 3.0;
496            evidence.push("Contract source code is not verified".to_string());
497        }
498
499        score = score.clamp(0.0, 10.0);
500
501        if !evidence.is_empty() {
502            factors.push(RiskFactor {
503                name: "Rugpull Risk (Contract)".to_string(),
504                category: RiskCategory::Reputation,
505                score,
506                weight: 0.35,
507                description: "Contract-level rugpull indicators".to_string(),
508                evidence,
509            });
510        }
511    }
512
513    if let Some(ta) = token_analytics {
514        let mut evidence = Vec::new();
515        let mut score: f32 = 0.0;
516
517        // Extreme concentration
518        if let Some(top10) = ta.top_10_concentration {
519            if top10 > 80.0 {
520                score += 4.0;
521                evidence.push(format!("Top 10 holders control {:.1}% of supply", top10));
522            } else if top10 > 50.0 {
523                score += 2.0;
524                evidence.push(format!("Top 10 holders control {:.1}% of supply", top10));
525            }
526        }
527
528        // Very new token
529        if let Some(age) = ta.token_age_hours {
530            if age < 24.0 {
531                score += 3.0;
532                evidence.push(format!("Token is very new ({:.0}h old)", age));
533            } else if age < 72.0 {
534                score += 1.5;
535                evidence.push(format!("Token is recently created ({:.0}h old)", age));
536            }
537        }
538
539        // No sells (honeypot indicator)
540        if ta.total_sells_24h == 0 && ta.total_buys_24h > 10 {
541            score += 4.0;
542            evidence.push(format!(
543                "No sells in 24h with {} buys (potential honeypot)",
544                ta.total_buys_24h
545            ));
546        }
547
548        // Low liquidity
549        if ta.liquidity_usd < 10_000.0 && ta.liquidity_usd > 0.0 {
550            score += 2.0;
551            evidence.push(format!("Very low liquidity: ${:.0}", ta.liquidity_usd));
552        }
553
554        score = score.clamp(0.0, 10.0);
555
556        if !evidence.is_empty() {
557            factors.push(RiskFactor {
558                name: "Rugpull Risk (Token)".to_string(),
559                category: RiskCategory::Reputation,
560                score,
561                weight: 0.35,
562                description: "Token-level rugpull indicators".to_string(),
563                evidence,
564            });
565        }
566    }
567
568    factors
569}
570
571/// Detect whale activity from transaction data.
572///
573/// Returns whale-related risk factors based on large transactions
574/// and holder concentration.
575pub fn detect_whale_activity(
576    transactions: &[crate::chains::Transaction],
577    avg_tx_value_usd: f64,
578    whale_threshold_usd: f64,
579) -> RiskFactor {
580    let mut evidence = Vec::new();
581    let mut score: f32 = 0.0;
582
583    let large_txs: Vec<_> = transactions
584        .iter()
585        .filter(|tx| {
586            // Parse value if possible (rough heuristic)
587            tx.value
588                .parse::<f64>()
589                .map(|v| v > whale_threshold_usd)
590                .unwrap_or(false)
591        })
592        .collect();
593
594    if !large_txs.is_empty() {
595        let pct = (large_txs.len() as f64 / transactions.len() as f64) * 100.0;
596        score += (pct / 10.0) as f32;
597        evidence.push(format!(
598            "{} whale-sized transactions ({:.1}% of total)",
599            large_txs.len(),
600            pct
601        ));
602    }
603
604    if avg_tx_value_usd > whale_threshold_usd * 0.5 {
605        score += 2.0;
606        evidence.push(format!(
607            "Average transaction size ${:.0} is near whale threshold ${:.0}",
608            avg_tx_value_usd, whale_threshold_usd
609        ));
610    }
611
612    if evidence.is_empty() {
613        evidence.push("No significant whale activity detected".to_string());
614    }
615
616    score = score.clamp(0.0, 10.0);
617
618    RiskFactor {
619        name: "Whale Activity".to_string(),
620        category: RiskCategory::Behavioral,
621        score,
622        weight: 0.15,
623        description: "Large transaction and whale holder analysis".to_string(),
624        evidence,
625    }
626}
627
628/// Detect timelock patterns from contract analysis.
629pub fn detect_timelock(
630    contract_analysis: &crate::contract::ContractAnalysis,
631) -> Option<RiskFactor> {
632    let src = contract_analysis.source_info.as_ref()?;
633    let code_lower = src.source_code.to_lowercase();
634
635    let mut evidence = Vec::new();
636    let mut has_timelock = false;
637
638    if code_lower.contains("timelockcontroller") || code_lower.contains("timelock") {
639        has_timelock = true;
640        evidence.push("TimelockController pattern detected".to_string());
641    }
642
643    if code_lower.contains("delay")
644        && code_lower.contains("queue")
645        && code_lower.contains("execute")
646    {
647        has_timelock = true;
648        evidence.push("Queue/delay/execute governance pattern found".to_string());
649    }
650
651    if code_lower.contains("mindelay") || code_lower.contains("minimum_delay") {
652        evidence.push("Minimum delay parameter found".to_string());
653    }
654
655    // Timelock presence reduces risk
656    let score = if has_timelock { 2.0 } else { 5.0 };
657
658    Some(RiskFactor {
659        name: "Timelock".to_string(),
660        category: RiskCategory::Entity,
661        score,
662        weight: 0.10,
663        description: if has_timelock {
664            "Timelock governance detected (reduces admin risk)".to_string()
665        } else {
666            "No timelock governance detected for admin operations".to_string()
667        },
668        evidence,
669    })
670}
671
672/// Detect multisig patterns from contract analysis and bytecode.
673pub fn detect_multisig(
674    contract_analysis: &crate::contract::ContractAnalysis,
675) -> Option<RiskFactor> {
676    let mut evidence = Vec::new();
677    let mut is_multisig = false;
678
679    // Check source code
680    if let Some(src) = &contract_analysis.source_info {
681        let code_lower = src.source_code.to_lowercase();
682
683        if code_lower.contains("gnosis")
684            || code_lower.contains("safe") && code_lower.contains("multisig")
685        {
686            is_multisig = true;
687            evidence.push("Gnosis Safe / multisig wallet pattern detected".to_string());
688        }
689
690        if code_lower.contains("threshold") && code_lower.contains("owners") {
691            is_multisig = true;
692            evidence.push("Multi-owner threshold pattern (M-of-N signatures)".to_string());
693        }
694
695        if code_lower.contains("confirmtransaction") && code_lower.contains("executetransaction") {
696            is_multisig = true;
697            evidence.push("Confirm/execute transaction pattern (multisig workflow)".to_string());
698        }
699    }
700
701    // Check admin address (if known, could check if it's a multisig)
702    if let Some(proxy) = &contract_analysis.proxy_info
703        && let Some(admin) = &proxy.admin_address
704    {
705        evidence.push(format!(
706            "Proxy admin address: {} (verify if multisig)",
707            admin
708        ));
709    }
710
711    let score = if is_multisig { 2.0 } else { 4.0 };
712
713    Some(RiskFactor {
714        name: "Multisig Governance".to_string(),
715        category: RiskCategory::Entity,
716        score,
717        weight: 0.10,
718        description: if is_multisig {
719            "Multisig governance detected (reduces single-key risk)".to_string()
720        } else {
721            "No multisig governance detected".to_string()
722        },
723        evidence,
724    })
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::compliance::datasource;
731
732    #[test]
733    fn test_risk_level_from_score() {
734        assert!(matches!(RiskLevel::from_score(2.0), RiskLevel::Low));
735        assert!(matches!(RiskLevel::from_score(5.0), RiskLevel::Medium));
736        assert!(matches!(RiskLevel::from_score(7.5), RiskLevel::High));
737        assert!(matches!(RiskLevel::from_score(9.0), RiskLevel::Critical));
738    }
739
740    #[test]
741    fn test_risk_level_boundaries() {
742        assert!(matches!(RiskLevel::from_score(0.0), RiskLevel::Low));
743        assert!(matches!(RiskLevel::from_score(3.0), RiskLevel::Low));
744        assert!(matches!(RiskLevel::from_score(3.01), RiskLevel::Medium));
745        assert!(matches!(RiskLevel::from_score(6.0), RiskLevel::Medium));
746        assert!(matches!(RiskLevel::from_score(6.01), RiskLevel::High));
747        assert!(matches!(RiskLevel::from_score(8.0), RiskLevel::High));
748        assert!(matches!(RiskLevel::from_score(8.01), RiskLevel::Critical));
749        assert!(matches!(RiskLevel::from_score(10.0), RiskLevel::Critical));
750    }
751
752    #[test]
753    fn test_risk_level_emojis() {
754        assert_eq!(RiskLevel::Low.emoji(), "🟢");
755        assert_eq!(RiskLevel::Medium.emoji(), "🟡");
756        assert_eq!(RiskLevel::High.emoji(), "🔴");
757        assert_eq!(RiskLevel::Critical.emoji(), "âš«");
758    }
759
760    #[test]
761    fn test_weighted_score_calculation() {
762        let engine = RiskEngine::new();
763        let factors = vec![
764            RiskFactor {
765                name: "Test1".to_string(),
766                category: RiskCategory::Behavioral,
767                score: 5.0,
768                weight: 0.5,
769                description: "Test".to_string(),
770                evidence: vec![],
771            },
772            RiskFactor {
773                name: "Test2".to_string(),
774                category: RiskCategory::Association,
775                score: 3.0,
776                weight: 0.5,
777                description: "Test".to_string(),
778                evidence: vec![],
779            },
780        ];
781
782        // (5.0 * 0.5 + 3.0 * 0.5) / (0.5 + 0.5) = 4.0
783        let score = engine.calculate_weighted_score(&factors);
784        assert!((score - 4.0).abs() < 0.01);
785    }
786
787    #[test]
788    fn test_weighted_score_empty_factors() {
789        let engine = RiskEngine::new();
790        let score = engine.calculate_weighted_score(&[]);
791        assert_eq!(score, 0.0);
792    }
793
794    #[test]
795    fn test_weighted_score_zero_weight() {
796        let engine = RiskEngine::new();
797        let factors = vec![RiskFactor {
798            name: "Test".to_string(),
799            category: RiskCategory::Behavioral,
800            score: 5.0,
801            weight: 0.0,
802            description: "Test".to_string(),
803            evidence: vec![],
804        }];
805        let score = engine.calculate_weighted_score(&factors);
806        assert_eq!(score, 0.0);
807    }
808
809    #[test]
810    fn test_weighted_score_clamped() {
811        let engine = RiskEngine::new();
812        let factors = vec![RiskFactor {
813            name: "High".to_string(),
814            category: RiskCategory::Behavioral,
815            score: 15.0,
816            weight: 1.0,
817            description: "Test".to_string(),
818            evidence: vec![],
819        }];
820        let score = engine.calculate_weighted_score(&factors);
821        assert_eq!(score, 10.0);
822    }
823
824    #[test]
825    fn test_recommendations_by_level() {
826        let engine = RiskEngine::new();
827        let factors = vec![];
828
829        let low_recs = engine.generate_recommendations(&factors, RiskLevel::Low);
830        assert!(low_recs.iter().any(|r| r.contains("Standard monitoring")));
831
832        let med_recs = engine.generate_recommendations(&factors, RiskLevel::Medium);
833        assert!(
834            med_recs
835                .iter()
836                .any(|r| r.contains("Standard due diligence"))
837        );
838
839        let high_recs = engine.generate_recommendations(&factors, RiskLevel::High);
840        assert!(
841            high_recs
842                .iter()
843                .any(|r| r.contains("Enhanced due diligence"))
844        );
845
846        let crit_recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
847        assert!(
848            crit_recs
849                .iter()
850                .any(|r| r.contains("Immediate investigation"))
851        );
852    }
853
854    #[test]
855    fn test_recommendations_high_score_factors() {
856        let engine = RiskEngine::new();
857        let factors = vec![RiskFactor {
858            name: "CriticalIssue".to_string(),
859            category: RiskCategory::Behavioral,
860            score: 8.5,
861            weight: 1.0,
862            description: "Critical issue".to_string(),
863            evidence: vec!["Evidence".to_string()],
864        }];
865
866        let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
867        assert!(recs.iter().any(|r| r.contains("CriticalIssue")));
868    }
869
870    #[test]
871    fn test_risk_factor_creation() {
872        let factor = RiskFactor {
873            name: "TestFactor".to_string(),
874            category: RiskCategory::Entity,
875            score: 7.5,
876            weight: 0.25,
877            description: "Test description".to_string(),
878            evidence: vec!["Evidence 1".to_string(), "Evidence 2".to_string()],
879        };
880
881        assert_eq!(factor.name, "TestFactor");
882        assert!(matches!(factor.category, RiskCategory::Entity));
883        assert_eq!(factor.score, 7.5);
884        assert_eq!(factor.weight, 0.25);
885        assert_eq!(factor.evidence.len(), 2);
886    }
887
888    #[test]
889    fn test_all_risk_categories() {
890        let _categories = [
891            RiskCategory::Behavioral,
892            RiskCategory::Association,
893            RiskCategory::Source,
894            RiskCategory::Destination,
895            RiskCategory::Entity,
896            RiskCategory::Sanctions,
897            RiskCategory::Reputation,
898        ];
899    }
900
901    #[tokio::test]
902    async fn test_risk_engine_creation() {
903        let engine = RiskEngine::new();
904        let assessment = engine
905            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
906            .await
907            .unwrap();
908
909        assert_eq!(
910            assessment.address,
911            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
912        );
913        assert_eq!(assessment.chain, "ethereum");
914        assert!(assessment.overall_score >= 0.0 && assessment.overall_score <= 10.0);
915        assert!(!assessment.factors.is_empty());
916        assert!(!assessment.recommendations.is_empty());
917    }
918
919    #[tokio::test]
920    async fn test_risk_assessment_different_addresses() {
921        let engine = RiskEngine::new();
922
923        let addresses = vec![
924            ("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum"),
925            ("0x0000000000000000000000000000000000000000", "ethereum"),
926        ];
927
928        for (addr, chain) in addresses {
929            let assessment = engine.assess_address(addr, chain).await.unwrap();
930            assert_eq!(assessment.address, addr);
931            assert_eq!(assessment.chain, chain);
932        }
933    }
934
935    #[test]
936    fn test_risk_engine_default() {
937        let engine = RiskEngine::default();
938        // Should create engine without data client
939        let score = engine.calculate_weighted_score(&[]);
940        assert_eq!(score, 0.0);
941    }
942
943    #[test]
944    fn test_risk_engine_with_data_client() {
945        let sources = datasource::DataSources::new("test_key".to_string());
946        let client = datasource::BlockchainDataClient::new(sources);
947        let _engine = RiskEngine::with_data_client(client);
948        // Just verify it creates without panicking
949    }
950
951    #[tokio::test]
952    async fn test_assess_address_has_all_factors() {
953        let engine = RiskEngine::new();
954        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
955
956        // Without a data client, should have 4 factors (behavior, association, source, entity)
957        assert_eq!(assessment.factors.len(), 4);
958
959        let categories: Vec<_> = assessment.factors.iter().map(|f| f.category).collect();
960        assert!(categories.contains(&RiskCategory::Behavioral));
961        assert!(categories.contains(&RiskCategory::Association));
962        assert!(categories.contains(&RiskCategory::Source));
963        assert!(categories.contains(&RiskCategory::Entity));
964    }
965
966    #[tokio::test]
967    async fn test_assess_address_factors_have_evidence() {
968        let engine = RiskEngine::new();
969        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
970
971        for factor in &assessment.factors {
972            assert!(
973                !factor.evidence.is_empty(),
974                "Factor {} has no evidence",
975                factor.name
976            );
977            // Without data client, evidence should mention "No data client configured"
978            assert!(
979                factor
980                    .evidence
981                    .iter()
982                    .any(|e| e.contains("No data client configured")
983                        || e.contains("not in known entity")),
984                "Factor {} doesn't have expected evidence: {:?}",
985                factor.name,
986                factor.evidence
987            );
988        }
989    }
990
991    #[tokio::test]
992    async fn test_assess_address_score_in_bounds() {
993        let engine = RiskEngine::new();
994        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
995
996        assert!(assessment.overall_score >= 0.0);
997        assert!(assessment.overall_score <= 10.0);
998
999        for factor in &assessment.factors {
1000            assert!(factor.score >= 0.0);
1001            assert!(factor.score <= 10.0);
1002            assert!(factor.weight >= 0.0);
1003            assert!(factor.weight <= 1.0);
1004        }
1005    }
1006
1007    #[test]
1008    fn test_risk_assessment_serialization() {
1009        let assessment = RiskAssessment {
1010            address: "0xtest".to_string(),
1011            chain: "ethereum".to_string(),
1012            overall_score: 3.5,
1013            risk_level: RiskLevel::Medium,
1014            factors: vec![],
1015            assessed_at: Utc::now(),
1016            recommendations: vec!["Test recommendation".to_string()],
1017        };
1018
1019        let json = serde_json::to_string(&assessment).unwrap();
1020        assert!(json.contains("0xtest"));
1021        assert!(json.contains("ethereum"));
1022        assert!(json.contains("Medium"));
1023
1024        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1025        assert_eq!(deserialized.address, "0xtest");
1026        assert_eq!(deserialized.overall_score, 3.5);
1027    }
1028
1029    #[test]
1030    fn test_risk_factor_serialization() {
1031        let factor = RiskFactor {
1032            name: "Test".to_string(),
1033            category: RiskCategory::Behavioral,
1034            score: 5.0,
1035            weight: 0.25,
1036            description: "Test factor".to_string(),
1037            evidence: vec!["Evidence 1".to_string()],
1038        };
1039
1040        let json = serde_json::to_string(&factor).unwrap();
1041        assert!(json.contains("Behavioral"));
1042
1043        let deserialized: RiskFactor = serde_json::from_str(&json).unwrap();
1044        assert_eq!(deserialized.name, "Test");
1045        assert_eq!(deserialized.score, 5.0);
1046    }
1047
1048    #[test]
1049    fn test_recommendations_critical_includes_sar() {
1050        let engine = RiskEngine::new();
1051        let recs = engine.generate_recommendations(&[], RiskLevel::Critical);
1052        assert!(recs.iter().any(|r| r.contains("SAR")));
1053        assert!(recs.iter().any(|r| r.contains("suspending")));
1054    }
1055
1056    #[test]
1057    fn test_recommendations_high_includes_verify_source() {
1058        let engine = RiskEngine::new();
1059        let recs = engine.generate_recommendations(&[], RiskLevel::High);
1060        assert!(recs.iter().any(|r| r.contains("Verify source")));
1061    }
1062
1063    #[test]
1064    fn test_recommendations_medium_includes_reassessment() {
1065        let engine = RiskEngine::new();
1066        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1067        assert!(recs.iter().any(|r| r.contains("re-assessment")));
1068    }
1069
1070    #[test]
1071    fn test_weighted_score_single_factor() {
1072        let engine = RiskEngine::new();
1073        let factors = vec![RiskFactor {
1074            name: "Single".to_string(),
1075            category: RiskCategory::Source,
1076            score: 7.0,
1077            weight: 1.0,
1078            description: "Test".to_string(),
1079            evidence: vec![],
1080        }];
1081        let score = engine.calculate_weighted_score(&factors);
1082        assert!((score - 7.0).abs() < 0.01);
1083    }
1084
1085    fn make_test_tx(timestamp: &str, value_eth: &str) -> datasource::EtherscanTransaction {
1086        let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
1087        datasource::EtherscanTransaction {
1088            block_number: "1".to_string(),
1089            timestamp: timestamp.to_string(),
1090            hash: "0x1".to_string(),
1091            from: "0xa".to_string(),
1092            to: "0xb".to_string(),
1093            value: value_wei.to_string(),
1094            gas: "21000".to_string(),
1095            gas_price: "20000000000".to_string(),
1096            is_error: "0".to_string(),
1097            txreceipt_status: "1".to_string(),
1098            input: "0x".to_string(),
1099            contract_address: "".to_string(),
1100            cumulative_gas_used: "21000".to_string(),
1101            gas_used: "21000".to_string(),
1102            confirmations: "100".to_string(),
1103        }
1104    }
1105
1106    #[test]
1107    fn test_pattern_analysis_no_structuring() {
1108        // Normal amounts, not just under thresholds
1109        let txs = vec![
1110            make_test_tx("1609459200", "1.5"),
1111            make_test_tx("1609459300", "2.3"),
1112            make_test_tx("1609459400", "0.7"),
1113        ];
1114
1115        let analysis = analyze_patterns(&txs);
1116        assert!(!analysis.structuring_detected);
1117    }
1118
1119    #[test]
1120    fn test_pattern_analysis_no_round_numbers() {
1121        let txs = vec![
1122            make_test_tx("1609459200", "1.234"),
1123            make_test_tx("1609459300", "0.567"),
1124            make_test_tx("1609459400", "3.891"),
1125        ];
1126
1127        let analysis = analyze_patterns(&txs);
1128        assert!(!analysis.round_number_pattern);
1129    }
1130
1131    #[test]
1132    fn test_pattern_analysis_single_tx() {
1133        let txs = vec![make_test_tx("1609459200", "1.0")];
1134
1135        let analysis = analyze_patterns(&txs);
1136        assert_eq!(analysis.total_transactions, 1);
1137        // With a single timestamp, velocity can't be computed
1138        assert_eq!(analysis.velocity_score, 0.0);
1139    }
1140
1141    #[tokio::test]
1142    async fn test_assess_address_generates_all_factors() {
1143        let engine = RiskEngine::new();
1144        let assessment = engine
1145            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1146            .await
1147            .unwrap();
1148        // Should have 4 risk factors (behavior, associations, sources, entity)
1149        assert_eq!(assessment.factors.len(), 4);
1150        // Check factor names
1151        let factor_names: Vec<&str> = assessment.factors.iter().map(|f| f.name.as_str()).collect();
1152        assert!(factor_names.contains(&"Behavioral Patterns"));
1153        assert!(factor_names.contains(&"Address Associations"));
1154        assert!(factor_names.contains(&"Source of Funds"));
1155        assert!(factor_names.contains(&"Entity Identification"));
1156    }
1157
1158    #[test]
1159    fn test_risk_assessment_json_roundtrip() {
1160        let assessment = RiskAssessment {
1161            address: "0xtest".to_string(),
1162            chain: "ethereum".to_string(),
1163            overall_score: 35.0,
1164            risk_level: RiskLevel::Medium,
1165            factors: vec![RiskFactor {
1166                name: "Test Factor".to_string(),
1167                category: RiskCategory::Behavioral,
1168                score: 30.0,
1169                weight: 0.25,
1170                description: "test details".to_string(),
1171                evidence: vec!["evidence1".to_string()],
1172            }],
1173            recommendations: vec!["recommendation".to_string()],
1174            assessed_at: Utc::now(),
1175        };
1176        let json = serde_json::to_string(&assessment).unwrap();
1177        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1178        assert_eq!(deserialized.address, "0xtest");
1179        assert_eq!(deserialized.overall_score, 35.0);
1180        assert_eq!(deserialized.factors.len(), 1);
1181    }
1182
1183    #[test]
1184    fn test_generate_recommendations_low_risk() {
1185        let engine = RiskEngine::new();
1186        let recs = engine.generate_recommendations(&[], RiskLevel::Low);
1187        assert!(!recs.is_empty());
1188        // Low risk should have standard monitoring recommendation
1189        assert!(recs.iter().any(|r| r.contains("Standard monitoring")));
1190    }
1191
1192    #[test]
1193    fn test_generate_recommendations_high_risk() {
1194        let engine = RiskEngine::new();
1195        let factors = vec![RiskFactor {
1196            name: "Behavioral Patterns".to_string(),
1197            category: RiskCategory::Behavioral,
1198            score: 80.0,
1199            weight: 0.3,
1200            description: "concerning".to_string(),
1201            evidence: vec!["High velocity".to_string()],
1202        }];
1203        let recs = engine.generate_recommendations(&factors, RiskLevel::High);
1204        assert!(!recs.is_empty());
1205    }
1206
1207    #[test]
1208    fn test_calculate_weighted_score_empty() {
1209        let engine = RiskEngine::new();
1210        let score = engine.calculate_weighted_score(&[]);
1211        assert_eq!(score, 0.0);
1212    }
1213
1214    #[test]
1215    fn test_analyze_patterns_structuring() {
1216        // Create transactions with values just under 10000 (structuring pattern)
1217        let txs: Vec<datasource::EtherscanTransaction> = (0..5)
1218            .map(|i| {
1219                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "9.5");
1220                tx.value = format!(
1221                    "{}",
1222                    (9500 + i * 100) as u128 * 1_000_000_000_000_000_000u128
1223                );
1224                tx
1225            })
1226            .collect();
1227        let analysis = analyze_patterns(&txs);
1228        assert_eq!(analysis.total_transactions, 5);
1229    }
1230
1231    #[test]
1232    fn test_analyze_patterns_round_numbers() {
1233        // Create transactions with round ETH values
1234        let txs: Vec<datasource::EtherscanTransaction> = (0..10)
1235            .map(|i| {
1236                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1237                // 1 ETH = 1e18 wei, 10 ETH = 10e18 wei, etc.
1238                tx.value = format!("{}", 10u128.pow(18) * (i + 1) as u128);
1239                tx
1240            })
1241            .collect();
1242        let analysis = analyze_patterns(&txs);
1243        assert!(analysis.round_number_pattern);
1244    }
1245
1246    #[test]
1247    fn test_analyze_patterns_high_velocity() {
1248        // Create many transactions spread over 2 days (high velocity)
1249        // Velocity = tx_count / days; 100 txs over 2 days = 50 tx/day
1250        let txs: Vec<datasource::EtherscanTransaction> = (0..100)
1251            .map(|i| {
1252                make_test_tx(&format!("{}", 1700000000 + i * 1800), "0.1") // 100 txs over ~2 days
1253            })
1254            .collect();
1255        let analysis = analyze_patterns(&txs);
1256        assert!(analysis.velocity_score > 1.0); // More than 1 tx per day
1257    }
1258
1259    fn mock_etherscan_tx_response(txs: &[datasource::EtherscanTransaction]) -> String {
1260        let result_json = serde_json::to_string(txs).unwrap();
1261        format!(
1262            r#"{{"status":"1","message":"OK","result":{}}}"#,
1263            result_json
1264        )
1265    }
1266
1267    #[tokio::test]
1268    async fn test_risk_engine_with_data_client_assess() {
1269        let mut server = mockito::Server::new_async().await;
1270
1271        // Create test transactions with various patterns
1272        let txs: Vec<datasource::EtherscanTransaction> = (0..20)
1273            .map(|i| {
1274                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1275                tx.from = if i % 2 == 0 {
1276                    "0xSender".to_string()
1277                } else {
1278                    "0xAddr".to_string()
1279                };
1280                tx.to = if i % 2 == 0 {
1281                    "0xAddr".to_string()
1282                } else {
1283                    format!("0xRecipient{}", i)
1284                };
1285                tx.is_error = if i == 5 {
1286                    "1".to_string()
1287                } else {
1288                    "0".to_string()
1289                };
1290                tx.contract_address = if i == 10 {
1291                    "0xContract".to_string()
1292                } else {
1293                    String::new()
1294                };
1295                tx
1296            })
1297            .collect();
1298
1299        let body = mock_etherscan_tx_response(&txs);
1300        let _mock = server
1301            .mock("GET", mockito::Matcher::Any)
1302            .with_status(200)
1303            .with_header("content-type", "application/json")
1304            .with_body(&body)
1305            .expect_at_least(1)
1306            .create_async()
1307            .await;
1308
1309        let sources = datasource::DataSources::new("test_key".to_string());
1310        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1311        let engine = RiskEngine::with_data_client(client);
1312        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1313
1314        assert_eq!(assessment.factors.len(), 4);
1315        assert!(assessment.overall_score > 0.0);
1316        assert!(!assessment.recommendations.is_empty());
1317
1318        // Behavioral factor should have evidence about analyzed transactions
1319        let behavior = assessment
1320            .factors
1321            .iter()
1322            .find(|f| f.name == "Behavioral Patterns")
1323            .unwrap();
1324        assert!(behavior.evidence.iter().any(|e| e.contains("Analyzed")));
1325
1326        // Association factor should have counterparty evidence
1327        let assoc = assessment
1328            .factors
1329            .iter()
1330            .find(|f| f.name == "Address Associations")
1331            .unwrap();
1332        assert!(assoc.evidence.iter().any(|e| e.contains("counterpart")));
1333
1334        // Source factor should mention incoming transactions
1335        let source = assessment
1336            .factors
1337            .iter()
1338            .find(|f| f.name == "Source of Funds")
1339            .unwrap();
1340        assert!(source.evidence.iter().any(|e| e.contains("incoming")));
1341    }
1342
1343    #[tokio::test]
1344    async fn test_risk_engine_with_data_client_api_error() {
1345        let mut server = mockito::Server::new_async().await;
1346        let _mock = server
1347            .mock("GET", mockito::Matcher::Any)
1348            .with_status(200)
1349            .with_header("content-type", "application/json")
1350            .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
1351            .create_async()
1352            .await;
1353
1354        let sources = datasource::DataSources::new("test_key".to_string());
1355        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1356        let engine = RiskEngine::with_data_client(client);
1357        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1358
1359        // Should still produce an assessment, but with error evidence
1360        assert_eq!(assessment.factors.len(), 4);
1361        // Behavior factor should mention the error
1362        let behavior = assessment
1363            .factors
1364            .iter()
1365            .find(|f| f.name == "Behavioral Patterns")
1366            .unwrap();
1367        assert!(
1368            behavior
1369                .evidence
1370                .iter()
1371                .any(|e| e.contains("Could not fetch"))
1372        );
1373    }
1374
1375    #[tokio::test]
1376    async fn test_risk_engine_with_data_client_self_transfers() {
1377        let mut server = mockito::Server::new_async().await;
1378
1379        // Create self-transfers (from == to)
1380        let mut txs = Vec::new();
1381        for i in 0..5 {
1382            let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1383            tx.from = "0xAddr".to_string();
1384            tx.to = "0xAddr".to_string(); // self-transfer
1385            txs.push(tx);
1386        }
1387
1388        let body = mock_etherscan_tx_response(&txs);
1389        let _mock = server
1390            .mock("GET", mockito::Matcher::Any)
1391            .with_status(200)
1392            .with_header("content-type", "application/json")
1393            .with_body(&body)
1394            .create_async()
1395            .await;
1396
1397        let sources = datasource::DataSources::new("test_key".to_string());
1398        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1399        let engine = RiskEngine::with_data_client(client);
1400        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1401
1402        // Association factor should mention self-transfers
1403        let assoc = assessment
1404            .factors
1405            .iter()
1406            .find(|f| f.name == "Address Associations")
1407            .unwrap();
1408        assert!(assoc.evidence.iter().any(|e| e.contains("self-transfer")));
1409    }
1410
1411    #[test]
1412    fn test_generate_recommendations_critical() {
1413        let engine = RiskEngine::new();
1414        let factors = vec![RiskFactor {
1415            name: "Behavioral Patterns".to_string(),
1416            category: RiskCategory::Behavioral,
1417            score: 9.0,
1418            weight: 0.25,
1419            description: "test".to_string(),
1420            evidence: vec![],
1421        }];
1422        let recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
1423        assert!(recs.iter().any(|r| r.contains("Immediate investigation")));
1424        assert!(recs.iter().any(|r| r.contains("SAR")));
1425    }
1426
1427    #[test]
1428    fn test_generate_recommendations_high() {
1429        let engine = RiskEngine::new();
1430        let recs = engine.generate_recommendations(&[], RiskLevel::High);
1431        assert!(recs.iter().any(|r| r.contains("Enhanced due diligence")));
1432    }
1433
1434    #[test]
1435    fn test_generate_recommendations_medium() {
1436        let engine = RiskEngine::new();
1437        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1438        assert!(recs.iter().any(|r| r.contains("Standard due diligence")));
1439    }
1440
1441    #[test]
1442    fn test_generate_recommendations_with_high_score_factor() {
1443        let engine = RiskEngine::new();
1444        let factors = vec![RiskFactor {
1445            name: "Test Factor".to_string(),
1446            category: RiskCategory::Behavioral,
1447            score: 8.5,
1448            weight: 0.25,
1449            description: "test".to_string(),
1450            evidence: vec![],
1451        }];
1452        let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
1453        assert!(
1454            recs.iter()
1455                .any(|r| r.contains("Address Test Factor concerns"))
1456        );
1457    }
1458
1459    // ========================================================================
1460    // Tests with data client for pattern analysis branches
1461    // ========================================================================
1462
1463    fn mock_etherscan_json_response(txs: &[serde_json::Value]) -> String {
1464        serde_json::json!({
1465            "status": "1",
1466            "message": "OK",
1467            "result": txs
1468        })
1469        .to_string()
1470    }
1471
1472    fn make_tx_with_idx(
1473        idx: u64,
1474        from: &str,
1475        to: &str,
1476        value: &str,
1477        timestamp: &str,
1478    ) -> serde_json::Value {
1479        serde_json::json!({
1480            "hash": format!("0x{:064x}", idx),
1481            "from": from,
1482            "to": to,
1483            "value": value,
1484            "timeStamp": timestamp,
1485            "blockNumber": "18000000",
1486            "gasUsed": "21000",
1487            "gasPrice": "50000000000",
1488            "isError": "0",
1489            "input": "0x"
1490        })
1491    }
1492
1493    #[tokio::test]
1494    async fn test_risk_engine_with_client_structuring_pattern() {
1495        let mut server = mockito::Server::new_async().await;
1496
1497        // Create transactions that trigger structuring detection
1498        // (amounts just under $10,000 = ~2.86 ETH at $3500)
1499        let txs: Vec<serde_json::Value> = (0..15)
1500            .map(|i| {
1501                make_tx_with_idx(
1502                    i as u64,
1503                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1504                    &format!("0x{:040x}", i + 1),
1505                    "9900000000000000000", // ~9.9 ETH
1506                    &format!("{}", 1700000000 + i * 3600),
1507                )
1508            })
1509            .collect();
1510
1511        let _mock = server
1512            .mock("GET", mockito::Matcher::Any)
1513            .with_status(200)
1514            .with_body(mock_etherscan_json_response(&txs))
1515            .create_async()
1516            .await;
1517
1518        let sources = datasource::DataSources::new("test_key".to_string());
1519        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1520        let engine = RiskEngine::with_data_client(client);
1521
1522        let assessment = engine
1523            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1524            .await
1525            .unwrap();
1526
1527        // Should have run with the client and produced a valid assessment
1528        assert!(!assessment.address.is_empty());
1529        assert!(assessment.overall_score >= 0.0);
1530    }
1531
1532    #[tokio::test]
1533    async fn test_risk_engine_with_client_api_error() {
1534        let mut server = mockito::Server::new_async().await;
1535
1536        let _mock = server
1537            .mock("GET", mockito::Matcher::Any)
1538            .with_status(200)
1539            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
1540            .create_async()
1541            .await;
1542
1543        let sources = datasource::DataSources::new("test_key".to_string());
1544        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1545        let engine = RiskEngine::with_data_client(client);
1546
1547        // Should still succeed (error paths return default factors)
1548        let assessment = engine
1549            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1550            .await
1551            .unwrap();
1552
1553        assert!(!assessment.address.is_empty());
1554    }
1555
1556    #[tokio::test]
1557    async fn test_risk_engine_with_client_many_counterparties() {
1558        let mut server = mockito::Server::new_async().await;
1559
1560        // Create transactions with > 100 unique counterparties
1561        let txs: Vec<serde_json::Value> = (0..120)
1562            .map(|i| {
1563                make_tx_with_idx(
1564                    i as u64,
1565                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1566                    &format!("0x{:040x}", i + 1),
1567                    "1000000000000000000",
1568                    &format!("{}", 1700000000 + i * 600),
1569                )
1570            })
1571            .collect();
1572
1573        let _mock = server
1574            .mock("GET", mockito::Matcher::Any)
1575            .with_status(200)
1576            .with_body(mock_etherscan_json_response(&txs))
1577            .create_async()
1578            .await;
1579
1580        let sources = datasource::DataSources::new("test_key".to_string());
1581        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1582        let engine = RiskEngine::with_data_client(client);
1583
1584        let assessment = engine
1585            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1586            .await
1587            .unwrap();
1588
1589        // Should have elevated risk due to high counterparty count
1590        assert!(assessment.overall_score > 0.0);
1591    }
1592
1593    #[test]
1594    fn test_gini_coefficient_equal_distribution() {
1595        // All equal holdings = Gini 0
1596        let holdings = vec![10.0, 10.0, 10.0, 10.0, 10.0];
1597        let gini = compute_gini_coefficient(&holdings);
1598        assert!(gini < 0.01, "Expected near-zero Gini, got {}", gini);
1599    }
1600
1601    #[test]
1602    fn test_gini_coefficient_concentrated() {
1603        // One holder has everything
1604        let holdings = vec![0.0, 0.0, 0.0, 0.0, 100.0];
1605        let gini = compute_gini_coefficient(&holdings);
1606        assert!(gini > 0.7, "Expected high Gini, got {}", gini);
1607    }
1608
1609    #[test]
1610    fn test_gini_coefficient_empty() {
1611        let gini = compute_gini_coefficient(&[]);
1612        assert_eq!(gini, 0.0);
1613    }
1614
1615    #[test]
1616    fn test_rugpull_indicators_none() {
1617        let factors = detect_rugpull_indicators(None, None);
1618        assert!(factors.is_empty());
1619    }
1620
1621    #[test]
1622    fn test_whale_detection_no_whales() {
1623        let txs = vec![];
1624        let factor = detect_whale_activity(&txs, 100.0, 100_000.0);
1625        assert!(factor.score < 1.0);
1626    }
1627
1628    // ========================================================================
1629    // Rugpull Indicator Tests
1630    // ========================================================================
1631
1632    fn make_ca(
1633        verified: bool,
1634        ac: Option<crate::contract::access::AccessControlMap>,
1635    ) -> crate::contract::ContractAnalysis {
1636        crate::contract::ContractAnalysis {
1637            address: "0xtest".into(),
1638            chain: "ethereum".into(),
1639            is_verified: verified,
1640            source_info: None,
1641            proxy_info: None,
1642            access_control: ac,
1643            vulnerabilities: vec![],
1644            defi_analysis: None,
1645            external_info: None,
1646            security_score: 50,
1647            security_summary: String::new(),
1648        }
1649    }
1650
1651    fn make_ac(
1652        funcs: Vec<(&str, &str)>,
1653        renounced: bool,
1654        tx_origin: bool,
1655    ) -> crate::contract::access::AccessControlMap {
1656        let priv_fns = funcs
1657            .iter()
1658            .map(|(name, cap)| crate::contract::access::PrivilegedFunction {
1659                name: name.to_string(),
1660                modifiers: vec![],
1661                capability: cap.to_string(),
1662                risk: crate::contract::access::PrivilegeRisk::High,
1663            })
1664            .collect();
1665        crate::contract::access::AccessControlMap {
1666            ownership_pattern: None,
1667            has_renounced_ownership: renounced,
1668            has_role_based_access: false,
1669            uses_tx_origin: tx_origin,
1670            tx_origin_locations: vec![],
1671            modifiers: vec![],
1672            privileged_functions: priv_fns,
1673            roles: vec![],
1674            auth_analysis: crate::contract::access::AuthAnalysis {
1675                msg_sender_checks: 0,
1676                tx_origin_checks: 0,
1677                has_origin_sender_comparison: false,
1678                summary: String::new(),
1679            },
1680        }
1681    }
1682
1683    #[test]
1684    fn test_rugpull_mint_evidence() {
1685        let ac = make_ac(vec![("mint", "Mint")], false, false);
1686        let ca = make_ca(true, Some(ac));
1687        let factors = detect_rugpull_indicators(Some(&ca), None);
1688        assert!(!factors.is_empty());
1689        let f = &factors[0];
1690        assert!(f.evidence.iter().any(|e| e.contains("mint")));
1691    }
1692
1693    #[test]
1694    fn test_rugpull_pause_blacklist_setfee() {
1695        let ac = make_ac(
1696            vec![
1697                ("pause", "Pause"),
1698                ("blacklist", "Block"),
1699                ("setFee", "Fee"),
1700            ],
1701            false,
1702            false,
1703        );
1704        let ca = make_ca(true, Some(ac));
1705        let factors = detect_rugpull_indicators(Some(&ca), None);
1706        let f = &factors[0];
1707        assert!(f.evidence.iter().any(|e| e.contains("pause")));
1708        assert!(f.evidence.iter().any(|e| e.contains("blacklist")));
1709        assert!(f.evidence.iter().any(|e| e.contains("fee")));
1710    }
1711
1712    #[test]
1713    fn test_rugpull_renounced_reduces_risk() {
1714        let ac = make_ac(vec![("mint", "Mint")], true, false);
1715        let ca = make_ca(true, Some(ac));
1716        let factors = detect_rugpull_indicators(Some(&ca), None);
1717        let f = &factors[0];
1718        assert!(f.evidence.iter().any(|e| e.contains("renounced")));
1719    }
1720
1721    #[test]
1722    fn test_rugpull_tx_origin() {
1723        let ac = make_ac(vec![], false, true);
1724        let ca = make_ca(true, Some(ac));
1725        let factors = detect_rugpull_indicators(Some(&ca), None);
1726        let f = &factors[0];
1727        assert!(f.evidence.iter().any(|e| e.contains("tx.origin")));
1728    }
1729
1730    #[test]
1731    fn test_rugpull_unverified() {
1732        let ac = make_ac(vec![], false, false);
1733        let ca = make_ca(false, Some(ac));
1734        let factors = detect_rugpull_indicators(Some(&ca), None);
1735        let f = &factors[0];
1736        assert!(f.evidence.iter().any(|e| e.contains("not verified")));
1737    }
1738
1739    fn make_ta(
1740        top10: Option<f64>,
1741        age: Option<f64>,
1742        buys: u64,
1743        sells: u64,
1744        liq: f64,
1745    ) -> crate::chains::TokenAnalytics {
1746        crate::chains::TokenAnalytics {
1747            token: crate::chains::Token {
1748                contract_address: "0x".into(),
1749                symbol: "T".into(),
1750                name: "T".into(),
1751                decimals: 18,
1752            },
1753            chain: "ethereum".into(),
1754            holders: vec![],
1755            total_holders: 0,
1756            volume_24h: 0.0,
1757            volume_7d: 0.0,
1758            price_usd: 0.0,
1759            price_change_24h: 0.0,
1760            price_change_7d: 0.0,
1761            liquidity_usd: liq,
1762            market_cap: None,
1763            fdv: None,
1764            total_supply: None,
1765            circulating_supply: None,
1766            price_history: vec![],
1767            volume_history: vec![],
1768            holder_history: vec![],
1769            dex_pairs: vec![],
1770            fetched_at: 0,
1771            top_10_concentration: top10,
1772            top_50_concentration: None,
1773            top_100_concentration: None,
1774            price_change_6h: 0.0,
1775            price_change_1h: 0.0,
1776            total_buys_24h: buys,
1777            total_sells_24h: sells,
1778            total_buys_6h: 0,
1779            total_sells_6h: 0,
1780            total_buys_1h: 0,
1781            total_sells_1h: 0,
1782            token_age_hours: age,
1783            image_url: None,
1784            websites: vec![],
1785            socials: vec![],
1786            dexscreener_url: None,
1787        }
1788    }
1789
1790    #[test]
1791    fn test_rugpull_high_concentration() {
1792        let ta = make_ta(Some(85.0), None, 0, 0, 100000.0);
1793        let factors = detect_rugpull_indicators(None, Some(&ta));
1794        assert!(!factors.is_empty());
1795        assert!(factors[0].evidence.iter().any(|e| e.contains("85.0%")));
1796    }
1797
1798    #[test]
1799    fn test_rugpull_moderate_concentration() {
1800        let ta = make_ta(Some(55.0), None, 0, 0, 100000.0);
1801        let factors = detect_rugpull_indicators(None, Some(&ta));
1802        assert!(!factors.is_empty());
1803        assert!(factors[0].evidence.iter().any(|e| e.contains("55.0%")));
1804    }
1805
1806    #[test]
1807    fn test_rugpull_very_new_token() {
1808        let ta = make_ta(None, Some(12.0), 0, 0, 100000.0);
1809        let factors = detect_rugpull_indicators(None, Some(&ta));
1810        assert!(!factors.is_empty());
1811        assert!(factors[0].evidence.iter().any(|e| e.contains("very new")));
1812    }
1813
1814    #[test]
1815    fn test_rugpull_recently_created() {
1816        let ta = make_ta(None, Some(48.0), 0, 0, 100000.0);
1817        let factors = detect_rugpull_indicators(None, Some(&ta));
1818        assert!(!factors.is_empty());
1819        assert!(
1820            factors[0]
1821                .evidence
1822                .iter()
1823                .any(|e| e.contains("recently created"))
1824        );
1825    }
1826
1827    #[test]
1828    fn test_rugpull_honeypot() {
1829        let ta = make_ta(None, None, 20, 0, 100000.0);
1830        let factors = detect_rugpull_indicators(None, Some(&ta));
1831        assert!(!factors.is_empty());
1832        assert!(factors[0].evidence.iter().any(|e| e.contains("honeypot")));
1833    }
1834
1835    #[test]
1836    fn test_rugpull_low_liquidity() {
1837        let ta = make_ta(None, None, 0, 0, 5000.0);
1838        let factors = detect_rugpull_indicators(None, Some(&ta));
1839        assert!(!factors.is_empty());
1840        assert!(
1841            factors[0]
1842                .evidence
1843                .iter()
1844                .any(|e| e.contains("low liquidity") || e.contains("Very low"))
1845        );
1846    }
1847
1848    // ========================================================================
1849    // Whale Activity Tests
1850    // ========================================================================
1851
1852    fn make_whale_tx(value: &str) -> crate::chains::Transaction {
1853        crate::chains::Transaction {
1854            hash: "0xh".into(),
1855            block_number: Some(1),
1856            timestamp: Some(0),
1857            from: "0xa".into(),
1858            to: Some("0xb".into()),
1859            value: value.to_string(),
1860            gas_limit: 21000,
1861            gas_used: Some(21000),
1862            gas_price: "0".into(),
1863            nonce: 0,
1864            input: "0x".into(),
1865            status: Some(true),
1866        }
1867    }
1868
1869    #[test]
1870    fn test_whale_with_large_txs() {
1871        let txs = vec![
1872            make_whale_tx("200000"),
1873            make_whale_tx("50"),
1874            make_whale_tx("150000"),
1875        ];
1876        let factor = detect_whale_activity(&txs, 50000.0, 100000.0);
1877        assert!(factor.evidence.iter().any(|e| e.contains("whale-sized")));
1878    }
1879
1880    #[test]
1881    fn test_whale_avg_near_threshold() {
1882        let txs = vec![make_whale_tx("50")];
1883        let factor = detect_whale_activity(&txs, 60000.0, 100000.0);
1884        assert!(
1885            factor
1886                .evidence
1887                .iter()
1888                .any(|e| e.contains("near whale threshold"))
1889        );
1890    }
1891
1892    // ========================================================================
1893    // Timelock Tests
1894    // ========================================================================
1895
1896    fn make_ca_with_source(code: &str) -> crate::contract::ContractAnalysis {
1897        let mut ca = make_ca(true, None);
1898        ca.source_info = Some(crate::contract::source::ContractSource {
1899            contract_name: "Test".into(),
1900            source_code: code.to_string(),
1901            abi: "[]".into(),
1902            compiler_version: "v0.8.19".into(),
1903            optimization_used: true,
1904            optimization_runs: 200,
1905            evm_version: "paris".into(),
1906            license_type: "MIT".into(),
1907            is_proxy: false,
1908            implementation_address: None,
1909            constructor_arguments: String::new(),
1910            library: String::new(),
1911            swarm_source: String::new(),
1912            parsed_abi: vec![],
1913        });
1914        ca
1915    }
1916
1917    #[test]
1918    fn test_timelock_controller_detected() {
1919        let ca = make_ca_with_source("contract G is TimelockController { }");
1920        let f = detect_timelock(&ca).unwrap();
1921        assert_eq!(f.score, 2.0);
1922        assert!(f.evidence.iter().any(|e| e.contains("TimelockController")));
1923    }
1924
1925    #[test]
1926    fn test_timelock_queue_delay_execute() {
1927        let ca = make_ca_with_source("function queue() {} function execute() {} uint delay;");
1928        let f = detect_timelock(&ca).unwrap();
1929        assert_eq!(f.score, 2.0);
1930    }
1931
1932    #[test]
1933    fn test_timelock_mindelay() {
1934        let ca = make_ca_with_source("TimelockController tl; uint minDelay;");
1935        let f = detect_timelock(&ca).unwrap();
1936        assert!(f.evidence.iter().any(|e| e.contains("Minimum delay")));
1937    }
1938
1939    #[test]
1940    fn test_no_timelock() {
1941        let ca = make_ca_with_source("contract Token { function transfer() {} }");
1942        let f = detect_timelock(&ca).unwrap();
1943        assert_eq!(f.score, 5.0);
1944    }
1945
1946    #[test]
1947    fn test_timelock_no_source() {
1948        let ca = make_ca(true, None);
1949        assert!(detect_timelock(&ca).is_none());
1950    }
1951
1952    // ========================================================================
1953    // Multisig Tests
1954    // ========================================================================
1955
1956    #[test]
1957    fn test_multisig_gnosis() {
1958        let ca = make_ca_with_source("import Gnosis; contract W is GnosisSafe { }");
1959        let f = detect_multisig(&ca).unwrap();
1960        assert_eq!(f.score, 2.0);
1961    }
1962
1963    #[test]
1964    fn test_multisig_threshold_owners() {
1965        let ca = make_ca_with_source("uint threshold; address[] owners;");
1966        let f = detect_multisig(&ca).unwrap();
1967        assert_eq!(f.score, 2.0);
1968    }
1969
1970    #[test]
1971    fn test_multisig_confirm_execute() {
1972        let ca = make_ca_with_source(
1973            "function confirmTransaction() {} function executeTransaction() {}",
1974        );
1975        let f = detect_multisig(&ca).unwrap();
1976        assert_eq!(f.score, 2.0);
1977    }
1978
1979    #[test]
1980    fn test_no_multisig() {
1981        let ca = make_ca_with_source("contract Token { }");
1982        let f = detect_multisig(&ca).unwrap();
1983        assert_eq!(f.score, 4.0);
1984    }
1985
1986    #[test]
1987    fn test_multisig_with_proxy_admin() {
1988        let mut ca = make_ca_with_source("contract Token { }");
1989        ca.proxy_info = Some(crate::contract::proxy::ProxyInfo {
1990            is_proxy: true,
1991            proxy_type: "EIP-1967".into(),
1992            implementation_address: None,
1993            admin_address: Some("0xadmin".into()),
1994            beacon_address: None,
1995            details: vec![],
1996        });
1997        let f = detect_multisig(&ca).unwrap();
1998        assert!(f.evidence.iter().any(|e| e.contains("0xadmin")));
1999    }
2000
2001    // ========================================================================
2002    // Gini Coefficient Edge Cases
2003    // ========================================================================
2004
2005    #[test]
2006    fn test_gini_all_zeros() {
2007        assert_eq!(compute_gini_coefficient(&[0.0, 0.0, 0.0]), 0.0);
2008    }
2009
2010    #[test]
2011    fn test_gini_single_element() {
2012        assert_eq!(compute_gini_coefficient(&[100.0]), 0.0);
2013    }
2014
2015    #[test]
2016    fn test_gini_two_equal() {
2017        let g = compute_gini_coefficient(&[50.0, 50.0]);
2018        assert!(g < 0.01);
2019    }
2020
2021    #[test]
2022    fn test_gini_two_unequal() {
2023        let g = compute_gini_coefficient(&[1.0, 99.0]);
2024        assert!(g > 0.0);
2025    }
2026}