1use 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#[derive(Debug, Deserialize)]
15pub struct TokenHealthRequest {
16 pub token: String,
18 #[serde(default = "default_chain")]
20 pub chain: String,
21 #[serde(default)]
23 pub with_market: bool,
24 #[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
37pub async fn handle(
42 State(state): State<Arc<AppState>>,
43 Json(req): Json<TokenHealthRequest>,
44) -> impl IntoResponse {
45 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 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 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 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 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 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
179fn is_dex_venue(venue: &str) -> bool {
181 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
182}
183
184fn 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(), };
314 let response = handle(State(state), axum::Json(req)).await.into_response();
315 let status = response.status();
316 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"); }
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 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}