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 factory = DefaultClientFactory {
168            chains_config: config.chains.clone(),
169        };
170        let state = std::sync::Arc::new(AppState { config, factory });
171        let req = AddressRequest {
172            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
173            chain: "ethereum".to_string(),
174            include_txs: false,
175            include_tokens: true,
176            limit: 10,
177            dossier: false,
178        };
179        let response = handle(State(state), axum::Json(req)).await.into_response();
180        // Will likely return error (no API key) or success
181        let status = response.status();
182        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
183    }
184
185    #[tokio::test]
186    async fn test_handle_address_unsupported_chain_bad_request() {
187        use crate::chains::DefaultClientFactory;
188        use crate::config::Config;
189        use crate::web::AppState;
190        use axum::extract::State;
191        use axum::http::StatusCode;
192        use axum::response::IntoResponse;
193
194        // Use a temp data dir to avoid local address book interfering
195        let tmp = tempfile::tempdir().unwrap();
196        let config = Config {
197            address_book: crate::config::AddressBookConfig {
198                data_dir: Some(tmp.path().to_path_buf()),
199            },
200            ..Default::default()
201        };
202        let factory = DefaultClientFactory {
203            chains_config: config.chains.clone(),
204        };
205        let state = std::sync::Arc::new(AppState { config, factory });
206        let req = AddressRequest {
207            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
208            chain: "bitcoin".to_string(),
209            include_txs: false,
210            include_tokens: true,
211            limit: 10,
212            dossier: false,
213        };
214        let response = handle(State(state), axum::Json(req)).await.into_response();
215        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
216        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
217            .await
218            .unwrap();
219        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
220        assert!(
221            json["error"]
222                .as_str()
223                .unwrap()
224                .contains("Unsupported chain")
225        );
226    }
227
228    #[tokio::test]
229    async fn test_handle_address_book_label_not_found() {
230        use crate::chains::DefaultClientFactory;
231        use crate::config::Config;
232        use crate::web::AppState;
233        use axum::extract::State;
234        use axum::http::StatusCode;
235        use axum::response::IntoResponse;
236
237        let tmp = tempfile::tempdir().unwrap();
238        let config = Config {
239            address_book: crate::config::AddressBookConfig {
240                data_dir: Some(tmp.path().to_path_buf()),
241            },
242            ..Default::default()
243        };
244        let factory = DefaultClientFactory {
245            chains_config: config.chains.clone(),
246        };
247        let state = std::sync::Arc::new(AppState { config, factory });
248        let req = AddressRequest {
249            address: "@nonexistent-label".to_string(),
250            chain: "ethereum".to_string(),
251            include_txs: false,
252            include_tokens: false,
253            limit: 10,
254            dossier: false,
255        };
256        let response = handle(State(state), axum::Json(req)).await.into_response();
257        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
258        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
259            .await
260            .unwrap();
261        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
262        assert!(
263            json["error"]
264                .as_str()
265                .unwrap()
266                .contains("@nonexistent-label")
267        );
268    }
269}