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_with_http};
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().without_prompts(),
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(
69            fetch_token_summary_by_name_with_http(
70                &req.market_venue,
71                &analytics,
72                &thresholds,
73                state.factory.http(),
74            )
75            .await,
76        )
77    } else {
78        None
79    };
80    let (market_summary, market_error) = match market_result {
81        Some(Ok(summary)) => (Some(summary), None),
82        Some(Err(e)) => (None, Some(e.to_string())),
83        None => (None, None),
84    };
85
86    // Build combined JSON
87    let market_json = market_summary.map(|m| m.to_json());
88
89    Json(serde_json::json!({
90        "analytics": analytics,
91        "market": market_json,
92        "market_error": market_error,
93    }))
94    .into_response()
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use scope::market::DexVenue;
101
102    #[test]
103    fn test_deserialize_full() {
104        let json = serde_json::json!({
105            "token": "USDC",
106            "chain": "polygon",
107            "with_market": true,
108            "market_venue": "biconomy"
109        });
110        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
111        assert_eq!(req.token, "USDC");
112        assert_eq!(req.chain, "polygon");
113        assert!(req.with_market);
114        assert_eq!(req.market_venue, "biconomy");
115    }
116
117    #[test]
118    fn test_deserialize_minimal() {
119        let json = serde_json::json!({
120            "token": "USDC"
121        });
122        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
123        assert_eq!(req.token, "USDC");
124        assert_eq!(req.chain, "ethereum");
125        assert!(!req.with_market);
126        assert_eq!(req.market_venue, "binance");
127    }
128
129    #[test]
130    fn test_defaults() {
131        assert_eq!(super::super::default_chain(), "ethereum");
132        assert_eq!(super::super::default_venue(), "binance");
133    }
134
135    #[test]
136    fn test_with_market_flag() {
137        let json = serde_json::json!({
138            "token": "USDC",
139            "with_market": true
140        });
141        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
142        assert!(req.with_market);
143
144        let json_false = serde_json::json!({
145            "token": "USDC",
146            "with_market": false
147        });
148        let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
149        assert!(!req_false.with_market);
150    }
151
152    #[tokio::test]
153    async fn test_handle_token_health_direct() {
154        use crate::AppState;
155        use axum::extract::State;
156        use axum::response::IntoResponse;
157        use scope::chains::DefaultClientFactory;
158        use scope::config::Config;
159
160        let config = Config::default();
161        let http: std::sync::Arc<dyn scope::http::HttpClient> =
162            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
163        let factory = DefaultClientFactory {
164            chains_config: config.chains.clone(),
165            http,
166        };
167        let state = std::sync::Arc::new(AppState::new(config, factory));
168        let req = TokenHealthRequest {
169            token: "USDC".to_string(),
170            chain: "ethereum".to_string(),
171            with_market: false,
172            market_venue: "binance".to_string(),
173            health: HealthOverrides::default(),
174        };
175        let response = handle(State(state), axum::Json(req)).await.into_response();
176        let status = response.status();
177        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
178    }
179
180    #[tokio::test]
181    async fn test_handle_token_health_with_market() {
182        use crate::AppState;
183        use axum::extract::State;
184        use axum::response::IntoResponse;
185        use scope::chains::DefaultClientFactory;
186        use scope::config::Config;
187
188        let config = Config::default();
189        let http: std::sync::Arc<dyn scope::http::HttpClient> =
190            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
191        let factory = DefaultClientFactory {
192            chains_config: config.chains.clone(),
193            http,
194        };
195        let state = std::sync::Arc::new(AppState::new(config, factory));
196        let req = TokenHealthRequest {
197            token: "USDC".to_string(),
198            chain: "ethereum".to_string(),
199            with_market: true,
200            market_venue: "eth".to_string(),
201            health: HealthOverrides::default(),
202        };
203        let response = handle(State(state), axum::Json(req)).await.into_response();
204        let status = response.status();
205        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
206    }
207
208    #[tokio::test]
209    async fn test_handle_token_health_with_cex_market() {
210        use crate::AppState;
211        use axum::extract::State;
212        use axum::response::IntoResponse;
213        use scope::chains::DefaultClientFactory;
214        use scope::config::Config;
215
216        let config = Config::default();
217        let http: std::sync::Arc<dyn scope::http::HttpClient> =
218            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
219        let factory = DefaultClientFactory {
220            chains_config: config.chains.clone(),
221            http,
222        };
223        let state = std::sync::Arc::new(AppState::new(config, factory));
224        let req = TokenHealthRequest {
225            token: "USDC".to_string(),
226            chain: "ethereum".to_string(),
227            with_market: true,
228            market_venue: "binance".to_string(), // CEX path
229            health: HealthOverrides::default(),
230        };
231        let response = handle(State(state), axum::Json(req)).await.into_response();
232        let status = response.status();
233        // Either succeeds (200) or fails gracefully (500)
234        assert!(status.is_success() || status.is_server_error());
235    }
236
237    #[test]
238    fn test_is_dex_venue() {
239        assert!(DexVenue::parse("ethereum").is_some());
240        assert!(DexVenue::parse("eth").is_some());
241        assert!(DexVenue::parse("Ethereum").is_some());
242        assert!(DexVenue::parse("ETH").is_some());
243        assert!(DexVenue::parse("solana").is_some());
244        assert!(DexVenue::parse("Solana").is_some());
245        assert!(DexVenue::parse("binance").is_none());
246        assert!(DexVenue::parse("mexc").is_none());
247        assert!(DexVenue::parse("okx").is_none());
248        assert!(DexVenue::parse("").is_none());
249    }
250
251    #[test]
252    fn test_dex_venue_to_chain() {
253        assert_eq!(
254            DexVenue::parse("ethereum").map(DexVenue::chain),
255            Some("ethereum")
256        );
257        assert_eq!(
258            DexVenue::parse("eth").map(DexVenue::chain),
259            Some("ethereum")
260        );
261        assert_eq!(
262            DexVenue::parse("Ethereum").map(DexVenue::chain),
263            Some("ethereum")
264        );
265        assert_eq!(
266            DexVenue::parse("solana").map(DexVenue::chain),
267            Some("solana")
268        );
269        assert_eq!(
270            DexVenue::parse("Solana").map(DexVenue::chain),
271            Some("solana")
272        );
273        assert_eq!(DexVenue::parse("unknown"), None); // no silent ethereum
274    }
275
276    #[tokio::test]
277    async fn test_handle_token_health_invalid_token_error_path() {
278        use crate::AppState;
279        use axum::extract::State;
280        use axum::response::IntoResponse;
281        use scope::chains::DefaultClientFactory;
282        use scope::config::Config;
283
284        let config = Config::default();
285        let http: std::sync::Arc<dyn scope::http::HttpClient> =
286            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
287        let factory = DefaultClientFactory {
288            chains_config: config.chains.clone(),
289            http,
290        };
291        let state = std::sync::Arc::new(AppState::new(config, factory));
292        let req = TokenHealthRequest {
293            token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
294            chain: "ethereum".to_string(),
295            with_market: false,
296            market_venue: "binance".to_string(),
297            health: HealthOverrides::default(),
298        };
299        let response = handle(State(state), axum::Json(req)).await.into_response();
300        let status = response.status();
301        // Empty analytics (200), not found (404, via `status_for`), or a
302        // network failure offline (500).
303        assert!(
304            status.is_success()
305                || status == axum::http::StatusCode::NOT_FOUND
306                || status.is_server_error(),
307            "{status}"
308        );
309    }
310
311    #[test]
312    fn test_token_health_request_debug() {
313        let req = TokenHealthRequest {
314            token: "USDC".to_string(),
315            chain: "ethereum".to_string(),
316            with_market: true,
317            market_venue: "binance".to_string(),
318            health: HealthOverrides::default(),
319        };
320        let debug = format!("{:?}", req);
321        assert!(debug.contains("TokenHealthRequest"));
322    }
323
324    #[test]
325    fn test_deserialize_with_optional_venue() {
326        let json = serde_json::json!({
327            "token": "DAI",
328            "market_venue": "mexc"
329        });
330        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
331        assert_eq!(req.token, "DAI");
332        assert_eq!(req.market_venue, "mexc");
333    }
334
335    #[tokio::test]
336    async fn test_handle_token_health_success_json_structure() {
337        use crate::AppState;
338        use axum::body;
339        use axum::extract::State;
340        use axum::response::IntoResponse;
341        use scope::chains::DefaultClientFactory;
342        use scope::config::Config;
343
344        let config = Config::default();
345        let http: std::sync::Arc<dyn scope::http::HttpClient> =
346            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
347        let factory = DefaultClientFactory {
348            chains_config: config.chains.clone(),
349            http,
350        };
351        let state = std::sync::Arc::new(AppState::new(config, factory));
352        let req = TokenHealthRequest {
353            token: "USDC".to_string(),
354            chain: "ethereum".to_string(),
355            with_market: false,
356            market_venue: "binance".to_string(),
357            health: HealthOverrides::default(),
358        };
359        let response = handle(State(state), axum::Json(req)).await.into_response();
360        if response.status().is_success() {
361            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
362                .await
363                .unwrap();
364            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
365            assert!(json.get("analytics").is_some());
366            assert!(json.get("market").is_some());
367        }
368    }
369
370    #[tokio::test]
371    async fn test_handle_token_health_label_not_found() {
372        use crate::AppState;
373        use axum::extract::State;
374        use axum::http::StatusCode;
375        use axum::response::IntoResponse;
376        use scope::chains::DefaultClientFactory;
377        use scope::config::Config;
378
379        let tmp = tempfile::tempdir().unwrap();
380        let config = Config {
381            address_book: scope::config::AddressBookConfig {
382                data_dir: Some(tmp.path().to_path_buf()),
383            },
384            ..Default::default()
385        };
386        let http: std::sync::Arc<dyn scope::http::HttpClient> =
387            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
388        let factory = DefaultClientFactory {
389            chains_config: config.chains.clone(),
390            http,
391        };
392        let state = std::sync::Arc::new(AppState::new(config, factory));
393        let req = TokenHealthRequest {
394            token: "@nonexistent".to_string(),
395            chain: "ethereum".to_string(),
396            with_market: false,
397            market_venue: "binance".to_string(),
398            health: HealthOverrides::default(),
399        };
400        let response = handle(State(state), axum::Json(req)).await.into_response();
401        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
402        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
403            .await
404            .unwrap();
405        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
406        assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
407    }
408}