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 = datasource::BlockchainDataClient::new(sources);
975        let _engine = RiskEngine::with_data_client(client);
976        // Just verify it creates without panicking
977    }
978
979    #[tokio::test]
980    async fn test_assess_address_has_all_factors() {
981        let engine = RiskEngine::new();
982        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
983
984        // Without a data client, all 4 factors are listed as not assessed
985        // (a default score made a report with no data read as Low).
986        assert!(assessment.factors.is_empty());
987        for name in [
988            "Behavioral Patterns",
989            "Address Associations",
990            "Source of Funds",
991            "Entity Identification",
992        ] {
993            assert!(
994                assessment
995                    .not_assessed
996                    .iter()
997                    .any(|r| r.starts_with(name) && r.contains("no Etherscan API key")),
998                "{name}: {:?}",
999                assessment.not_assessed
1000            );
1001        }
1002        assert!(matches!(assessment.risk_level, RiskLevel::Incomplete));
1003        assert!(
1004            !assessment
1005                .recommendations
1006                .iter()
1007                .any(|r| r.contains("Standard monitoring"))
1008        );
1009    }
1010
1011    #[tokio::test]
1012    async fn test_assess_address_factors_have_evidence() {
1013        let engine = RiskEngine::new();
1014        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
1015
1016        for factor in &assessment.factors {
1017            assert!(
1018                !factor.evidence.is_empty(),
1019                "Factor {} has no evidence",
1020                factor.name
1021            );
1022            // Without data client, evidence should mention "No data client configured"
1023            assert!(
1024                factor
1025                    .evidence
1026                    .iter()
1027                    .any(|e| e.contains("No data client configured")
1028                        || e.contains("not in known entity")),
1029                "Factor {} doesn't have expected evidence: {:?}",
1030                factor.name,
1031                factor.evidence
1032            );
1033        }
1034    }
1035
1036    #[tokio::test]
1037    async fn test_assess_address_score_in_bounds() {
1038        let engine = RiskEngine::new();
1039        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
1040
1041        assert!(assessment.overall_score >= 0.0);
1042        assert!(assessment.overall_score <= 10.0);
1043
1044        for factor in &assessment.factors {
1045            assert!(factor.score >= 0.0);
1046            assert!(factor.score <= 10.0);
1047            assert!(factor.weight >= 0.0);
1048            assert!(factor.weight <= 1.0);
1049        }
1050    }
1051
1052    #[test]
1053    fn test_risk_assessment_serialization() {
1054        let assessment = RiskAssessment {
1055            address: "0xtest".to_string(),
1056            chain: "ethereum".to_string(),
1057            overall_score: 3.5,
1058            risk_level: RiskLevel::Medium,
1059            factors: vec![],
1060            not_assessed: Vec::new(),
1061            assessed_at: Utc::now(),
1062            recommendations: vec!["Test recommendation".to_string()],
1063        };
1064
1065        let json = serde_json::to_string(&assessment).unwrap();
1066        assert!(json.contains("0xtest"));
1067        assert!(json.contains("ethereum"));
1068        assert!(json.contains("Medium"));
1069
1070        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1071        assert_eq!(deserialized.address, "0xtest");
1072        assert_eq!(deserialized.overall_score, 3.5);
1073    }
1074
1075    #[test]
1076    fn test_risk_factor_serialization() {
1077        let factor = RiskFactor {
1078            name: "Test".to_string(),
1079            category: RiskCategory::Behavioral,
1080            score: 5.0,
1081            weight: 0.25,
1082            description: "Test factor".to_string(),
1083            evidence: vec!["Evidence 1".to_string()],
1084        };
1085
1086        let json = serde_json::to_string(&factor).unwrap();
1087        assert!(json.contains("Behavioral"));
1088
1089        let deserialized: RiskFactor = serde_json::from_str(&json).unwrap();
1090        assert_eq!(deserialized.name, "Test");
1091        assert_eq!(deserialized.score, 5.0);
1092    }
1093
1094    #[test]
1095    fn test_recommendations_critical_includes_sar() {
1096        let engine = RiskEngine::new();
1097        let recs = engine.generate_recommendations(&[], RiskLevel::Critical);
1098        assert!(recs.iter().any(|r| r.contains("SAR")));
1099        assert!(recs.iter().any(|r| r.contains("suspending")));
1100    }
1101
1102    #[test]
1103    fn test_recommendations_high_includes_verify_source() {
1104        let engine = RiskEngine::new();
1105        let recs = engine.generate_recommendations(&[], RiskLevel::High);
1106        assert!(recs.iter().any(|r| r.contains("Verify source")));
1107    }
1108
1109    #[test]
1110    fn test_recommendations_medium_includes_reassessment() {
1111        let engine = RiskEngine::new();
1112        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1113        assert!(recs.iter().any(|r| r.contains("re-assessment")));
1114    }
1115
1116    #[test]
1117    fn test_weighted_score_single_factor() {
1118        let engine = RiskEngine::new();
1119        let factors = vec![RiskFactor {
1120            name: "Single".to_string(),
1121            category: RiskCategory::Source,
1122            score: 7.0,
1123            weight: 1.0,
1124            description: "Test".to_string(),
1125            evidence: vec![],
1126        }];
1127        let score = engine.calculate_weighted_score(&factors);
1128        assert!((score - 7.0).abs() < 0.01);
1129    }
1130
1131    fn make_test_tx(timestamp: &str, value_eth: &str) -> datasource::EtherscanTransaction {
1132        let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
1133        datasource::EtherscanTransaction {
1134            block_number: "1".to_string(),
1135            timestamp: timestamp.to_string(),
1136            hash: "0x1".to_string(),
1137            from: "0xa".to_string(),
1138            to: "0xb".to_string(),
1139            value: value_wei.to_string(),
1140            gas: "21000".to_string(),
1141            gas_price: "20000000000".to_string(),
1142            is_error: "0".to_string(),
1143            txreceipt_status: "1".to_string(),
1144            input: "0x".to_string(),
1145            contract_address: "".to_string(),
1146            cumulative_gas_used: "21000".to_string(),
1147            gas_used: "21000".to_string(),
1148            confirmations: "100".to_string(),
1149        }
1150    }
1151
1152    #[test]
1153    fn test_pattern_analysis_no_structuring() {
1154        // Normal amounts, not just under thresholds
1155        let txs = vec![
1156            make_test_tx("1609459200", "1.5"),
1157            make_test_tx("1609459300", "2.3"),
1158            make_test_tx("1609459400", "0.7"),
1159        ];
1160
1161        let analysis = analyze_patterns(&txs);
1162        assert!(!analysis.structuring_detected);
1163    }
1164
1165    #[test]
1166    fn test_pattern_analysis_no_round_numbers() {
1167        let txs = vec![
1168            make_test_tx("1609459200", "1.234"),
1169            make_test_tx("1609459300", "0.567"),
1170            make_test_tx("1609459400", "3.891"),
1171        ];
1172
1173        let analysis = analyze_patterns(&txs);
1174        assert!(!analysis.round_number_pattern);
1175    }
1176
1177    #[test]
1178    fn test_pattern_analysis_single_tx() {
1179        let txs = vec![make_test_tx("1609459200", "1.0")];
1180
1181        let analysis = analyze_patterns(&txs);
1182        assert_eq!(analysis.total_transactions, 1);
1183        // With a single timestamp, velocity can't be computed
1184        assert_eq!(analysis.velocity_score, 0.0);
1185    }
1186
1187    #[tokio::test]
1188    async fn test_assess_address_generates_all_factors() {
1189        let engine = RiskEngine::new();
1190        let assessment = engine
1191            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1192            .await
1193            .unwrap();
1194        // Every factor is accounted for: scored or listed as not assessed.
1195        assert_eq!(assessment.factors.len() + assessment.not_assessed.len(), 4);
1196    }
1197
1198    #[test]
1199    fn test_risk_assessment_json_roundtrip() {
1200        let assessment = RiskAssessment {
1201            address: "0xtest".to_string(),
1202            chain: "ethereum".to_string(),
1203            overall_score: 35.0,
1204            risk_level: RiskLevel::Medium,
1205            factors: vec![RiskFactor {
1206                name: "Test Factor".to_string(),
1207                category: RiskCategory::Behavioral,
1208                score: 30.0,
1209                weight: 0.25,
1210                description: "test details".to_string(),
1211                evidence: vec!["evidence1".to_string()],
1212            }],
1213            recommendations: vec!["recommendation".to_string()],
1214            not_assessed: Vec::new(),
1215            assessed_at: Utc::now(),
1216        };
1217        let json = serde_json::to_string(&assessment).unwrap();
1218        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1219        assert_eq!(deserialized.address, "0xtest");
1220        assert_eq!(deserialized.overall_score, 35.0);
1221        assert_eq!(deserialized.factors.len(), 1);
1222    }
1223
1224    #[test]
1225    fn test_generate_recommendations_low_risk() {
1226        let engine = RiskEngine::new();
1227        let recs = engine.generate_recommendations(&[], RiskLevel::Low);
1228        assert!(!recs.is_empty());
1229        // Low risk should have standard monitoring recommendation
1230        assert!(recs.iter().any(|r| r.contains("Standard monitoring")));
1231    }
1232
1233    #[test]
1234    fn test_generate_recommendations_high_risk() {
1235        let engine = RiskEngine::new();
1236        let factors = vec![RiskFactor {
1237            name: "Behavioral Patterns".to_string(),
1238            category: RiskCategory::Behavioral,
1239            score: 80.0,
1240            weight: 0.3,
1241            description: "concerning".to_string(),
1242            evidence: vec!["High velocity".to_string()],
1243        }];
1244        let recs = engine.generate_recommendations(&factors, RiskLevel::High);
1245        assert!(!recs.is_empty());
1246    }
1247
1248    #[test]
1249    fn test_calculate_weighted_score_empty() {
1250        let engine = RiskEngine::new();
1251        let score = engine.calculate_weighted_score(&[]);
1252        assert_eq!(score, 0.0);
1253    }
1254
1255    #[test]
1256    fn test_analyze_patterns_structuring() {
1257        // Create transactions with values just under 10000 (structuring pattern)
1258        let txs: Vec<datasource::EtherscanTransaction> = (0..5)
1259            .map(|i| {
1260                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "9.5");
1261                tx.value = format!(
1262                    "{}",
1263                    (9500 + i * 100) as u128 * 1_000_000_000_000_000_000u128
1264                );
1265                tx
1266            })
1267            .collect();
1268        let analysis = analyze_patterns(&txs);
1269        assert_eq!(analysis.total_transactions, 5);
1270    }
1271
1272    #[test]
1273    fn test_analyze_patterns_round_numbers() {
1274        // Create transactions with round ETH values
1275        let txs: Vec<datasource::EtherscanTransaction> = (0..10)
1276            .map(|i| {
1277                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1278                // 1 ETH = 1e18 wei, 10 ETH = 10e18 wei, etc.
1279                tx.value = format!("{}", 10u128.pow(18) * (i + 1) as u128);
1280                tx
1281            })
1282            .collect();
1283        let analysis = analyze_patterns(&txs);
1284        assert!(analysis.round_number_pattern);
1285    }
1286
1287    #[test]
1288    fn test_analyze_patterns_high_velocity() {
1289        // Create many transactions spread over 2 days (high velocity)
1290        // Velocity = tx_count / days; 100 txs over 2 days = 50 tx/day
1291        let txs: Vec<datasource::EtherscanTransaction> = (0..100)
1292            .map(|i| {
1293                make_test_tx(&format!("{}", 1700000000 + i * 1800), "0.1") // 100 txs over ~2 days
1294            })
1295            .collect();
1296        let analysis = analyze_patterns(&txs);
1297        assert!(analysis.velocity_score > 1.0); // More than 1 tx per day
1298    }
1299
1300    fn mock_etherscan_tx_response(txs: &[datasource::EtherscanTransaction]) -> String {
1301        let result_json = serde_json::to_string(txs).unwrap();
1302        format!(
1303            r#"{{"status":"1","message":"OK","result":{}}}"#,
1304            result_json
1305        )
1306    }
1307
1308    #[tokio::test]
1309    async fn test_risk_engine_with_data_client_assess() {
1310        let mut server = mockito::Server::new_async().await;
1311
1312        // Create test transactions with various patterns
1313        let txs: Vec<datasource::EtherscanTransaction> = (0..20)
1314            .map(|i| {
1315                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1316                tx.from = if i % 2 == 0 {
1317                    "0xSender".to_string()
1318                } else {
1319                    "0xAddr".to_string()
1320                };
1321                tx.to = if i % 2 == 0 {
1322                    "0xAddr".to_string()
1323                } else {
1324                    format!("0xRecipient{}", i)
1325                };
1326                tx.is_error = if i == 5 {
1327                    "1".to_string()
1328                } else {
1329                    "0".to_string()
1330                };
1331                tx.contract_address = if i == 10 {
1332                    "0xContract".to_string()
1333                } else {
1334                    String::new()
1335                };
1336                tx
1337            })
1338            .collect();
1339
1340        let body = mock_etherscan_tx_response(&txs);
1341        let _mock = server
1342            .mock("GET", mockito::Matcher::Any)
1343            .with_status(200)
1344            .with_header("content-type", "application/json")
1345            .with_body(&body)
1346            .expect_at_least(1)
1347            .create_async()
1348            .await;
1349
1350        let sources = datasource::DataSources::new("test_key".to_string());
1351        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1352        let engine = RiskEngine::with_data_client(client);
1353        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1354
1355        assert_eq!(assessment.factors.len(), 4);
1356        assert!(assessment.overall_score > 0.0);
1357        assert!(!assessment.recommendations.is_empty());
1358
1359        // Behavioral factor should have evidence about analyzed transactions
1360        let behavior = assessment
1361            .factors
1362            .iter()
1363            .find(|f| f.name == "Behavioral Patterns")
1364            .unwrap();
1365        assert!(behavior.evidence.iter().any(|e| e.contains("Analyzed")));
1366
1367        // Association factor should have counterparty evidence
1368        let assoc = assessment
1369            .factors
1370            .iter()
1371            .find(|f| f.name == "Address Associations")
1372            .unwrap();
1373        assert!(assoc.evidence.iter().any(|e| e.contains("counterpart")));
1374
1375        // Source factor should mention incoming transactions
1376        let source = assessment
1377            .factors
1378            .iter()
1379            .find(|f| f.name == "Source of Funds")
1380            .unwrap();
1381        assert!(source.evidence.iter().any(|e| e.contains("incoming")));
1382    }
1383
1384    #[tokio::test]
1385    async fn test_risk_engine_with_data_client_api_error() {
1386        let mut server = mockito::Server::new_async().await;
1387        let _mock = server
1388            .mock("GET", mockito::Matcher::Any)
1389            .with_status(200)
1390            .with_header("content-type", "application/json")
1391            .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
1392            .create_async()
1393            .await;
1394
1395        let sources = datasource::DataSources::new("test_key".to_string());
1396        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1397        let engine = RiskEngine::with_data_client(client);
1398        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1399
1400        // A failed fetch is not data: the three transaction factors are
1401        // not assessed, with the reason, and the result is incomplete.
1402        assert_eq!(assessment.factors.len(), 1, "{:?}", assessment.not_assessed);
1403        assert_eq!(assessment.factors[0].name, "Entity Identification");
1404        assert_eq!(assessment.not_assessed.len(), 3);
1405        assert!(
1406            assessment
1407                .not_assessed
1408                .iter()
1409                .all(|r| r.contains("could not fetch transactions"))
1410        );
1411        assert!(matches!(assessment.risk_level, RiskLevel::Incomplete));
1412    }
1413
1414    #[tokio::test]
1415    async fn test_risk_engine_with_data_client_self_transfers() {
1416        let mut server = mockito::Server::new_async().await;
1417
1418        // Create self-transfers (from == to)
1419        let mut txs = Vec::new();
1420        for i in 0..5 {
1421            let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1422            tx.from = "0xAddr".to_string();
1423            tx.to = "0xAddr".to_string(); // self-transfer
1424            txs.push(tx);
1425        }
1426
1427        let body = mock_etherscan_tx_response(&txs);
1428        let _mock = server
1429            .mock("GET", mockito::Matcher::Any)
1430            .with_status(200)
1431            .with_header("content-type", "application/json")
1432            .with_body(&body)
1433            .create_async()
1434            .await;
1435
1436        let sources = datasource::DataSources::new("test_key".to_string());
1437        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1438        let engine = RiskEngine::with_data_client(client);
1439        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1440
1441        // Association factor should mention self-transfers
1442        let assoc = assessment
1443            .factors
1444            .iter()
1445            .find(|f| f.name == "Address Associations")
1446            .unwrap();
1447        assert!(assoc.evidence.iter().any(|e| e.contains("self-transfer")));
1448    }
1449
1450    #[test]
1451    fn test_generate_recommendations_critical() {
1452        let engine = RiskEngine::new();
1453        let factors = vec![RiskFactor {
1454            name: "Behavioral Patterns".to_string(),
1455            category: RiskCategory::Behavioral,
1456            score: 9.0,
1457            weight: 0.25,
1458            description: "test".to_string(),
1459            evidence: vec![],
1460        }];
1461        let recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
1462        assert!(recs.iter().any(|r| r.contains("Immediate investigation")));
1463        assert!(recs.iter().any(|r| r.contains("SAR")));
1464    }
1465
1466    #[test]
1467    fn test_generate_recommendations_high() {
1468        let engine = RiskEngine::new();
1469        let recs = engine.generate_recommendations(&[], RiskLevel::High);
1470        assert!(recs.iter().any(|r| r.contains("Enhanced due diligence")));
1471    }
1472
1473    #[test]
1474    fn test_generate_recommendations_medium() {
1475        let engine = RiskEngine::new();
1476        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1477        assert!(recs.iter().any(|r| r.contains("Standard due diligence")));
1478    }
1479
1480    #[test]
1481    fn test_generate_recommendations_with_high_score_factor() {
1482        let engine = RiskEngine::new();
1483        let factors = vec![RiskFactor {
1484            name: "Test Factor".to_string(),
1485            category: RiskCategory::Behavioral,
1486            score: 8.5,
1487            weight: 0.25,
1488            description: "test".to_string(),
1489            evidence: vec![],
1490        }];
1491        let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
1492        assert!(
1493            recs.iter()
1494                .any(|r| r.contains("Address Test Factor concerns"))
1495        );
1496    }
1497
1498    // ========================================================================
1499    // Tests with data client for pattern analysis branches
1500    // ========================================================================
1501
1502    fn mock_etherscan_json_response(txs: &[serde_json::Value]) -> String {
1503        serde_json::json!({
1504            "status": "1",
1505            "message": "OK",
1506            "result": txs
1507        })
1508        .to_string()
1509    }
1510
1511    fn make_tx_with_idx(
1512        idx: u64,
1513        from: &str,
1514        to: &str,
1515        value: &str,
1516        timestamp: &str,
1517    ) -> serde_json::Value {
1518        serde_json::json!({
1519            "hash": format!("0x{:064x}", idx),
1520            "from": from,
1521            "to": to,
1522            "value": value,
1523            "timeStamp": timestamp,
1524            "blockNumber": "18000000",
1525            "gasUsed": "21000",
1526            "gasPrice": "50000000000",
1527            "isError": "0",
1528            "input": "0x"
1529        })
1530    }
1531
1532    #[tokio::test]
1533    async fn test_risk_engine_with_client_structuring_pattern() {
1534        let mut server = mockito::Server::new_async().await;
1535
1536        // Create transactions that trigger structuring detection
1537        // (amounts just under $10,000 = ~2.86 ETH at $3500)
1538        let txs: Vec<serde_json::Value> = (0..15)
1539            .map(|i| {
1540                make_tx_with_idx(
1541                    i as u64,
1542                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1543                    &format!("0x{:040x}", i + 1),
1544                    "9900000000000000000", // ~9.9 ETH
1545                    &format!("{}", 1700000000 + i * 3600),
1546                )
1547            })
1548            .collect();
1549
1550        let _mock = server
1551            .mock("GET", mockito::Matcher::Any)
1552            .with_status(200)
1553            .with_body(mock_etherscan_json_response(&txs))
1554            .create_async()
1555            .await;
1556
1557        let sources = datasource::DataSources::new("test_key".to_string());
1558        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1559        let engine = RiskEngine::with_data_client(client);
1560
1561        let assessment = engine
1562            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1563            .await
1564            .unwrap();
1565
1566        // Should have run with the client and produced a valid assessment
1567        assert!(!assessment.address.is_empty());
1568        assert!(assessment.overall_score >= 0.0);
1569    }
1570
1571    #[tokio::test]
1572    async fn test_risk_engine_with_client_api_error() {
1573        let mut server = mockito::Server::new_async().await;
1574
1575        let _mock = server
1576            .mock("GET", mockito::Matcher::Any)
1577            .with_status(200)
1578            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
1579            .create_async()
1580            .await;
1581
1582        let sources = datasource::DataSources::new("test_key".to_string());
1583        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1584        let engine = RiskEngine::with_data_client(client);
1585
1586        // Should still succeed (error paths return default factors)
1587        let assessment = engine
1588            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1589            .await
1590            .unwrap();
1591
1592        assert!(!assessment.address.is_empty());
1593    }
1594
1595    #[tokio::test]
1596    async fn test_risk_engine_with_client_many_counterparties() {
1597        let mut server = mockito::Server::new_async().await;
1598
1599        // Create transactions with > 100 unique counterparties
1600        let txs: Vec<serde_json::Value> = (0..120)
1601            .map(|i| {
1602                make_tx_with_idx(
1603                    i as u64,
1604                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1605                    &format!("0x{:040x}", i + 1),
1606                    "1000000000000000000",
1607                    &format!("{}", 1700000000 + i * 600),
1608                )
1609            })
1610            .collect();
1611
1612        let _mock = server
1613            .mock("GET", mockito::Matcher::Any)
1614            .with_status(200)
1615            .with_body(mock_etherscan_json_response(&txs))
1616            .create_async()
1617            .await;
1618
1619        let sources = datasource::DataSources::new("test_key".to_string());
1620        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1621        let engine = RiskEngine::with_data_client(client);
1622
1623        let assessment = engine
1624            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1625            .await
1626            .unwrap();
1627
1628        // Should have elevated risk due to high counterparty count
1629        assert!(assessment.overall_score > 0.0);
1630    }
1631
1632    #[test]
1633    fn test_gini_coefficient_equal_distribution() {
1634        // All equal holdings = Gini 0
1635        let holdings = vec![10.0, 10.0, 10.0, 10.0, 10.0];
1636        let gini = compute_gini_coefficient(&holdings);
1637        assert!(gini < 0.01, "Expected near-zero Gini, got {}", gini);
1638    }
1639
1640    #[test]
1641    fn test_gini_coefficient_concentrated() {
1642        // One holder has everything
1643        let holdings = vec![0.0, 0.0, 0.0, 0.0, 100.0];
1644        let gini = compute_gini_coefficient(&holdings);
1645        assert!(gini > 0.7, "Expected high Gini, got {}", gini);
1646    }
1647
1648    #[test]
1649    fn test_gini_coefficient_empty() {
1650        let gini = compute_gini_coefficient(&[]);
1651        assert_eq!(gini, 0.0);
1652    }
1653
1654    #[test]
1655    fn test_rugpull_indicators_none() {
1656        let factors = detect_rugpull_indicators(None, None);
1657        assert!(factors.is_empty());
1658    }
1659
1660    #[test]
1661    fn test_whale_detection_no_whales() {
1662        let txs = vec![];
1663        let factor = detect_whale_activity(&txs, 100.0, 100_000.0);
1664        assert!(factor.score < 1.0);
1665    }
1666
1667    // ========================================================================
1668    // Rugpull Indicator Tests
1669    // ========================================================================
1670
1671    fn make_ca(
1672        verified: bool,
1673        ac: Option<crate::contract::access::AccessControlMap>,
1674    ) -> crate::contract::ContractAnalysis {
1675        crate::contract::ContractAnalysis {
1676            address: "0xtest".into(),
1677            chain: "ethereum".into(),
1678            is_verified: verified,
1679            source_info: None,
1680            proxy_info: None,
1681            access_control: ac,
1682            vulnerabilities: vec![],
1683            defi_analysis: None,
1684            external_info: None,
1685            security_score: 50,
1686            security_summary: String::new(),
1687        }
1688    }
1689
1690    fn make_ac(
1691        funcs: Vec<(&str, &str)>,
1692        renounced: bool,
1693        tx_origin: bool,
1694    ) -> crate::contract::access::AccessControlMap {
1695        let priv_fns = funcs
1696            .iter()
1697            .map(|(name, cap)| crate::contract::access::PrivilegedFunction {
1698                name: name.to_string(),
1699                modifiers: vec![],
1700                capability: cap.to_string(),
1701                risk: crate::contract::access::PrivilegeRisk::High,
1702            })
1703            .collect();
1704        crate::contract::access::AccessControlMap {
1705            ownership_pattern: None,
1706            has_renounced_ownership: renounced,
1707            has_role_based_access: false,
1708            uses_tx_origin: tx_origin,
1709            tx_origin_locations: vec![],
1710            modifiers: vec![],
1711            privileged_functions: priv_fns,
1712            roles: vec![],
1713            auth_analysis: crate::contract::access::AuthAnalysis {
1714                msg_sender_checks: 0,
1715                tx_origin_checks: 0,
1716                has_origin_sender_comparison: false,
1717                summary: String::new(),
1718            },
1719        }
1720    }
1721
1722    #[test]
1723    fn test_rugpull_mint_evidence() {
1724        let ac = make_ac(vec![("mint", "Mint")], false, false);
1725        let ca = make_ca(true, Some(ac));
1726        let factors = detect_rugpull_indicators(Some(&ca), None);
1727        assert!(!factors.is_empty());
1728        let f = &factors[0];
1729        assert!(f.evidence.iter().any(|e| e.contains("mint")));
1730    }
1731
1732    #[test]
1733    fn test_rugpull_pause_blacklist_setfee() {
1734        let ac = make_ac(
1735            vec![
1736                ("pause", "Pause"),
1737                ("blacklist", "Block"),
1738                ("setFee", "Fee"),
1739            ],
1740            false,
1741            false,
1742        );
1743        let ca = make_ca(true, Some(ac));
1744        let factors = detect_rugpull_indicators(Some(&ca), None);
1745        let f = &factors[0];
1746        assert!(f.evidence.iter().any(|e| e.contains("pause")));
1747        assert!(f.evidence.iter().any(|e| e.contains("blacklist")));
1748        assert!(f.evidence.iter().any(|e| e.contains("fee")));
1749    }
1750
1751    #[test]
1752    fn test_rugpull_renounced_reduces_risk() {
1753        let ac = make_ac(vec![("mint", "Mint")], true, false);
1754        let ca = make_ca(true, Some(ac));
1755        let factors = detect_rugpull_indicators(Some(&ca), None);
1756        let f = &factors[0];
1757        assert!(f.evidence.iter().any(|e| e.contains("renounced")));
1758    }
1759
1760    #[test]
1761    fn test_rugpull_tx_origin() {
1762        let ac = make_ac(vec![], false, true);
1763        let ca = make_ca(true, Some(ac));
1764        let factors = detect_rugpull_indicators(Some(&ca), None);
1765        let f = &factors[0];
1766        assert!(f.evidence.iter().any(|e| e.contains("tx.origin")));
1767    }
1768
1769    #[test]
1770    fn test_rugpull_unverified() {
1771        let ac = make_ac(vec![], false, false);
1772        let ca = make_ca(false, Some(ac));
1773        let factors = detect_rugpull_indicators(Some(&ca), None);
1774        let f = &factors[0];
1775        assert!(f.evidence.iter().any(|e| e.contains("not verified")));
1776    }
1777
1778    // M7: the audit cases. 5 buys and 0 sells is a honeypot sign, and no
1779    // liquidity is very low, in compliance as in the crawl report.
1780    #[test]
1781    fn test_token_rules_match_the_report_on_the_audit_cases() {
1782        let ta = make_ta(None, None, 5, 0, 0.0);
1783        let f = detect_rugpull_indicators(None, Some(&ta));
1784        let evidence: Vec<&String> = f.iter().flat_map(|x| &x.evidence).collect();
1785        assert!(
1786            evidence.iter().any(|e| e.contains("potential honeypot")),
1787            "{evidence:?}"
1788        );
1789        assert!(
1790            evidence.iter().any(|e| e.contains("Very low liquidity")),
1791            "{evidence:?}"
1792        );
1793    }
1794
1795    fn make_ta(
1796        top10: Option<f64>,
1797        age: Option<f64>,
1798        buys: u64,
1799        sells: u64,
1800        liq: f64,
1801    ) -> crate::chains::TokenAnalytics {
1802        crate::chains::TokenAnalytics {
1803            token: crate::chains::Token {
1804                contract_address: "0x".into(),
1805                symbol: "T".into(),
1806                name: "T".into(),
1807                decimals: 18,
1808            },
1809            chain: "ethereum".into(),
1810            holders: vec![],
1811            total_holders: 0,
1812            volume_24h: 0.0,
1813            volume_7d: 0.0,
1814            price_usd: 0.0,
1815            price_change_24h: 0.0,
1816            price_change_7d: 0.0,
1817            liquidity_usd: liq,
1818            market_cap: None,
1819            fdv: None,
1820            total_supply: None,
1821            circulating_supply: None,
1822            price_history: vec![],
1823            volume_history: vec![],
1824            holder_history: vec![],
1825            dex_pairs: vec![],
1826            fetched_at: 0,
1827            top_10_concentration: top10,
1828            top_50_concentration: None,
1829            top_100_concentration: None,
1830            price_change_6h: 0.0,
1831            price_change_1h: 0.0,
1832            total_buys_24h: buys,
1833            total_sells_24h: sells,
1834            total_buys_6h: 0,
1835            total_sells_6h: 0,
1836            total_buys_1h: 0,
1837            total_sells_1h: 0,
1838            token_age_hours: age,
1839            image_url: None,
1840            websites: vec![],
1841            socials: vec![],
1842            dexscreener_url: None,
1843        }
1844    }
1845
1846    #[test]
1847    fn test_rugpull_high_concentration() {
1848        let ta = make_ta(Some(85.0), None, 0, 0, 100000.0);
1849        let factors = detect_rugpull_indicators(None, Some(&ta));
1850        assert!(!factors.is_empty());
1851        assert!(factors[0].evidence.iter().any(|e| e.contains("85.0%")));
1852    }
1853
1854    #[test]
1855    fn test_rugpull_moderate_concentration() {
1856        let ta = make_ta(Some(55.0), None, 0, 0, 100000.0);
1857        let factors = detect_rugpull_indicators(None, Some(&ta));
1858        assert!(!factors.is_empty());
1859        assert!(factors[0].evidence.iter().any(|e| e.contains("55.0%")));
1860    }
1861
1862    #[test]
1863    fn test_rugpull_very_new_token() {
1864        let ta = make_ta(None, Some(12.0), 0, 0, 100000.0);
1865        let factors = detect_rugpull_indicators(None, Some(&ta));
1866        assert!(!factors.is_empty());
1867        assert!(factors[0].evidence.iter().any(|e| e.contains("very new")));
1868    }
1869
1870    #[test]
1871    fn test_rugpull_recently_created() {
1872        let ta = make_ta(None, Some(48.0), 0, 0, 100000.0);
1873        let factors = detect_rugpull_indicators(None, Some(&ta));
1874        assert!(!factors.is_empty());
1875        assert!(
1876            factors[0]
1877                .evidence
1878                .iter()
1879                .any(|e| e.contains("recently created"))
1880        );
1881    }
1882
1883    #[test]
1884    fn test_rugpull_honeypot() {
1885        let ta = make_ta(None, None, 20, 0, 100000.0);
1886        let factors = detect_rugpull_indicators(None, Some(&ta));
1887        assert!(!factors.is_empty());
1888        assert!(factors[0].evidence.iter().any(|e| e.contains("honeypot")));
1889    }
1890
1891    #[test]
1892    fn test_rugpull_low_liquidity() {
1893        let ta = make_ta(None, None, 0, 0, 5000.0);
1894        let factors = detect_rugpull_indicators(None, Some(&ta));
1895        assert!(!factors.is_empty());
1896        assert!(
1897            factors[0]
1898                .evidence
1899                .iter()
1900                .any(|e| e.contains("low liquidity") || e.contains("Very low"))
1901        );
1902    }
1903
1904    // ========================================================================
1905    // Whale Activity Tests
1906    // ========================================================================
1907
1908    fn make_whale_tx(value: &str) -> crate::chains::Transaction {
1909        crate::chains::Transaction {
1910            hash: "0xh".into(),
1911            block_number: Some(1),
1912            timestamp: Some(0),
1913            from: "0xa".into(),
1914            to: Some("0xb".into()),
1915            value: value.to_string(),
1916            gas_limit: 21000,
1917            gas_used: Some(21000),
1918            gas_price: "0".into(),
1919            nonce: 0,
1920            input: "0x".into(),
1921            status: Some(true),
1922        }
1923    }
1924
1925    #[test]
1926    fn test_whale_with_large_txs() {
1927        let txs = vec![
1928            make_whale_tx("200000"),
1929            make_whale_tx("50"),
1930            make_whale_tx("150000"),
1931        ];
1932        let factor = detect_whale_activity(&txs, 50000.0, 100000.0);
1933        assert!(factor.evidence.iter().any(|e| e.contains("whale-sized")));
1934    }
1935
1936    #[test]
1937    fn test_whale_avg_near_threshold() {
1938        let txs = vec![make_whale_tx("50")];
1939        let factor = detect_whale_activity(&txs, 60000.0, 100000.0);
1940        assert!(
1941            factor
1942                .evidence
1943                .iter()
1944                .any(|e| e.contains("near whale threshold"))
1945        );
1946    }
1947
1948    // ========================================================================
1949    // Timelock Tests
1950    // ========================================================================
1951
1952    fn make_ca_with_source(code: &str) -> crate::contract::ContractAnalysis {
1953        let mut ca = make_ca(true, None);
1954        ca.source_info = Some(crate::contract::source::ContractSource {
1955            contract_name: "Test".into(),
1956            source_code: code.to_string(),
1957            abi: "[]".into(),
1958            compiler_version: "v0.8.19".into(),
1959            optimization_used: true,
1960            optimization_runs: 200,
1961            evm_version: "paris".into(),
1962            license_type: "MIT".into(),
1963            is_proxy: false,
1964            implementation_address: None,
1965            constructor_arguments: String::new(),
1966            library: String::new(),
1967            swarm_source: String::new(),
1968            parsed_abi: vec![],
1969        });
1970        ca
1971    }
1972
1973    #[test]
1974    fn test_timelock_controller_detected() {
1975        let ca = make_ca_with_source("contract G is TimelockController { }");
1976        let f = detect_timelock(&ca).unwrap();
1977        assert_eq!(f.score, 2.0);
1978        assert!(f.evidence.iter().any(|e| e.contains("TimelockController")));
1979    }
1980
1981    #[test]
1982    fn test_timelock_queue_delay_execute() {
1983        let ca = make_ca_with_source("function queue() {} function execute() {} uint delay;");
1984        let f = detect_timelock(&ca).unwrap();
1985        assert_eq!(f.score, 2.0);
1986    }
1987
1988    #[test]
1989    fn test_timelock_mindelay() {
1990        let ca = make_ca_with_source("TimelockController tl; uint minDelay;");
1991        let f = detect_timelock(&ca).unwrap();
1992        assert!(f.evidence.iter().any(|e| e.contains("Minimum delay")));
1993    }
1994
1995    #[test]
1996    fn test_no_timelock() {
1997        let ca = make_ca_with_source("contract Token { function transfer() {} }");
1998        let f = detect_timelock(&ca).unwrap();
1999        assert_eq!(f.score, 5.0);
2000    }
2001
2002    #[test]
2003    fn test_timelock_no_source() {
2004        let ca = make_ca(true, None);
2005        assert!(detect_timelock(&ca).is_none());
2006    }
2007
2008    // ========================================================================
2009    // Multisig Tests
2010    // ========================================================================
2011
2012    #[test]
2013    fn test_multisig_gnosis() {
2014        let ca = make_ca_with_source("import Gnosis; contract W is GnosisSafe { }");
2015        let f = detect_multisig(&ca).unwrap();
2016        assert_eq!(f.score, 2.0);
2017    }
2018
2019    #[test]
2020    fn test_multisig_threshold_owners() {
2021        let ca = make_ca_with_source("uint threshold; address[] owners;");
2022        let f = detect_multisig(&ca).unwrap();
2023        assert_eq!(f.score, 2.0);
2024    }
2025
2026    #[test]
2027    fn test_multisig_confirm_execute() {
2028        let ca = make_ca_with_source(
2029            "function confirmTransaction() {} function executeTransaction() {}",
2030        );
2031        let f = detect_multisig(&ca).unwrap();
2032        assert_eq!(f.score, 2.0);
2033    }
2034
2035    #[test]
2036    fn test_no_multisig() {
2037        let ca = make_ca_with_source("contract Token { }");
2038        let f = detect_multisig(&ca).unwrap();
2039        assert_eq!(f.score, 4.0);
2040    }
2041
2042    #[test]
2043    fn test_multisig_with_proxy_admin() {
2044        let mut ca = make_ca_with_source("contract Token { }");
2045        ca.proxy_info = Some(crate::contract::proxy::ProxyInfo {
2046            is_proxy: true,
2047            proxy_type: "EIP-1967".into(),
2048            implementation_address: None,
2049            admin_address: Some("0xadmin".into()),
2050            beacon_address: None,
2051            details: vec![],
2052        });
2053        let f = detect_multisig(&ca).unwrap();
2054        assert!(f.evidence.iter().any(|e| e.contains("0xadmin")));
2055    }
2056
2057    // ========================================================================
2058    // Gini Coefficient Edge Cases
2059    // ========================================================================
2060
2061    #[test]
2062    fn test_gini_all_zeros() {
2063        assert_eq!(compute_gini_coefficient(&[0.0, 0.0, 0.0]), 0.0);
2064    }
2065
2066    #[test]
2067    fn test_gini_single_element() {
2068        assert_eq!(compute_gini_coefficient(&[100.0]), 0.0);
2069    }
2070
2071    #[test]
2072    fn test_gini_two_equal() {
2073        let g = compute_gini_coefficient(&[50.0, 50.0]);
2074        assert!(g < 0.01);
2075    }
2076
2077    #[test]
2078    fn test_gini_two_unequal() {
2079        let g = compute_gini_coefficient(&[1.0, 99.0]);
2080        assert!(g > 0.0);
2081    }
2082}