Skip to main content

scope/web/api/
address.rs

1//! Address analysis API handler.
2
3use crate::chains::ChainClientFactory;
4use crate::cli::address::{self, AddressArgs};
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 address analysis.
14#[derive(Debug, Deserialize)]
15pub struct AddressRequest {
16    /// Blockchain address to analyze.
17    pub address: String,
18    /// Target chain (default: "ethereum").
19    #[serde(default = "default_chain")]
20    pub chain: String,
21    /// Include transaction history.
22    #[serde(default)]
23    pub include_txs: bool,
24    /// Include token balances.
25    #[serde(default)]
26    pub include_tokens: bool,
27    /// Max transactions to retrieve.
28    #[serde(default = "default_limit")]
29    pub limit: u32,
30    /// Generate dossier (address + risk).
31    #[serde(default)]
32    pub dossier: bool,
33}
34
35fn default_chain() -> String {
36    "ethereum".to_string()
37}
38
39fn default_limit() -> u32 {
40    100
41}
42
43/// POST /api/address — Analyze a blockchain address.
44///
45/// Supports address book shortcuts: pass `@label` as the address to
46/// resolve it from the address book. The chain will also be set from
47/// the book entry unless explicitly overridden.
48pub async fn handle(
49    State(state): State<Arc<AppState>>,
50    Json(req): Json<AddressRequest>,
51) -> impl IntoResponse {
52    // Resolve address book shortcuts (@label or direct address match)
53    let resolved = match super::resolve_address_book(&req.address, &state.config) {
54        Ok(r) => r,
55        Err(e) => {
56            return (
57                StatusCode::BAD_REQUEST,
58                Json(serde_json::json!({ "error": e })),
59            )
60                .into_response();
61        }
62    };
63    let address = resolved.value;
64    let chain = resolved.chain.unwrap_or(req.chain);
65
66    let args = AddressArgs {
67        address,
68        chain,
69        format: None,
70        include_txs: req.include_txs,
71        include_tokens: req.include_tokens,
72        limit: req.limit,
73        report: None,
74        dossier: req.dossier,
75    };
76
77    let client: Box<dyn crate::chains::ChainClient> =
78        match state.factory.create_chain_client(&args.chain) {
79            Ok(c) => c,
80            Err(e) => {
81                return (
82                    StatusCode::BAD_REQUEST,
83                    Json(serde_json::json!({ "error": e.to_string() })),
84                )
85                    .into_response();
86            }
87        };
88
89    match address::analyze_address(&args, client.as_ref()).await {
90        Ok(report) => Json(serde_json::json!(report)).into_response(),
91        Err(e) => (
92            StatusCode::INTERNAL_SERVER_ERROR,
93            Json(serde_json::json!({ "error": e.to_string() })),
94        )
95            .into_response(),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_deserialize_full() {
105        let json = serde_json::json!({
106            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
107            "chain": "polygon",
108            "include_txs": true,
109            "include_tokens": true,
110            "limit": 50,
111            "dossier": true
112        });
113        let req: AddressRequest = serde_json::from_value(json).unwrap();
114        assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
115        assert_eq!(req.chain, "polygon");
116        assert!(req.include_txs);
117        assert!(req.include_tokens);
118        assert_eq!(req.limit, 50);
119        assert!(req.dossier);
120    }
121
122    #[test]
123    fn test_deserialize_minimal() {
124        let json = serde_json::json!({
125            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
126        });
127        let req: AddressRequest = serde_json::from_value(json).unwrap();
128        assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
129        assert_eq!(req.chain, "ethereum");
130        assert!(!req.include_txs);
131        assert!(!req.include_tokens);
132        assert_eq!(req.limit, 100);
133        assert!(!req.dossier);
134    }
135
136    #[test]
137    fn test_defaults() {
138        assert_eq!(default_chain(), "ethereum");
139        assert_eq!(default_limit(), 100);
140    }
141
142    #[test]
143    fn test_deserialize_with_options() {
144        let json = serde_json::json!({
145            "address": "0x1234567890123456789012345678901234567890",
146            "include_txs": true,
147            "dossier": true
148        });
149        let req: AddressRequest = serde_json::from_value(json).unwrap();
150        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
151        assert_eq!(req.chain, "ethereum");
152        assert!(req.include_txs);
153        assert!(!req.include_tokens);
154        assert_eq!(req.limit, 100);
155        assert!(req.dossier);
156    }
157
158    #[tokio::test]
159    async fn test_handle_address_direct() {
160        use crate::chains::DefaultClientFactory;
161        use crate::config::Config;
162        use crate::web::AppState;
163        use axum::extract::State;
164        use axum::response::IntoResponse;
165
166        let config = Config::default();
167        let http: std::sync::Arc<dyn crate::http::HttpClient> =
168            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
169        let factory = DefaultClientFactory {
170            chains_config: config.chains.clone(),
171            http,
172        };
173        let state = std::sync::Arc::new(AppState { config, factory });
174        let req = AddressRequest {
175            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
176            chain: "ethereum".to_string(),
177            include_txs: false,
178            include_tokens: true,
179            limit: 10,
180            dossier: false,
181        };
182        let response = handle(State(state), axum::Json(req)).await.into_response();
183        // Will likely return error (no API key) or success
184        let status = response.status();
185        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
186    }
187
188    #[tokio::test]
189    async fn test_handle_address_unsupported_chain_bad_request() {
190        use crate::chains::DefaultClientFactory;
191        use crate::config::Config;
192        use crate::web::AppState;
193        use axum::extract::State;
194        use axum::http::StatusCode;
195        use axum::response::IntoResponse;
196
197        // Use a temp data dir to avoid local address book interfering
198        let tmp = tempfile::tempdir().unwrap();
199        let config = Config {
200            address_book: crate::config::AddressBookConfig {
201                data_dir: Some(tmp.path().to_path_buf()),
202            },
203            ..Default::default()
204        };
205        let http: std::sync::Arc<dyn crate::http::HttpClient> =
206            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
207        let factory = DefaultClientFactory {
208            chains_config: config.chains.clone(),
209            http,
210        };
211        let state = std::sync::Arc::new(AppState { config, factory });
212        let req = AddressRequest {
213            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
214            chain: "bitcoin".to_string(),
215            include_txs: false,
216            include_tokens: true,
217            limit: 10,
218            dossier: false,
219        };
220        let response = handle(State(state), axum::Json(req)).await.into_response();
221        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
222        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
223            .await
224            .unwrap();
225        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
226        assert!(
227            json["error"]
228                .as_str()
229                .unwrap()
230                .contains("Unsupported chain")
231        );
232    }
233
234    #[tokio::test]
235    async fn test_handle_address_book_label_not_found() {
236        use crate::chains::DefaultClientFactory;
237        use crate::config::Config;
238        use crate::web::AppState;
239        use axum::extract::State;
240        use axum::http::StatusCode;
241        use axum::response::IntoResponse;
242
243        let tmp = tempfile::tempdir().unwrap();
244        let config = Config {
245            address_book: crate::config::AddressBookConfig {
246                data_dir: Some(tmp.path().to_path_buf()),
247            },
248            ..Default::default()
249        };
250        let http: std::sync::Arc<dyn crate::http::HttpClient> =
251            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
252        let factory = DefaultClientFactory {
253            chains_config: config.chains.clone(),
254            http,
255        };
256        let state = std::sync::Arc::new(AppState { config, factory });
257        let req = AddressRequest {
258            address: "@nonexistent-label".to_string(),
259            chain: "ethereum".to_string(),
260            include_txs: false,
261            include_tokens: false,
262            limit: 10,
263            dossier: false,
264        };
265        let response = handle(State(state), axum::Json(req)).await.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!(
272            json["error"]
273                .as_str()
274                .unwrap()
275                .contains("@nonexistent-label")
276        );
277    }
278}