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 factory = DefaultClientFactory {
145 chains_config: config.chains.clone(),
146 };
147 let state = std::sync::Arc::new(AppState { config, factory });
148 let req = ComplianceRiskRequest {
149 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
150 chain: "ethereum".to_string(),
151 detailed: true,
152 };
153 let response = handle_risk(State(state), axum::Json(req))
154 .await
155 .into_response();
156 let status = response.status();
157 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
158 }
159
160 #[tokio::test]
161 async fn test_handle_risk_with_etherscan_key() {
162 use crate::chains::DefaultClientFactory;
163 use crate::config::Config;
164 use crate::web::AppState;
165 use axum::extract::State;
166 use axum::response::IntoResponse;
167
168 let old_key = std::env::var_os("ETHERSCAN_API_KEY");
169 unsafe { std::env::set_var("ETHERSCAN_API_KEY", "test_key_for_coverage") };
170
171 let config = Config::default();
172 let factory = DefaultClientFactory {
173 chains_config: config.chains.clone(),
174 };
175 let state = std::sync::Arc::new(AppState { config, factory });
176 let req = ComplianceRiskRequest {
177 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
178 chain: "ethereum".to_string(),
179 detailed: false,
180 };
181 let response = handle_risk(State(state), axum::Json(req))
182 .await
183 .into_response();
184
185 if let Some(k) = old_key {
186 unsafe { std::env::set_var("ETHERSCAN_API_KEY", k) };
187 } else {
188 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
189 }
190
191 let status = response.status();
192 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
193 }
194
195 #[tokio::test]
196 async fn test_handle_risk_error_response() {
197 use crate::chains::DefaultClientFactory;
198 use crate::config::Config;
199 use crate::web::AppState;
200 use axum::extract::State;
201 use axum::http::StatusCode;
202 use axum::response::IntoResponse;
203
204 let config = Config::default();
205 let factory = DefaultClientFactory {
206 chains_config: config.chains.clone(),
207 };
208 let state = std::sync::Arc::new(AppState { config, factory });
209 let req = ComplianceRiskRequest {
210 address: "invalid-address".to_string(),
211 chain: "ethereum".to_string(),
212 detailed: false,
213 };
214 let response = handle_risk(State(state), axum::Json(req))
215 .await
216 .into_response();
217 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
218 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
219 .await
220 .unwrap();
221 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
222 assert!(json.get("error").is_some());
223 }
224 }
225
226 #[tokio::test]
227 async fn test_handle_risk_label_not_found() {
228 use crate::chains::DefaultClientFactory;
229 use crate::config::Config;
230 use crate::web::AppState;
231 use axum::extract::State;
232 use axum::http::StatusCode;
233 use axum::response::IntoResponse;
234
235 let tmp = tempfile::tempdir().unwrap();
236 let config = Config {
237 address_book: crate::config::AddressBookConfig {
238 data_dir: Some(tmp.path().to_path_buf()),
239 },
240 ..Default::default()
241 };
242 let factory = DefaultClientFactory {
243 chains_config: config.chains.clone(),
244 };
245 let state = std::sync::Arc::new(AppState { config, factory });
246 let req = ComplianceRiskRequest {
247 address: "@fake-wallet".to_string(),
248 chain: "ethereum".to_string(),
249 detailed: false,
250 };
251 let response = handle_risk(State(state), axum::Json(req))
252 .await
253 .into_response();
254 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
255 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
256 .await
257 .unwrap();
258 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
259 assert!(json["error"].as_str().unwrap().contains("@fake-wallet"));
260 }
261}