Skip to main content

scope/compliance/
mod.rs

1//! Compliance module for Scope
2//!
3//! Provides risk scoring, transaction taint analysis, pattern detection,
4//! and compliance reporting for blockchain addresses and transactions.
5
6pub mod datasource;
7pub mod etherscan;
8pub mod risk;
9pub mod token_risk;
10
11use risk::{RiskAssessment, RiskEngine};
12
13/// Main compliance analyzer
14pub struct ComplianceAnalyzer {
15    risk_engine: RiskEngine,
16}
17
18impl Default for ComplianceAnalyzer {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl ComplianceAnalyzer {
25    /// Create a new compliance analyzer
26    pub fn new() -> Self {
27        Self {
28            risk_engine: RiskEngine::new(),
29        }
30    }
31
32    /// Analyze an address for compliance risks
33    pub async fn analyze_address(
34        &self,
35        address: &str,
36        chain: &str,
37    ) -> anyhow::Result<RiskAssessment> {
38        self.risk_engine.assess_address(address, chain).await
39    }
40
41    /// Check if an address matches known sanctions lists
42    ///
43    /// Note: Sanctions checking requires external API integration.
44    /// This function returns a structure ready for future implementation.
45    pub fn check_sanctions(&self, _address: &str) -> SanctionsCheckResult {
46        // Future: Integrate with OFAC, EU, UN sanctions databases
47        SanctionsCheckResult {
48            is_sanctioned: false,
49            lists_checked: vec![],
50            matches: vec![],
51        }
52    }
53}
54
55/// Result of sanctions list check
56#[derive(Debug, Clone)]
57pub struct SanctionsCheckResult {
58    pub is_sanctioned: bool,
59    pub lists_checked: Vec<String>,
60    pub matches: Vec<SanctionsMatch>,
61}
62
63/// Individual sanctions list match
64#[derive(Debug, Clone)]
65pub struct SanctionsMatch {
66    pub list_name: String,
67    pub entity_name: String,
68    pub match_type: MatchType,
69    pub confidence: f32,
70}
71
72/// Type of sanctions match
73#[derive(Debug, Clone)]
74pub enum MatchType {
75    Exact,
76    Partial,
77    Associated,
78}
79
80impl SanctionsCheckResult {
81    /// Check if any sanctions list matches were found
82    pub fn has_matches(&self) -> bool {
83        !self.matches.is_empty()
84    }
85
86    /// Get formatted summary of the check
87    pub fn summary(&self) -> String {
88        if self.is_sanctioned {
89            format!(
90                "⚠️  SANCTIONS MATCH FOUND! Checked {} lists, found {} matches.",
91                self.lists_checked.len(),
92                self.matches.len()
93            )
94        } else {
95            format!(
96                "✅ No sanctions matches. Checked: {}",
97                self.lists_checked.join(", ")
98            )
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_sanctions_check_result() {
109        let result = SanctionsCheckResult {
110            is_sanctioned: false,
111            lists_checked: vec!["OFAC".to_string()],
112            matches: vec![],
113        };
114
115        assert!(!result.has_matches());
116    }
117
118    #[test]
119    fn test_sanctions_match_found() {
120        let result = SanctionsCheckResult {
121            is_sanctioned: true,
122            lists_checked: vec!["OFAC".to_string()],
123            matches: vec![SanctionsMatch {
124                list_name: "OFAC".to_string(),
125                entity_name: "Test Entity".to_string(),
126                match_type: MatchType::Exact,
127                confidence: 1.0,
128            }],
129        };
130
131        assert!(result.has_matches());
132        assert!(result.summary().contains("SANCTIONS MATCH"));
133    }
134
135    #[test]
136    fn test_sanctions_no_match_summary() {
137        let result = SanctionsCheckResult {
138            is_sanctioned: false,
139            lists_checked: vec!["OFAC".to_string(), "EU".to_string()],
140            matches: vec![],
141        };
142
143        let summary = result.summary();
144        assert!(summary.contains("No sanctions matches"));
145        assert!(summary.contains("OFAC"));
146        assert!(summary.contains("EU"));
147    }
148
149    #[test]
150    fn test_compliance_analyzer_new() {
151        let analyzer = ComplianceAnalyzer::new();
152        let result = analyzer.check_sanctions("0xtest");
153        assert!(!result.is_sanctioned);
154        assert!(result.lists_checked.is_empty());
155        assert!(result.matches.is_empty());
156    }
157
158    #[test]
159    fn test_compliance_analyzer_default() {
160        let analyzer = ComplianceAnalyzer::default();
161        let result = analyzer.check_sanctions("0xtest");
162        assert!(!result.has_matches());
163    }
164
165    #[tokio::test]
166    async fn test_compliance_analyze_address() {
167        let analyzer = ComplianceAnalyzer::new();
168        let assessment = analyzer
169            .analyze_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum")
170            .await
171            .unwrap();
172        assert_eq!(
173            assessment.address,
174            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
175        );
176        assert_eq!(assessment.chain, "ethereum");
177        // No data client: nothing is scored, and every factor is listed.
178        assert_eq!(assessment.factors.len() + assessment.not_assessed.len(), 4);
179    }
180
181    #[test]
182    fn test_match_type_variants() {
183        // Test all match type variants
184        let exact = MatchType::Exact;
185        let partial = MatchType::Partial;
186        let associated = MatchType::Associated;
187        assert!(format!("{:?}", exact).contains("Exact"));
188        assert!(format!("{:?}", partial).contains("Partial"));
189        assert!(format!("{:?}", associated).contains("Associated"));
190    }
191
192    #[test]
193    fn test_sanctions_check_result_debug() {
194        let result = SanctionsCheckResult {
195            is_sanctioned: false,
196            lists_checked: vec![],
197            matches: vec![],
198        };
199        let debug = format!("{:?}", result);
200        assert!(debug.contains("SanctionsCheckResult"));
201    }
202
203    #[test]
204    fn test_sanctions_match_debug() {
205        let m = SanctionsMatch {
206            list_name: "OFAC".to_string(),
207            entity_name: "Test".to_string(),
208            match_type: MatchType::Partial,
209            confidence: 0.85,
210        };
211        let debug = format!("{:?}", m);
212        assert!(debug.contains("OFAC"));
213        assert!(debug.contains("0.85"));
214    }
215
216    #[test]
217    fn test_sanctions_check_result_empty_lists() {
218        let result = SanctionsCheckResult {
219            is_sanctioned: false,
220            lists_checked: vec![],
221            matches: vec![],
222        };
223        let summary = result.summary();
224        assert!(summary.contains("No sanctions matches"));
225        assert!(summary.contains("Checked: "));
226    }
227
228    #[test]
229    fn test_sanctions_check_result_sanctioned_summary() {
230        let result = SanctionsCheckResult {
231            is_sanctioned: true,
232            lists_checked: vec!["OFAC".to_string(), "EU".to_string()],
233            matches: vec![
234                SanctionsMatch {
235                    list_name: "OFAC".to_string(),
236                    entity_name: "Entity A".to_string(),
237                    match_type: MatchType::Exact,
238                    confidence: 1.0,
239                },
240                SanctionsMatch {
241                    list_name: "EU".to_string(),
242                    entity_name: "Entity B".to_string(),
243                    match_type: MatchType::Partial,
244                    confidence: 0.9,
245                },
246            ],
247        };
248        let summary = result.summary();
249        assert!(summary.contains("SANCTIONS MATCH"));
250        assert!(summary.contains("2 lists"));
251        assert!(summary.contains("2 matches"));
252    }
253
254    #[test]
255    fn test_sanctions_match_clone() {
256        let m = SanctionsMatch {
257            list_name: "OFAC".to_string(),
258            entity_name: "Test".to_string(),
259            match_type: MatchType::Associated,
260            confidence: 0.75,
261        };
262        let cloned = m.clone();
263        assert_eq!(cloned.list_name, "OFAC");
264        assert_eq!(cloned.confidence, 0.75);
265    }
266}