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