Skip to main content

scope_web/
monitor.rs

1//! WebSocket monitor handler for live token data streaming.
2//!
3//! Streams real-time token price, volume, and transaction data
4//! to connected browser clients via WebSocket.
5
6use 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/// Query parameters for the WebSocket monitor connection.
18#[derive(Debug, Deserialize)]
19pub struct MonitorQuery {
20    /// Token address or symbol to monitor.
21    pub token: String,
22    /// Chain (default: "ethereum").
23    #[serde(default = "crate::api::default_chain")]
24    pub chain: String,
25    /// Refresh interval in seconds (default: 5).
26    #[serde(default = "default_refresh")]
27    pub refresh: u64,
28    /// Optional exchange venue ID (e.g., "binance") — when set, exchange data
29    /// (order book, ticker, recent trades) is included in each update frame.
30    #[serde(default)]
31    pub venue: Option<String>,
32    /// Pair for exchange data, read like the CLI pair (default: the token
33    /// symbol with the venue's default quote).
34    #[serde(default)]
35    pub pair: Option<String>,
36}
37
38fn default_refresh() -> u64 {
39    5
40}
41
42/// The venue's exchange client, or the one unknown-venue error.
43fn 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
55/// WS /ws/monitor — WebSocket endpoint for live token monitoring.
56pub 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
64/// Handles an individual WebSocket connection.
65///
66/// Polls DexScreener at the configured interval and sends JSON updates
67/// containing price, volume, and activity data.
68async 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    // Resolve token to address if needed
73    let token_input = params.token.clone();
74    let chain = params.chain.clone();
75
76    // Optionally create an exchange client for CEX data. An unknown venue is
77    // the one unknown-venue error, reported on the socket (same frame shape
78    // as a DexTokenData error), not silently dropped.
79    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    // Send initial connection message
98    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        // Fetch latest DEX token data
116        let data: scope::error::Result<DexTokenData> =
117            dex_client.get_token_data(&chain, &token_input).await;
118
119        // Optionally fetch exchange snapshot in parallel
120        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                // Attach exchange data if available
165                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                // Still attach exchange data even on DEX error
177                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            // Client disconnected
190            break;
191        }
192
193        // Wait for next refresh or client message
194        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                        // Handle client commands (e.g., change token)
201                        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
215/// Levels and trades a monitor frame lists, at most.
216const FRAME_ITEMS: usize = 20;
217
218/// Attach exchange snapshot data (order book, ticker, trades) to a JSON
219/// frame, in the one JSON form of each (`OrderBook::to_json`, `Ticker` and
220/// `Trade` serialization).
221fn 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    /// An unknown venue used to be silently dropped (no exchange data, no
250    /// word); now the one unknown-venue error reaches the client.
251    #[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        // Zero-quantity top levels are not real orders.
389        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    // ========================================================================
495    // The endpoint over a real socket: the router on a local port, a mock
496    // DEX, and a venue transport that answers from canned replies.
497    // ========================================================================
498
499    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    /// Answers a Binance-shaped venue's book, ticker and trades requests;
506    /// anything else is a 404. Nothing leaves the process.
507    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    /// The mock chain and DEX clients, with the canned venue transport.
544    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    /// Serves the router on a free local port.
564    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    /// A minimal WebSocket client: the handshake, unfragmented frames.
580    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            // Byte by byte, so no frame byte is read with the headers.
592            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        /// The next text frame as JSON, or `None` when the server closed.
602        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        /// Sends a masked frame, as a client must.
627        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        // A ping is answered at once, before the next refresh.
659        ws.send(1, br#"{"type":"ping"}"#).await;
660        assert_eq!(ws.recv().await.unwrap()["type"], "pong");
661        // Then the stream goes on.
662        assert_eq!(ws.recv().await.unwrap()["type"], "update");
663        // Another text message is ignored; a close ends the stream.
664        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        // With no pair, the token is the base, with the venue's quote.
675        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}