Skip to main content

scope/compliance/
risk.rs

1//! Risk Scoring Engine for Scope
2//!
3//! Provides compliance-grade risk analysis for blockchain addresses.
4//! Aggregates data from multiple sources to produce comprehensive risk scores.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Risk level classification
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum RiskLevel {
12    Low,      // 0-3
13    Medium,   // 4-6
14    High,     // 7-8
15    Critical, // 9-10
16}
17
18impl RiskLevel {
19    pub fn from_score(score: f32) -> Self {
20        match score {
21            s if s <= 3.0 => RiskLevel::Low,
22            s if s <= 6.0 => RiskLevel::Medium,
23            s if s <= 8.0 => RiskLevel::High,
24            _ => RiskLevel::Critical,
25        }
26    }
27
28    pub fn emoji(&self) -> &'static str {
29        match self {
30            RiskLevel::Low => "🟢",
31            RiskLevel::Medium => "🟡",
32            RiskLevel::High => "🔴",
33            RiskLevel::Critical => "âš«",
34        }
35    }
36}
37
38/// Individual risk factor with weight and score
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RiskFactor {
41    pub name: String,
42    pub category: RiskCategory,
43    pub score: f32,  // 0-10
44    pub weight: f32, // 0-1, contribution to final score
45    pub description: String,
46    pub evidence: Vec<String>,
47}
48
49/// Risk category for organizing factors
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub enum RiskCategory {
52    Behavioral,  // Transaction patterns, velocity
53    Association, // Connected to known bad addresses
54    Source,      // Funds from suspicious sources
55    Destination, // Funds to suspicious destinations
56    Entity,      // Known entity (exchange, mixer, etc.)
57    Sanctions,   // OFAC, sanctions lists
58    Reputation,  // Community reports, scam databases
59}
60
61/// Comprehensive risk assessment for an address
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RiskAssessment {
64    pub address: String,
65    pub chain: String,
66    pub overall_score: f32, // 0-10
67    pub risk_level: RiskLevel,
68    pub factors: Vec<RiskFactor>,
69    pub assessed_at: DateTime<Utc>,
70    pub recommendations: Vec<String>,
71}
72
73use super::datasource::{BlockchainDataClient, analyze_patterns};
74
75/// Risk scoring engine configuration
76#[derive(Debug)]
77pub struct RiskEngine {
78    /// Data client for fetching blockchain data
79    data_client: Option<BlockchainDataClient>,
80}
81
82impl Default for RiskEngine {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl RiskEngine {
89    /// Create new risk engine without data sources (basic scoring only)
90    pub fn new() -> Self {
91        Self { data_client: None }
92    }
93
94    /// Create new risk engine with data sources for enhanced analysis
95    pub fn with_data_client(client: BlockchainDataClient) -> Self {
96        Self {
97            data_client: Some(client),
98        }
99    }
100
101    /// Assess risk for a single address
102    pub async fn assess_address(
103        &self,
104        address: &str,
105        chain: &str,
106    ) -> anyhow::Result<RiskAssessment> {
107        let mut factors = Vec::new();
108
109        // 1. Behavioral Analysis (Transaction Patterns)
110        if let Ok(factor) = self.analyze_behavior(address, chain).await {
111            factors.push(factor);
112        }
113
114        // 2. Association Analysis (Connected Addresses)
115        if let Ok(factor) = self.analyze_associations(address, chain).await {
116            factors.push(factor);
117        }
118
119        // 3. Source Analysis (Where funds came from)
120        if let Ok(factor) = self.analyze_sources(address, chain).await {
121            factors.push(factor);
122        }
123
124        // 4. Entity Recognition (Known services)
125        if let Ok(factor) = self.identify_entity(address, chain).await {
126            factors.push(factor);
127        }
128
129        // Calculate weighted score
130        let overall_score = self.calculate_weighted_score(&factors);
131        let risk_level = RiskLevel::from_score(overall_score);
132
133        // Generate recommendations
134        let recommendations = self.generate_recommendations(&factors, risk_level);
135
136        Ok(RiskAssessment {
137            address: address.to_string(),
138            chain: chain.to_string(),
139            overall_score,
140            risk_level,
141            factors,
142            assessed_at: Utc::now(),
143            recommendations,
144        })
145    }
146
147    /// Analyze transaction behavior patterns
148    async fn analyze_behavior(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
149        let mut evidence = Vec::new();
150        let mut score: f32 = 2.0; // Default low score
151
152        // Fetch real transaction data if available
153        if let Some(client) = &self.data_client {
154            match client.get_transactions(address, chain).await {
155                Ok(txs) => {
156                    let analysis = analyze_patterns(&txs);
157
158                    // Adjust score based on patterns
159                    if analysis.structuring_detected {
160                        score += 3.0;
161                        evidence.push(
162                            "Structuring pattern detected (amounts just under thresholds)"
163                                .to_string(),
164                        );
165                    }
166
167                    if analysis.round_number_pattern {
168                        score += 1.5;
169                        evidence.push("Round number pattern suggests automation".to_string());
170                    }
171
172                    if analysis.velocity_score > 10.0 {
173                        score += 2.0;
174                        evidence.push(format!(
175                            "High transaction velocity: {:.1} tx/day",
176                            analysis.velocity_score
177                        ));
178                    }
179
180                    if analysis.unusual_hours > 0 {
181                        score += 1.0;
182                        evidence.push(format!(
183                            "{} transactions during unusual hours",
184                            analysis.unusual_hours
185                        ));
186                    }
187
188                    evidence.push(format!(
189                        "Analyzed {} transactions",
190                        analysis.total_transactions
191                    ));
192                }
193                Err(e) => {
194                    evidence.push(format!("Could not fetch transaction data: {}", e));
195                }
196            }
197        } else {
198            evidence.push("No data client configured - using default scores".to_string());
199        }
200
201        // Ensure score stays in bounds
202        score = score.clamp(0.0, 10.0);
203
204        Ok(RiskFactor {
205            name: "Behavioral Patterns".to_string(),
206            category: RiskCategory::Behavioral,
207            score,
208            weight: 0.25,
209            description: "Transaction velocity and pattern analysis".to_string(),
210            evidence,
211        })
212    }
213
214    /// Analyze associations with known addresses
215    async fn analyze_associations(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
216        let mut evidence = Vec::new();
217        let mut score: f32 = 1.5; // Default low score
218
219        // Fetch transaction data to analyze connections
220        if let Some(client) = &self.data_client {
221            match client.get_transactions(address, chain).await {
222                Ok(txs) => {
223                    // Count unique counterparties
224                    let mut counterparties = std::collections::HashSet::new();
225                    for tx in &txs {
226                        counterparties.insert(tx.from.clone());
227                        counterparties.insert(tx.to.clone());
228                    }
229                    counterparties.remove(address);
230
231                    evidence.push(format!(
232                        "Found {} unique counterparties",
233                        counterparties.len()
234                    ));
235
236                    // High number of counterparties can indicate mixing
237                    if counterparties.len() > 100 {
238                        score += 2.0;
239                        evidence.push(
240                            "High number of counterparties may indicate mixing service".to_string(),
241                        );
242                    }
243
244                    // Check for self-transfers (looping)
245                    let self_transfers = txs.iter().filter(|tx| tx.from == tx.to).count();
246                    if self_transfers > 0 {
247                        score += 1.0;
248                        evidence.push(format!("{} self-transfers detected", self_transfers));
249                    }
250                }
251                Err(e) => {
252                    evidence.push(format!("Could not analyze associations: {}", e));
253                }
254            }
255        } else {
256            evidence.push("No data client configured - using default scores".to_string());
257        }
258
259        score = score.clamp(0.0, 10.0);
260
261        Ok(RiskFactor {
262            name: "Address Associations".to_string(),
263            category: RiskCategory::Association,
264            score,
265            weight: 0.30,
266            description: "Connections to known high-risk addresses".to_string(),
267            evidence,
268        })
269    }
270
271    /// Analyze source of funds
272    async fn analyze_sources(&self, address: &str, chain: &str) -> anyhow::Result<RiskFactor> {
273        let mut evidence = Vec::new();
274        let mut score: f32 = 2.0; // Default low-medium score
275
276        if let Some(client) = &self.data_client {
277            match client.get_transactions(address, chain).await {
278                Ok(txs) => {
279                    // Analyze incoming transactions (where this address is the recipient)
280                    let incoming: Vec<_> = txs
281                        .iter()
282                        .filter(|tx| tx.to.to_lowercase() == address.to_lowercase())
283                        .collect();
284
285                    evidence.push(format!("Analyzed {} incoming transactions", incoming.len()));
286
287                    // Check for failed transactions
288                    let failed = txs.iter().filter(|tx| tx.is_error == "1").count();
289                    if failed > 0 {
290                        score += 1.0;
291                        evidence.push(format!("{} failed transactions detected", failed));
292                    }
293
294                    // Check for contract interactions (more complex, higher risk)
295                    let contract_calls = txs
296                        .iter()
297                        .filter(|tx| !tx.contract_address.is_empty())
298                        .count();
299                    if contract_calls > 0 {
300                        evidence.push(format!("{} contract interactions", contract_calls));
301                    }
302                }
303                Err(e) => {
304                    evidence.push(format!("Could not analyze sources: {}", e));
305                }
306            }
307        } else {
308            evidence.push("No data client configured - using default scores".to_string());
309        }
310
311        score = score.clamp(0.0, 10.0);
312
313        Ok(RiskFactor {
314            name: "Source of Funds".to_string(),
315            category: RiskCategory::Source,
316            score,
317            weight: 0.25,
318            description: "Origin analysis of incoming funds".to_string(),
319            evidence,
320        })
321    }
322
323    /// Identify if address belongs to known entity
324    async fn identify_entity(&self, address: &str, _chain: &str) -> anyhow::Result<RiskFactor> {
325        let mut evidence = Vec::new();
326        let mut score: f32 = 2.0;
327
328        // Check for known entity patterns
329        // This would typically integrate with a database of known addresses
330
331        // Placeholder: Check if address has code (is a contract)
332        if let Some(client) = &self.data_client {
333            // Try to get internal transactions - contracts often have these
334            match client.get_internal_transactions(address).await {
335                Ok(internal_txs) => {
336                    if !internal_txs.is_empty() {
337                        evidence.push(format!(
338                            "Contract interactions detected: {} internal transactions",
339                            internal_txs.len()
340                        ));
341                        score += 0.5; // Slight increase for being a contract
342                    }
343                }
344                Err(_) => {
345                    // Not necessarily an error - EOAs don't have internal transactions
346                }
347            }
348        }
349
350        // Known exchange addresses would be checked here
351        evidence.push("Address not in known entity database (implement integration)".to_string());
352
353        score = score.clamp(0.0, 10.0);
354
355        Ok(RiskFactor {
356            name: "Entity Identification".to_string(),
357            category: RiskCategory::Entity,
358            score,
359            weight: 0.20,
360            description: "Known entity classification".to_string(),
361            evidence,
362        })
363    }
364
365    /// Calculate weighted score from factors
366    fn calculate_weighted_score(&self, factors: &[RiskFactor]) -> f32 {
367        if factors.is_empty() {
368            return 0.0;
369        }
370
371        let weighted_sum: f32 = factors.iter().map(|f| f.score * f.weight).sum();
372
373        let total_weight: f32 = factors.iter().map(|f| f.weight).sum();
374
375        if total_weight == 0.0 {
376            return 0.0;
377        }
378
379        (weighted_sum / total_weight).clamp(0.0, 10.0)
380    }
381
382    /// Generate recommendations based on risk factors
383    fn generate_recommendations(&self, factors: &[RiskFactor], level: RiskLevel) -> Vec<String> {
384        let mut recommendations = Vec::new();
385
386        match level {
387            RiskLevel::Critical => {
388                recommendations.push("Immediate investigation required".to_string());
389                recommendations.push("Consider suspending transactions".to_string());
390                recommendations.push("File SAR if applicable".to_string());
391            }
392            RiskLevel::High => {
393                recommendations.push("Enhanced due diligence recommended".to_string());
394                recommendations.push("Monitor transactions closely".to_string());
395                recommendations.push("Verify source of funds".to_string());
396            }
397            RiskLevel::Medium => {
398                recommendations.push("Standard due diligence".to_string());
399                recommendations.push("Periodic re-assessment".to_string());
400            }
401            RiskLevel::Low => {
402                recommendations.push("Standard monitoring".to_string());
403            }
404        }
405
406        // Add factor-specific recommendations
407        for factor in factors {
408            if factor.score > 7.0 {
409                recommendations.push(format!("Address {} concerns immediately", factor.name));
410            }
411        }
412
413        recommendations
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::compliance::datasource;
421
422    #[test]
423    fn test_risk_level_from_score() {
424        assert!(matches!(RiskLevel::from_score(2.0), RiskLevel::Low));
425        assert!(matches!(RiskLevel::from_score(5.0), RiskLevel::Medium));
426        assert!(matches!(RiskLevel::from_score(7.5), RiskLevel::High));
427        assert!(matches!(RiskLevel::from_score(9.0), RiskLevel::Critical));
428    }
429
430    #[test]
431    fn test_risk_level_boundaries() {
432        assert!(matches!(RiskLevel::from_score(0.0), RiskLevel::Low));
433        assert!(matches!(RiskLevel::from_score(3.0), RiskLevel::Low));
434        assert!(matches!(RiskLevel::from_score(3.01), RiskLevel::Medium));
435        assert!(matches!(RiskLevel::from_score(6.0), RiskLevel::Medium));
436        assert!(matches!(RiskLevel::from_score(6.01), RiskLevel::High));
437        assert!(matches!(RiskLevel::from_score(8.0), RiskLevel::High));
438        assert!(matches!(RiskLevel::from_score(8.01), RiskLevel::Critical));
439        assert!(matches!(RiskLevel::from_score(10.0), RiskLevel::Critical));
440    }
441
442    #[test]
443    fn test_risk_level_emojis() {
444        assert_eq!(RiskLevel::Low.emoji(), "🟢");
445        assert_eq!(RiskLevel::Medium.emoji(), "🟡");
446        assert_eq!(RiskLevel::High.emoji(), "🔴");
447        assert_eq!(RiskLevel::Critical.emoji(), "âš«");
448    }
449
450    #[test]
451    fn test_weighted_score_calculation() {
452        let engine = RiskEngine::new();
453        let factors = vec![
454            RiskFactor {
455                name: "Test1".to_string(),
456                category: RiskCategory::Behavioral,
457                score: 5.0,
458                weight: 0.5,
459                description: "Test".to_string(),
460                evidence: vec![],
461            },
462            RiskFactor {
463                name: "Test2".to_string(),
464                category: RiskCategory::Association,
465                score: 3.0,
466                weight: 0.5,
467                description: "Test".to_string(),
468                evidence: vec![],
469            },
470        ];
471
472        // (5.0 * 0.5 + 3.0 * 0.5) / (0.5 + 0.5) = 4.0
473        let score = engine.calculate_weighted_score(&factors);
474        assert!((score - 4.0).abs() < 0.01);
475    }
476
477    #[test]
478    fn test_weighted_score_empty_factors() {
479        let engine = RiskEngine::new();
480        let score = engine.calculate_weighted_score(&[]);
481        assert_eq!(score, 0.0);
482    }
483
484    #[test]
485    fn test_weighted_score_zero_weight() {
486        let engine = RiskEngine::new();
487        let factors = vec![RiskFactor {
488            name: "Test".to_string(),
489            category: RiskCategory::Behavioral,
490            score: 5.0,
491            weight: 0.0,
492            description: "Test".to_string(),
493            evidence: vec![],
494        }];
495        let score = engine.calculate_weighted_score(&factors);
496        assert_eq!(score, 0.0);
497    }
498
499    #[test]
500    fn test_weighted_score_clamped() {
501        let engine = RiskEngine::new();
502        let factors = vec![RiskFactor {
503            name: "High".to_string(),
504            category: RiskCategory::Behavioral,
505            score: 15.0,
506            weight: 1.0,
507            description: "Test".to_string(),
508            evidence: vec![],
509        }];
510        let score = engine.calculate_weighted_score(&factors);
511        assert_eq!(score, 10.0);
512    }
513
514    #[test]
515    fn test_recommendations_by_level() {
516        let engine = RiskEngine::new();
517        let factors = vec![];
518
519        let low_recs = engine.generate_recommendations(&factors, RiskLevel::Low);
520        assert!(low_recs.iter().any(|r| r.contains("Standard monitoring")));
521
522        let med_recs = engine.generate_recommendations(&factors, RiskLevel::Medium);
523        assert!(
524            med_recs
525                .iter()
526                .any(|r| r.contains("Standard due diligence"))
527        );
528
529        let high_recs = engine.generate_recommendations(&factors, RiskLevel::High);
530        assert!(
531            high_recs
532                .iter()
533                .any(|r| r.contains("Enhanced due diligence"))
534        );
535
536        let crit_recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
537        assert!(
538            crit_recs
539                .iter()
540                .any(|r| r.contains("Immediate investigation"))
541        );
542    }
543
544    #[test]
545    fn test_recommendations_high_score_factors() {
546        let engine = RiskEngine::new();
547        let factors = vec![RiskFactor {
548            name: "CriticalIssue".to_string(),
549            category: RiskCategory::Behavioral,
550            score: 8.5,
551            weight: 1.0,
552            description: "Critical issue".to_string(),
553            evidence: vec!["Evidence".to_string()],
554        }];
555
556        let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
557        assert!(recs.iter().any(|r| r.contains("CriticalIssue")));
558    }
559
560    #[test]
561    fn test_risk_factor_creation() {
562        let factor = RiskFactor {
563            name: "TestFactor".to_string(),
564            category: RiskCategory::Entity,
565            score: 7.5,
566            weight: 0.25,
567            description: "Test description".to_string(),
568            evidence: vec!["Evidence 1".to_string(), "Evidence 2".to_string()],
569        };
570
571        assert_eq!(factor.name, "TestFactor");
572        assert!(matches!(factor.category, RiskCategory::Entity));
573        assert_eq!(factor.score, 7.5);
574        assert_eq!(factor.weight, 0.25);
575        assert_eq!(factor.evidence.len(), 2);
576    }
577
578    #[test]
579    fn test_all_risk_categories() {
580        let _categories = [
581            RiskCategory::Behavioral,
582            RiskCategory::Association,
583            RiskCategory::Source,
584            RiskCategory::Destination,
585            RiskCategory::Entity,
586            RiskCategory::Sanctions,
587            RiskCategory::Reputation,
588        ];
589    }
590
591    #[tokio::test]
592    async fn test_risk_engine_creation() {
593        let engine = RiskEngine::new();
594        let assessment = engine
595            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
596            .await
597            .unwrap();
598
599        assert_eq!(
600            assessment.address,
601            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
602        );
603        assert_eq!(assessment.chain, "ethereum");
604        assert!(assessment.overall_score >= 0.0 && assessment.overall_score <= 10.0);
605        assert!(!assessment.factors.is_empty());
606        assert!(!assessment.recommendations.is_empty());
607    }
608
609    #[tokio::test]
610    async fn test_risk_assessment_different_addresses() {
611        let engine = RiskEngine::new();
612
613        let addresses = vec![
614            ("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum"),
615            ("0x0000000000000000000000000000000000000000", "ethereum"),
616        ];
617
618        for (addr, chain) in addresses {
619            let assessment = engine.assess_address(addr, chain).await.unwrap();
620            assert_eq!(assessment.address, addr);
621            assert_eq!(assessment.chain, chain);
622        }
623    }
624
625    #[test]
626    fn test_risk_engine_default() {
627        let engine = RiskEngine::default();
628        // Should create engine without data client
629        let score = engine.calculate_weighted_score(&[]);
630        assert_eq!(score, 0.0);
631    }
632
633    #[test]
634    fn test_risk_engine_with_data_client() {
635        let sources = datasource::DataSources::new("test_key".to_string());
636        let client = datasource::BlockchainDataClient::new(sources);
637        let _engine = RiskEngine::with_data_client(client);
638        // Just verify it creates without panicking
639    }
640
641    #[tokio::test]
642    async fn test_assess_address_has_all_factors() {
643        let engine = RiskEngine::new();
644        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
645
646        // Without a data client, should have 4 factors (behavior, association, source, entity)
647        assert_eq!(assessment.factors.len(), 4);
648
649        let categories: Vec<_> = assessment.factors.iter().map(|f| f.category).collect();
650        assert!(categories.contains(&RiskCategory::Behavioral));
651        assert!(categories.contains(&RiskCategory::Association));
652        assert!(categories.contains(&RiskCategory::Source));
653        assert!(categories.contains(&RiskCategory::Entity));
654    }
655
656    #[tokio::test]
657    async fn test_assess_address_factors_have_evidence() {
658        let engine = RiskEngine::new();
659        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
660
661        for factor in &assessment.factors {
662            assert!(
663                !factor.evidence.is_empty(),
664                "Factor {} has no evidence",
665                factor.name
666            );
667            // Without data client, evidence should mention "No data client configured"
668            assert!(
669                factor
670                    .evidence
671                    .iter()
672                    .any(|e| e.contains("No data client configured")
673                        || e.contains("not in known entity")),
674                "Factor {} doesn't have expected evidence: {:?}",
675                factor.name,
676                factor.evidence
677            );
678        }
679    }
680
681    #[tokio::test]
682    async fn test_assess_address_score_in_bounds() {
683        let engine = RiskEngine::new();
684        let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
685
686        assert!(assessment.overall_score >= 0.0);
687        assert!(assessment.overall_score <= 10.0);
688
689        for factor in &assessment.factors {
690            assert!(factor.score >= 0.0);
691            assert!(factor.score <= 10.0);
692            assert!(factor.weight >= 0.0);
693            assert!(factor.weight <= 1.0);
694        }
695    }
696
697    #[test]
698    fn test_risk_assessment_serialization() {
699        let assessment = RiskAssessment {
700            address: "0xtest".to_string(),
701            chain: "ethereum".to_string(),
702            overall_score: 3.5,
703            risk_level: RiskLevel::Medium,
704            factors: vec![],
705            assessed_at: Utc::now(),
706            recommendations: vec!["Test recommendation".to_string()],
707        };
708
709        let json = serde_json::to_string(&assessment).unwrap();
710        assert!(json.contains("0xtest"));
711        assert!(json.contains("ethereum"));
712        assert!(json.contains("Medium"));
713
714        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
715        assert_eq!(deserialized.address, "0xtest");
716        assert_eq!(deserialized.overall_score, 3.5);
717    }
718
719    #[test]
720    fn test_risk_factor_serialization() {
721        let factor = RiskFactor {
722            name: "Test".to_string(),
723            category: RiskCategory::Behavioral,
724            score: 5.0,
725            weight: 0.25,
726            description: "Test factor".to_string(),
727            evidence: vec!["Evidence 1".to_string()],
728        };
729
730        let json = serde_json::to_string(&factor).unwrap();
731        assert!(json.contains("Behavioral"));
732
733        let deserialized: RiskFactor = serde_json::from_str(&json).unwrap();
734        assert_eq!(deserialized.name, "Test");
735        assert_eq!(deserialized.score, 5.0);
736    }
737
738    #[test]
739    fn test_recommendations_critical_includes_sar() {
740        let engine = RiskEngine::new();
741        let recs = engine.generate_recommendations(&[], RiskLevel::Critical);
742        assert!(recs.iter().any(|r| r.contains("SAR")));
743        assert!(recs.iter().any(|r| r.contains("suspending")));
744    }
745
746    #[test]
747    fn test_recommendations_high_includes_verify_source() {
748        let engine = RiskEngine::new();
749        let recs = engine.generate_recommendations(&[], RiskLevel::High);
750        assert!(recs.iter().any(|r| r.contains("Verify source")));
751    }
752
753    #[test]
754    fn test_recommendations_medium_includes_reassessment() {
755        let engine = RiskEngine::new();
756        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
757        assert!(recs.iter().any(|r| r.contains("re-assessment")));
758    }
759
760    #[test]
761    fn test_weighted_score_single_factor() {
762        let engine = RiskEngine::new();
763        let factors = vec![RiskFactor {
764            name: "Single".to_string(),
765            category: RiskCategory::Source,
766            score: 7.0,
767            weight: 1.0,
768            description: "Test".to_string(),
769            evidence: vec![],
770        }];
771        let score = engine.calculate_weighted_score(&factors);
772        assert!((score - 7.0).abs() < 0.01);
773    }
774
775    fn make_test_tx(timestamp: &str, value_eth: &str) -> datasource::EtherscanTransaction {
776        let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
777        datasource::EtherscanTransaction {
778            block_number: "1".to_string(),
779            timestamp: timestamp.to_string(),
780            hash: "0x1".to_string(),
781            from: "0xa".to_string(),
782            to: "0xb".to_string(),
783            value: value_wei.to_string(),
784            gas: "21000".to_string(),
785            gas_price: "20000000000".to_string(),
786            is_error: "0".to_string(),
787            txreceipt_status: "1".to_string(),
788            input: "0x".to_string(),
789            contract_address: "".to_string(),
790            cumulative_gas_used: "21000".to_string(),
791            gas_used: "21000".to_string(),
792            confirmations: "100".to_string(),
793        }
794    }
795
796    #[test]
797    fn test_pattern_analysis_no_structuring() {
798        // Normal amounts, not just under thresholds
799        let txs = vec![
800            make_test_tx("1609459200", "1.5"),
801            make_test_tx("1609459300", "2.3"),
802            make_test_tx("1609459400", "0.7"),
803        ];
804
805        let analysis = analyze_patterns(&txs);
806        assert!(!analysis.structuring_detected);
807    }
808
809    #[test]
810    fn test_pattern_analysis_no_round_numbers() {
811        let txs = vec![
812            make_test_tx("1609459200", "1.234"),
813            make_test_tx("1609459300", "0.567"),
814            make_test_tx("1609459400", "3.891"),
815        ];
816
817        let analysis = analyze_patterns(&txs);
818        assert!(!analysis.round_number_pattern);
819    }
820
821    #[test]
822    fn test_pattern_analysis_single_tx() {
823        let txs = vec![make_test_tx("1609459200", "1.0")];
824
825        let analysis = analyze_patterns(&txs);
826        assert_eq!(analysis.total_transactions, 1);
827        // With a single timestamp, velocity can't be computed
828        assert_eq!(analysis.velocity_score, 0.0);
829    }
830
831    #[tokio::test]
832    async fn test_assess_address_generates_all_factors() {
833        let engine = RiskEngine::new();
834        let assessment = engine
835            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
836            .await
837            .unwrap();
838        // Should have 4 risk factors (behavior, associations, sources, entity)
839        assert_eq!(assessment.factors.len(), 4);
840        // Check factor names
841        let factor_names: Vec<&str> = assessment.factors.iter().map(|f| f.name.as_str()).collect();
842        assert!(factor_names.contains(&"Behavioral Patterns"));
843        assert!(factor_names.contains(&"Address Associations"));
844        assert!(factor_names.contains(&"Source of Funds"));
845        assert!(factor_names.contains(&"Entity Identification"));
846    }
847
848    #[test]
849    fn test_risk_assessment_json_roundtrip() {
850        let assessment = RiskAssessment {
851            address: "0xtest".to_string(),
852            chain: "ethereum".to_string(),
853            overall_score: 35.0,
854            risk_level: RiskLevel::Medium,
855            factors: vec![RiskFactor {
856                name: "Test Factor".to_string(),
857                category: RiskCategory::Behavioral,
858                score: 30.0,
859                weight: 0.25,
860                description: "test details".to_string(),
861                evidence: vec!["evidence1".to_string()],
862            }],
863            recommendations: vec!["recommendation".to_string()],
864            assessed_at: Utc::now(),
865        };
866        let json = serde_json::to_string(&assessment).unwrap();
867        let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
868        assert_eq!(deserialized.address, "0xtest");
869        assert_eq!(deserialized.overall_score, 35.0);
870        assert_eq!(deserialized.factors.len(), 1);
871    }
872
873    #[test]
874    fn test_generate_recommendations_low_risk() {
875        let engine = RiskEngine::new();
876        let recs = engine.generate_recommendations(&[], RiskLevel::Low);
877        assert!(!recs.is_empty());
878        // Low risk should have standard monitoring recommendation
879        assert!(recs.iter().any(|r| r.contains("Standard monitoring")));
880    }
881
882    #[test]
883    fn test_generate_recommendations_high_risk() {
884        let engine = RiskEngine::new();
885        let factors = vec![RiskFactor {
886            name: "Behavioral Patterns".to_string(),
887            category: RiskCategory::Behavioral,
888            score: 80.0,
889            weight: 0.3,
890            description: "concerning".to_string(),
891            evidence: vec!["High velocity".to_string()],
892        }];
893        let recs = engine.generate_recommendations(&factors, RiskLevel::High);
894        assert!(!recs.is_empty());
895    }
896
897    #[test]
898    fn test_calculate_weighted_score_empty() {
899        let engine = RiskEngine::new();
900        let score = engine.calculate_weighted_score(&[]);
901        assert_eq!(score, 0.0);
902    }
903
904    #[test]
905    fn test_analyze_patterns_structuring() {
906        // Create transactions with values just under 10000 (structuring pattern)
907        let txs: Vec<datasource::EtherscanTransaction> = (0..5)
908            .map(|i| {
909                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "9.5");
910                tx.value = format!(
911                    "{}",
912                    (9500 + i * 100) as u128 * 1_000_000_000_000_000_000u128
913                );
914                tx
915            })
916            .collect();
917        let analysis = analyze_patterns(&txs);
918        assert_eq!(analysis.total_transactions, 5);
919    }
920
921    #[test]
922    fn test_analyze_patterns_round_numbers() {
923        // Create transactions with round ETH values
924        let txs: Vec<datasource::EtherscanTransaction> = (0..10)
925            .map(|i| {
926                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
927                // 1 ETH = 1e18 wei, 10 ETH = 10e18 wei, etc.
928                tx.value = format!("{}", 10u128.pow(18) * (i + 1) as u128);
929                tx
930            })
931            .collect();
932        let analysis = analyze_patterns(&txs);
933        assert!(analysis.round_number_pattern);
934    }
935
936    #[test]
937    fn test_analyze_patterns_high_velocity() {
938        // Create many transactions spread over 2 days (high velocity)
939        // Velocity = tx_count / days; 100 txs over 2 days = 50 tx/day
940        let txs: Vec<datasource::EtherscanTransaction> = (0..100)
941            .map(|i| {
942                make_test_tx(&format!("{}", 1700000000 + i * 1800), "0.1") // 100 txs over ~2 days
943            })
944            .collect();
945        let analysis = analyze_patterns(&txs);
946        assert!(analysis.velocity_score > 1.0); // More than 1 tx per day
947    }
948
949    fn mock_etherscan_tx_response(txs: &[datasource::EtherscanTransaction]) -> String {
950        let result_json = serde_json::to_string(txs).unwrap();
951        format!(
952            r#"{{"status":"1","message":"OK","result":{}}}"#,
953            result_json
954        )
955    }
956
957    #[tokio::test]
958    async fn test_risk_engine_with_data_client_assess() {
959        let mut server = mockito::Server::new_async().await;
960
961        // Create test transactions with various patterns
962        let txs: Vec<datasource::EtherscanTransaction> = (0..20)
963            .map(|i| {
964                let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
965                tx.from = if i % 2 == 0 {
966                    "0xSender".to_string()
967                } else {
968                    "0xAddr".to_string()
969                };
970                tx.to = if i % 2 == 0 {
971                    "0xAddr".to_string()
972                } else {
973                    format!("0xRecipient{}", i)
974                };
975                tx.is_error = if i == 5 {
976                    "1".to_string()
977                } else {
978                    "0".to_string()
979                };
980                tx.contract_address = if i == 10 {
981                    "0xContract".to_string()
982                } else {
983                    String::new()
984                };
985                tx
986            })
987            .collect();
988
989        let body = mock_etherscan_tx_response(&txs);
990        let _mock = server
991            .mock("GET", mockito::Matcher::Any)
992            .with_status(200)
993            .with_header("content-type", "application/json")
994            .with_body(&body)
995            .expect_at_least(1)
996            .create_async()
997            .await;
998
999        let sources = datasource::DataSources::new("test_key".to_string());
1000        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1001        let engine = RiskEngine::with_data_client(client);
1002        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1003
1004        assert_eq!(assessment.factors.len(), 4);
1005        assert!(assessment.overall_score > 0.0);
1006        assert!(!assessment.recommendations.is_empty());
1007
1008        // Behavioral factor should have evidence about analyzed transactions
1009        let behavior = assessment
1010            .factors
1011            .iter()
1012            .find(|f| f.name == "Behavioral Patterns")
1013            .unwrap();
1014        assert!(behavior.evidence.iter().any(|e| e.contains("Analyzed")));
1015
1016        // Association factor should have counterparty evidence
1017        let assoc = assessment
1018            .factors
1019            .iter()
1020            .find(|f| f.name == "Address Associations")
1021            .unwrap();
1022        assert!(assoc.evidence.iter().any(|e| e.contains("counterpart")));
1023
1024        // Source factor should mention incoming transactions
1025        let source = assessment
1026            .factors
1027            .iter()
1028            .find(|f| f.name == "Source of Funds")
1029            .unwrap();
1030        assert!(source.evidence.iter().any(|e| e.contains("incoming")));
1031    }
1032
1033    #[tokio::test]
1034    async fn test_risk_engine_with_data_client_api_error() {
1035        let mut server = mockito::Server::new_async().await;
1036        let _mock = server
1037            .mock("GET", mockito::Matcher::Any)
1038            .with_status(200)
1039            .with_header("content-type", "application/json")
1040            .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
1041            .create_async()
1042            .await;
1043
1044        let sources = datasource::DataSources::new("test_key".to_string());
1045        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1046        let engine = RiskEngine::with_data_client(client);
1047        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1048
1049        // Should still produce an assessment, but with error evidence
1050        assert_eq!(assessment.factors.len(), 4);
1051        // Behavior factor should mention the error
1052        let behavior = assessment
1053            .factors
1054            .iter()
1055            .find(|f| f.name == "Behavioral Patterns")
1056            .unwrap();
1057        assert!(
1058            behavior
1059                .evidence
1060                .iter()
1061                .any(|e| e.contains("Could not fetch"))
1062        );
1063    }
1064
1065    #[tokio::test]
1066    async fn test_risk_engine_with_data_client_self_transfers() {
1067        let mut server = mockito::Server::new_async().await;
1068
1069        // Create self-transfers (from == to)
1070        let mut txs = Vec::new();
1071        for i in 0..5 {
1072            let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1073            tx.from = "0xAddr".to_string();
1074            tx.to = "0xAddr".to_string(); // self-transfer
1075            txs.push(tx);
1076        }
1077
1078        let body = mock_etherscan_tx_response(&txs);
1079        let _mock = server
1080            .mock("GET", mockito::Matcher::Any)
1081            .with_status(200)
1082            .with_header("content-type", "application/json")
1083            .with_body(&body)
1084            .create_async()
1085            .await;
1086
1087        let sources = datasource::DataSources::new("test_key".to_string());
1088        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1089        let engine = RiskEngine::with_data_client(client);
1090        let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1091
1092        // Association factor should mention self-transfers
1093        let assoc = assessment
1094            .factors
1095            .iter()
1096            .find(|f| f.name == "Address Associations")
1097            .unwrap();
1098        assert!(assoc.evidence.iter().any(|e| e.contains("self-transfer")));
1099    }
1100
1101    #[test]
1102    fn test_generate_recommendations_critical() {
1103        let engine = RiskEngine::new();
1104        let factors = vec![RiskFactor {
1105            name: "Behavioral Patterns".to_string(),
1106            category: RiskCategory::Behavioral,
1107            score: 9.0,
1108            weight: 0.25,
1109            description: "test".to_string(),
1110            evidence: vec![],
1111        }];
1112        let recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
1113        assert!(recs.iter().any(|r| r.contains("Immediate investigation")));
1114        assert!(recs.iter().any(|r| r.contains("SAR")));
1115    }
1116
1117    #[test]
1118    fn test_generate_recommendations_high() {
1119        let engine = RiskEngine::new();
1120        let recs = engine.generate_recommendations(&[], RiskLevel::High);
1121        assert!(recs.iter().any(|r| r.contains("Enhanced due diligence")));
1122    }
1123
1124    #[test]
1125    fn test_generate_recommendations_medium() {
1126        let engine = RiskEngine::new();
1127        let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1128        assert!(recs.iter().any(|r| r.contains("Standard due diligence")));
1129    }
1130
1131    #[test]
1132    fn test_generate_recommendations_with_high_score_factor() {
1133        let engine = RiskEngine::new();
1134        let factors = vec![RiskFactor {
1135            name: "Test Factor".to_string(),
1136            category: RiskCategory::Behavioral,
1137            score: 8.5,
1138            weight: 0.25,
1139            description: "test".to_string(),
1140            evidence: vec![],
1141        }];
1142        let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
1143        assert!(
1144            recs.iter()
1145                .any(|r| r.contains("Address Test Factor concerns"))
1146        );
1147    }
1148
1149    // ========================================================================
1150    // Tests with data client for pattern analysis branches
1151    // ========================================================================
1152
1153    fn mock_etherscan_json_response(txs: &[serde_json::Value]) -> String {
1154        serde_json::json!({
1155            "status": "1",
1156            "message": "OK",
1157            "result": txs
1158        })
1159        .to_string()
1160    }
1161
1162    fn make_tx_with_idx(
1163        idx: u64,
1164        from: &str,
1165        to: &str,
1166        value: &str,
1167        timestamp: &str,
1168    ) -> serde_json::Value {
1169        serde_json::json!({
1170            "hash": format!("0x{:064x}", idx),
1171            "from": from,
1172            "to": to,
1173            "value": value,
1174            "timeStamp": timestamp,
1175            "blockNumber": "18000000",
1176            "gasUsed": "21000",
1177            "gasPrice": "50000000000",
1178            "isError": "0",
1179            "input": "0x"
1180        })
1181    }
1182
1183    #[tokio::test]
1184    async fn test_risk_engine_with_client_structuring_pattern() {
1185        let mut server = mockito::Server::new_async().await;
1186
1187        // Create transactions that trigger structuring detection
1188        // (amounts just under $10,000 = ~2.86 ETH at $3500)
1189        let txs: Vec<serde_json::Value> = (0..15)
1190            .map(|i| {
1191                make_tx_with_idx(
1192                    i as u64,
1193                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1194                    &format!("0x{:040x}", i + 1),
1195                    "9900000000000000000", // ~9.9 ETH
1196                    &format!("{}", 1700000000 + i * 3600),
1197                )
1198            })
1199            .collect();
1200
1201        let _mock = server
1202            .mock("GET", mockito::Matcher::Any)
1203            .with_status(200)
1204            .with_body(mock_etherscan_json_response(&txs))
1205            .create_async()
1206            .await;
1207
1208        let sources = datasource::DataSources::new("test_key".to_string());
1209        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1210        let engine = RiskEngine::with_data_client(client);
1211
1212        let assessment = engine
1213            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1214            .await
1215            .unwrap();
1216
1217        // Should have run with the client and produced a valid assessment
1218        assert!(!assessment.address.is_empty());
1219        assert!(assessment.overall_score >= 0.0);
1220    }
1221
1222    #[tokio::test]
1223    async fn test_risk_engine_with_client_api_error() {
1224        let mut server = mockito::Server::new_async().await;
1225
1226        let _mock = server
1227            .mock("GET", mockito::Matcher::Any)
1228            .with_status(200)
1229            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
1230            .create_async()
1231            .await;
1232
1233        let sources = datasource::DataSources::new("test_key".to_string());
1234        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1235        let engine = RiskEngine::with_data_client(client);
1236
1237        // Should still succeed (error paths return default factors)
1238        let assessment = engine
1239            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1240            .await
1241            .unwrap();
1242
1243        assert!(!assessment.address.is_empty());
1244    }
1245
1246    #[tokio::test]
1247    async fn test_risk_engine_with_client_many_counterparties() {
1248        let mut server = mockito::Server::new_async().await;
1249
1250        // Create transactions with > 100 unique counterparties
1251        let txs: Vec<serde_json::Value> = (0..120)
1252            .map(|i| {
1253                make_tx_with_idx(
1254                    i as u64,
1255                    "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1256                    &format!("0x{:040x}", i + 1),
1257                    "1000000000000000000",
1258                    &format!("{}", 1700000000 + i * 600),
1259                )
1260            })
1261            .collect();
1262
1263        let _mock = server
1264            .mock("GET", mockito::Matcher::Any)
1265            .with_status(200)
1266            .with_body(mock_etherscan_json_response(&txs))
1267            .create_async()
1268            .await;
1269
1270        let sources = datasource::DataSources::new("test_key".to_string());
1271        let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1272        let engine = RiskEngine::with_data_client(client);
1273
1274        let assessment = engine
1275            .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1276            .await
1277            .unwrap();
1278
1279        // Should have elevated risk due to high counterparty count
1280        assert!(assessment.overall_score > 0.0);
1281    }
1282}