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