Skip to main content

scope/web/api/
compliance.rs

1//! Compliance risk assessment API handler.
2
3use 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/// 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 = "default_chain")]
20    pub chain: String,
21    /// Include detailed breakdown.
22    #[serde(default)]
23    pub detailed: bool,
24}
25
26fn default_chain() -> String {
27    "ethereum".to_string()
28}
29
30/// POST /api/compliance/risk — Risk assessment for an address.
31///
32/// Supports address book shortcuts: pass `@label` as the address to
33/// resolve it from the address book.
34pub async fn handle_risk(
35    State(_state): State<Arc<AppState>>,
36    Json(req): Json<ComplianceRiskRequest>,
37) -> impl IntoResponse {
38    // Resolve address book shortcuts (@label or direct address match)
39    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    // Build risk engine (with Etherscan key if available)
53    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}