1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum RiskLevel {
12 Low, Medium, High, Critical, }
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#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RiskFactor {
41 pub name: String,
42 pub category: RiskCategory,
43 pub score: f32, pub weight: f32, pub description: String,
46 pub evidence: Vec<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub enum RiskCategory {
52 Behavioral, Association, Source, Destination, Entity, Sanctions, Reputation, }
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RiskAssessment {
64 pub address: String,
65 pub chain: String,
66 pub overall_score: f32, 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#[derive(Debug)]
77pub struct RiskEngine {
78 data_client: Option<BlockchainDataClient>,
80}
81
82impl Default for RiskEngine {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl RiskEngine {
89 pub fn new() -> Self {
91 Self { data_client: None }
92 }
93
94 pub fn with_data_client(client: BlockchainDataClient) -> Self {
96 Self {
97 data_client: Some(client),
98 }
99 }
100
101 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 if let Ok(factor) = self.analyze_behavior(address, chain).await {
111 factors.push(factor);
112 }
113
114 if let Ok(factor) = self.analyze_associations(address, chain).await {
116 factors.push(factor);
117 }
118
119 if let Ok(factor) = self.analyze_sources(address, chain).await {
121 factors.push(factor);
122 }
123
124 if let Ok(factor) = self.identify_entity(address, chain).await {
126 factors.push(factor);
127 }
128
129 let overall_score = self.calculate_weighted_score(&factors);
131 let risk_level = RiskLevel::from_score(overall_score);
132
133 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 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; 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 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 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 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; if let Some(client) = &self.data_client {
221 match client.get_transactions(address, chain).await {
222 Ok(txs) => {
223 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 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 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 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; if let Some(client) = &self.data_client {
277 match client.get_transactions(address, chain).await {
278 Ok(txs) => {
279 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 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 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 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 if let Some(client) = &self.data_client {
333 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; }
343 }
344 Err(_) => {
345 }
347 }
348 }
349
350 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 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 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 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 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 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 }
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 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 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 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 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 assert_eq!(assessment.factors.len(), 4);
840 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 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 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 let txs: Vec<datasource::EtherscanTransaction> = (0..10)
925 .map(|i| {
926 let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
927 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 let txs: Vec<datasource::EtherscanTransaction> = (0..100)
941 .map(|i| {
942 make_test_tx(&format!("{}", 1700000000 + i * 1800), "0.1") })
944 .collect();
945 let analysis = analyze_patterns(&txs);
946 assert!(analysis.velocity_score > 1.0); }
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 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 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 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 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 assert_eq!(assessment.factors.len(), 4);
1051 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 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(); 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 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 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 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", &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 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 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 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 assert!(assessment.overall_score > 0.0);
1281 }
1282}