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 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(), };
323 let response = handle(State(state), axum::Json(req)).await.into_response();
324 let status = response.status();
325 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"); }
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 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}