Skip to main content

scope_web/api/
insights.rs

1//! Unified insights API handler.
2
3use 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/// Request body for insights analysis.
14#[derive(Debug, Deserialize)]
15pub struct InsightsRequest {
16    /// Target: address, tx hash, or token symbol/name.
17    pub target: String,
18    /// Override detected chain.
19    pub chain: Option<String>,
20    /// Decode tx input (for tx targets).
21    #[serde(default)]
22    pub decode: bool,
23    /// Include internal trace (for tx targets).
24    #[serde(default)]
25    pub trace: bool,
26}
27
28/// POST /api/insights — Unified insights for any target.
29///
30/// Supports address book shortcuts: pass `@label` as the target to
31/// resolve it from the address book. The chain will also be set from
32/// the book entry unless explicitly overridden.
33///
34/// Returns the insights markdown as JSON `{ "markdown": "..." }` along
35/// with structured metadata about the detected target type.
36pub async fn handle(
37    State(state): State<Arc<AppState>>,
38    Json(req): Json<InsightsRequest>,
39) -> impl IntoResponse {
40    // Resolve address book shortcuts (@label or direct address match)
41    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    // Run the insights command which builds markdown output
63    // We capture it by running the underlying functions directly
64    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    // Run insights - it prints to stdout so we need to capture
74    // For the web API, we reconstruct the data using the inferred target
75    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        // Use a temp data dir to avoid local address book interfering
414        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()), // Unsupported chain
431            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}