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::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/// 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) => {
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    // Run the insights command which builds markdown output
69    // We capture it by running the underlying functions directly
70    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    // Run insights - it prints to stdout so we need to capture
79    // For the web API, we reconstruct the data using the inferred target
80    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        // Use a temp data dir to avoid local address book interfering
430        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()), // Unsupported chain
447            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}