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