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 http: std::sync::Arc<dyn crate::http::HttpClient> =
257            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
258        let factory = DefaultClientFactory {
259            chains_config: config.chains.clone(),
260            http,
261        };
262        let state = std::sync::Arc::new(AppState { config, factory });
263        let req = TokenHealthRequest {
264            token: "USDC".to_string(),
265            chain: "ethereum".to_string(),
266            with_market: false,
267            market_venue: "binance".to_string(),
268        };
269        let response = handle(State(state), axum::Json(req)).await.into_response();
270        let status = response.status();
271        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
272    }
273
274    #[tokio::test]
275    async fn test_handle_token_health_with_market() {
276        use crate::chains::DefaultClientFactory;
277        use crate::config::Config;
278        use crate::web::AppState;
279        use axum::extract::State;
280        use axum::response::IntoResponse;
281
282        let config = Config::default();
283        let http: std::sync::Arc<dyn crate::http::HttpClient> =
284            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
285        let factory = DefaultClientFactory {
286            chains_config: config.chains.clone(),
287            http,
288        };
289        let state = std::sync::Arc::new(AppState { config, factory });
290        let req = TokenHealthRequest {
291            token: "USDC".to_string(),
292            chain: "ethereum".to_string(),
293            with_market: true,
294            market_venue: "eth".to_string(),
295        };
296        let response = handle(State(state), axum::Json(req)).await.into_response();
297        let status = response.status();
298        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
299    }
300
301    #[tokio::test]
302    async fn test_handle_token_health_with_cex_market() {
303        use crate::chains::DefaultClientFactory;
304        use crate::config::Config;
305        use crate::web::AppState;
306        use axum::extract::State;
307        use axum::response::IntoResponse;
308
309        let config = Config::default();
310        let http: std::sync::Arc<dyn crate::http::HttpClient> =
311            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
312        let factory = DefaultClientFactory {
313            chains_config: config.chains.clone(),
314            http,
315        };
316        let state = std::sync::Arc::new(AppState { config, factory });
317        let req = TokenHealthRequest {
318            token: "USDC".to_string(),
319            chain: "ethereum".to_string(),
320            with_market: true,
321            market_venue: "binance".to_string(), // CEX path
322        };
323        let response = handle(State(state), axum::Json(req)).await.into_response();
324        let status = response.status();
325        // Either succeeds (200) or fails gracefully (500)
326        assert!(status.is_success() || status.is_server_error());
327    }
328
329    #[test]
330    fn test_is_dex_venue() {
331        assert!(is_dex_venue("ethereum"));
332        assert!(is_dex_venue("eth"));
333        assert!(is_dex_venue("Ethereum"));
334        assert!(is_dex_venue("ETH"));
335        assert!(is_dex_venue("solana"));
336        assert!(is_dex_venue("Solana"));
337        assert!(!is_dex_venue("binance"));
338        assert!(!is_dex_venue("mexc"));
339        assert!(!is_dex_venue("okx"));
340        assert!(!is_dex_venue(""));
341    }
342
343    #[test]
344    fn test_dex_venue_to_chain() {
345        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
346        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
347        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
348        assert_eq!(dex_venue_to_chain("solana"), "solana");
349        assert_eq!(dex_venue_to_chain("Solana"), "solana");
350        assert_eq!(dex_venue_to_chain("unknown"), "ethereum"); // default
351    }
352
353    #[tokio::test]
354    async fn test_handle_token_health_invalid_token_error_path() {
355        use crate::chains::DefaultClientFactory;
356        use crate::config::Config;
357        use crate::web::AppState;
358        use axum::extract::State;
359        use axum::response::IntoResponse;
360
361        let config = Config::default();
362        let http: std::sync::Arc<dyn crate::http::HttpClient> =
363            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
364        let factory = DefaultClientFactory {
365            chains_config: config.chains.clone(),
366            http,
367        };
368        let state = std::sync::Arc::new(AppState { config, factory });
369        let req = TokenHealthRequest {
370            token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
371            chain: "ethereum".to_string(),
372            with_market: false,
373            market_venue: "binance".to_string(),
374        };
375        let response = handle(State(state), axum::Json(req)).await.into_response();
376        let status = response.status();
377        // May succeed (empty analytics) or 500 (fetch error)
378        assert!(status.is_success() || status.is_server_error());
379    }
380
381    #[test]
382    fn test_token_health_request_debug() {
383        let req = TokenHealthRequest {
384            token: "USDC".to_string(),
385            chain: "ethereum".to_string(),
386            with_market: true,
387            market_venue: "binance".to_string(),
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::chains::DefaultClientFactory;
407        use crate::config::Config;
408        use crate::web::AppState;
409        use axum::body;
410        use axum::extract::State;
411        use axum::response::IntoResponse;
412
413        let config = Config::default();
414        let http: std::sync::Arc<dyn crate::http::HttpClient> =
415            std::sync::Arc::new(crate::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        };
427        let response = handle(State(state), axum::Json(req)).await.into_response();
428        if response.status().is_success() {
429            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
430                .await
431                .unwrap();
432            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
433            assert!(json.get("analytics").is_some());
434            assert!(json.get("market").is_some());
435        }
436    }
437
438    #[tokio::test]
439    async fn test_handle_token_health_label_not_found() {
440        use crate::chains::DefaultClientFactory;
441        use crate::config::Config;
442        use crate::web::AppState;
443        use axum::extract::State;
444        use axum::http::StatusCode;
445        use axum::response::IntoResponse;
446
447        let tmp = tempfile::tempdir().unwrap();
448        let config = Config {
449            address_book: crate::config::AddressBookConfig {
450                data_dir: Some(tmp.path().to_path_buf()),
451            },
452            ..Default::default()
453        };
454        let http: std::sync::Arc<dyn crate::http::HttpClient> =
455            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
456        let factory = DefaultClientFactory {
457            chains_config: config.chains.clone(),
458            http,
459        };
460        let state = std::sync::Arc::new(AppState { config, factory });
461        let req = TokenHealthRequest {
462            token: "@nonexistent".to_string(),
463            chain: "ethereum".to_string(),
464            with_market: false,
465            market_venue: "binance".to_string(),
466        };
467        let response = handle(State(state), axum::Json(req)).await.into_response();
468        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
469        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
470            .await
471            .unwrap();
472        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
473        assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
474    }
475}