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