Skip to main content

scope_web/api/
compliance.rs

1//! Compliance risk assessment API handler.
2
3use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::response::IntoResponse;
7use scope::compliance::risk::RiskEngine;
8use serde::Deserialize;
9use std::sync::Arc;
10
11use super::{error_response, status_for};
12
13/// Request body for compliance risk analysis.
14#[derive(Debug, Deserialize)]
15pub struct ComplianceRiskRequest {
16    /// Address to assess.
17    pub address: String,
18    /// Chain (default: "ethereum").
19    #[serde(default = "super::default_chain")]
20    pub chain: String,
21    /// Include detailed breakdown.
22    #[serde(default)]
23    pub detailed: bool,
24}
25
26/// POST /api/compliance/risk — Risk assessment for an address.
27///
28/// Supports address book shortcuts: pass `@label` as the address to
29/// resolve it from the address book.
30pub async fn handle_risk(
31    State(_state): State<Arc<AppState>>,
32    Json(req): Json<ComplianceRiskRequest>,
33) -> impl IntoResponse {
34    // Resolve address book shortcuts (@label or direct address match)
35    let resolved = match super::resolve_address_book(&req.address, &_state.config) {
36        Ok(r) => r,
37        Err(e) => return error_response(status_for(&e), e.to_string()),
38    };
39    let address = resolved.value;
40    let chain = resolved.chain.unwrap_or(req.chain);
41
42    // Build risk engine (with Etherscan key if available)
43    let engine = RiskEngine::from_env_with_http(_state.factory.http());
44
45    match engine.assess_address(&address, &chain).await {
46        Ok(assessment) => Json(serde_json::json!({
47            "address": assessment.address,
48            "chain": assessment.chain,
49            "overall_score": assessment.overall_score,
50            "risk_level": format!("{:?}", assessment.risk_level),
51            "factors": assessment.factors.iter().map(|f| {
52                serde_json::json!({
53                    "name": f.name,
54                    "weight": f.weight,
55                    "score": f.score,
56                    "description": f.description,
57                })
58            }).collect::<Vec<_>>(),
59        }))
60        .into_response(),
61        Err(e) => {
62            let e = scope::error::ScopeError::Compliance(e.to_string());
63            error_response(status_for(&e), e.to_string())
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn test_deserialize_full() {
74        let json = serde_json::json!({
75            "address": "0x1234567890123456789012345678901234567890",
76            "chain": "polygon",
77            "detailed": true
78        });
79        let req: ComplianceRiskRequest = serde_json::from_value(json).unwrap();
80        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
81        assert_eq!(req.chain, "polygon");
82        assert!(req.detailed);
83    }
84
85    #[test]
86    fn test_deserialize_minimal() {
87        let json = serde_json::json!({
88            "address": "0x1234567890123456789012345678901234567890"
89        });
90        let req: ComplianceRiskRequest = serde_json::from_value(json).unwrap();
91        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
92        assert_eq!(req.chain, "ethereum");
93        assert!(!req.detailed);
94    }
95
96    #[test]
97    fn test_default_chain() {
98        assert_eq!(super::super::default_chain(), "ethereum");
99    }
100
101    #[test]
102    fn test_detailed_flag() {
103        let json = serde_json::json!({
104            "address": "0x1234567890123456789012345678901234567890",
105            "detailed": true
106        });
107        let req: ComplianceRiskRequest = serde_json::from_value(json).unwrap();
108        assert!(req.detailed);
109
110        let json_false = serde_json::json!({
111            "address": "0x1234567890123456789012345678901234567890",
112            "detailed": false
113        });
114        let req_false: ComplianceRiskRequest = serde_json::from_value(json_false).unwrap();
115        assert!(!req_false.detailed);
116    }
117
118    #[tokio::test]
119    async fn test_handle_risk_direct() {
120        use crate::AppState;
121        use axum::extract::State;
122        use axum::response::IntoResponse;
123        use scope::chains::DefaultClientFactory;
124        use scope::config::Config;
125
126        let config = Config::default();
127        let http: std::sync::Arc<dyn scope::http::HttpClient> =
128            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
129        let factory = DefaultClientFactory {
130            chains_config: config.chains.clone(),
131            http,
132        };
133        let state = std::sync::Arc::new(AppState::new(config, factory));
134        let req = ComplianceRiskRequest {
135            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
136            chain: "ethereum".to_string(),
137            detailed: true,
138        };
139        let response = handle_risk(State(state), axum::Json(req))
140            .await
141            .into_response();
142        let status = response.status();
143        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
144    }
145
146    #[tokio::test]
147    async fn test_handle_risk_with_etherscan_key() {
148        use crate::AppState;
149        use axum::extract::State;
150        use axum::response::IntoResponse;
151        use scope::chains::DefaultClientFactory;
152        use scope::config::Config;
153
154        let old_key = std::env::var_os("ETHERSCAN_API_KEY");
155        unsafe { std::env::set_var("ETHERSCAN_API_KEY", "test_key_for_coverage") };
156
157        let config = Config::default();
158        let http: std::sync::Arc<dyn scope::http::HttpClient> =
159            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
160        let factory = DefaultClientFactory {
161            chains_config: config.chains.clone(),
162            http,
163        };
164        let state = std::sync::Arc::new(AppState::new(config, factory));
165        let req = ComplianceRiskRequest {
166            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
167            chain: "ethereum".to_string(),
168            detailed: false,
169        };
170        let response = handle_risk(State(state), axum::Json(req))
171            .await
172            .into_response();
173
174        if let Some(k) = old_key {
175            unsafe { std::env::set_var("ETHERSCAN_API_KEY", k) };
176        } else {
177            unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
178        }
179
180        let status = response.status();
181        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
182    }
183
184    #[tokio::test]
185    async fn test_handle_risk_error_response() {
186        use crate::AppState;
187        use axum::extract::State;
188        use axum::http::StatusCode;
189        use axum::response::IntoResponse;
190        use scope::chains::DefaultClientFactory;
191        use scope::config::Config;
192
193        let config = Config::default();
194        let http: std::sync::Arc<dyn scope::http::HttpClient> =
195            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
196        let factory = DefaultClientFactory {
197            chains_config: config.chains.clone(),
198            http,
199        };
200        let state = std::sync::Arc::new(AppState::new(config, factory));
201        let req = ComplianceRiskRequest {
202            address: "invalid-address".to_string(),
203            chain: "ethereum".to_string(),
204            detailed: false,
205        };
206        let response = handle_risk(State(state), axum::Json(req))
207            .await
208            .into_response();
209        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
210            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
211                .await
212                .unwrap();
213            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
214            assert!(json.get("error").is_some());
215        }
216    }
217
218    #[tokio::test]
219    async fn test_handle_risk_label_not_found() {
220        use crate::AppState;
221        use axum::extract::State;
222        use axum::http::StatusCode;
223        use axum::response::IntoResponse;
224        use scope::chains::DefaultClientFactory;
225        use scope::config::Config;
226
227        let tmp = tempfile::tempdir().unwrap();
228        let config = Config {
229            address_book: scope::config::AddressBookConfig {
230                data_dir: Some(tmp.path().to_path_buf()),
231            },
232            ..Default::default()
233        };
234        let http: std::sync::Arc<dyn scope::http::HttpClient> =
235            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
236        let factory = DefaultClientFactory {
237            chains_config: config.chains.clone(),
238            http,
239        };
240        let state = std::sync::Arc::new(AppState::new(config, factory));
241        let req = ComplianceRiskRequest {
242            address: "@fake-wallet".to_string(),
243            chain: "ethereum".to_string(),
244            detailed: false,
245        };
246        let response = handle_risk(State(state), axum::Json(req))
247            .await
248            .into_response();
249        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
250        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
251            .await
252            .unwrap();
253        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
254        assert!(json["error"].as_str().unwrap().contains("@fake-wallet"));
255    }
256}