1use crate::AppState;
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Query, State};
9use axum::response::IntoResponse;
10use scope::chains::DexDataSource;
11use scope::chains::dex::DexTokenData;
12use scope::market::{ExchangeClient, Pair, VenueRegistry};
13use serde::Deserialize;
14use std::sync::Arc;
15use std::time::Duration;
16
17#[derive(Debug, Deserialize)]
19pub struct MonitorQuery {
20 pub token: String,
22 #[serde(default = "crate::api::default_chain")]
24 pub chain: String,
25 #[serde(default = "default_refresh")]
27 pub refresh: u64,
28 #[serde(default)]
31 pub venue: Option<String>,
32 #[serde(default)]
35 pub pair: Option<String>,
36}
37
38fn default_refresh() -> u64 {
39 5
40}
41
42fn exchange_client_for(
44 venue: Option<&str>,
45 http: Arc<dyn scope::http::HttpClient>,
46) -> scope::error::Result<Option<ExchangeClient>> {
47 let Some(id) = venue else {
48 return Ok(None);
49 };
50 VenueRegistry::load()?
51 .create_exchange_client_with_http(id, http)
52 .map(Some)
53}
54
55pub async fn ws_handler(
57 ws: WebSocketUpgrade,
58 State(state): State<Arc<AppState>>,
59 Query(params): Query<MonitorQuery>,
60) -> impl IntoResponse {
61 ws.on_upgrade(move |socket| handle_socket(socket, state, params))
62}
63
64async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>, params: MonitorQuery) {
69 let dex_client: Box<dyn DexDataSource> = state.factory.create_dex_client();
70 let refresh = Duration::from_secs(params.refresh.max(1));
71
72 let token_input = params.token.clone();
74 let chain = params.chain.clone();
75
76 let exchange_client: Option<ExchangeClient> =
80 match exchange_client_for(params.venue.as_deref(), state.factory.http()) {
81 Ok(client) => client,
82 Err(e) => {
83 let msg = serde_json::json!({"type": "error", "message": e.to_string()});
84 let _ = socket.send(Message::Text(msg.to_string())).await;
85 return;
86 }
87 };
88
89 let exchange_pair: Option<String> = exchange_client.as_ref().map(|ec| {
90 let pair = match params.pair.as_deref() {
91 Some(pair) => Pair::parse(pair),
92 None => Pair::base_only(&token_input),
93 };
94 ec.venue_symbol(&pair)
95 });
96
97 let init_msg = serde_json::json!({
99 "type": "connected",
100 "token": token_input,
101 "chain": chain,
102 "refresh_secs": params.refresh,
103 "venue": params.venue,
104 "exchange_pair": exchange_pair,
105 });
106 if socket
107 .send(Message::Text(init_msg.to_string()))
108 .await
109 .is_err()
110 {
111 return;
112 }
113
114 loop {
115 let data: scope::error::Result<DexTokenData> =
117 dex_client.get_token_data(&chain, &token_input).await;
118
119 let exchange_snapshot = if let (Some(ec), Some(pair)) = (&exchange_client, &exchange_pair) {
121 Some(
122 ec.fetch_market_snapshot(pair, scope::market::exchange::DEFAULT_SNAPSHOT_TRADES)
123 .await,
124 )
125 } else {
126 None
127 };
128
129 let msg = match data {
130 Ok(token_data) => {
131 let mut frame = serde_json::json!({
132 "type": "update",
133 "timestamp": chrono::Utc::now().to_rfc3339(),
134 "token": {
135 "symbol": token_data.symbol,
136 "name": token_data.name,
137 "address": token_data.address,
138 },
139 "price_usd": token_data.price_usd,
140 "price_change_24h": token_data.price_change_24h,
141 "price_change_6h": token_data.price_change_6h,
142 "price_change_1h": token_data.price_change_1h,
143 "volume_24h": token_data.volume_24h,
144 "volume_6h": token_data.volume_6h,
145 "volume_1h": token_data.volume_1h,
146 "liquidity_usd": token_data.liquidity_usd,
147 "market_cap": token_data.market_cap,
148 "buys_24h": token_data.total_buys_24h,
149 "sells_24h": token_data.total_sells_24h,
150 "buys_1h": token_data.total_buys_1h,
151 "sells_1h": token_data.total_sells_1h,
152 "pairs": token_data.pairs.iter().take(5).map(|p| {
153 serde_json::json!({
154 "dex": p.dex_name,
155 "base": p.base_token,
156 "quote": p.quote_token,
157 "price_usd": p.price_usd,
158 "volume_24h": p.volume_24h,
159 "liquidity_usd": p.liquidity_usd,
160 })
161 }).collect::<Vec<_>>(),
162 });
163
164 if let Some(snap) = &exchange_snapshot {
166 attach_exchange_data(&mut frame, snap);
167 }
168
169 frame
170 }
171 Err(e) => {
172 let mut frame = serde_json::json!({
173 "type": "error",
174 "message": e.to_string(),
175 });
176 if let Some(snap) = &exchange_snapshot {
178 attach_exchange_data(&mut frame, snap);
179 }
180 frame
181 }
182 };
183
184 if socket.send(Message::Text(msg.to_string())).await.is_err() {
185 break;
187 }
188
189 tokio::select! {
191 _ = tokio::time::sleep(refresh) => {},
192 msg = socket.recv() => {
193 match msg {
194 Some(Ok(Message::Close(_))) | None => break,
195 Some(Ok(Message::Text(text))) => {
196 if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(&text)
198 && cmd.get("type").and_then(|t| t.as_str()) == Some("ping")
199 {
200 let pong = serde_json::json!({"type": "pong"});
201 let _ = socket.send(Message::Text(pong.to_string())).await;
202 }
203 }
204 _ => {}
205 }
206 }
207 }
208 }
209}
210
211const FRAME_ITEMS: usize = 20;
213
214fn attach_exchange_data(frame: &mut serde_json::Value, snap: &scope::market::MarketSnapshot) {
218 if let Some(book) = &snap.order_book {
219 frame["exchange_order_book"] = book.to_json(Some(FRAME_ITEMS));
220 }
221 if let Some(ticker) = &snap.ticker {
222 frame["exchange_ticker"] = serde_json::json!(ticker);
223 }
224 if let Some(trades) = &snap.recent_trades {
225 let recent: Vec<_> = trades.iter().take(FRAME_ITEMS).collect();
226 frame["exchange_trades"] = serde_json::json!(recent);
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use scope::market::{MarketSnapshot, OrderBook, OrderBookLevel, Ticker, Trade, TradeSide};
234
235 #[test]
236 fn test_default_chain() {
237 assert_eq!(crate::api::default_chain(), "ethereum");
238 }
239
240 #[test]
241 fn test_default_refresh() {
242 assert_eq!(default_refresh(), 5);
243 }
244
245 #[test]
248 fn test_exchange_client_for_unknown_venue_is_a_request_fault() {
249 let err = exchange_client_for(Some("nonexistent_venue_xyz"), scope::http::test_transport())
250 .err()
251 .unwrap();
252 assert!(
253 matches!(err, scope::error::ScopeError::InvalidInput(_)),
254 "{err:?}"
255 );
256 assert!(err.to_string().contains("Unknown venue"), "{err}");
257 }
258
259 #[test]
260 fn test_exchange_client_for_none_and_known_venue() {
261 assert!(
262 exchange_client_for(None, scope::http::test_transport())
263 .unwrap()
264 .is_none()
265 );
266 assert!(
267 exchange_client_for(Some("binance"), scope::http::test_transport())
268 .unwrap()
269 .is_some()
270 );
271 }
272
273 #[test]
274 fn test_deserialize_monitor_query_full() {
275 let json = serde_json::json!({
276 "token": "USDC",
277 "chain": "solana",
278 "refresh": 10
279 });
280 let query: MonitorQuery = serde_json::from_value(json).unwrap();
281 assert_eq!(query.token, "USDC");
282 assert_eq!(query.chain, "solana");
283 assert_eq!(query.refresh, 10);
284 assert!(query.venue.is_none());
285 }
286
287 #[test]
288 fn test_deserialize_monitor_query_minimal() {
289 let json = serde_json::json!({
290 "token": "ETH"
291 });
292 let query: MonitorQuery = serde_json::from_value(json).unwrap();
293 assert_eq!(query.token, "ETH");
294 assert_eq!(query.chain, "ethereum");
295 assert_eq!(query.refresh, 5);
296 assert!(query.venue.is_none());
297 assert!(query.pair.is_none());
298 }
299
300 #[test]
301 fn test_deserialize_monitor_query_with_venue() {
302 let json = serde_json::json!({
303 "token": "BTC",
304 "venue": "binance",
305 "pair": "USDC",
306 "refresh": 10
307 });
308 let query: MonitorQuery = serde_json::from_value(json).unwrap();
309 assert_eq!(query.token, "BTC");
310 assert_eq!(query.venue.as_deref(), Some("binance"));
311 assert_eq!(query.pair.as_deref(), Some("USDC"));
312 }
313
314 #[test]
315 fn test_attach_exchange_data_full() {
316 let snapshot = MarketSnapshot {
317 order_book: Some(OrderBook {
318 pair: "BTC/USDT".to_string(),
319 source: scope::market::BookSource::Exchange,
320 bids: vec![OrderBookLevel {
321 price: 50000.0,
322 quantity: 1.5,
323 }],
324 asks: vec![OrderBookLevel {
325 price: 50010.0,
326 quantity: 2.0,
327 }],
328 }),
329 ticker: Some(Ticker {
330 pair: "BTC/USDT".to_string(),
331 last_price: Some(50005.0),
332 high_24h: Some(51000.0),
333 low_24h: Some(49000.0),
334 volume_24h: Some(1_000_000.0),
335 quote_volume_24h: Some(50_000_000.0),
336 price_change_pct_24h: None,
337 best_bid: Some(50000.0),
338 best_ask: Some(50010.0),
339 }),
340 recent_trades: Some(vec![Trade {
341 price: 50005.0,
342 quantity: 0.5,
343 quote_quantity: Some(25002.5),
344 timestamp_ms: 1700000000000,
345 side: TradeSide::Buy,
346 id: None,
347 }]),
348 };
349
350 let mut frame = serde_json::json!({"type": "update"});
351 attach_exchange_data(&mut frame, &snapshot);
352
353 assert!(frame.get("exchange_order_book").is_some());
354 assert!(frame.get("exchange_ticker").is_some());
355 assert!(frame.get("exchange_trades").is_some());
356 assert_eq!(
357 frame["exchange_ticker"]["last_price"].as_f64().unwrap(),
358 50005.0
359 );
360 }
361
362 #[test]
363 fn test_attach_exchange_data_empty() {
364 let snapshot = MarketSnapshot {
365 order_book: None,
366 ticker: None,
367 recent_trades: None,
368 };
369
370 let mut frame = serde_json::json!({"type": "update"});
371 attach_exchange_data(&mut frame, &snapshot);
372
373 assert!(frame.get("exchange_order_book").is_none());
374 assert!(frame.get("exchange_ticker").is_none());
375 assert!(frame.get("exchange_trades").is_none());
376 }
377
378 #[test]
379 fn test_attach_exchange_data_spread_matches_the_market_summary() {
380 use scope::market::{
381 BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel,
382 };
383 let level = |price, quantity| OrderBookLevel { price, quantity };
384 let book = OrderBook {
386 pair: "X/USDT".to_string(),
387 source: BookSource::Exchange,
388 bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
389 asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
390 };
391 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
392 let snapshot = scope::market::MarketSnapshot {
393 order_book: Some(book),
394 ticker: None,
395 recent_trades: None,
396 };
397 let mut frame = serde_json::json!({"type": "update"});
398 attach_exchange_data(&mut frame, &snapshot);
399 assert_eq!(
400 frame["exchange_order_book"]["spread"].as_f64(),
401 summary.spread
402 );
403 assert_eq!(
404 frame["exchange_order_book"]["mid_price"].as_f64(),
405 summary.mid_price
406 );
407 }
408
409 #[test]
410 fn test_attach_exchange_data_with_sell_trade() {
411 let snapshot = MarketSnapshot {
412 order_book: None,
413 ticker: None,
414 recent_trades: Some(vec![Trade {
415 price: 50005.0,
416 quantity: 0.5,
417 quote_quantity: Some(25002.5),
418 timestamp_ms: 1700000000000,
419 side: TradeSide::Sell,
420 id: None,
421 }]),
422 };
423
424 let mut frame = serde_json::json!({"type": "update"});
425 attach_exchange_data(&mut frame, &snapshot);
426
427 assert!(frame.get("exchange_trades").is_some());
428 let trades = frame["exchange_trades"].as_array().unwrap();
429 assert_eq!(trades.len(), 1);
430 assert_eq!(trades[0]["side"], "sell");
431 }
432
433 #[test]
434 fn test_attach_exchange_data_order_book_only() {
435 let snapshot = MarketSnapshot {
436 order_book: Some(OrderBook {
437 pair: "ETH/USDT".to_string(),
438 source: scope::market::BookSource::Exchange,
439 bids: vec![OrderBookLevel {
440 price: 2000.0,
441 quantity: 2.0,
442 }],
443 asks: vec![OrderBookLevel {
444 price: 2005.0,
445 quantity: 1.5,
446 }],
447 }),
448 ticker: None,
449 recent_trades: None,
450 };
451
452 let mut frame = serde_json::json!({"type": "update"});
453 attach_exchange_data(&mut frame, &snapshot);
454
455 assert!(frame.get("exchange_order_book").is_some());
456 assert_eq!(frame["exchange_order_book"]["pair"], "ETH/USDT");
457 assert_eq!(frame["exchange_order_book"]["best_bid"], 2000.0);
458 assert_eq!(frame["exchange_order_book"]["best_ask"], 2005.0);
459 assert!(frame.get("exchange_ticker").is_none());
460 assert!(frame.get("exchange_trades").is_none());
461 }
462
463 #[test]
464 fn test_attach_exchange_data_ticker_only() {
465 let snapshot = MarketSnapshot {
466 order_book: None,
467 ticker: Some(Ticker {
468 pair: "SOL/USDT".to_string(),
469 last_price: Some(100.5),
470 high_24h: Some(102.0),
471 low_24h: Some(99.0),
472 volume_24h: Some(50000.0),
473 quote_volume_24h: Some(5_000_000.0),
474 price_change_pct_24h: None,
475 best_bid: Some(100.0),
476 best_ask: Some(101.0),
477 }),
478 recent_trades: None,
479 };
480
481 let mut frame = serde_json::json!({"type": "update"});
482 attach_exchange_data(&mut frame, &snapshot);
483
484 assert!(frame.get("exchange_ticker").is_some());
485 assert_eq!(frame["exchange_ticker"]["pair"], "SOL/USDT");
486 assert_eq!(frame["exchange_ticker"]["last_price"], 100.5);
487 assert!(frame.get("exchange_order_book").is_none());
488 }
489
490 use scope::chains::ChainClientFactory;
496 use scope::chains::mocks::MockClientFactory;
497 use scope::http::{HttpClient, Request, Response};
498 use tokio::io::{AsyncReadExt, AsyncWriteExt};
499 use tokio::net::TcpStream;
500
501 struct CannedVenue;
504
505 impl HttpClient for CannedVenue {
506 fn send<'a, 'b>(
507 &'a self,
508 req: Request,
509 ) -> std::pin::Pin<
510 Box<dyn std::future::Future<Output = scope::error::Result<Response>> + Send + 'b>,
511 >
512 where
513 'a: 'b,
514 Self: 'b,
515 {
516 let body = if req.url.contains("/api/v3/depth") {
517 Some(r#"{"bids":[["0.999","100"]],"asks":[["1.001","200"]]}"#)
518 } else if req.url.contains("/api/v3/ticker/24hr") {
519 Some(
520 r#"{"lastPrice":"1.0","highPrice":"1.1","lowPrice":"0.9","volume":"10","quoteVolume":"10","priceChangePercent":"0.5","bidPrice":"0.999","askPrice":"1.001"}"#,
521 )
522 } else if req.url.contains("/api/v3/trades") {
523 Some(
524 r#"[{"id":9,"price":"1.0","qty":"3","quoteQty":"3","time":1700000000000,"isBuyerMaker":false}]"#,
525 )
526 } else {
527 None
528 };
529 Box::pin(async move {
530 Ok(Response {
531 status_code: if body.is_some() { 200 } else { 404 },
532 headers: Default::default(),
533 body: body.unwrap_or_default().to_string(),
534 })
535 })
536 }
537 }
538
539 struct TestFactory(MockClientFactory);
541
542 impl ChainClientFactory for TestFactory {
543 fn create_chain_client(
544 &self,
545 chain: &str,
546 ) -> scope::error::Result<Box<dyn scope::chains::ChainClient>> {
547 self.0.create_chain_client(chain)
548 }
549
550 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
551 self.0.create_dex_client()
552 }
553
554 fn http(&self) -> Arc<dyn HttpClient> {
555 Arc::new(CannedVenue)
556 }
557 }
558
559 async fn serve(mock: MockClientFactory) -> std::net::SocketAddr {
561 let state = Arc::new(crate::AppState::new(
562 scope::config::Config::default(),
563 TestFactory(mock),
564 ));
565 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
566 let addr = listener.local_addr().unwrap();
567 tokio::spawn(async move {
568 axum::serve(listener, crate::build_router(state))
569 .await
570 .unwrap()
571 });
572 addr
573 }
574
575 struct Client(TcpStream);
577
578 impl Client {
579 async fn connect(addr: std::net::SocketAddr, query: &str) -> Self {
580 let mut s = TcpStream::connect(addr).await.unwrap();
581 let req = format!(
582 "GET /ws/monitor?{query} HTTP/1.1\r\nHost: {addr}\r\nUpgrade: websocket\r\n\
583 Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
584 Sec-WebSocket-Version: 13\r\n\r\n"
585 );
586 s.write_all(req.as_bytes()).await.unwrap();
587 let mut head = Vec::new();
589 while !head.ends_with(b"\r\n\r\n") {
590 head.push(s.read_u8().await.unwrap());
591 }
592 let head = String::from_utf8(head).unwrap();
593 assert!(head.starts_with("HTTP/1.1 101"), "{head}");
594 Self(s)
595 }
596
597 async fn recv(&mut self) -> Option<serde_json::Value> {
599 let read = async {
600 let mut h = [0u8; 2];
601 if self.0.read_exact(&mut h).await.is_err() {
602 return None;
603 }
604 let len = match h[1] & 0x7f {
605 126 => u64::from(self.0.read_u16().await.unwrap()),
606 127 => self.0.read_u64().await.unwrap(),
607 n => u64::from(n),
608 };
609 let mut payload = vec![0u8; len as usize];
610 self.0.read_exact(&mut payload).await.unwrap();
611 match h[0] & 0x0f {
612 1 => Some(serde_json::from_slice(&payload).unwrap()),
613 8 => None,
614 op => panic!("unexpected opcode {op}"),
615 }
616 };
617 tokio::time::timeout(Duration::from_secs(10), read)
618 .await
619 .expect("a frame within 10s")
620 }
621
622 async fn send(&mut self, opcode: u8, payload: &[u8]) {
624 assert!(payload.len() < 126);
625 let mask = [0x12, 0x34, 0x56, 0x78];
626 let mut frame = vec![0x80 | opcode, 0x80 | payload.len() as u8];
627 frame.extend(mask);
628 frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4]));
629 self.0.write_all(&frame).await.unwrap();
630 }
631 }
632
633 #[tokio::test]
634 async fn test_the_socket_streams_dex_updates_and_answers_a_ping() {
635 let addr = serve(MockClientFactory::new()).await;
636 let mut ws = Client::connect(addr, "token=0xmocktoken&chain=base&refresh=1").await;
637 let hello = ws.recv().await.unwrap();
638 assert_eq!(hello["type"], "connected");
639 assert_eq!(hello["token"], "0xmocktoken");
640 assert_eq!(hello["chain"], "base");
641 assert_eq!(hello["refresh_secs"], 1);
642 assert!(hello["venue"].is_null() && hello["exchange_pair"].is_null());
643
644 let update = ws.recv().await.unwrap();
645 assert_eq!(update["type"], "update", "{update}");
646 assert_eq!(update["token"]["symbol"], "MOCK");
647 assert_eq!(update["price_usd"], 1.0);
648 assert!(update["pairs"].as_array().unwrap().len() <= 5);
649 assert!(
650 update.get("exchange_order_book").is_none(),
651 "no venue asked"
652 );
653
654 ws.send(1, br#"{"type":"ping"}"#).await;
656 assert_eq!(ws.recv().await.unwrap()["type"], "pong");
657 assert_eq!(ws.recv().await.unwrap()["type"], "update");
659 ws.send(1, b"not json").await;
661 ws.send(8, &[]).await;
662 while let Some(frame) = ws.recv().await {
663 assert_eq!(frame["type"], "update", "{frame}");
664 }
665 }
666
667 #[tokio::test]
668 async fn test_a_venue_adds_its_book_ticker_and_trades_to_each_update() {
669 let addr = serve(MockClientFactory::new()).await;
670 let mut ws = Client::connect(addr, "token=BTC&venue=binance").await;
672 let hello = ws.recv().await.unwrap();
673 assert_eq!(hello["venue"], "binance");
674 assert_eq!(hello["exchange_pair"], "BTCUSDT");
675 let update = ws.recv().await.unwrap();
676 assert_eq!(update["type"], "update", "{update}");
677 assert_eq!(update["exchange_order_book"]["best_bid"], 0.999);
678 assert_eq!(update["exchange_ticker"]["last_price"], 1.0);
679 assert_eq!(update["exchange_trades"][0]["side"], "buy");
680 }
681
682 #[tokio::test]
683 async fn test_a_dex_failure_is_an_error_frame_that_keeps_the_exchange_data() {
684 let mut mock = MockClientFactory::new();
685 mock.mock_dex.token_data = None;
686 let addr = serve(mock).await;
687 let mut ws = Client::connect(addr, "token=ETH&venue=binance&pair=ETH/USDC").await;
688 let hello = ws.recv().await.unwrap();
689 assert_eq!(hello["exchange_pair"], "ETHUSDC", "the asked pair");
690 let frame = ws.recv().await.unwrap();
691 assert_eq!(frame["type"], "error", "{frame}");
692 assert!(
693 frame["message"].as_str().unwrap().contains("No DEX data"),
694 "{frame}"
695 );
696 assert_eq!(frame["exchange_ticker"]["last_price"], 1.0, "{frame}");
697 assert!(frame["exchange_trades"].is_array(), "{frame}");
698 }
699
700 #[tokio::test]
701 async fn test_an_unknown_venue_is_reported_and_the_socket_closes() {
702 let addr = serve(MockClientFactory::new()).await;
703 let mut ws = Client::connect(addr, "token=BTC&venue=nonexistent_venue_xyz").await;
704 let frame = ws.recv().await.unwrap();
705 assert_eq!(frame["type"], "error", "{frame}");
706 assert!(
707 frame["message"].as_str().unwrap().contains("Unknown venue"),
708 "{frame}"
709 );
710 assert!(ws.recv().await.is_none(), "no stream after the error");
711 }
712}