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::response::IntoResponse;
7use scope::market::{HealthOverrides, fetch_token_summary_by_name};
8use scope_cli::cli::crawl::{self, Period};
9use serde::Deserialize;
10use std::sync::Arc;
11
12/// Request body for token health.
13#[derive(Debug, Deserialize)]
14pub struct TokenHealthRequest {
15    /// Token symbol or contract address.
16    pub token: String,
17    /// Target chain (default: "ethereum").
18    #[serde(default = "super::default_chain")]
19    pub chain: String,
20    /// Include market/order book data.
21    #[serde(default)]
22    pub with_market: bool,
23    /// Market venue; see scope::market::VENUE_HELP.
24    #[serde(default = "super::default_venue")]
25    pub market_venue: String,
26    /// Health threshold overrides for `with_market` (top-level JSON keys).
27    /// Unset keys use config `market.health`.
28    #[serde(flatten)]
29    pub health: HealthOverrides,
30}
31
32/// POST /api/token-health — Token health suite.
33///
34/// Supports address book shortcuts: pass `@label` as the token to
35/// resolve it from the address book.
36pub async fn handle(
37    State(state): State<Arc<AppState>>,
38    Json(req): Json<TokenHealthRequest>,
39) -> impl IntoResponse {
40    // Resolve address book shortcuts (@label or direct address match)
41    let resolved = match super::resolve_address_book(&req.token, &state.config) {
42        Ok(r) => r,
43        Err(e) => return super::error_response(super::status_for(&e), e.to_string()),
44    };
45    let token = resolved.value;
46    let chain = resolved.chain.unwrap_or(req.chain);
47
48    // Fetch DEX analytics
49    let analytics = match crawl::fetch_analytics_for_input(
50        &token,
51        &chain,
52        Period::Hour24,
53        10,
54        state.factory.as_ref(),
55        None,
56        &scope_cli::cli::output::Output::stdio(),
57    )
58    .await
59    {
60        Ok(a) => a,
61        Err(e) => return super::error_response(super::status_for(&e), e.to_string()),
62    };
63
64    // Optionally fetch market data. A market error leaves `market` null and
65    // is reported in `market_error`; the analytics still return.
66    let market_result = if req.with_market {
67        let thresholds = state.config.market.health.with_overrides(&req.health);
68        Some(fetch_token_summary_by_name(&req.market_venue, &analytics, &thresholds).await)
69    } else {
70        None
71    };
72    let (market_summary, market_error) = match market_result {
73        Some(Ok(summary)) => (Some(summary), None),
74        Some(Err(e)) => (None, Some(e.to_string())),
75        None => (None, None),
76    };
77
78    // Build combined JSON
79    let market_json = market_summary.map(|m| m.to_json());
80
81    Json(serde_json::json!({
82        "analytics": analytics,
83        "market": market_json,
84        "market_error": market_error,
85    }))
86    .into_response()
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use scope::market::DexVenue;
93
94    #[test]
95    fn test_deserialize_full() {
96        let json = serde_json::json!({
97            "token": "USDC",
98            "chain": "polygon",
99            "with_market": true,
100            "market_venue": "biconomy"
101        });
102        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
103        assert_eq!(req.token, "USDC");
104        assert_eq!(req.chain, "polygon");
105        assert!(req.with_market);
106        assert_eq!(req.market_venue, "biconomy");
107    }
108
109    #[test]
110    fn test_deserialize_minimal() {
111        let json = serde_json::json!({
112            "token": "USDC"
113        });
114        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
115        assert_eq!(req.token, "USDC");
116        assert_eq!(req.chain, "ethereum");
117        assert!(!req.with_market);
118        assert_eq!(req.market_venue, "binance");
119    }
120
121    #[test]
122    fn test_defaults() {
123        assert_eq!(super::super::default_chain(), "ethereum");
124        assert_eq!(super::super::default_venue(), "binance");
125    }
126
127    #[test]
128    fn test_with_market_flag() {
129        let json = serde_json::json!({
130            "token": "USDC",
131            "with_market": true
132        });
133        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
134        assert!(req.with_market);
135
136        let json_false = serde_json::json!({
137            "token": "USDC",
138            "with_market": false
139        });
140        let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
141        assert!(!req_false.with_market);
142    }
143
144    #[tokio::test]
145    async fn test_handle_token_health_direct() {
146        use crate::AppState;
147        use axum::extract::State;
148        use axum::response::IntoResponse;
149        use scope::chains::DefaultClientFactory;
150        use scope::config::Config;
151
152        let config = Config::default();
153        let http: std::sync::Arc<dyn scope::http::HttpClient> =
154            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
155        let factory = DefaultClientFactory {
156            chains_config: config.chains.clone(),
157            http,
158        };
159        let state = std::sync::Arc::new(AppState::new(config, factory));
160        let req = TokenHealthRequest {
161            token: "USDC".to_string(),
162            chain: "ethereum".to_string(),
163            with_market: false,
164            market_venue: "binance".to_string(),
165            health: HealthOverrides::default(),
166        };
167        let response = handle(State(state), axum::Json(req)).await.into_response();
168        let status = response.status();
169        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
170    }
171
172    #[tokio::test]
173    async fn test_handle_token_health_with_market() {
174        use crate::AppState;
175        use axum::extract::State;
176        use axum::response::IntoResponse;
177        use scope::chains::DefaultClientFactory;
178        use scope::config::Config;
179
180        let config = Config::default();
181        let http: std::sync::Arc<dyn scope::http::HttpClient> =
182            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
183        let factory = DefaultClientFactory {
184            chains_config: config.chains.clone(),
185            http,
186        };
187        let state = std::sync::Arc::new(AppState::new(config, factory));
188        let req = TokenHealthRequest {
189            token: "USDC".to_string(),
190            chain: "ethereum".to_string(),
191            with_market: true,
192            market_venue: "eth".to_string(),
193            health: HealthOverrides::default(),
194        };
195        let response = handle(State(state), axum::Json(req)).await.into_response();
196        let status = response.status();
197        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
198    }
199
200    #[tokio::test]
201    async fn test_handle_token_health_with_cex_market() {
202        use crate::AppState;
203        use axum::extract::State;
204        use axum::response::IntoResponse;
205        use scope::chains::DefaultClientFactory;
206        use scope::config::Config;
207
208        let config = Config::default();
209        let http: std::sync::Arc<dyn scope::http::HttpClient> =
210            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
211        let factory = DefaultClientFactory {
212            chains_config: config.chains.clone(),
213            http,
214        };
215        let state = std::sync::Arc::new(AppState::new(config, factory));
216        let req = TokenHealthRequest {
217            token: "USDC".to_string(),
218            chain: "ethereum".to_string(),
219            with_market: true,
220            market_venue: "binance".to_string(), // CEX path
221            health: HealthOverrides::default(),
222        };
223        let response = handle(State(state), axum::Json(req)).await.into_response();
224        let status = response.status();
225        // Either succeeds (200) or fails gracefully (500)
226        assert!(status.is_success() || status.is_server_error());
227    }
228
229    #[test]
230    fn test_is_dex_venue() {
231        assert!(DexVenue::parse("ethereum").is_some());
232        assert!(DexVenue::parse("eth").is_some());
233        assert!(DexVenue::parse("Ethereum").is_some());
234        assert!(DexVenue::parse("ETH").is_some());
235        assert!(DexVenue::parse("solana").is_some());
236        assert!(DexVenue::parse("Solana").is_some());
237        assert!(DexVenue::parse("binance").is_none());
238        assert!(DexVenue::parse("mexc").is_none());
239        assert!(DexVenue::parse("okx").is_none());
240        assert!(DexVenue::parse("").is_none());
241    }
242
243    #[test]
244    fn test_dex_venue_to_chain() {
245        assert_eq!(
246            DexVenue::parse("ethereum").map(DexVenue::chain),
247            Some("ethereum")
248        );
249        assert_eq!(
250            DexVenue::parse("eth").map(DexVenue::chain),
251            Some("ethereum")
252        );
253        assert_eq!(
254            DexVenue::parse("Ethereum").map(DexVenue::chain),
255            Some("ethereum")
256        );
257        assert_eq!(
258            DexVenue::parse("solana").map(DexVenue::chain),
259            Some("solana")
260        );
261        assert_eq!(
262            DexVenue::parse("Solana").map(DexVenue::chain),
263            Some("solana")
264        );
265        assert_eq!(DexVenue::parse("unknown"), None); // no silent ethereum
266    }
267
268    #[tokio::test]
269    async fn test_handle_token_health_invalid_token_error_path() {
270        use crate::AppState;
271        use axum::extract::State;
272        use axum::response::IntoResponse;
273        use scope::chains::DefaultClientFactory;
274        use scope::config::Config;
275
276        let config = Config::default();
277        let http: std::sync::Arc<dyn scope::http::HttpClient> =
278            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
279        let factory = DefaultClientFactory {
280            chains_config: config.chains.clone(),
281            http,
282        };
283        let state = std::sync::Arc::new(AppState::new(config, factory));
284        let req = TokenHealthRequest {
285            token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
286            chain: "ethereum".to_string(),
287            with_market: false,
288            market_venue: "binance".to_string(),
289            health: HealthOverrides::default(),
290        };
291        let response = handle(State(state), axum::Json(req)).await.into_response();
292        let status = response.status();
293        // Empty analytics (200), not found (404, via `status_for`), or a
294        // network failure offline (500).
295        assert!(
296            status.is_success()
297                || status == axum::http::StatusCode::NOT_FOUND
298                || status.is_server_error(),
299            "{status}"
300        );
301    }
302
303    #[test]
304    fn test_token_health_request_debug() {
305        let req = TokenHealthRequest {
306            token: "USDC".to_string(),
307            chain: "ethereum".to_string(),
308            with_market: true,
309            market_venue: "binance".to_string(),
310            health: HealthOverrides::default(),
311        };
312        let debug = format!("{:?}", req);
313        assert!(debug.contains("TokenHealthRequest"));
314    }
315
316    #[test]
317    fn test_deserialize_with_optional_venue() {
318        let json = serde_json::json!({
319            "token": "DAI",
320            "market_venue": "mexc"
321        });
322        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
323        assert_eq!(req.token, "DAI");
324        assert_eq!(req.market_venue, "mexc");
325    }
326
327    #[tokio::test]
328    async fn test_handle_token_health_success_json_structure() {
329        use crate::AppState;
330        use axum::body;
331        use axum::extract::State;
332        use axum::response::IntoResponse;
333        use scope::chains::DefaultClientFactory;
334        use scope::config::Config;
335
336        let config = Config::default();
337        let http: std::sync::Arc<dyn scope::http::HttpClient> =
338            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
339        let factory = DefaultClientFactory {
340            chains_config: config.chains.clone(),
341            http,
342        };
343        let state = std::sync::Arc::new(AppState::new(config, factory));
344        let req = TokenHealthRequest {
345            token: "USDC".to_string(),
346            chain: "ethereum".to_string(),
347            with_market: false,
348            market_venue: "binance".to_string(),
349            health: HealthOverrides::default(),
350        };
351        let response = handle(State(state), axum::Json(req)).await.into_response();
352        if response.status().is_success() {
353            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
354                .await
355                .unwrap();
356            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
357            assert!(json.get("analytics").is_some());
358            assert!(json.get("market").is_some());
359        }
360    }
361
362    #[tokio::test]
363    async fn test_handle_token_health_label_not_found() {
364        use crate::AppState;
365        use axum::extract::State;
366        use axum::http::StatusCode;
367        use axum::response::IntoResponse;
368        use scope::chains::DefaultClientFactory;
369        use scope::config::Config;
370
371        let tmp = tempfile::tempdir().unwrap();
372        let config = Config {
373            address_book: scope::config::AddressBookConfig {
374                data_dir: Some(tmp.path().to_path_buf()),
375            },
376            ..Default::default()
377        };
378        let http: std::sync::Arc<dyn scope::http::HttpClient> =
379            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
380        let factory = DefaultClientFactory {
381            chains_config: config.chains.clone(),
382            http,
383        };
384        let state = std::sync::Arc::new(AppState::new(config, factory));
385        let req = TokenHealthRequest {
386            token: "@nonexistent".to_string(),
387            chain: "ethereum".to_string(),
388            with_market: false,
389            market_venue: "binance".to_string(),
390            health: HealthOverrides::default(),
391        };
392        let response = handle(State(state), axum::Json(req)).await.into_response();
393        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
394        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
395            .await
396            .unwrap();
397        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
398        assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
399    }
400}