scope_web/api/
compliance.rs1use 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#[derive(Debug, Deserialize)]
15pub struct ComplianceRiskRequest {
16 pub address: String,
18 #[serde(default = "super::default_chain")]
20 pub chain: String,
21 #[serde(default)]
23 pub detailed: bool,
24}
25
26pub async fn handle_risk(
31 State(_state): State<Arc<AppState>>,
32 Json(req): Json<ComplianceRiskRequest>,
33) -> impl IntoResponse {
34 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 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}