Skip to main content

scope/web/api/
token_health.rs

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