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().into())).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().into()))
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
185 .send(Message::Text(msg.to_string().into()))
186 .await
187 .is_err()
188 {
189 break;
191 }
192
193 tokio::select! {
195 _ = tokio::time::sleep(refresh) => {},
196 msg = socket.recv() => {
197 match msg {
198 Some(Ok(Message::Close(_))) | None => break,
199 Some(Ok(Message::Text(text))) => {
200 if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(&text)
202 && cmd.get("type").and_then(|t| t.as_str()) == Some("ping")
203 {
204 let pong = serde_json::json!({"type": "pong"});
205 let _ = socket.send(Message::Text(pong.to_string().into())).await;
206 }
207 }
208 _ => {}
209 }
210 }
211 }
212 }
213}
214
215const FRAME_ITEMS: usize = 20;
217
218fn attach_exchange_data(frame: &mut serde_json::Value, snap: &scope::market::MarketSnapshot) {
222 if let Some(book) = &snap.order_book {
223 frame["exchange_order_book"] = book.to_json(Some(FRAME_ITEMS));
224 }
225 if let Some(ticker) = &snap.ticker {
226 frame["exchange_ticker"] = serde_json::json!(ticker);
227 }
228 if let Some(trades) = &snap.recent_trades {
229 let recent: Vec<_> = trades.iter().take(FRAME_ITEMS).collect();
230 frame["exchange_trades"] = serde_json::json!(recent);
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use scope::market::{MarketSnapshot, OrderBook, OrderBookLevel, Ticker, Trade, TradeSide};
238
239 #[test]
240 fn test_default_chain() {
241 assert_eq!(crate::api::default_chain(), "ethereum");
242 }
243
244 #[test]
245 fn test_default_refresh() {
246 assert_eq!(default_refresh(), 5);
247 }
248
249 #[test]
252 fn test_exchange_client_for_unknown_venue_is_a_request_fault() {
253 let err = exchange_client_for(Some("nonexistent_venue_xyz"), scope::http::test_transport())
254 .err()
255 .unwrap();
256 assert!(
257 matches!(err, scope::error::ScopeError::InvalidInput(_)),
258 "{err:?}"
259 );
260 assert!(err.to_string().contains("Unknown venue"), "{err}");
261 }
262
263 #[test]
264 fn test_exchange_client_for_none_and_known_venue() {
265 assert!(
266 exchange_client_for(None, scope::http::test_transport())
267 .unwrap()
268 .is_none()
269 );
270 assert!(
271 exchange_client_for(Some("binance"), scope::http::test_transport())
272 .unwrap()
273 .is_some()
274 );
275 }
276
277 #[test]
278 fn test_deserialize_monitor_query_full() {
279 let json = serde_json::json!({
280 "token": "USDC",
281 "chain": "solana",
282 "refresh": 10
283 });
284 let query: MonitorQuery = serde_json::from_value(json).unwrap();
285 assert_eq!(query.token, "USDC");
286 assert_eq!(query.chain, "solana");
287 assert_eq!(query.refresh, 10);
288 assert!(query.venue.is_none());
289 }
290
291 #[test]
292 fn test_deserialize_monitor_query_minimal() {
293 let json = serde_json::json!({
294 "token": "ETH"
295 });
296 let query: MonitorQuery = serde_json::from_value(json).unwrap();
297 assert_eq!(query.token, "ETH");
298 assert_eq!(query.chain, "ethereum");
299 assert_eq!(query.refresh, 5);
300 assert!(query.venue.is_none());
301 assert!(query.pair.is_none());
302 }
303
304 #[test]
305 fn test_deserialize_monitor_query_with_venue() {
306 let json = serde_json::json!({
307 "token": "BTC",
308 "venue": "binance",
309 "pair": "USDC",
310 "refresh": 10
311 });
312 let query: MonitorQuery = serde_json::from_value(json).unwrap();
313 assert_eq!(query.token, "BTC");
314 assert_eq!(query.venue.as_deref(), Some("binance"));
315 assert_eq!(query.pair.as_deref(), Some("USDC"));
316 }
317
318 #[test]
319 fn test_attach_exchange_data_full() {
320 let snapshot = MarketSnapshot {
321 order_book: Some(OrderBook {
322 pair: "BTC/USDT".to_string(),
323 source: scope::market::BookSource::Exchange,
324 bids: vec![OrderBookLevel {
325 price: 50000.0,
326 quantity: 1.5,
327 }],
328 asks: vec![OrderBookLevel {
329 price: 50010.0,
330 quantity: 2.0,
331 }],
332 }),
333 ticker: Some(Ticker {
334 pair: "BTC/USDT".to_string(),
335 last_price: Some(50005.0),
336 high_24h: Some(51000.0),
337 low_24h: Some(49000.0),
338 volume_24h: Some(1_000_000.0),
339 quote_volume_24h: Some(50_000_000.0),
340 price_change_pct_24h: None,
341 best_bid: Some(50000.0),
342 best_ask: Some(50010.0),
343 }),
344 recent_trades: Some(vec![Trade {
345 price: 50005.0,
346 quantity: 0.5,
347 quote_quantity: Some(25002.5),
348 timestamp_ms: 1700000000000,
349 side: TradeSide::Buy,
350 id: None,
351 }]),
352 };
353
354 let mut frame = serde_json::json!({"type": "update"});
355 attach_exchange_data(&mut frame, &snapshot);
356
357 assert!(frame.get("exchange_order_book").is_some());
358 assert!(frame.get("exchange_ticker").is_some());
359 assert!(frame.get("exchange_trades").is_some());
360 assert_eq!(
361 frame["exchange_ticker"]["last_price"].as_f64().unwrap(),
362 50005.0
363 );
364 }
365
366 #[test]
367 fn test_attach_exchange_data_empty() {
368 let snapshot = MarketSnapshot {
369 order_book: None,
370 ticker: None,
371 recent_trades: None,
372 };
373
374 let mut frame = serde_json::json!({"type": "update"});
375 attach_exchange_data(&mut frame, &snapshot);
376
377 assert!(frame.get("exchange_order_book").is_none());
378 assert!(frame.get("exchange_ticker").is_none());
379 assert!(frame.get("exchange_trades").is_none());
380 }
381
382 #[test]
383 fn test_attach_exchange_data_spread_matches_the_market_summary() {
384 use scope::market::{
385 BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel,
386 };
387 let level = |price, quantity| OrderBookLevel { price, quantity };
388 let book = OrderBook {
390 pair: "X/USDT".to_string(),
391 source: BookSource::Exchange,
392 bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
393 asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
394 };
395 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
396 let snapshot = scope::market::MarketSnapshot {
397 order_book: Some(book),
398 ticker: None,
399 recent_trades: None,
400 };
401 let mut frame = serde_json::json!({"type": "update"});
402 attach_exchange_data(&mut frame, &snapshot);
403 assert_eq!(
404 frame["exchange_order_book"]["spread"].as_f64(),
405 summary.spread
406 );
407 assert_eq!(
408 frame["exchange_order_book"]["mid_price"].as_f64(),
409 summary.mid_price
410 );
411 }
412
413 #[test]
414 fn test_attach_exchange_data_with_sell_trade() {
415 let snapshot = MarketSnapshot {
416 order_book: None,
417 ticker: None,
418 recent_trades: Some(vec![Trade {
419 price: 50005.0,
420 quantity: 0.5,
421 quote_quantity: Some(25002.5),
422 timestamp_ms: 1700000000000,
423 side: TradeSide::Sell,
424 id: None,
425 }]),
426 };
427
428 let mut frame = serde_json::json!({"type": "update"});
429 attach_exchange_data(&mut frame, &snapshot);
430
431 assert!(frame.get("exchange_trades").is_some());
432 let trades = frame["exchange_trades"].as_array().unwrap();
433 assert_eq!(trades.len(), 1);
434 assert_eq!(trades[0]["side"], "sell");
435 }
436
437 #[test]
438 fn test_attach_exchange_data_order_book_only() {
439 let snapshot = MarketSnapshot {
440 order_book: Some(OrderBook {
441 pair: "ETH/USDT".to_string(),
442 source: scope::market::BookSource::Exchange,
443 bids: vec![OrderBookLevel {
444 price: 2000.0,
445 quantity: 2.0,
446 }],
447 asks: vec![OrderBookLevel {
448 price: 2005.0,
449 quantity: 1.5,
450 }],
451 }),
452 ticker: None,
453 recent_trades: None,
454 };
455
456 let mut frame = serde_json::json!({"type": "update"});
457 attach_exchange_data(&mut frame, &snapshot);
458
459 assert!(frame.get("exchange_order_book").is_some());
460 assert_eq!(frame["exchange_order_book"]["pair"], "ETH/USDT");
461 assert_eq!(frame["exchange_order_book"]["best_bid"], 2000.0);
462 assert_eq!(frame["exchange_order_book"]["best_ask"], 2005.0);
463 assert!(frame.get("exchange_ticker").is_none());
464 assert!(frame.get("exchange_trades").is_none());
465 }
466
467 #[test]
468 fn test_attach_exchange_data_ticker_only() {
469 let snapshot = MarketSnapshot {
470 order_book: None,
471 ticker: Some(Ticker {
472 pair: "SOL/USDT".to_string(),
473 last_price: Some(100.5),
474 high_24h: Some(102.0),
475 low_24h: Some(99.0),
476 volume_24h: Some(50000.0),
477 quote_volume_24h: Some(5_000_000.0),
478 price_change_pct_24h: None,
479 best_bid: Some(100.0),
480 best_ask: Some(101.0),
481 }),
482 recent_trades: None,
483 };
484
485 let mut frame = serde_json::json!({"type": "update"});
486 attach_exchange_data(&mut frame, &snapshot);
487
488 assert!(frame.get("exchange_ticker").is_some());
489 assert_eq!(frame["exchange_ticker"]["pair"], "SOL/USDT");
490 assert_eq!(frame["exchange_ticker"]["last_price"], 100.5);
491 assert!(frame.get("exchange_order_book").is_none());
492 }
493
494 use scope::chains::ChainClientFactory;
500 use scope::chains::mocks::MockClientFactory;
501 use scope::http::{HttpClient, Request, Response};
502 use tokio::io::{AsyncReadExt, AsyncWriteExt};
503 use tokio::net::TcpStream;
504
505 struct CannedVenue;
508
509 impl HttpClient for CannedVenue {
510 fn send<'a, 'b>(
511 &'a self,
512 req: Request,
513 ) -> std::pin::Pin<
514 Box<dyn std::future::Future<Output = scope::error::Result<Response>> + Send + 'b>,
515 >
516 where
517 'a: 'b,
518 Self: 'b,
519 {
520 let body = if req.url.contains("/api/v3/depth") {
521 Some(r#"{"bids":[["0.999","100"]],"asks":[["1.001","200"]]}"#)
522 } else if req.url.contains("/api/v3/ticker/24hr") {
523 Some(
524 r#"{"lastPrice":"1.0","highPrice":"1.1","lowPrice":"0.9","volume":"10","quoteVolume":"10","priceChangePercent":"0.5","bidPrice":"0.999","askPrice":"1.001"}"#,
525 )
526 } else if req.url.contains("/api/v3/trades") {
527 Some(
528 r#"[{"id":9,"price":"1.0","qty":"3","quoteQty":"3","time":1700000000000,"isBuyerMaker":false}]"#,
529 )
530 } else {
531 None
532 };
533 Box::pin(async move {
534 Ok(Response {
535 status_code: if body.is_some() { 200 } else { 404 },
536 headers: Default::default(),
537 body: body.unwrap_or_default().to_string(),
538 })
539 })
540 }
541 }
542
543 struct TestFactory(MockClientFactory);
545
546 impl ChainClientFactory for TestFactory {
547 fn create_chain_client(
548 &self,
549 chain: &str,
550 ) -> scope::error::Result<Box<dyn scope::chains::ChainClient>> {
551 self.0.create_chain_client(chain)
552 }
553
554 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
555 self.0.create_dex_client()
556 }
557
558 fn http(&self) -> Arc<dyn HttpClient> {
559 Arc::new(CannedVenue)
560 }
561 }
562
563 async fn serve(mock: MockClientFactory) -> std::net::SocketAddr {
565 let state = Arc::new(crate::AppState::new(
566 scope::config::Config::default(),
567 TestFactory(mock),
568 ));
569 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
570 let addr = listener.local_addr().unwrap();
571 tokio::spawn(async move {
572 axum::serve(listener, crate::build_router(state))
573 .await
574 .unwrap()
575 });
576 addr
577 }
578
579 struct Client(TcpStream);
581
582 impl Client {
583 async fn connect(addr: std::net::SocketAddr, query: &str) -> Self {
584 let mut s = TcpStream::connect(addr).await.unwrap();
585 let req = format!(
586 "GET /ws/monitor?{query} HTTP/1.1\r\nHost: {addr}\r\nUpgrade: websocket\r\n\
587 Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
588 Sec-WebSocket-Version: 13\r\n\r\n"
589 );
590 s.write_all(req.as_bytes()).await.unwrap();
591 let mut head = Vec::new();
593 while !head.ends_with(b"\r\n\r\n") {
594 head.push(s.read_u8().await.unwrap());
595 }
596 let head = String::from_utf8(head).unwrap();
597 assert!(head.starts_with("HTTP/1.1 101"), "{head}");
598 Self(s)
599 }
600
601 async fn recv(&mut self) -> Option<serde_json::Value> {
603 let read = async {
604 let mut h = [0u8; 2];
605 if self.0.read_exact(&mut h).await.is_err() {
606 return None;
607 }
608 let len = match h[1] & 0x7f {
609 126 => u64::from(self.0.read_u16().await.unwrap()),
610 127 => self.0.read_u64().await.unwrap(),
611 n => u64::from(n),
612 };
613 let mut payload = vec![0u8; len as usize];
614 self.0.read_exact(&mut payload).await.unwrap();
615 match h[0] & 0x0f {
616 1 => Some(serde_json::from_slice(&payload).unwrap()),
617 8 => None,
618 op => panic!("unexpected opcode {op}"),
619 }
620 };
621 tokio::time::timeout(Duration::from_secs(10), read)
622 .await
623 .expect("a frame within 10s")
624 }
625
626 async fn send(&mut self, opcode: u8, payload: &[u8]) {
628 assert!(payload.len() < 126);
629 let mask = [0x12, 0x34, 0x56, 0x78];
630 let mut frame = vec![0x80 | opcode, 0x80 | payload.len() as u8];
631 frame.extend(mask);
632 frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4]));
633 self.0.write_all(&frame).await.unwrap();
634 }
635 }
636
637 #[tokio::test]
638 async fn test_the_socket_streams_dex_updates_and_answers_a_ping() {
639 let addr = serve(MockClientFactory::new()).await;
640 let mut ws = Client::connect(addr, "token=0xmocktoken&chain=base&refresh=1").await;
641 let hello = ws.recv().await.unwrap();
642 assert_eq!(hello["type"], "connected");
643 assert_eq!(hello["token"], "0xmocktoken");
644 assert_eq!(hello["chain"], "base");
645 assert_eq!(hello["refresh_secs"], 1);
646 assert!(hello["venue"].is_null() && hello["exchange_pair"].is_null());
647
648 let update = ws.recv().await.unwrap();
649 assert_eq!(update["type"], "update", "{update}");
650 assert_eq!(update["token"]["symbol"], "MOCK");
651 assert_eq!(update["price_usd"], 1.0);
652 assert!(update["pairs"].as_array().unwrap().len() <= 5);
653 assert!(
654 update.get("exchange_order_book").is_none(),
655 "no venue asked"
656 );
657
658 ws.send(1, br#"{"type":"ping"}"#).await;
660 assert_eq!(ws.recv().await.unwrap()["type"], "pong");
661 assert_eq!(ws.recv().await.unwrap()["type"], "update");
663 ws.send(1, b"not json").await;
665 ws.send(8, &[]).await;
666 while let Some(frame) = ws.recv().await {
667 assert_eq!(frame["type"], "update", "{frame}");
668 }
669 }
670
671 #[tokio::test]
672 async fn test_a_venue_adds_its_book_ticker_and_trades_to_each_update() {
673 let addr = serve(MockClientFactory::new()).await;
674 let mut ws = Client::connect(addr, "token=BTC&venue=binance").await;
676 let hello = ws.recv().await.unwrap();
677 assert_eq!(hello["venue"], "binance");
678 assert_eq!(hello["exchange_pair"], "BTCUSDT");
679 let update = ws.recv().await.unwrap();
680 assert_eq!(update["type"], "update", "{update}");
681 assert_eq!(update["exchange_order_book"]["best_bid"], 0.999);
682 assert_eq!(update["exchange_ticker"]["last_price"], 1.0);
683 assert_eq!(update["exchange_trades"][0]["side"], "buy");
684 }
685
686 #[tokio::test]
687 async fn test_a_dex_failure_is_an_error_frame_that_keeps_the_exchange_data() {
688 let mut mock = MockClientFactory::new();
689 mock.mock_dex.token_data = None;
690 let addr = serve(mock).await;
691 let mut ws = Client::connect(addr, "token=ETH&venue=binance&pair=ETH/USDC").await;
692 let hello = ws.recv().await.unwrap();
693 assert_eq!(hello["exchange_pair"], "ETHUSDC", "the asked pair");
694 let frame = ws.recv().await.unwrap();
695 assert_eq!(frame["type"], "error", "{frame}");
696 assert!(
697 frame["message"].as_str().unwrap().contains("No DEX data"),
698 "{frame}"
699 );
700 assert_eq!(frame["exchange_ticker"]["last_price"], 1.0, "{frame}");
701 assert!(frame["exchange_trades"].is_array(), "{frame}");
702 }
703
704 #[tokio::test]
705 async fn test_an_unknown_venue_is_reported_and_the_socket_closes() {
706 let addr = serve(MockClientFactory::new()).await;
707 let mut ws = Client::connect(addr, "token=BTC&venue=nonexistent_venue_xyz").await;
708 let frame = ws.recv().await.unwrap();
709 assert_eq!(frame["type"], "error", "{frame}");
710 assert!(
711 frame["message"].as_str().unwrap().contains("Unknown venue"),
712 "{frame}"
713 );
714 assert!(ws.recv().await.is_none(), "no stream after the error");
715 }
716}