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