Skip to main content

scope_web/api/
token_health.rs

1//! Token health API handler.
2
3use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use scope::market::{HealthOverrides, MarketSummary, VenueRegistry, order_book_from_analytics};
9use scope_cli::cli::crawl::{self, Period};
10use serde::Deserialize;
11use std::sync::Arc;
12
13/// Request body for token health.
14#[derive(Debug, Deserialize)]
15pub struct TokenHealthRequest {
16    /// Token symbol or contract address.
17    pub token: String,
18    /// Target chain (default: "ethereum").
19    #[serde(default = "default_chain")]
20    pub chain: String,
21    /// Include market/order book data.
22    #[serde(default)]
23    pub with_market: bool,
24    /// Market venue: "binance", "biconomy", "eth", "solana".
25    #[serde(default = "default_venue")]
26    pub market_venue: String,
27    /// Health threshold overrides for `with_market` (top-level JSON keys).
28    /// Unset keys use config `market.health`.
29    #[serde(flatten)]
30    pub health: HealthOverrides,
31}
32
33fn default_chain() -> String {
34    "ethereum".to_string()
35}
36
37fn default_venue() -> String {
38    "binance".to_string()
39}
40
41/// POST /api/token-health — Token health suite.
42///
43/// Supports address book shortcuts: pass `@label` as the token to
44/// resolve it from the address book.
45pub async fn handle(
46    State(state): State<Arc<AppState>>,
47    Json(req): Json<TokenHealthRequest>,
48) -> impl IntoResponse {
49    // Resolve address book shortcuts (@label or direct address match)
50    let resolved = match super::resolve_address_book(&req.token, &state.config) {
51        Ok(r) => r,
52        Err(e) => {
53            return (
54                StatusCode::BAD_REQUEST,
55                Json(serde_json::json!({ "error": e })),
56            )
57                .into_response();
58        }
59    };
60    let token = resolved.value;
61    let chain = resolved.chain.unwrap_or(req.chain);
62
63    // Fetch DEX analytics
64    let analytics = match crawl::fetch_analytics_for_input(
65        &token,
66        &chain,
67        Period::Hour24,
68        10,
69        &state.factory,
70        None,
71    )
72    .await
73    {
74        Ok(a) => a,
75        Err(e) => {
76            return (
77                StatusCode::INTERNAL_SERVER_ERROR,
78                Json(serde_json::json!({ "error": e.to_string() })),
79            )
80                .into_response();
81        }
82    };
83
84    // Optionally fetch market data
85    let venue_id = &req.market_venue;
86    let market_summary = if req.with_market {
87        let thresholds = state.config.market.health.with_overrides(&req.health);
88
89        if !is_dex_venue(venue_id) {
90            // CEX venue — use venue registry
91            if let Ok(registry) = VenueRegistry::load()
92                && let Ok(exchange) = registry.create_exchange_client(venue_id)
93            {
94                let pair = exchange.format_pair(&analytics.token.symbol);
95                match exchange.fetch_order_book(&pair).await {
96                    Ok(book) => {
97                        let volume_24h = if exchange.has_ticker() {
98                            exchange
99                                .fetch_ticker(&pair)
100                                .await
101                                .ok()
102                                .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
103                        } else {
104                            None
105                        };
106                        Some(MarketSummary::from_order_book(
107                            &book,
108                            &thresholds,
109                            volume_24h,
110                        ))
111                    }
112                    Err(_) => None,
113                }
114            } else {
115                None
116            }
117        } else {
118            // DEX venue
119            let venue_chain = dex_venue_to_chain(venue_id);
120            if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
121            {
122                let best_pair = analytics
123                    .dex_pairs
124                    .iter()
125                    .max_by(|a, b| {
126                        a.liquidity_usd
127                            .partial_cmp(&b.liquidity_usd)
128                            .unwrap_or(std::cmp::Ordering::Equal)
129                    })
130                    .unwrap();
131                let book =
132                    order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
133                let volume_24h = Some(best_pair.volume_24h);
134                Some(MarketSummary::from_order_book(
135                    &book,
136                    &thresholds,
137                    volume_24h,
138                ))
139            } else {
140                None
141            }
142        }
143    } else {
144        None
145    };
146
147    // Build combined JSON
148    let market_json = market_summary.map(|m| {
149        serde_json::json!({
150            "pair": m.pair,
151            "peg_target": m.peg_target,
152            "best_bid": m.best_bid,
153            "best_ask": m.best_ask,
154            "mid_price": m.mid_price,
155            "spread": m.spread,
156            "bid_depth": m.bid_depth,
157            "ask_depth": m.ask_depth,
158            "healthy": m.healthy,
159            "volume_24h": m.volume_24h,
160            "checks": m.checks.iter().map(|c| match c {
161                scope::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
162                scope::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
163            }).collect::<Vec<_>>()
164        })
165    });
166
167    Json(serde_json::json!({
168        "analytics": analytics,
169        "market": market_json,
170    }))
171    .into_response()
172}
173
174/// Whether the venue string refers to a DEX venue.
175fn is_dex_venue(venue: &str) -> bool {
176    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
177}
178
179/// Resolve DEX venue name to a canonical chain name.
180fn dex_venue_to_chain(venue: &str) -> &str {
181    match venue.to_lowercase().as_str() {
182        "ethereum" | "eth" => "ethereum",
183        "solana" => "solana",
184        _ => "ethereum",
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_deserialize_full() {
194        let json = serde_json::json!({
195            "token": "USDC",
196            "chain": "polygon",
197            "with_market": true,
198            "market_venue": "biconomy"
199        });
200        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
201        assert_eq!(req.token, "USDC");
202        assert_eq!(req.chain, "polygon");
203        assert!(req.with_market);
204        assert_eq!(req.market_venue, "biconomy");
205    }
206
207    #[test]
208    fn test_deserialize_minimal() {
209        let json = serde_json::json!({
210            "token": "USDC"
211        });
212        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
213        assert_eq!(req.token, "USDC");
214        assert_eq!(req.chain, "ethereum");
215        assert!(!req.with_market);
216        assert_eq!(req.market_venue, "binance");
217    }
218
219    #[test]
220    fn test_defaults() {
221        assert_eq!(default_chain(), "ethereum");
222        assert_eq!(default_venue(), "binance");
223    }
224
225    #[test]
226    fn test_with_market_flag() {
227        let json = serde_json::json!({
228            "token": "USDC",
229            "with_market": true
230        });
231        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
232        assert!(req.with_market);
233
234        let json_false = serde_json::json!({
235            "token": "USDC",
236            "with_market": false
237        });
238        let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
239        assert!(!req_false.with_market);
240    }
241
242    #[tokio::test]
243    async fn test_handle_token_health_direct() {
244        use crate::AppState;
245        use axum::extract::State;
246        use axum::response::IntoResponse;
247        use scope::chains::DefaultClientFactory;
248        use scope::config::Config;
249
250        let config = Config::default();
251        let http: std::sync::Arc<dyn scope::http::HttpClient> =
252            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
253        let factory = DefaultClientFactory {
254            chains_config: config.chains.clone(),
255            http,
256        };
257        let state = std::sync::Arc::new(AppState { config, factory });
258        let req = TokenHealthRequest {
259            token: "USDC".to_string(),
260            chain: "ethereum".to_string(),
261            with_market: false,
262            market_venue: "binance".to_string(),
263            health: HealthOverrides::default(),
264        };
265        let response = handle(State(state), axum::Json(req)).await.into_response();
266        let status = response.status();
267        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
268    }
269
270    #[tokio::test]
271    async fn test_handle_token_health_with_market() {
272        use crate::AppState;
273        use axum::extract::State;
274        use axum::response::IntoResponse;
275        use scope::chains::DefaultClientFactory;
276        use scope::config::Config;
277
278        let config = Config::default();
279        let http: std::sync::Arc<dyn scope::http::HttpClient> =
280            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
281        let factory = DefaultClientFactory {
282            chains_config: config.chains.clone(),
283            http,
284        };
285        let state = std::sync::Arc::new(AppState { config, factory });
286        let req = TokenHealthRequest {
287            token: "USDC".to_string(),
288            chain: "ethereum".to_string(),
289            with_market: true,
290            market_venue: "eth".to_string(),
291            health: HealthOverrides::default(),
292        };
293        let response = handle(State(state), axum::Json(req)).await.into_response();
294        let status = response.status();
295        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
296    }
297
298    #[tokio::test]
299    async fn test_handle_token_health_with_cex_market() {
300        use crate::AppState;
301        use axum::extract::State;
302        use axum::response::IntoResponse;
303        use scope::chains::DefaultClientFactory;
304        use scope::config::Config;
305
306        let config = Config::default();
307        let http: std::sync::Arc<dyn scope::http::HttpClient> =
308            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
309        let factory = DefaultClientFactory {
310            chains_config: config.chains.clone(),
311            http,
312        };
313        let state = std::sync::Arc::new(AppState { config, factory });
314        let req = TokenHealthRequest {
315            token: "USDC".to_string(),
316            chain: "ethereum".to_string(),
317            with_market: true,
318            market_venue: "binance".to_string(), // CEX path
319            health: HealthOverrides::default(),
320        };
321        let response = handle(State(state), axum::Json(req)).await.into_response();
322        let status = response.status();
323        // Either succeeds (200) or fails gracefully (500)
324        assert!(status.is_success() || status.is_server_error());
325    }
326
327    #[test]
328    fn test_is_dex_venue() {
329        assert!(is_dex_venue("ethereum"));
330        assert!(is_dex_venue("eth"));
331        assert!(is_dex_venue("Ethereum"));
332        assert!(is_dex_venue("ETH"));
333        assert!(is_dex_venue("solana"));
334        assert!(is_dex_venue("Solana"));
335        assert!(!is_dex_venue("binance"));
336        assert!(!is_dex_venue("mexc"));
337        assert!(!is_dex_venue("okx"));
338        assert!(!is_dex_venue(""));
339    }
340
341    #[test]
342    fn test_dex_venue_to_chain() {
343        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
344        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
345        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
346        assert_eq!(dex_venue_to_chain("solana"), "solana");
347        assert_eq!(dex_venue_to_chain("Solana"), "solana");
348        assert_eq!(dex_venue_to_chain("unknown"), "ethereum"); // default
349    }
350
351    #[tokio::test]
352    async fn test_handle_token_health_invalid_token_error_path() {
353        use crate::AppState;
354        use axum::extract::State;
355        use axum::response::IntoResponse;
356        use scope::chains::DefaultClientFactory;
357        use scope::config::Config;
358
359        let config = Config::default();
360        let http: std::sync::Arc<dyn scope::http::HttpClient> =
361            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
362        let factory = DefaultClientFactory {
363            chains_config: config.chains.clone(),
364            http,
365        };
366        let state = std::sync::Arc::new(AppState { config, factory });
367        let req = TokenHealthRequest {
368            token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
369            chain: "ethereum".to_string(),
370            with_market: false,
371            market_venue: "binance".to_string(),
372            health: HealthOverrides::default(),
373        };
374        let response = handle(State(state), axum::Json(req)).await.into_response();
375        let status = response.status();
376        // May succeed (empty analytics) or 500 (fetch error)
377        assert!(status.is_success() || status.is_server_error());
378    }
379
380    #[test]
381    fn test_token_health_request_debug() {
382        let req = TokenHealthRequest {
383            token: "USDC".to_string(),
384            chain: "ethereum".to_string(),
385            with_market: true,
386            market_venue: "binance".to_string(),
387            health: HealthOverrides::default(),
388        };
389        let debug = format!("{:?}", req);
390        assert!(debug.contains("TokenHealthRequest"));
391    }
392
393    #[test]
394    fn test_deserialize_with_optional_venue() {
395        let json = serde_json::json!({
396            "token": "DAI",
397            "market_venue": "mexc"
398        });
399        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
400        assert_eq!(req.token, "DAI");
401        assert_eq!(req.market_venue, "mexc");
402    }
403
404    #[tokio::test]
405    async fn test_handle_token_health_success_json_structure() {
406        use crate::AppState;
407        use axum::body;
408        use axum::extract::State;
409        use axum::response::IntoResponse;
410        use scope::chains::DefaultClientFactory;
411        use scope::config::Config;
412
413        let config = Config::default();
414        let http: std::sync::Arc<dyn scope::http::HttpClient> =
415            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
416        let factory = DefaultClientFactory {
417            chains_config: config.chains.clone(),
418            http,
419        };
420        let state = std::sync::Arc::new(AppState { config, factory });
421        let req = TokenHealthRequest {
422            token: "USDC".to_string(),
423            chain: "ethereum".to_string(),
424            with_market: false,
425            market_venue: "binance".to_string(),
426            health: HealthOverrides::default(),
427        };
428        let response = handle(State(state), axum::Json(req)).await.into_response();
429        if response.status().is_success() {
430            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
431                .await
432                .unwrap();
433            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
434            assert!(json.get("analytics").is_some());
435            assert!(json.get("market").is_some());
436        }
437    }
438
439    #[tokio::test]
440    async fn test_handle_token_health_label_not_found() {
441        use crate::AppState;
442        use axum::extract::State;
443        use axum::http::StatusCode;
444        use axum::response::IntoResponse;
445        use scope::chains::DefaultClientFactory;
446        use scope::config::Config;
447
448        let tmp = tempfile::tempdir().unwrap();
449        let config = Config {
450            address_book: scope::config::AddressBookConfig {
451                data_dir: Some(tmp.path().to_path_buf()),
452            },
453            ..Default::default()
454        };
455        let http: std::sync::Arc<dyn scope::http::HttpClient> =
456            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
457        let factory = DefaultClientFactory {
458            chains_config: config.chains.clone(),
459            http,
460        };
461        let state = std::sync::Arc::new(AppState { config, factory });
462        let req = TokenHealthRequest {
463            token: "@nonexistent".to_string(),
464            chain: "ethereum".to_string(),
465            with_market: false,
466            market_venue: "binance".to_string(),
467            health: HealthOverrides::default(),
468        };
469        let response = handle(State(state), axum::Json(req)).await.into_response();
470        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
471        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
472            .await
473            .unwrap();
474        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
475        assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
476    }
477}