1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use scope::market::{HealthOverrides, MarketSummary, VenueRegistry, order_book_from_analytics};
9use scope_cli::cli::crawl::{self, Period};
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 #[serde(flatten)]
30 pub health: HealthOverrides,
31}
32
33fn default_chain() -> String {
34 "ethereum".to_string()
35}
36
37fn default_venue() -> String {
38 "binance".to_string()
39}
40
41pub async fn handle(
46 State(state): State<Arc<AppState>>,
47 Json(req): Json<TokenHealthRequest>,
48) -> impl IntoResponse {
49 let resolved = match super::resolve_address_book(&req.token, &state.config) {
51 Ok(r) => r,
52 Err(e) => {
53 return (
54 StatusCode::BAD_REQUEST,
55 Json(serde_json::json!({ "error": e })),
56 )
57 .into_response();
58 }
59 };
60 let token = resolved.value;
61 let chain = resolved.chain.unwrap_or(req.chain);
62
63 let analytics = match crawl::fetch_analytics_for_input(
65 &token,
66 &chain,
67 Period::Hour24,
68 10,
69 &state.factory,
70 None,
71 )
72 .await
73 {
74 Ok(a) => a,
75 Err(e) => {
76 return (
77 StatusCode::INTERNAL_SERVER_ERROR,
78 Json(serde_json::json!({ "error": e.to_string() })),
79 )
80 .into_response();
81 }
82 };
83
84 let venue_id = &req.market_venue;
86 let market_summary = if req.with_market {
87 let thresholds = state.config.market.health.with_overrides(&req.health);
88
89 if !is_dex_venue(venue_id) {
90 if let Ok(registry) = VenueRegistry::load()
92 && let Ok(exchange) = registry.create_exchange_client(venue_id)
93 {
94 let pair = exchange.format_pair(&analytics.token.symbol);
95 match exchange.fetch_order_book(&pair).await {
96 Ok(book) => {
97 let volume_24h = if exchange.has_ticker() {
98 exchange
99 .fetch_ticker(&pair)
100 .await
101 .ok()
102 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
103 } else {
104 None
105 };
106 Some(MarketSummary::from_order_book(
107 &book,
108 &thresholds,
109 volume_24h,
110 ))
111 }
112 Err(_) => None,
113 }
114 } else {
115 None
116 }
117 } else {
118 let venue_chain = dex_venue_to_chain(venue_id);
120 if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
121 {
122 let best_pair = analytics
123 .dex_pairs
124 .iter()
125 .max_by(|a, b| {
126 a.liquidity_usd
127 .partial_cmp(&b.liquidity_usd)
128 .unwrap_or(std::cmp::Ordering::Equal)
129 })
130 .unwrap();
131 let book =
132 order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
133 let volume_24h = Some(best_pair.volume_24h);
134 Some(MarketSummary::from_order_book(
135 &book,
136 &thresholds,
137 volume_24h,
138 ))
139 } else {
140 None
141 }
142 }
143 } else {
144 None
145 };
146
147 let market_json = market_summary.map(|m| {
149 serde_json::json!({
150 "pair": m.pair,
151 "peg_target": m.peg_target,
152 "best_bid": m.best_bid,
153 "best_ask": m.best_ask,
154 "mid_price": m.mid_price,
155 "spread": m.spread,
156 "bid_depth": m.bid_depth,
157 "ask_depth": m.ask_depth,
158 "healthy": m.healthy,
159 "volume_24h": m.volume_24h,
160 "checks": m.checks.iter().map(|c| match c {
161 scope::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
162 scope::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
163 }).collect::<Vec<_>>()
164 })
165 });
166
167 Json(serde_json::json!({
168 "analytics": analytics,
169 "market": market_json,
170 }))
171 .into_response()
172}
173
174fn is_dex_venue(venue: &str) -> bool {
176 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
177}
178
179fn dex_venue_to_chain(venue: &str) -> &str {
181 match venue.to_lowercase().as_str() {
182 "ethereum" | "eth" => "ethereum",
183 "solana" => "solana",
184 _ => "ethereum",
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_deserialize_full() {
194 let json = serde_json::json!({
195 "token": "USDC",
196 "chain": "polygon",
197 "with_market": true,
198 "market_venue": "biconomy"
199 });
200 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
201 assert_eq!(req.token, "USDC");
202 assert_eq!(req.chain, "polygon");
203 assert!(req.with_market);
204 assert_eq!(req.market_venue, "biconomy");
205 }
206
207 #[test]
208 fn test_deserialize_minimal() {
209 let json = serde_json::json!({
210 "token": "USDC"
211 });
212 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
213 assert_eq!(req.token, "USDC");
214 assert_eq!(req.chain, "ethereum");
215 assert!(!req.with_market);
216 assert_eq!(req.market_venue, "binance");
217 }
218
219 #[test]
220 fn test_defaults() {
221 assert_eq!(default_chain(), "ethereum");
222 assert_eq!(default_venue(), "binance");
223 }
224
225 #[test]
226 fn test_with_market_flag() {
227 let json = serde_json::json!({
228 "token": "USDC",
229 "with_market": true
230 });
231 let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
232 assert!(req.with_market);
233
234 let json_false = serde_json::json!({
235 "token": "USDC",
236 "with_market": false
237 });
238 let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
239 assert!(!req_false.with_market);
240 }
241
242 #[tokio::test]
243 async fn test_handle_token_health_direct() {
244 use crate::AppState;
245 use axum::extract::State;
246 use axum::response::IntoResponse;
247 use scope::chains::DefaultClientFactory;
248 use scope::config::Config;
249
250 let config = Config::default();
251 let http: std::sync::Arc<dyn scope::http::HttpClient> =
252 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
253 let factory = DefaultClientFactory {
254 chains_config: config.chains.clone(),
255 http,
256 };
257 let state = std::sync::Arc::new(AppState { config, factory });
258 let req = TokenHealthRequest {
259 token: "USDC".to_string(),
260 chain: "ethereum".to_string(),
261 with_market: false,
262 market_venue: "binance".to_string(),
263 health: HealthOverrides::default(),
264 };
265 let response = handle(State(state), axum::Json(req)).await.into_response();
266 let status = response.status();
267 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
268 }
269
270 #[tokio::test]
271 async fn test_handle_token_health_with_market() {
272 use crate::AppState;
273 use axum::extract::State;
274 use axum::response::IntoResponse;
275 use scope::chains::DefaultClientFactory;
276 use scope::config::Config;
277
278 let config = Config::default();
279 let http: std::sync::Arc<dyn scope::http::HttpClient> =
280 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
281 let factory = DefaultClientFactory {
282 chains_config: config.chains.clone(),
283 http,
284 };
285 let state = std::sync::Arc::new(AppState { config, factory });
286 let req = TokenHealthRequest {
287 token: "USDC".to_string(),
288 chain: "ethereum".to_string(),
289 with_market: true,
290 market_venue: "eth".to_string(),
291 health: HealthOverrides::default(),
292 };
293 let response = handle(State(state), axum::Json(req)).await.into_response();
294 let status = response.status();
295 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
296 }
297
298 #[tokio::test]
299 async fn test_handle_token_health_with_cex_market() {
300 use crate::AppState;
301 use axum::extract::State;
302 use axum::response::IntoResponse;
303 use scope::chains::DefaultClientFactory;
304 use scope::config::Config;
305
306 let config = Config::default();
307 let http: std::sync::Arc<dyn scope::http::HttpClient> =
308 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
309 let factory = DefaultClientFactory {
310 chains_config: config.chains.clone(),
311 http,
312 };
313 let state = std::sync::Arc::new(AppState { config, factory });
314 let req = TokenHealthRequest {
315 token: "USDC".to_string(),
316 chain: "ethereum".to_string(),
317 with_market: true,
318 market_venue: "binance".to_string(), health: HealthOverrides::default(),
320 };
321 let response = handle(State(state), axum::Json(req)).await.into_response();
322 let status = response.status();
323 assert!(status.is_success() || status.is_server_error());
325 }
326
327 #[test]
328 fn test_is_dex_venue() {
329 assert!(is_dex_venue("ethereum"));
330 assert!(is_dex_venue("eth"));
331 assert!(is_dex_venue("Ethereum"));
332 assert!(is_dex_venue("ETH"));
333 assert!(is_dex_venue("solana"));
334 assert!(is_dex_venue("Solana"));
335 assert!(!is_dex_venue("binance"));
336 assert!(!is_dex_venue("mexc"));
337 assert!(!is_dex_venue("okx"));
338 assert!(!is_dex_venue(""));
339 }
340
341 #[test]
342 fn test_dex_venue_to_chain() {
343 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
344 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
345 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
346 assert_eq!(dex_venue_to_chain("solana"), "solana");
347 assert_eq!(dex_venue_to_chain("Solana"), "solana");
348 assert_eq!(dex_venue_to_chain("unknown"), "ethereum"); }
350
351 #[tokio::test]
352 async fn test_handle_token_health_invalid_token_error_path() {
353 use crate::AppState;
354 use axum::extract::State;
355 use axum::response::IntoResponse;
356 use scope::chains::DefaultClientFactory;
357 use scope::config::Config;
358
359 let config = Config::default();
360 let http: std::sync::Arc<dyn scope::http::HttpClient> =
361 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
362 let factory = DefaultClientFactory {
363 chains_config: config.chains.clone(),
364 http,
365 };
366 let state = std::sync::Arc::new(AppState { config, factory });
367 let req = TokenHealthRequest {
368 token: "INVALID_TOKEN_XYZ_NONEXISTENT".to_string(),
369 chain: "ethereum".to_string(),
370 with_market: false,
371 market_venue: "binance".to_string(),
372 health: HealthOverrides::default(),
373 };
374 let response = handle(State(state), axum::Json(req)).await.into_response();
375 let status = response.status();
376 assert!(status.is_success() || status.is_server_error());
378 }
379
380 #[test]
381 fn test_token_health_request_debug() {
382 let req = TokenHealthRequest {
383 token: "USDC".to_string(),
384 chain: "ethereum".to_string(),
385 with_market: true,
386 market_venue: "binance".to_string(),
387 health: HealthOverrides::default(),
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::AppState;
407 use axum::body;
408 use axum::extract::State;
409 use axum::response::IntoResponse;
410 use scope::chains::DefaultClientFactory;
411 use scope::config::Config;
412
413 let config = Config::default();
414 let http: std::sync::Arc<dyn scope::http::HttpClient> =
415 std::sync::Arc::new(scope::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 health: HealthOverrides::default(),
427 };
428 let response = handle(State(state), axum::Json(req)).await.into_response();
429 if response.status().is_success() {
430 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
431 .await
432 .unwrap();
433 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
434 assert!(json.get("analytics").is_some());
435 assert!(json.get("market").is_some());
436 }
437 }
438
439 #[tokio::test]
440 async fn test_handle_token_health_label_not_found() {
441 use crate::AppState;
442 use axum::extract::State;
443 use axum::http::StatusCode;
444 use axum::response::IntoResponse;
445 use scope::chains::DefaultClientFactory;
446 use scope::config::Config;
447
448 let tmp = tempfile::tempdir().unwrap();
449 let config = Config {
450 address_book: scope::config::AddressBookConfig {
451 data_dir: Some(tmp.path().to_path_buf()),
452 },
453 ..Default::default()
454 };
455 let http: std::sync::Arc<dyn scope::http::HttpClient> =
456 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
457 let factory = DefaultClientFactory {
458 chains_config: config.chains.clone(),
459 http,
460 };
461 let state = std::sync::Arc::new(AppState { config, factory });
462 let req = TokenHealthRequest {
463 token: "@nonexistent".to_string(),
464 chain: "ethereum".to_string(),
465 with_market: false,
466 market_venue: "binance".to_string(),
467 health: HealthOverrides::default(),
468 };
469 let response = handle(State(state), axum::Json(req)).await.into_response();
470 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
471 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
472 .await
473 .unwrap();
474 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
475 assert!(json["error"].as_str().unwrap().contains("@nonexistent"));
476 }
477}