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
417pub fn compute_gini_coefficient(percentages: &[f64]) -> f64 {
423 if percentages.is_empty() {
424 return 0.0;
425 }
426 let n = percentages.len() as f64;
427 let mut sorted = percentages.to_vec();
428 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
429
430 let sum: f64 = sorted.iter().sum();
431 if sum == 0.0 {
432 return 0.0;
433 }
434
435 let mut numerator = 0.0;
436 for (i, &val) in sorted.iter().enumerate() {
437 numerator += (2.0 * (i as f64 + 1.0) - n - 1.0) * val;
438 }
439
440 (numerator / (n * sum)).clamp(0.0, 1.0)
441}
442
443pub fn detect_rugpull_indicators(
447 contract_analysis: Option<&crate::contract::ContractAnalysis>,
448 token_analytics: Option<&crate::chains::TokenAnalytics>,
449) -> Vec<RiskFactor> {
450 let mut factors = Vec::new();
451
452 if let Some(ca) = contract_analysis {
453 let mut evidence = Vec::new();
454 let mut score: f32 = 0.0;
455
456 if let Some(ac) = &ca.access_control {
458 for pf in &ac.privileged_functions {
459 match pf.name.to_lowercase().as_str() {
460 n if n.contains("mint") => {
461 score += 3.0;
462 evidence.push(format!("Owner can mint tokens: {}", pf.name));
463 }
464 n if n.contains("pause") => {
465 score += 2.0;
466 evidence.push(format!("Owner can pause transfers: {}", pf.name));
467 }
468 n if n.contains("blacklist") => {
469 score += 2.5;
470 evidence.push(format!("Owner can blacklist addresses: {}", pf.name));
471 }
472 n if n.contains("setfee") || n.contains("settax") => {
473 score += 2.0;
474 evidence.push(format!("Owner can change fees: {}", pf.name));
475 }
476 _ => {}
477 }
478 }
479
480 if ac.has_renounced_ownership {
482 score -= 3.0;
483 evidence.push("Ownership has been renounced".to_string());
484 }
485
486 if ac.uses_tx_origin {
488 score += 2.0;
489 evidence.push("Uses tx.origin for authorization".to_string());
490 }
491 }
492
493 if !ca.is_verified {
495 score += 3.0;
496 evidence.push("Contract source code is not verified".to_string());
497 }
498
499 score = score.clamp(0.0, 10.0);
500
501 if !evidence.is_empty() {
502 factors.push(RiskFactor {
503 name: "Rugpull Risk (Contract)".to_string(),
504 category: RiskCategory::Reputation,
505 score,
506 weight: 0.35,
507 description: "Contract-level rugpull indicators".to_string(),
508 evidence,
509 });
510 }
511 }
512
513 if let Some(ta) = token_analytics {
514 let mut evidence = Vec::new();
515 let mut score: f32 = 0.0;
516
517 if let Some(top10) = ta.top_10_concentration {
519 if top10 > 80.0 {
520 score += 4.0;
521 evidence.push(format!("Top 10 holders control {:.1}% of supply", top10));
522 } else if top10 > 50.0 {
523 score += 2.0;
524 evidence.push(format!("Top 10 holders control {:.1}% of supply", top10));
525 }
526 }
527
528 if let Some(age) = ta.token_age_hours {
530 if age < 24.0 {
531 score += 3.0;
532 evidence.push(format!("Token is very new ({:.0}h old)", age));
533 } else if age < 72.0 {
534 score += 1.5;
535 evidence.push(format!("Token is recently created ({:.0}h old)", age));
536 }
537 }
538
539 if ta.total_sells_24h == 0 && ta.total_buys_24h > 10 {
541 score += 4.0;
542 evidence.push(format!(
543 "No sells in 24h with {} buys (potential honeypot)",
544 ta.total_buys_24h
545 ));
546 }
547
548 if ta.liquidity_usd < 10_000.0 && ta.liquidity_usd > 0.0 {
550 score += 2.0;
551 evidence.push(format!("Very low liquidity: ${:.0}", ta.liquidity_usd));
552 }
553
554 score = score.clamp(0.0, 10.0);
555
556 if !evidence.is_empty() {
557 factors.push(RiskFactor {
558 name: "Rugpull Risk (Token)".to_string(),
559 category: RiskCategory::Reputation,
560 score,
561 weight: 0.35,
562 description: "Token-level rugpull indicators".to_string(),
563 evidence,
564 });
565 }
566 }
567
568 factors
569}
570
571pub fn detect_whale_activity(
576 transactions: &[crate::chains::Transaction],
577 avg_tx_value_usd: f64,
578 whale_threshold_usd: f64,
579) -> RiskFactor {
580 let mut evidence = Vec::new();
581 let mut score: f32 = 0.0;
582
583 let large_txs: Vec<_> = transactions
584 .iter()
585 .filter(|tx| {
586 tx.value
588 .parse::<f64>()
589 .map(|v| v > whale_threshold_usd)
590 .unwrap_or(false)
591 })
592 .collect();
593
594 if !large_txs.is_empty() {
595 let pct = (large_txs.len() as f64 / transactions.len() as f64) * 100.0;
596 score += (pct / 10.0) as f32;
597 evidence.push(format!(
598 "{} whale-sized transactions ({:.1}% of total)",
599 large_txs.len(),
600 pct
601 ));
602 }
603
604 if avg_tx_value_usd > whale_threshold_usd * 0.5 {
605 score += 2.0;
606 evidence.push(format!(
607 "Average transaction size ${:.0} is near whale threshold ${:.0}",
608 avg_tx_value_usd, whale_threshold_usd
609 ));
610 }
611
612 if evidence.is_empty() {
613 evidence.push("No significant whale activity detected".to_string());
614 }
615
616 score = score.clamp(0.0, 10.0);
617
618 RiskFactor {
619 name: "Whale Activity".to_string(),
620 category: RiskCategory::Behavioral,
621 score,
622 weight: 0.15,
623 description: "Large transaction and whale holder analysis".to_string(),
624 evidence,
625 }
626}
627
628pub fn detect_timelock(
630 contract_analysis: &crate::contract::ContractAnalysis,
631) -> Option<RiskFactor> {
632 let src = contract_analysis.source_info.as_ref()?;
633 let code_lower = src.source_code.to_lowercase();
634
635 let mut evidence = Vec::new();
636 let mut has_timelock = false;
637
638 if code_lower.contains("timelockcontroller") || code_lower.contains("timelock") {
639 has_timelock = true;
640 evidence.push("TimelockController pattern detected".to_string());
641 }
642
643 if code_lower.contains("delay")
644 && code_lower.contains("queue")
645 && code_lower.contains("execute")
646 {
647 has_timelock = true;
648 evidence.push("Queue/delay/execute governance pattern found".to_string());
649 }
650
651 if code_lower.contains("mindelay") || code_lower.contains("minimum_delay") {
652 evidence.push("Minimum delay parameter found".to_string());
653 }
654
655 let score = if has_timelock { 2.0 } else { 5.0 };
657
658 Some(RiskFactor {
659 name: "Timelock".to_string(),
660 category: RiskCategory::Entity,
661 score,
662 weight: 0.10,
663 description: if has_timelock {
664 "Timelock governance detected (reduces admin risk)".to_string()
665 } else {
666 "No timelock governance detected for admin operations".to_string()
667 },
668 evidence,
669 })
670}
671
672pub fn detect_multisig(
674 contract_analysis: &crate::contract::ContractAnalysis,
675) -> Option<RiskFactor> {
676 let mut evidence = Vec::new();
677 let mut is_multisig = false;
678
679 if let Some(src) = &contract_analysis.source_info {
681 let code_lower = src.source_code.to_lowercase();
682
683 if code_lower.contains("gnosis")
684 || code_lower.contains("safe") && code_lower.contains("multisig")
685 {
686 is_multisig = true;
687 evidence.push("Gnosis Safe / multisig wallet pattern detected".to_string());
688 }
689
690 if code_lower.contains("threshold") && code_lower.contains("owners") {
691 is_multisig = true;
692 evidence.push("Multi-owner threshold pattern (M-of-N signatures)".to_string());
693 }
694
695 if code_lower.contains("confirmtransaction") && code_lower.contains("executetransaction") {
696 is_multisig = true;
697 evidence.push("Confirm/execute transaction pattern (multisig workflow)".to_string());
698 }
699 }
700
701 if let Some(proxy) = &contract_analysis.proxy_info
703 && let Some(admin) = &proxy.admin_address
704 {
705 evidence.push(format!(
706 "Proxy admin address: {} (verify if multisig)",
707 admin
708 ));
709 }
710
711 let score = if is_multisig { 2.0 } else { 4.0 };
712
713 Some(RiskFactor {
714 name: "Multisig Governance".to_string(),
715 category: RiskCategory::Entity,
716 score,
717 weight: 0.10,
718 description: if is_multisig {
719 "Multisig governance detected (reduces single-key risk)".to_string()
720 } else {
721 "No multisig governance detected".to_string()
722 },
723 evidence,
724 })
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use crate::compliance::datasource;
731
732 #[test]
733 fn test_risk_level_from_score() {
734 assert!(matches!(RiskLevel::from_score(2.0), RiskLevel::Low));
735 assert!(matches!(RiskLevel::from_score(5.0), RiskLevel::Medium));
736 assert!(matches!(RiskLevel::from_score(7.5), RiskLevel::High));
737 assert!(matches!(RiskLevel::from_score(9.0), RiskLevel::Critical));
738 }
739
740 #[test]
741 fn test_risk_level_boundaries() {
742 assert!(matches!(RiskLevel::from_score(0.0), RiskLevel::Low));
743 assert!(matches!(RiskLevel::from_score(3.0), RiskLevel::Low));
744 assert!(matches!(RiskLevel::from_score(3.01), RiskLevel::Medium));
745 assert!(matches!(RiskLevel::from_score(6.0), RiskLevel::Medium));
746 assert!(matches!(RiskLevel::from_score(6.01), RiskLevel::High));
747 assert!(matches!(RiskLevel::from_score(8.0), RiskLevel::High));
748 assert!(matches!(RiskLevel::from_score(8.01), RiskLevel::Critical));
749 assert!(matches!(RiskLevel::from_score(10.0), RiskLevel::Critical));
750 }
751
752 #[test]
753 fn test_risk_level_emojis() {
754 assert_eq!(RiskLevel::Low.emoji(), "🟢");
755 assert_eq!(RiskLevel::Medium.emoji(), "🟡");
756 assert_eq!(RiskLevel::High.emoji(), "🔴");
757 assert_eq!(RiskLevel::Critical.emoji(), "âš«");
758 }
759
760 #[test]
761 fn test_weighted_score_calculation() {
762 let engine = RiskEngine::new();
763 let factors = vec![
764 RiskFactor {
765 name: "Test1".to_string(),
766 category: RiskCategory::Behavioral,
767 score: 5.0,
768 weight: 0.5,
769 description: "Test".to_string(),
770 evidence: vec![],
771 },
772 RiskFactor {
773 name: "Test2".to_string(),
774 category: RiskCategory::Association,
775 score: 3.0,
776 weight: 0.5,
777 description: "Test".to_string(),
778 evidence: vec![],
779 },
780 ];
781
782 let score = engine.calculate_weighted_score(&factors);
784 assert!((score - 4.0).abs() < 0.01);
785 }
786
787 #[test]
788 fn test_weighted_score_empty_factors() {
789 let engine = RiskEngine::new();
790 let score = engine.calculate_weighted_score(&[]);
791 assert_eq!(score, 0.0);
792 }
793
794 #[test]
795 fn test_weighted_score_zero_weight() {
796 let engine = RiskEngine::new();
797 let factors = vec![RiskFactor {
798 name: "Test".to_string(),
799 category: RiskCategory::Behavioral,
800 score: 5.0,
801 weight: 0.0,
802 description: "Test".to_string(),
803 evidence: vec![],
804 }];
805 let score = engine.calculate_weighted_score(&factors);
806 assert_eq!(score, 0.0);
807 }
808
809 #[test]
810 fn test_weighted_score_clamped() {
811 let engine = RiskEngine::new();
812 let factors = vec![RiskFactor {
813 name: "High".to_string(),
814 category: RiskCategory::Behavioral,
815 score: 15.0,
816 weight: 1.0,
817 description: "Test".to_string(),
818 evidence: vec![],
819 }];
820 let score = engine.calculate_weighted_score(&factors);
821 assert_eq!(score, 10.0);
822 }
823
824 #[test]
825 fn test_recommendations_by_level() {
826 let engine = RiskEngine::new();
827 let factors = vec![];
828
829 let low_recs = engine.generate_recommendations(&factors, RiskLevel::Low);
830 assert!(low_recs.iter().any(|r| r.contains("Standard monitoring")));
831
832 let med_recs = engine.generate_recommendations(&factors, RiskLevel::Medium);
833 assert!(
834 med_recs
835 .iter()
836 .any(|r| r.contains("Standard due diligence"))
837 );
838
839 let high_recs = engine.generate_recommendations(&factors, RiskLevel::High);
840 assert!(
841 high_recs
842 .iter()
843 .any(|r| r.contains("Enhanced due diligence"))
844 );
845
846 let crit_recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
847 assert!(
848 crit_recs
849 .iter()
850 .any(|r| r.contains("Immediate investigation"))
851 );
852 }
853
854 #[test]
855 fn test_recommendations_high_score_factors() {
856 let engine = RiskEngine::new();
857 let factors = vec![RiskFactor {
858 name: "CriticalIssue".to_string(),
859 category: RiskCategory::Behavioral,
860 score: 8.5,
861 weight: 1.0,
862 description: "Critical issue".to_string(),
863 evidence: vec!["Evidence".to_string()],
864 }];
865
866 let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
867 assert!(recs.iter().any(|r| r.contains("CriticalIssue")));
868 }
869
870 #[test]
871 fn test_risk_factor_creation() {
872 let factor = RiskFactor {
873 name: "TestFactor".to_string(),
874 category: RiskCategory::Entity,
875 score: 7.5,
876 weight: 0.25,
877 description: "Test description".to_string(),
878 evidence: vec!["Evidence 1".to_string(), "Evidence 2".to_string()],
879 };
880
881 assert_eq!(factor.name, "TestFactor");
882 assert!(matches!(factor.category, RiskCategory::Entity));
883 assert_eq!(factor.score, 7.5);
884 assert_eq!(factor.weight, 0.25);
885 assert_eq!(factor.evidence.len(), 2);
886 }
887
888 #[test]
889 fn test_all_risk_categories() {
890 let _categories = [
891 RiskCategory::Behavioral,
892 RiskCategory::Association,
893 RiskCategory::Source,
894 RiskCategory::Destination,
895 RiskCategory::Entity,
896 RiskCategory::Sanctions,
897 RiskCategory::Reputation,
898 ];
899 }
900
901 #[tokio::test]
902 async fn test_risk_engine_creation() {
903 let engine = RiskEngine::new();
904 let assessment = engine
905 .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
906 .await
907 .unwrap();
908
909 assert_eq!(
910 assessment.address,
911 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
912 );
913 assert_eq!(assessment.chain, "ethereum");
914 assert!(assessment.overall_score >= 0.0 && assessment.overall_score <= 10.0);
915 assert!(!assessment.factors.is_empty());
916 assert!(!assessment.recommendations.is_empty());
917 }
918
919 #[tokio::test]
920 async fn test_risk_assessment_different_addresses() {
921 let engine = RiskEngine::new();
922
923 let addresses = vec![
924 ("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum"),
925 ("0x0000000000000000000000000000000000000000", "ethereum"),
926 ];
927
928 for (addr, chain) in addresses {
929 let assessment = engine.assess_address(addr, chain).await.unwrap();
930 assert_eq!(assessment.address, addr);
931 assert_eq!(assessment.chain, chain);
932 }
933 }
934
935 #[test]
936 fn test_risk_engine_default() {
937 let engine = RiskEngine::default();
938 let score = engine.calculate_weighted_score(&[]);
940 assert_eq!(score, 0.0);
941 }
942
943 #[test]
944 fn test_risk_engine_with_data_client() {
945 let sources = datasource::DataSources::new("test_key".to_string());
946 let client = datasource::BlockchainDataClient::new(sources);
947 let _engine = RiskEngine::with_data_client(client);
948 }
950
951 #[tokio::test]
952 async fn test_assess_address_has_all_factors() {
953 let engine = RiskEngine::new();
954 let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
955
956 assert_eq!(assessment.factors.len(), 4);
958
959 let categories: Vec<_> = assessment.factors.iter().map(|f| f.category).collect();
960 assert!(categories.contains(&RiskCategory::Behavioral));
961 assert!(categories.contains(&RiskCategory::Association));
962 assert!(categories.contains(&RiskCategory::Source));
963 assert!(categories.contains(&RiskCategory::Entity));
964 }
965
966 #[tokio::test]
967 async fn test_assess_address_factors_have_evidence() {
968 let engine = RiskEngine::new();
969 let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
970
971 for factor in &assessment.factors {
972 assert!(
973 !factor.evidence.is_empty(),
974 "Factor {} has no evidence",
975 factor.name
976 );
977 assert!(
979 factor
980 .evidence
981 .iter()
982 .any(|e| e.contains("No data client configured")
983 || e.contains("not in known entity")),
984 "Factor {} doesn't have expected evidence: {:?}",
985 factor.name,
986 factor.evidence
987 );
988 }
989 }
990
991 #[tokio::test]
992 async fn test_assess_address_score_in_bounds() {
993 let engine = RiskEngine::new();
994 let assessment = engine.assess_address("0xtest", "ethereum").await.unwrap();
995
996 assert!(assessment.overall_score >= 0.0);
997 assert!(assessment.overall_score <= 10.0);
998
999 for factor in &assessment.factors {
1000 assert!(factor.score >= 0.0);
1001 assert!(factor.score <= 10.0);
1002 assert!(factor.weight >= 0.0);
1003 assert!(factor.weight <= 1.0);
1004 }
1005 }
1006
1007 #[test]
1008 fn test_risk_assessment_serialization() {
1009 let assessment = RiskAssessment {
1010 address: "0xtest".to_string(),
1011 chain: "ethereum".to_string(),
1012 overall_score: 3.5,
1013 risk_level: RiskLevel::Medium,
1014 factors: vec![],
1015 assessed_at: Utc::now(),
1016 recommendations: vec!["Test recommendation".to_string()],
1017 };
1018
1019 let json = serde_json::to_string(&assessment).unwrap();
1020 assert!(json.contains("0xtest"));
1021 assert!(json.contains("ethereum"));
1022 assert!(json.contains("Medium"));
1023
1024 let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1025 assert_eq!(deserialized.address, "0xtest");
1026 assert_eq!(deserialized.overall_score, 3.5);
1027 }
1028
1029 #[test]
1030 fn test_risk_factor_serialization() {
1031 let factor = RiskFactor {
1032 name: "Test".to_string(),
1033 category: RiskCategory::Behavioral,
1034 score: 5.0,
1035 weight: 0.25,
1036 description: "Test factor".to_string(),
1037 evidence: vec!["Evidence 1".to_string()],
1038 };
1039
1040 let json = serde_json::to_string(&factor).unwrap();
1041 assert!(json.contains("Behavioral"));
1042
1043 let deserialized: RiskFactor = serde_json::from_str(&json).unwrap();
1044 assert_eq!(deserialized.name, "Test");
1045 assert_eq!(deserialized.score, 5.0);
1046 }
1047
1048 #[test]
1049 fn test_recommendations_critical_includes_sar() {
1050 let engine = RiskEngine::new();
1051 let recs = engine.generate_recommendations(&[], RiskLevel::Critical);
1052 assert!(recs.iter().any(|r| r.contains("SAR")));
1053 assert!(recs.iter().any(|r| r.contains("suspending")));
1054 }
1055
1056 #[test]
1057 fn test_recommendations_high_includes_verify_source() {
1058 let engine = RiskEngine::new();
1059 let recs = engine.generate_recommendations(&[], RiskLevel::High);
1060 assert!(recs.iter().any(|r| r.contains("Verify source")));
1061 }
1062
1063 #[test]
1064 fn test_recommendations_medium_includes_reassessment() {
1065 let engine = RiskEngine::new();
1066 let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1067 assert!(recs.iter().any(|r| r.contains("re-assessment")));
1068 }
1069
1070 #[test]
1071 fn test_weighted_score_single_factor() {
1072 let engine = RiskEngine::new();
1073 let factors = vec![RiskFactor {
1074 name: "Single".to_string(),
1075 category: RiskCategory::Source,
1076 score: 7.0,
1077 weight: 1.0,
1078 description: "Test".to_string(),
1079 evidence: vec![],
1080 }];
1081 let score = engine.calculate_weighted_score(&factors);
1082 assert!((score - 7.0).abs() < 0.01);
1083 }
1084
1085 fn make_test_tx(timestamp: &str, value_eth: &str) -> datasource::EtherscanTransaction {
1086 let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
1087 datasource::EtherscanTransaction {
1088 block_number: "1".to_string(),
1089 timestamp: timestamp.to_string(),
1090 hash: "0x1".to_string(),
1091 from: "0xa".to_string(),
1092 to: "0xb".to_string(),
1093 value: value_wei.to_string(),
1094 gas: "21000".to_string(),
1095 gas_price: "20000000000".to_string(),
1096 is_error: "0".to_string(),
1097 txreceipt_status: "1".to_string(),
1098 input: "0x".to_string(),
1099 contract_address: "".to_string(),
1100 cumulative_gas_used: "21000".to_string(),
1101 gas_used: "21000".to_string(),
1102 confirmations: "100".to_string(),
1103 }
1104 }
1105
1106 #[test]
1107 fn test_pattern_analysis_no_structuring() {
1108 let txs = vec![
1110 make_test_tx("1609459200", "1.5"),
1111 make_test_tx("1609459300", "2.3"),
1112 make_test_tx("1609459400", "0.7"),
1113 ];
1114
1115 let analysis = analyze_patterns(&txs);
1116 assert!(!analysis.structuring_detected);
1117 }
1118
1119 #[test]
1120 fn test_pattern_analysis_no_round_numbers() {
1121 let txs = vec![
1122 make_test_tx("1609459200", "1.234"),
1123 make_test_tx("1609459300", "0.567"),
1124 make_test_tx("1609459400", "3.891"),
1125 ];
1126
1127 let analysis = analyze_patterns(&txs);
1128 assert!(!analysis.round_number_pattern);
1129 }
1130
1131 #[test]
1132 fn test_pattern_analysis_single_tx() {
1133 let txs = vec![make_test_tx("1609459200", "1.0")];
1134
1135 let analysis = analyze_patterns(&txs);
1136 assert_eq!(analysis.total_transactions, 1);
1137 assert_eq!(analysis.velocity_score, 0.0);
1139 }
1140
1141 #[tokio::test]
1142 async fn test_assess_address_generates_all_factors() {
1143 let engine = RiskEngine::new();
1144 let assessment = engine
1145 .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1146 .await
1147 .unwrap();
1148 assert_eq!(assessment.factors.len(), 4);
1150 let factor_names: Vec<&str> = assessment.factors.iter().map(|f| f.name.as_str()).collect();
1152 assert!(factor_names.contains(&"Behavioral Patterns"));
1153 assert!(factor_names.contains(&"Address Associations"));
1154 assert!(factor_names.contains(&"Source of Funds"));
1155 assert!(factor_names.contains(&"Entity Identification"));
1156 }
1157
1158 #[test]
1159 fn test_risk_assessment_json_roundtrip() {
1160 let assessment = RiskAssessment {
1161 address: "0xtest".to_string(),
1162 chain: "ethereum".to_string(),
1163 overall_score: 35.0,
1164 risk_level: RiskLevel::Medium,
1165 factors: vec![RiskFactor {
1166 name: "Test Factor".to_string(),
1167 category: RiskCategory::Behavioral,
1168 score: 30.0,
1169 weight: 0.25,
1170 description: "test details".to_string(),
1171 evidence: vec!["evidence1".to_string()],
1172 }],
1173 recommendations: vec!["recommendation".to_string()],
1174 assessed_at: Utc::now(),
1175 };
1176 let json = serde_json::to_string(&assessment).unwrap();
1177 let deserialized: RiskAssessment = serde_json::from_str(&json).unwrap();
1178 assert_eq!(deserialized.address, "0xtest");
1179 assert_eq!(deserialized.overall_score, 35.0);
1180 assert_eq!(deserialized.factors.len(), 1);
1181 }
1182
1183 #[test]
1184 fn test_generate_recommendations_low_risk() {
1185 let engine = RiskEngine::new();
1186 let recs = engine.generate_recommendations(&[], RiskLevel::Low);
1187 assert!(!recs.is_empty());
1188 assert!(recs.iter().any(|r| r.contains("Standard monitoring")));
1190 }
1191
1192 #[test]
1193 fn test_generate_recommendations_high_risk() {
1194 let engine = RiskEngine::new();
1195 let factors = vec![RiskFactor {
1196 name: "Behavioral Patterns".to_string(),
1197 category: RiskCategory::Behavioral,
1198 score: 80.0,
1199 weight: 0.3,
1200 description: "concerning".to_string(),
1201 evidence: vec!["High velocity".to_string()],
1202 }];
1203 let recs = engine.generate_recommendations(&factors, RiskLevel::High);
1204 assert!(!recs.is_empty());
1205 }
1206
1207 #[test]
1208 fn test_calculate_weighted_score_empty() {
1209 let engine = RiskEngine::new();
1210 let score = engine.calculate_weighted_score(&[]);
1211 assert_eq!(score, 0.0);
1212 }
1213
1214 #[test]
1215 fn test_analyze_patterns_structuring() {
1216 let txs: Vec<datasource::EtherscanTransaction> = (0..5)
1218 .map(|i| {
1219 let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "9.5");
1220 tx.value = format!(
1221 "{}",
1222 (9500 + i * 100) as u128 * 1_000_000_000_000_000_000u128
1223 );
1224 tx
1225 })
1226 .collect();
1227 let analysis = analyze_patterns(&txs);
1228 assert_eq!(analysis.total_transactions, 5);
1229 }
1230
1231 #[test]
1232 fn test_analyze_patterns_round_numbers() {
1233 let txs: Vec<datasource::EtherscanTransaction> = (0..10)
1235 .map(|i| {
1236 let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1237 tx.value = format!("{}", 10u128.pow(18) * (i + 1) as u128);
1239 tx
1240 })
1241 .collect();
1242 let analysis = analyze_patterns(&txs);
1243 assert!(analysis.round_number_pattern);
1244 }
1245
1246 #[test]
1247 fn test_analyze_patterns_high_velocity() {
1248 let txs: Vec<datasource::EtherscanTransaction> = (0..100)
1251 .map(|i| {
1252 make_test_tx(&format!("{}", 1700000000 + i * 1800), "0.1") })
1254 .collect();
1255 let analysis = analyze_patterns(&txs);
1256 assert!(analysis.velocity_score > 1.0); }
1258
1259 fn mock_etherscan_tx_response(txs: &[datasource::EtherscanTransaction]) -> String {
1260 let result_json = serde_json::to_string(txs).unwrap();
1261 format!(
1262 r#"{{"status":"1","message":"OK","result":{}}}"#,
1263 result_json
1264 )
1265 }
1266
1267 #[tokio::test]
1268 async fn test_risk_engine_with_data_client_assess() {
1269 let mut server = mockito::Server::new_async().await;
1270
1271 let txs: Vec<datasource::EtherscanTransaction> = (0..20)
1273 .map(|i| {
1274 let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1275 tx.from = if i % 2 == 0 {
1276 "0xSender".to_string()
1277 } else {
1278 "0xAddr".to_string()
1279 };
1280 tx.to = if i % 2 == 0 {
1281 "0xAddr".to_string()
1282 } else {
1283 format!("0xRecipient{}", i)
1284 };
1285 tx.is_error = if i == 5 {
1286 "1".to_string()
1287 } else {
1288 "0".to_string()
1289 };
1290 tx.contract_address = if i == 10 {
1291 "0xContract".to_string()
1292 } else {
1293 String::new()
1294 };
1295 tx
1296 })
1297 .collect();
1298
1299 let body = mock_etherscan_tx_response(&txs);
1300 let _mock = server
1301 .mock("GET", mockito::Matcher::Any)
1302 .with_status(200)
1303 .with_header("content-type", "application/json")
1304 .with_body(&body)
1305 .expect_at_least(1)
1306 .create_async()
1307 .await;
1308
1309 let sources = datasource::DataSources::new("test_key".to_string());
1310 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1311 let engine = RiskEngine::with_data_client(client);
1312 let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1313
1314 assert_eq!(assessment.factors.len(), 4);
1315 assert!(assessment.overall_score > 0.0);
1316 assert!(!assessment.recommendations.is_empty());
1317
1318 let behavior = assessment
1320 .factors
1321 .iter()
1322 .find(|f| f.name == "Behavioral Patterns")
1323 .unwrap();
1324 assert!(behavior.evidence.iter().any(|e| e.contains("Analyzed")));
1325
1326 let assoc = assessment
1328 .factors
1329 .iter()
1330 .find(|f| f.name == "Address Associations")
1331 .unwrap();
1332 assert!(assoc.evidence.iter().any(|e| e.contains("counterpart")));
1333
1334 let source = assessment
1336 .factors
1337 .iter()
1338 .find(|f| f.name == "Source of Funds")
1339 .unwrap();
1340 assert!(source.evidence.iter().any(|e| e.contains("incoming")));
1341 }
1342
1343 #[tokio::test]
1344 async fn test_risk_engine_with_data_client_api_error() {
1345 let mut server = mockito::Server::new_async().await;
1346 let _mock = server
1347 .mock("GET", mockito::Matcher::Any)
1348 .with_status(200)
1349 .with_header("content-type", "application/json")
1350 .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
1351 .create_async()
1352 .await;
1353
1354 let sources = datasource::DataSources::new("test_key".to_string());
1355 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1356 let engine = RiskEngine::with_data_client(client);
1357 let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1358
1359 assert_eq!(assessment.factors.len(), 4);
1361 let behavior = assessment
1363 .factors
1364 .iter()
1365 .find(|f| f.name == "Behavioral Patterns")
1366 .unwrap();
1367 assert!(
1368 behavior
1369 .evidence
1370 .iter()
1371 .any(|e| e.contains("Could not fetch"))
1372 );
1373 }
1374
1375 #[tokio::test]
1376 async fn test_risk_engine_with_data_client_self_transfers() {
1377 let mut server = mockito::Server::new_async().await;
1378
1379 let mut txs = Vec::new();
1381 for i in 0..5 {
1382 let mut tx = make_test_tx(&format!("{}", 1700000000 + i * 3600), "1.0");
1383 tx.from = "0xAddr".to_string();
1384 tx.to = "0xAddr".to_string(); txs.push(tx);
1386 }
1387
1388 let body = mock_etherscan_tx_response(&txs);
1389 let _mock = server
1390 .mock("GET", mockito::Matcher::Any)
1391 .with_status(200)
1392 .with_header("content-type", "application/json")
1393 .with_body(&body)
1394 .create_async()
1395 .await;
1396
1397 let sources = datasource::DataSources::new("test_key".to_string());
1398 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1399 let engine = RiskEngine::with_data_client(client);
1400 let assessment = engine.assess_address("0xAddr", "ethereum").await.unwrap();
1401
1402 let assoc = assessment
1404 .factors
1405 .iter()
1406 .find(|f| f.name == "Address Associations")
1407 .unwrap();
1408 assert!(assoc.evidence.iter().any(|e| e.contains("self-transfer")));
1409 }
1410
1411 #[test]
1412 fn test_generate_recommendations_critical() {
1413 let engine = RiskEngine::new();
1414 let factors = vec![RiskFactor {
1415 name: "Behavioral Patterns".to_string(),
1416 category: RiskCategory::Behavioral,
1417 score: 9.0,
1418 weight: 0.25,
1419 description: "test".to_string(),
1420 evidence: vec![],
1421 }];
1422 let recs = engine.generate_recommendations(&factors, RiskLevel::Critical);
1423 assert!(recs.iter().any(|r| r.contains("Immediate investigation")));
1424 assert!(recs.iter().any(|r| r.contains("SAR")));
1425 }
1426
1427 #[test]
1428 fn test_generate_recommendations_high() {
1429 let engine = RiskEngine::new();
1430 let recs = engine.generate_recommendations(&[], RiskLevel::High);
1431 assert!(recs.iter().any(|r| r.contains("Enhanced due diligence")));
1432 }
1433
1434 #[test]
1435 fn test_generate_recommendations_medium() {
1436 let engine = RiskEngine::new();
1437 let recs = engine.generate_recommendations(&[], RiskLevel::Medium);
1438 assert!(recs.iter().any(|r| r.contains("Standard due diligence")));
1439 }
1440
1441 #[test]
1442 fn test_generate_recommendations_with_high_score_factor() {
1443 let engine = RiskEngine::new();
1444 let factors = vec![RiskFactor {
1445 name: "Test Factor".to_string(),
1446 category: RiskCategory::Behavioral,
1447 score: 8.5,
1448 weight: 0.25,
1449 description: "test".to_string(),
1450 evidence: vec![],
1451 }];
1452 let recs = engine.generate_recommendations(&factors, RiskLevel::Low);
1453 assert!(
1454 recs.iter()
1455 .any(|r| r.contains("Address Test Factor concerns"))
1456 );
1457 }
1458
1459 fn mock_etherscan_json_response(txs: &[serde_json::Value]) -> String {
1464 serde_json::json!({
1465 "status": "1",
1466 "message": "OK",
1467 "result": txs
1468 })
1469 .to_string()
1470 }
1471
1472 fn make_tx_with_idx(
1473 idx: u64,
1474 from: &str,
1475 to: &str,
1476 value: &str,
1477 timestamp: &str,
1478 ) -> serde_json::Value {
1479 serde_json::json!({
1480 "hash": format!("0x{:064x}", idx),
1481 "from": from,
1482 "to": to,
1483 "value": value,
1484 "timeStamp": timestamp,
1485 "blockNumber": "18000000",
1486 "gasUsed": "21000",
1487 "gasPrice": "50000000000",
1488 "isError": "0",
1489 "input": "0x"
1490 })
1491 }
1492
1493 #[tokio::test]
1494 async fn test_risk_engine_with_client_structuring_pattern() {
1495 let mut server = mockito::Server::new_async().await;
1496
1497 let txs: Vec<serde_json::Value> = (0..15)
1500 .map(|i| {
1501 make_tx_with_idx(
1502 i as u64,
1503 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1504 &format!("0x{:040x}", i + 1),
1505 "9900000000000000000", &format!("{}", 1700000000 + i * 3600),
1507 )
1508 })
1509 .collect();
1510
1511 let _mock = server
1512 .mock("GET", mockito::Matcher::Any)
1513 .with_status(200)
1514 .with_body(mock_etherscan_json_response(&txs))
1515 .create_async()
1516 .await;
1517
1518 let sources = datasource::DataSources::new("test_key".to_string());
1519 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1520 let engine = RiskEngine::with_data_client(client);
1521
1522 let assessment = engine
1523 .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1524 .await
1525 .unwrap();
1526
1527 assert!(!assessment.address.is_empty());
1529 assert!(assessment.overall_score >= 0.0);
1530 }
1531
1532 #[tokio::test]
1533 async fn test_risk_engine_with_client_api_error() {
1534 let mut server = mockito::Server::new_async().await;
1535
1536 let _mock = server
1537 .mock("GET", mockito::Matcher::Any)
1538 .with_status(200)
1539 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
1540 .create_async()
1541 .await;
1542
1543 let sources = datasource::DataSources::new("test_key".to_string());
1544 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1545 let engine = RiskEngine::with_data_client(client);
1546
1547 let assessment = engine
1549 .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1550 .await
1551 .unwrap();
1552
1553 assert!(!assessment.address.is_empty());
1554 }
1555
1556 #[tokio::test]
1557 async fn test_risk_engine_with_client_many_counterparties() {
1558 let mut server = mockito::Server::new_async().await;
1559
1560 let txs: Vec<serde_json::Value> = (0..120)
1562 .map(|i| {
1563 make_tx_with_idx(
1564 i as u64,
1565 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1566 &format!("0x{:040x}", i + 1),
1567 "1000000000000000000",
1568 &format!("{}", 1700000000 + i * 600),
1569 )
1570 })
1571 .collect();
1572
1573 let _mock = server
1574 .mock("GET", mockito::Matcher::Any)
1575 .with_status(200)
1576 .with_body(mock_etherscan_json_response(&txs))
1577 .create_async()
1578 .await;
1579
1580 let sources = datasource::DataSources::new("test_key".to_string());
1581 let client = datasource::BlockchainDataClient::with_base_url(sources, &server.url());
1582 let engine = RiskEngine::with_data_client(client);
1583
1584 let assessment = engine
1585 .assess_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
1586 .await
1587 .unwrap();
1588
1589 assert!(assessment.overall_score > 0.0);
1591 }
1592
1593 #[test]
1594 fn test_gini_coefficient_equal_distribution() {
1595 let holdings = vec![10.0, 10.0, 10.0, 10.0, 10.0];
1597 let gini = compute_gini_coefficient(&holdings);
1598 assert!(gini < 0.01, "Expected near-zero Gini, got {}", gini);
1599 }
1600
1601 #[test]
1602 fn test_gini_coefficient_concentrated() {
1603 let holdings = vec![0.0, 0.0, 0.0, 0.0, 100.0];
1605 let gini = compute_gini_coefficient(&holdings);
1606 assert!(gini > 0.7, "Expected high Gini, got {}", gini);
1607 }
1608
1609 #[test]
1610 fn test_gini_coefficient_empty() {
1611 let gini = compute_gini_coefficient(&[]);
1612 assert_eq!(gini, 0.0);
1613 }
1614
1615 #[test]
1616 fn test_rugpull_indicators_none() {
1617 let factors = detect_rugpull_indicators(None, None);
1618 assert!(factors.is_empty());
1619 }
1620
1621 #[test]
1622 fn test_whale_detection_no_whales() {
1623 let txs = vec![];
1624 let factor = detect_whale_activity(&txs, 100.0, 100_000.0);
1625 assert!(factor.score < 1.0);
1626 }
1627
1628 fn make_ca(
1633 verified: bool,
1634 ac: Option<crate::contract::access::AccessControlMap>,
1635 ) -> crate::contract::ContractAnalysis {
1636 crate::contract::ContractAnalysis {
1637 address: "0xtest".into(),
1638 chain: "ethereum".into(),
1639 is_verified: verified,
1640 source_info: None,
1641 proxy_info: None,
1642 access_control: ac,
1643 vulnerabilities: vec![],
1644 defi_analysis: None,
1645 external_info: None,
1646 security_score: 50,
1647 security_summary: String::new(),
1648 }
1649 }
1650
1651 fn make_ac(
1652 funcs: Vec<(&str, &str)>,
1653 renounced: bool,
1654 tx_origin: bool,
1655 ) -> crate::contract::access::AccessControlMap {
1656 let priv_fns = funcs
1657 .iter()
1658 .map(|(name, cap)| crate::contract::access::PrivilegedFunction {
1659 name: name.to_string(),
1660 modifiers: vec![],
1661 capability: cap.to_string(),
1662 risk: crate::contract::access::PrivilegeRisk::High,
1663 })
1664 .collect();
1665 crate::contract::access::AccessControlMap {
1666 ownership_pattern: None,
1667 has_renounced_ownership: renounced,
1668 has_role_based_access: false,
1669 uses_tx_origin: tx_origin,
1670 tx_origin_locations: vec![],
1671 modifiers: vec![],
1672 privileged_functions: priv_fns,
1673 roles: vec![],
1674 auth_analysis: crate::contract::access::AuthAnalysis {
1675 msg_sender_checks: 0,
1676 tx_origin_checks: 0,
1677 has_origin_sender_comparison: false,
1678 summary: String::new(),
1679 },
1680 }
1681 }
1682
1683 #[test]
1684 fn test_rugpull_mint_evidence() {
1685 let ac = make_ac(vec![("mint", "Mint")], false, false);
1686 let ca = make_ca(true, Some(ac));
1687 let factors = detect_rugpull_indicators(Some(&ca), None);
1688 assert!(!factors.is_empty());
1689 let f = &factors[0];
1690 assert!(f.evidence.iter().any(|e| e.contains("mint")));
1691 }
1692
1693 #[test]
1694 fn test_rugpull_pause_blacklist_setfee() {
1695 let ac = make_ac(
1696 vec![
1697 ("pause", "Pause"),
1698 ("blacklist", "Block"),
1699 ("setFee", "Fee"),
1700 ],
1701 false,
1702 false,
1703 );
1704 let ca = make_ca(true, Some(ac));
1705 let factors = detect_rugpull_indicators(Some(&ca), None);
1706 let f = &factors[0];
1707 assert!(f.evidence.iter().any(|e| e.contains("pause")));
1708 assert!(f.evidence.iter().any(|e| e.contains("blacklist")));
1709 assert!(f.evidence.iter().any(|e| e.contains("fee")));
1710 }
1711
1712 #[test]
1713 fn test_rugpull_renounced_reduces_risk() {
1714 let ac = make_ac(vec![("mint", "Mint")], true, false);
1715 let ca = make_ca(true, Some(ac));
1716 let factors = detect_rugpull_indicators(Some(&ca), None);
1717 let f = &factors[0];
1718 assert!(f.evidence.iter().any(|e| e.contains("renounced")));
1719 }
1720
1721 #[test]
1722 fn test_rugpull_tx_origin() {
1723 let ac = make_ac(vec![], false, true);
1724 let ca = make_ca(true, Some(ac));
1725 let factors = detect_rugpull_indicators(Some(&ca), None);
1726 let f = &factors[0];
1727 assert!(f.evidence.iter().any(|e| e.contains("tx.origin")));
1728 }
1729
1730 #[test]
1731 fn test_rugpull_unverified() {
1732 let ac = make_ac(vec![], false, false);
1733 let ca = make_ca(false, Some(ac));
1734 let factors = detect_rugpull_indicators(Some(&ca), None);
1735 let f = &factors[0];
1736 assert!(f.evidence.iter().any(|e| e.contains("not verified")));
1737 }
1738
1739 fn make_ta(
1740 top10: Option<f64>,
1741 age: Option<f64>,
1742 buys: u64,
1743 sells: u64,
1744 liq: f64,
1745 ) -> crate::chains::TokenAnalytics {
1746 crate::chains::TokenAnalytics {
1747 token: crate::chains::Token {
1748 contract_address: "0x".into(),
1749 symbol: "T".into(),
1750 name: "T".into(),
1751 decimals: 18,
1752 },
1753 chain: "ethereum".into(),
1754 holders: vec![],
1755 total_holders: 0,
1756 volume_24h: 0.0,
1757 volume_7d: 0.0,
1758 price_usd: 0.0,
1759 price_change_24h: 0.0,
1760 price_change_7d: 0.0,
1761 liquidity_usd: liq,
1762 market_cap: None,
1763 fdv: None,
1764 total_supply: None,
1765 circulating_supply: None,
1766 price_history: vec![],
1767 volume_history: vec![],
1768 holder_history: vec![],
1769 dex_pairs: vec![],
1770 fetched_at: 0,
1771 top_10_concentration: top10,
1772 top_50_concentration: None,
1773 top_100_concentration: None,
1774 price_change_6h: 0.0,
1775 price_change_1h: 0.0,
1776 total_buys_24h: buys,
1777 total_sells_24h: sells,
1778 total_buys_6h: 0,
1779 total_sells_6h: 0,
1780 total_buys_1h: 0,
1781 total_sells_1h: 0,
1782 token_age_hours: age,
1783 image_url: None,
1784 websites: vec![],
1785 socials: vec![],
1786 dexscreener_url: None,
1787 }
1788 }
1789
1790 #[test]
1791 fn test_rugpull_high_concentration() {
1792 let ta = make_ta(Some(85.0), None, 0, 0, 100000.0);
1793 let factors = detect_rugpull_indicators(None, Some(&ta));
1794 assert!(!factors.is_empty());
1795 assert!(factors[0].evidence.iter().any(|e| e.contains("85.0%")));
1796 }
1797
1798 #[test]
1799 fn test_rugpull_moderate_concentration() {
1800 let ta = make_ta(Some(55.0), None, 0, 0, 100000.0);
1801 let factors = detect_rugpull_indicators(None, Some(&ta));
1802 assert!(!factors.is_empty());
1803 assert!(factors[0].evidence.iter().any(|e| e.contains("55.0%")));
1804 }
1805
1806 #[test]
1807 fn test_rugpull_very_new_token() {
1808 let ta = make_ta(None, Some(12.0), 0, 0, 100000.0);
1809 let factors = detect_rugpull_indicators(None, Some(&ta));
1810 assert!(!factors.is_empty());
1811 assert!(factors[0].evidence.iter().any(|e| e.contains("very new")));
1812 }
1813
1814 #[test]
1815 fn test_rugpull_recently_created() {
1816 let ta = make_ta(None, Some(48.0), 0, 0, 100000.0);
1817 let factors = detect_rugpull_indicators(None, Some(&ta));
1818 assert!(!factors.is_empty());
1819 assert!(
1820 factors[0]
1821 .evidence
1822 .iter()
1823 .any(|e| e.contains("recently created"))
1824 );
1825 }
1826
1827 #[test]
1828 fn test_rugpull_honeypot() {
1829 let ta = make_ta(None, None, 20, 0, 100000.0);
1830 let factors = detect_rugpull_indicators(None, Some(&ta));
1831 assert!(!factors.is_empty());
1832 assert!(factors[0].evidence.iter().any(|e| e.contains("honeypot")));
1833 }
1834
1835 #[test]
1836 fn test_rugpull_low_liquidity() {
1837 let ta = make_ta(None, None, 0, 0, 5000.0);
1838 let factors = detect_rugpull_indicators(None, Some(&ta));
1839 assert!(!factors.is_empty());
1840 assert!(
1841 factors[0]
1842 .evidence
1843 .iter()
1844 .any(|e| e.contains("low liquidity") || e.contains("Very low"))
1845 );
1846 }
1847
1848 fn make_whale_tx(value: &str) -> crate::chains::Transaction {
1853 crate::chains::Transaction {
1854 hash: "0xh".into(),
1855 block_number: Some(1),
1856 timestamp: Some(0),
1857 from: "0xa".into(),
1858 to: Some("0xb".into()),
1859 value: value.to_string(),
1860 gas_limit: 21000,
1861 gas_used: Some(21000),
1862 gas_price: "0".into(),
1863 nonce: 0,
1864 input: "0x".into(),
1865 status: Some(true),
1866 }
1867 }
1868
1869 #[test]
1870 fn test_whale_with_large_txs() {
1871 let txs = vec![
1872 make_whale_tx("200000"),
1873 make_whale_tx("50"),
1874 make_whale_tx("150000"),
1875 ];
1876 let factor = detect_whale_activity(&txs, 50000.0, 100000.0);
1877 assert!(factor.evidence.iter().any(|e| e.contains("whale-sized")));
1878 }
1879
1880 #[test]
1881 fn test_whale_avg_near_threshold() {
1882 let txs = vec![make_whale_tx("50")];
1883 let factor = detect_whale_activity(&txs, 60000.0, 100000.0);
1884 assert!(
1885 factor
1886 .evidence
1887 .iter()
1888 .any(|e| e.contains("near whale threshold"))
1889 );
1890 }
1891
1892 fn make_ca_with_source(code: &str) -> crate::contract::ContractAnalysis {
1897 let mut ca = make_ca(true, None);
1898 ca.source_info = Some(crate::contract::source::ContractSource {
1899 contract_name: "Test".into(),
1900 source_code: code.to_string(),
1901 abi: "[]".into(),
1902 compiler_version: "v0.8.19".into(),
1903 optimization_used: true,
1904 optimization_runs: 200,
1905 evm_version: "paris".into(),
1906 license_type: "MIT".into(),
1907 is_proxy: false,
1908 implementation_address: None,
1909 constructor_arguments: String::new(),
1910 library: String::new(),
1911 swarm_source: String::new(),
1912 parsed_abi: vec![],
1913 });
1914 ca
1915 }
1916
1917 #[test]
1918 fn test_timelock_controller_detected() {
1919 let ca = make_ca_with_source("contract G is TimelockController { }");
1920 let f = detect_timelock(&ca).unwrap();
1921 assert_eq!(f.score, 2.0);
1922 assert!(f.evidence.iter().any(|e| e.contains("TimelockController")));
1923 }
1924
1925 #[test]
1926 fn test_timelock_queue_delay_execute() {
1927 let ca = make_ca_with_source("function queue() {} function execute() {} uint delay;");
1928 let f = detect_timelock(&ca).unwrap();
1929 assert_eq!(f.score, 2.0);
1930 }
1931
1932 #[test]
1933 fn test_timelock_mindelay() {
1934 let ca = make_ca_with_source("TimelockController tl; uint minDelay;");
1935 let f = detect_timelock(&ca).unwrap();
1936 assert!(f.evidence.iter().any(|e| e.contains("Minimum delay")));
1937 }
1938
1939 #[test]
1940 fn test_no_timelock() {
1941 let ca = make_ca_with_source("contract Token { function transfer() {} }");
1942 let f = detect_timelock(&ca).unwrap();
1943 assert_eq!(f.score, 5.0);
1944 }
1945
1946 #[test]
1947 fn test_timelock_no_source() {
1948 let ca = make_ca(true, None);
1949 assert!(detect_timelock(&ca).is_none());
1950 }
1951
1952 #[test]
1957 fn test_multisig_gnosis() {
1958 let ca = make_ca_with_source("import Gnosis; contract W is GnosisSafe { }");
1959 let f = detect_multisig(&ca).unwrap();
1960 assert_eq!(f.score, 2.0);
1961 }
1962
1963 #[test]
1964 fn test_multisig_threshold_owners() {
1965 let ca = make_ca_with_source("uint threshold; address[] owners;");
1966 let f = detect_multisig(&ca).unwrap();
1967 assert_eq!(f.score, 2.0);
1968 }
1969
1970 #[test]
1971 fn test_multisig_confirm_execute() {
1972 let ca = make_ca_with_source(
1973 "function confirmTransaction() {} function executeTransaction() {}",
1974 );
1975 let f = detect_multisig(&ca).unwrap();
1976 assert_eq!(f.score, 2.0);
1977 }
1978
1979 #[test]
1980 fn test_no_multisig() {
1981 let ca = make_ca_with_source("contract Token { }");
1982 let f = detect_multisig(&ca).unwrap();
1983 assert_eq!(f.score, 4.0);
1984 }
1985
1986 #[test]
1987 fn test_multisig_with_proxy_admin() {
1988 let mut ca = make_ca_with_source("contract Token { }");
1989 ca.proxy_info = Some(crate::contract::proxy::ProxyInfo {
1990 is_proxy: true,
1991 proxy_type: "EIP-1967".into(),
1992 implementation_address: None,
1993 admin_address: Some("0xadmin".into()),
1994 beacon_address: None,
1995 details: vec![],
1996 });
1997 let f = detect_multisig(&ca).unwrap();
1998 assert!(f.evidence.iter().any(|e| e.contains("0xadmin")));
1999 }
2000
2001 #[test]
2006 fn test_gini_all_zeros() {
2007 assert_eq!(compute_gini_coefficient(&[0.0, 0.0, 0.0]), 0.0);
2008 }
2009
2010 #[test]
2011 fn test_gini_single_element() {
2012 assert_eq!(compute_gini_coefficient(&[100.0]), 0.0);
2013 }
2014
2015 #[test]
2016 fn test_gini_two_equal() {
2017 let g = compute_gini_coefficient(&[50.0, 50.0]);
2018 assert!(g < 0.01);
2019 }
2020
2021 #[test]
2022 fn test_gini_two_unequal() {
2023 let g = compute_gini_coefficient(&[1.0, 99.0]);
2024 assert!(g > 0.0);
2025 }
2026}