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        chain: chain_override,
67        decode: req.decode,
68        trace: req.trace,
69        health: Default::default(),
70    };
71
72    // Run insights - it prints to stdout so we need to capture
73    // For the web API, we reconstruct the data using the inferred target
74    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        // Use a temp data dir to avoid local address book interfering
413        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()), // Unsupported chain
430            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}