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