Skip to main content

scope_web/api/
address.rs

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