1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::response::IntoResponse;
7use scope_cli::cli::insights::{self, InsightsArgs};
8use serde::Deserialize;
9use std::sync::Arc;
10
11use super::{error_response, status_for};
12
13#[derive(Debug, Deserialize)]
15pub struct InsightsRequest {
16 pub target: String,
18 pub chain: Option<String>,
20 #[serde(default)]
22 pub decode: bool,
23 #[serde(default)]
25 pub trace: bool,
26}
27
28pub async fn handle(
37 State(state): State<Arc<AppState>>,
38 Json(req): Json<InsightsRequest>,
39) -> impl IntoResponse {
40 let resolved = match super::resolve_address_book(&req.target, &state.config) {
42 Ok(r) => r,
43 Err(e) => return error_response(status_for(&e), e.to_string()),
44 };
45 let target_str = resolved.value;
46 let chain_override = req.chain.or(resolved.chain);
47
48 let target = insights::infer_target(&target_str, chain_override.as_deref());
49
50 let target_type = match &target {
51 insights::InferredTarget::Address { chain } => {
52 serde_json::json!({ "type": "address", "chain": chain })
53 }
54 insights::InferredTarget::Transaction { chain } => {
55 serde_json::json!({ "type": "transaction", "chain": chain })
56 }
57 insights::InferredTarget::Token { chain } => {
58 serde_json::json!({ "type": "token", "chain": chain })
59 }
60 };
61
62 let args = InsightsArgs {
65 target: target_str.clone(),
66 token_contract: None,
67 chain: chain_override,
68 decode: req.decode,
69 trace: req.trace,
70 health: Default::default(),
71 };
72
73 match &target {
76 insights::InferredTarget::Address { chain } => {
77 let addr_args = scope_cli::cli::address::AddressArgs {
78 address: target_str.clone(),
79 chain: chain.clone(),
80 format: None,
81 include_txs: false,
82 include_tokens: true,
83 limit: 10,
84 report: None,
85 dossier: false,
86 };
87 let client: Box<dyn scope::chains::ChainClient> =
88 match state.factory.create_chain_client(chain) {
89 Ok(c) => c,
90 Err(e) => return error_response(status_for(&e), e.to_string()),
91 };
92 match scope_cli::cli::address::analyze_address(
93 &addr_args,
94 client.as_ref(),
95 &scope_cli::cli::output::Output::stdio().without_prompts(),
96 )
97 .await
98 {
99 Ok(report) => Json(serde_json::json!({
100 "target_info": target_type,
101 "data": report,
102 }))
103 .into_response(),
104 Err(e) => error_response(status_for(&e), e.to_string()),
105 }
106 }
107 insights::InferredTarget::Transaction { chain } => {
108 match scope_cli::cli::tx::fetch_transaction_report(
109 &target_str,
110 chain,
111 args.decode,
112 args.trace,
113 state.factory.as_ref(),
114 )
115 .await
116 {
117 Ok(report) => Json(serde_json::json!({
118 "target_info": target_type,
119 "data": report,
120 }))
121 .into_response(),
122 Err(e) => error_response(status_for(&e), e.to_string()),
123 }
124 }
125 insights::InferredTarget::Token { chain } => {
126 match scope_cli::cli::crawl::fetch_analytics_for_input(
127 &target_str,
128 chain,
129 scope_cli::cli::crawl::Period::Hour24,
130 10,
131 state.factory.as_ref(),
132 None,
133 &scope_cli::cli::output::Output::stdio().without_prompts(),
134 )
135 .await
136 {
137 Ok(analytics) => Json(serde_json::json!({
138 "target_info": target_type,
139 "data": analytics,
140 }))
141 .into_response(),
142 Err(e) => error_response(status_for(&e), e.to_string()),
143 }
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn test_deserialize_full() {
154 let json = serde_json::json!({
155 "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
156 "chain": "polygon",
157 "decode": true,
158 "trace": true
159 });
160 let req: InsightsRequest = serde_json::from_value(json).unwrap();
161 assert_eq!(req.target, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
162 assert_eq!(req.chain, Some("polygon".to_string()));
163 assert!(req.decode);
164 assert!(req.trace);
165 }
166
167 #[test]
168 fn test_deserialize_minimal() {
169 let json = serde_json::json!({
170 "target": "0x1234567890123456789012345678901234567890"
171 });
172 let req: InsightsRequest = serde_json::from_value(json).unwrap();
173 assert_eq!(req.target, "0x1234567890123456789012345678901234567890");
174 assert_eq!(req.chain, None);
175 assert!(!req.decode);
176 assert!(!req.trace);
177 }
178
179 #[test]
180 fn test_with_chain_override() {
181 let json = serde_json::json!({
182 "target": "USDC",
183 "chain": "ethereum"
184 });
185 let req: InsightsRequest = serde_json::from_value(json).unwrap();
186 assert_eq!(req.target, "USDC");
187 assert_eq!(req.chain, Some("ethereum".to_string()));
188 assert!(!req.decode);
189 assert!(!req.trace);
190 }
191
192 #[test]
193 fn test_flags() {
194 let json_decode = serde_json::json!({
195 "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
196 "decode": true,
197 "trace": false
198 });
199 let req_decode: InsightsRequest = serde_json::from_value(json_decode).unwrap();
200 assert!(req_decode.decode);
201 assert!(!req_decode.trace);
202
203 let json_trace = serde_json::json!({
204 "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
205 "decode": false,
206 "trace": true
207 });
208 let req_trace: InsightsRequest = serde_json::from_value(json_trace).unwrap();
209 assert!(!req_trace.decode);
210 assert!(req_trace.trace);
211
212 let json_both = serde_json::json!({
213 "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
214 "decode": true,
215 "trace": true
216 });
217 let req_both: InsightsRequest = serde_json::from_value(json_both).unwrap();
218 assert!(req_both.decode);
219 assert!(req_both.trace);
220 }
221
222 #[tokio::test]
223 async fn test_handle_insights_address() {
224 use crate::AppState;
225 use axum::extract::State;
226 use axum::response::IntoResponse;
227 use scope::chains::DefaultClientFactory;
228 use scope::config::Config;
229
230 let config = Config::default();
231 let http: std::sync::Arc<dyn scope::http::HttpClient> =
232 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
233 let factory = DefaultClientFactory {
234 chains_config: config.chains.clone(),
235 http,
236 };
237 let state = std::sync::Arc::new(AppState::new(config, factory));
238 let req = InsightsRequest {
239 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
240 chain: None,
241 decode: false,
242 trace: false,
243 };
244 let response = handle(State(state), axum::Json(req)).await.into_response();
245 let status = response.status();
246 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
247 }
248
249 #[tokio::test]
250 async fn test_handle_insights_token() {
251 use crate::AppState;
252 use axum::extract::State;
253 use axum::response::IntoResponse;
254 use scope::chains::DefaultClientFactory;
255 use scope::config::Config;
256
257 let config = Config::default();
258 let http: std::sync::Arc<dyn scope::http::HttpClient> =
259 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
260 let factory = DefaultClientFactory {
261 chains_config: config.chains.clone(),
262 http,
263 };
264 let state = std::sync::Arc::new(AppState::new(config, factory));
265 let req = InsightsRequest {
266 target: "USDC".to_string(),
267 chain: Some("ethereum".to_string()),
268 decode: false,
269 trace: false,
270 };
271 let response = handle(State(state), axum::Json(req)).await.into_response();
272 let status = response.status();
273 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
274 }
275
276 #[tokio::test]
277 async fn test_handle_insights_tx() {
278 use crate::AppState;
279 use axum::extract::State;
280 use axum::response::IntoResponse;
281 use scope::chains::DefaultClientFactory;
282 use scope::config::Config;
283
284 let config = Config::default();
285 let http: std::sync::Arc<dyn scope::http::HttpClient> =
286 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
287 let factory = DefaultClientFactory {
288 chains_config: config.chains.clone(),
289 http,
290 };
291 let state = std::sync::Arc::new(AppState::new(config, factory));
292 let req = InsightsRequest {
293 target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
294 .to_string(),
295 chain: None,
296 decode: true,
297 trace: false,
298 };
299 let response = handle(State(state), axum::Json(req)).await.into_response();
300 let status = response.status();
301 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
302 }
303
304 #[tokio::test]
305 async fn test_handle_insights_address_analyze_error() {
306 use crate::AppState;
307 use axum::extract::State;
308 use axum::http::StatusCode;
309 use axum::response::IntoResponse;
310 use scope::chains::DefaultClientFactory;
311 use scope::config::Config;
312
313 let config = Config::default();
314 let http: std::sync::Arc<dyn scope::http::HttpClient> =
315 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
316 let factory = DefaultClientFactory {
317 chains_config: config.chains.clone(),
318 http,
319 };
320 let state = std::sync::Arc::new(AppState::new(config, factory));
321 let req = InsightsRequest {
322 target: "0x0000000000000000000000000000000000000000".to_string(),
323 chain: Some("ethereum".to_string()),
324 decode: false,
325 trace: false,
326 };
327 let response = handle(State(state), axum::Json(req)).await.into_response();
328 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
329 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
330 .await
331 .unwrap();
332 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
333 assert!(json.get("error").is_some());
334 }
335 }
336
337 #[tokio::test]
338 async fn test_handle_insights_tx_error() {
339 use crate::AppState;
340 use axum::extract::State;
341 use axum::http::StatusCode;
342 use axum::response::IntoResponse;
343 use scope::chains::DefaultClientFactory;
344 use scope::config::Config;
345
346 let config = Config::default();
347 let http: std::sync::Arc<dyn scope::http::HttpClient> =
348 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
349 let factory = DefaultClientFactory {
350 chains_config: config.chains.clone(),
351 http,
352 };
353 let state = std::sync::Arc::new(AppState::new(config, factory));
354 let req = InsightsRequest {
355 target: "0x0000000000000000000000000000000000000000000000000000000000000000"
356 .to_string(),
357 chain: Some("ethereum".to_string()),
358 decode: false,
359 trace: false,
360 };
361 let response = handle(State(state), axum::Json(req)).await.into_response();
362 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
363 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
364 .await
365 .unwrap();
366 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
367 assert!(json.get("error").is_some());
368 }
369 }
370
371 #[tokio::test]
372 async fn test_handle_insights_token_error() {
373 use crate::AppState;
374 use axum::extract::State;
375 use axum::http::StatusCode;
376 use axum::response::IntoResponse;
377 use scope::chains::DefaultClientFactory;
378 use scope::config::Config;
379
380 let config = Config::default();
381 let http: std::sync::Arc<dyn scope::http::HttpClient> =
382 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
383 let factory = DefaultClientFactory {
384 chains_config: config.chains.clone(),
385 http,
386 };
387 let state = std::sync::Arc::new(AppState::new(config, factory));
388 let req = InsightsRequest {
389 target: "NONEXISTENT_TOKEN_XYZ_123".to_string(),
390 chain: Some("ethereum".to_string()),
391 decode: false,
392 trace: false,
393 };
394 let response = handle(State(state), axum::Json(req)).await.into_response();
395 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
396 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
397 .await
398 .unwrap();
399 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
400 assert!(json.get("error").is_some());
401 }
402 }
403
404 #[tokio::test]
405 async fn test_handle_insights_unsupported_chain_bad_request() {
406 use crate::AppState;
407 use axum::extract::State;
408 use axum::http::StatusCode;
409 use axum::response::IntoResponse;
410 use scope::chains::DefaultClientFactory;
411 use scope::config::Config;
412
413 let tmp = tempfile::tempdir().unwrap();
415 let config = Config {
416 address_book: scope::config::AddressBookConfig {
417 data_dir: Some(tmp.path().to_path_buf()),
418 },
419 ..Default::default()
420 };
421 let http: std::sync::Arc<dyn scope::http::HttpClient> =
422 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
423 let factory = DefaultClientFactory {
424 chains_config: config.chains.clone(),
425 http,
426 };
427 let state = std::sync::Arc::new(AppState::new(config, factory));
428 let req = InsightsRequest {
429 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
430 chain: Some("bitcoin".to_string()), decode: false,
432 trace: false,
433 };
434 let response = handle(State(state), axum::Json(req)).await.into_response();
435 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
436 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
437 .await
438 .unwrap();
439 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
440 assert!(
441 json["error"]
442 .as_str()
443 .unwrap()
444 .contains("Unsupported chain")
445 );
446 }
447
448 #[tokio::test]
449 async fn test_handle_insights_label_not_found() {
450 use crate::AppState;
451 use axum::extract::State;
452 use axum::http::StatusCode;
453 use axum::response::IntoResponse;
454 use scope::chains::DefaultClientFactory;
455 use scope::config::Config;
456
457 let tmp = tempfile::tempdir().unwrap();
458 let config = Config {
459 address_book: scope::config::AddressBookConfig {
460 data_dir: Some(tmp.path().to_path_buf()),
461 },
462 ..Default::default()
463 };
464 let http: std::sync::Arc<dyn scope::http::HttpClient> =
465 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
466 let factory = DefaultClientFactory {
467 chains_config: config.chains.clone(),
468 http,
469 };
470 let state = std::sync::Arc::new(AppState::new(config, factory));
471 let req = InsightsRequest {
472 target: "@no-such-label".to_string(),
473 chain: None,
474 decode: false,
475 trace: false,
476 };
477 let response = handle(State(state), axum::Json(req)).await.into_response();
478 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
479 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
480 .await
481 .unwrap();
482 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
483 assert!(json["error"].as_str().unwrap().contains("@no-such-label"));
484 }
485}