1use std::sync::{
19 Arc,
20 atomic::{AtomicBool, AtomicU8, Ordering},
21};
22
23use nautilus_live::{
24 SocketControl,
25 book::snapshot::SnapshotGate,
26 task::{TaskJoinOutcome, TaskSlot, finish_task},
27};
28use nautilus_network::{
29 ConnectionUrl, SocketStateSink,
30 http::create_standard_nautilus_headers,
31 mode::ConnectionMode,
32 websocket::{
33 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
34 channel_epoch_message_handler, proxy::ProxyUrl,
35 },
36};
37
38use super::{
39 handler::{FeedHandler, HandlerCommand},
40 messages::PolymarketWsMessage,
41};
42use crate::common::{
43 credential::Credential,
44 urls::{clob_ws_market_url, clob_ws_user_url},
45};
46
47pub(super) const POLYMARKET_HEARTBEAT_SECS: u64 = 10;
51pub(super) const POLYMARKET_HEARTBEAT_PAYLOAD: &str = "PING";
52
53const POLYMARKET_HEARTBEAT_TIMEOUT_SECS: u64 = POLYMARKET_HEARTBEAT_SECS * 3;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum WsChannel {
61 Market,
62 User,
63}
64
65#[derive(Clone, Debug)]
69pub struct WsSubscriptionHandle {
70 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
71}
72
73impl WsSubscriptionHandle {
74 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
76 self.cmd_tx
77 .read()
78 .await
79 .send(HandlerCommand::SubscribeMarket(asset_ids))
80 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
81 }
82
83 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
85 self.cmd_tx
86 .read()
87 .await
88 .send(HandlerCommand::UnsubscribeMarket(asset_ids))
89 .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
90 }
91
92 pub async fn cycle_market_subscription(
94 &self,
95 asset_ids: Vec<String>,
96 cancel: tokio_util::sync::CancellationToken,
97 responder: tokio::sync::oneshot::Sender<super::handler::CycleMarketOutcome>,
98 gate: SnapshotGate,
99 ) -> anyhow::Result<()> {
100 self.cmd_tx
101 .read()
102 .await
103 .send(HandlerCommand::CycleMarketSubscription {
104 asset_ids,
105 cancel,
106 responder,
107 gate,
108 })
109 .map_err(|e| anyhow::anyhow!("Failed to send CycleMarketSubscription: {e}"))
110 }
111
112 #[cfg(test)]
116 pub(crate) fn from_sender(sender: tokio::sync::mpsc::UnboundedSender<HandlerCommand>) -> Self {
117 Self {
118 cmd_tx: Arc::new(tokio::sync::RwLock::new(sender)),
119 }
120 }
121}
122
123#[derive(Debug)]
129pub struct PolymarketWebSocketClient {
130 channel: WsChannel,
131 url: ConnectionUrl,
132 connection_mode: Arc<AtomicU8>,
133 signal: Arc<AtomicBool>,
134 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
135 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>>,
136 credential: Option<Credential>,
137 subscriptions: SubscriptionState,
138 discovery_subscribed: Arc<AtomicBool>,
139 auth_tracker: AuthTracker,
140 user_subscribed: Arc<AtomicBool>,
143 task_handle: TaskSlot<()>,
144 subscribe_new_markets: bool,
145 transport_backend: TransportBackend,
146 proxy_url: Option<ProxyUrl>,
147 socket_sink: Option<SocketStateSink>,
148 socket_control: Option<SocketControl>,
149}
150
151#[derive(Clone, Debug)]
152pub(crate) struct PolymarketWebSocketShutdownHandle {
153 signal: Arc<AtomicBool>,
154}
155
156impl PolymarketWebSocketShutdownHandle {
157 pub(crate) fn begin_shutdown(&self) {
158 self.signal.store(true, Ordering::Relaxed);
159 }
160}
161
162impl PolymarketWebSocketClient {
163 #[must_use]
167 pub fn new_market(
168 base_url: Option<String>,
169 subscribe_new_markets: bool,
170 transport_backend: TransportBackend,
171 ) -> Self {
172 Self::new_market_with_proxy(base_url, subscribe_new_markets, transport_backend, None)
173 }
174
175 #[must_use]
177 pub fn new_market_with_proxy(
178 base_url: Option<String>,
179 subscribe_new_markets: bool,
180 transport_backend: TransportBackend,
181 proxy_url: Option<ProxyUrl>,
182 ) -> Self {
183 let url = base_url.unwrap_or_else(|| clob_ws_market_url().to_string());
184 Self::new_inner(
185 WsChannel::Market,
186 url,
187 None,
188 subscribe_new_markets,
189 transport_backend,
190 proxy_url,
191 )
192 }
193
194 #[must_use]
198 pub fn new_user(
199 base_url: Option<String>,
200 credential: Credential,
201 transport_backend: TransportBackend,
202 ) -> Self {
203 Self::new_user_with_proxy(base_url, credential, transport_backend, None)
204 }
205
206 #[must_use]
208 pub fn new_user_with_proxy(
209 base_url: Option<String>,
210 credential: Credential,
211 transport_backend: TransportBackend,
212 proxy_url: Option<ProxyUrl>,
213 ) -> Self {
214 let url = base_url.unwrap_or_else(|| clob_ws_user_url().to_string());
215 Self::new_inner(
216 WsChannel::User,
217 url,
218 Some(credential),
219 false,
220 transport_backend,
221 proxy_url,
222 )
223 }
224
225 fn new_inner(
226 channel: WsChannel,
227 url: String,
228 credential: Option<Credential>,
229 subscribe_new_markets: bool,
230 transport_backend: TransportBackend,
231 proxy_url: Option<ProxyUrl>,
232 ) -> Self {
233 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
234 Self {
235 channel,
236 url: url.into(),
237 connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Closed.as_u8())),
238 signal: Arc::new(AtomicBool::new(false)),
239 cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
240 out_rx: None,
241 credential,
242 subscriptions: SubscriptionState::new(':'),
243 discovery_subscribed: Arc::new(AtomicBool::new(false)),
244 auth_tracker: AuthTracker::new(),
245 user_subscribed: Arc::new(AtomicBool::new(false)),
246 task_handle: TaskSlot::new(),
247 subscribe_new_markets,
248 transport_backend,
249 proxy_url,
250 socket_sink: None,
251 socket_control: None,
252 }
253 }
254
255 #[must_use]
257 pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
258 self.socket_sink = Some(state_sink);
259 self
260 }
261
262 #[must_use]
264 pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
265 self.socket_control = Some(control);
266 self
267 }
268
269 #[cfg(test)]
270 pub(crate) fn proxy_url(&self) -> Option<&ProxyUrl> {
271 self.proxy_url.as_ref()
272 }
273
274 pub async fn connect(&mut self) -> anyhow::Result<()> {
276 let mode = ConnectionMode::from_atomic(&self.connection_mode);
277 if mode.is_active() || mode.is_reconnect() {
278 log::warn!("Polymarket WebSocket already connected or reconnecting");
279 return Ok(());
280 }
281
282 if self.task_handle.is_some() {
283 self.disconnect().await?;
284 }
285
286 let (message_handler, raw_rx) = channel_epoch_message_handler();
287 let cfg = self.websocket_config();
288
289 let client = WebSocketClient::epoch_builder()
290 .config(cfg)
291 .epoch_handler(message_handler)
292 .maybe_state_sink(
293 self.socket_control
294 .as_ref()
295 .map(SocketControl::sink)
296 .or_else(|| self.socket_sink.clone()),
297 )
298 .connect()
299 .await?;
300
301 if let Some(control) = &self.socket_control {
302 let handle = client.reconnect_handle();
303 control.register(move || handle.request_reconnect());
304 }
305 let connection_epoch = client.connection_epoch();
306
307 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
308 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<PolymarketWsMessage>();
309
310 *self.cmd_tx.write().await = cmd_tx.clone();
311 self.out_rx = Some(out_rx);
312
313 let client_mode = client.connection_mode_atomic();
314 self.connection_mode = client_mode;
315
316 log::debug!("Polymarket WebSocket connected: {}", self.url.origin());
317
318 let initial_market_replay = match self.channel {
321 WsChannel::Market => {
322 let topics = self.subscriptions.reset_after_reconnect();
323 if !topics.is_empty() || self.discovery_subscribed.load(Ordering::Relaxed) {
324 log::debug!(
325 "Replaying market subscription state onto new session: assets={}, discovery={}",
326 topics.len(),
327 self.discovery_subscribed.load(Ordering::Relaxed),
328 );
329 Some((topics, connection_epoch))
330 } else {
331 None
332 }
333 }
334 WsChannel::User => {
335 if self.user_subscribed.load(Ordering::Relaxed) {
336 log::debug!("Replaying user subscribe onto new session");
337 cmd_tx
338 .send(HandlerCommand::SubscribeUser)
339 .map_err(|e| anyhow::anyhow!("Failed to replay SubscribeUser: {e}"))?;
340 }
341 None
342 }
343 };
344
345 let signal = Arc::clone(&self.signal);
346 let channel = self.channel;
347 let credential = self.credential.clone();
348 let subscriptions = self.subscriptions.clone();
349 let discovery_subscribed = Arc::clone(&self.discovery_subscribed);
350 let auth_tracker = self.auth_tracker.clone();
351 let user_subscribed = self.user_subscribed.load(Ordering::Relaxed);
352 let subscribe_new_markets = self.subscribe_new_markets;
353
354 if let Err(e) = self.task_handle.spawn(async move {
355 let mut handler = FeedHandler::new(
356 signal,
357 channel,
358 Some(client),
359 cmd_rx,
360 raw_rx,
361 out_tx,
362 credential,
363 subscriptions,
364 discovery_subscribed,
365 initial_market_replay,
366 auth_tracker,
367 user_subscribed,
368 subscribe_new_markets,
369 );
370
371 loop {
372 match handler.next().await {
373 Some(PolymarketWsMessage::Reconnected { .. }) => {
374 log::info!("Polymarket WebSocket reconnected");
375
376 if handler
377 .send(PolymarketWsMessage::Reconnected { shard_id: None })
378 .is_err()
379 {
380 if handler.is_stopped() {
381 log::debug!("Output channel closed, stopping handler");
382 } else {
383 log::error!("Output channel closed, stopping handler");
384 }
385 break;
386 }
387 }
388 Some(msg) => {
389 if handler.send(msg).is_err() {
390 if handler.is_stopped() {
391 log::debug!("Output channel closed, stopping handler");
392 } else {
393 log::error!("Output channel closed, stopping handler");
394 }
395 break;
396 }
397 }
398 None => {
399 if handler.is_stopped() {
400 log::debug!("Stop signal received, ending handler task");
401 } else {
402 log::warn!("Polymarket WebSocket stream ended unexpectedly");
403 }
404 break;
405 }
406 }
407 }
408 log::debug!("Polymarket WebSocket handler task completed");
409 }) {
410 self.out_rx = None;
411 anyhow::bail!("Failed to start Polymarket WebSocket handler task: {e}");
412 }
413 Ok(())
414 }
415
416 fn websocket_config(&self) -> WebSocketConfig {
417 let heartbeat_payload = match self.channel {
420 WsChannel::Market => None,
421 WsChannel::User => Some(POLYMARKET_HEARTBEAT_PAYLOAD.to_string()),
422 };
423
424 let headers = create_standard_nautilus_headers();
425
426 WebSocketConfig {
427 url: self.url.expose().to_owned(),
428 headers,
429 heartbeat_interval_secs: Some(POLYMARKET_HEARTBEAT_SECS),
430 heartbeat_payload,
431 connect_timeout_ms: Some(15_000),
432 reconnect_delay_initial_ms: Some(250),
433 reconnect_delay_max_ms: Some(5_000),
434 reconnect_backoff_factor: Some(2.0),
435 reconnect_jitter_ms: Some(200),
436 reconnect_max_attempts: None,
437 heartbeat_timeout_secs: Some(POLYMARKET_HEARTBEAT_TIMEOUT_SECS),
438 idle_timeout_ms: None,
439 writer_capacity: None,
440 backend: self.transport_backend,
441 proxy_url: self.proxy_url.as_ref().map(|url| url.expose().to_string()),
442 max_message_size_bytes: None,
443 max_frame_size_bytes: None,
444 }
445 }
446
447 pub(crate) fn begin_shutdown(&self) {
448 self.signal.store(true, Ordering::Relaxed);
449 }
450
451 pub(crate) fn shutdown_handle(&self) -> PolymarketWebSocketShutdownHandle {
452 PolymarketWebSocketShutdownHandle {
453 signal: Arc::clone(&self.signal),
454 }
455 }
456
457 pub(crate) fn abort(&mut self) {
458 self.begin_shutdown();
459 self.connection_mode
460 .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
461 self.task_handle.abort();
462 self.auth_tracker.invalidate();
463
464 if let Some(control) = &self.socket_control {
465 control.deregister();
466 }
467 }
468
469 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
471 log::debug!("Disconnecting Polymarket WebSocket");
472 self.signal.store(true, Ordering::Relaxed);
473
474 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
475 log::debug!("Failed to send disconnect (handler may already be shut down): {e}");
476 }
477
478 let task_result = match finish_task(
479 &mut self.task_handle,
480 std::time::Duration::from_secs(2),
481 std::time::Duration::from_secs(2),
482 )
483 .await
484 {
485 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
486 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
487 "Polymarket WebSocket handler failed: {error}"
488 )),
489 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
490 "Polymarket WebSocket handler did not stop after abort"
491 )),
492 };
493 self.auth_tracker.invalidate();
496
497 if let Some(control) = &self.socket_control {
498 control.deregister();
499 }
500 log::debug!("Polymarket WebSocket disconnected");
501 task_result
502 }
503
504 #[must_use]
506 pub fn is_active(&self) -> bool {
507 ConnectionMode::from_atomic(&self.connection_mode).is_active()
508 }
509
510 pub(crate) fn has_task(&self) -> bool {
511 self.task_handle.is_some()
512 }
513
514 #[must_use]
516 pub fn url(&self) -> &str {
517 self.url.expose()
518 }
519
520 #[must_use]
522 pub fn subscription_count(&self) -> usize {
523 self.subscriptions.all_topics().len()
524 }
525
526 pub(crate) fn clear_reconnect_state(&self) {
531 self.subscriptions.clear();
532 self.discovery_subscribed.store(false, Ordering::Relaxed);
533 self.user_subscribed.store(false, Ordering::Relaxed);
534 self.auth_tracker.invalidate();
535 }
536
537 #[must_use]
539 pub fn is_authenticated(&self) -> bool {
540 self.auth_tracker.is_authenticated()
541 }
542
543 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
552 if self.channel != WsChannel::Market {
553 anyhow::bail!(
554 "subscribe_market() requires a market-channel client (created with new_market())"
555 );
556 }
557 self.cmd_tx
558 .read()
559 .await
560 .send(HandlerCommand::SubscribeMarket(asset_ids))
561 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
562 }
563
564 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
579 if self.channel != WsChannel::Market {
580 anyhow::bail!(
581 "unsubscribe_market() requires a market-channel client (created with new_market())"
582 );
583 }
584 self.cmd_tx
585 .read()
586 .await
587 .send(HandlerCommand::UnsubscribeMarket(asset_ids))
588 .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
589 }
590
591 pub async fn subscribe_user(&self) -> anyhow::Result<()> {
597 if self.channel != WsChannel::User {
598 anyhow::bail!(
599 "subscribe_user() requires a user-channel client (created with new_user())"
600 );
601 }
602 self.cmd_tx
603 .read()
604 .await
605 .send(HandlerCommand::SubscribeUser)
606 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeUser: {e}"))?;
607 self.user_subscribed.store(true, Ordering::Relaxed);
610 Ok(())
611 }
612
613 #[must_use]
615 pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
616 WsSubscriptionHandle {
617 cmd_tx: Arc::clone(&self.cmd_tx),
618 }
619 }
620
621 #[must_use]
627 pub fn take_message_receiver(
628 &mut self,
629 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
630 self.out_rx.take()
631 }
632
633 pub async fn next_message(&mut self) -> Option<PolymarketWsMessage> {
638 if let Some(ref mut rx) = self.out_rx {
639 rx.recv().await
640 } else {
641 None
642 }
643 }
644}
645
646impl Drop for PolymarketWebSocketClient {
647 fn drop(&mut self) {
648 self.signal.store(true, Ordering::Relaxed);
649
650 if let Some(handle) = self.task_handle.as_ref() {
651 handle.abort();
652 }
653
654 if let Some(control) = &self.socket_control {
655 control.deregister();
656 }
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use std::net::SocketAddr;
663
664 use axum::{
665 Router,
666 extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade},
667 response::Response,
668 routing::get,
669 };
670 use nautilus_network::{
671 RECONNECTED,
672 websocket::{TransportBackend, WebSocketConfig, proxy::ProxyUrl},
673 };
674 use rstest::rstest;
675
676 use super::*;
677
678 async fn handle_upgrade(ws: WebSocketUpgrade) -> Response {
679 ws.on_upgrade(handle_socket)
680 }
681
682 async fn handle_socket(mut socket: WebSocket) {
683 let _ = socket
684 .send(AxumWsMessage::Text(RECONNECTED.to_string().into()))
685 .await;
686 }
687
688 async fn start_test_server() -> SocketAddr {
689 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
690 .await
691 .expect("bind test websocket server");
692 let addr = listener.local_addr().expect("test websocket address");
693 let router = Router::new().route("/ws", get(handle_upgrade));
694
695 tokio::spawn(async move {
696 axum::serve(listener, router)
697 .await
698 .expect("test websocket server failed");
699 });
700
701 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
702 addr
703 }
704
705 #[rstest]
706 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
707 #[case(
708 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
709 "wss://stream.example.com:9443"
710 )]
711 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
712 let client = PolymarketWebSocketClient::new_market(
713 Some(url.to_string()),
714 false,
715 TransportBackend::default(),
716 );
717 let debug = format!("{client:?}");
718 assert!(debug.contains(&format!("url: {origin:?}")));
719 assert!(!debug.contains("SYN-"));
720 assert_eq!(client.url.expose(), url);
721 }
722
723 #[tokio::test]
724 async fn cancelled_disconnect_retains_handler_task() {
725 let mut client = PolymarketWebSocketClient::new_market(
726 Some("ws://127.0.0.1:0".to_string()),
727 false,
728 TransportBackend::default(),
729 );
730 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
731 client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
732 client
733 .task_handle
734 .insert(tokio::spawn(std::future::pending()));
735
736 {
737 let disconnect = client.disconnect();
738 tokio::pin!(disconnect);
739 tokio::select! {
740 result = &mut disconnect => panic!("disconnect completed unexpectedly: {result:?}"),
741 command = cmd_rx.recv() => assert!(command.is_some()),
742 }
743 }
744
745 assert!(client.task_handle.is_some());
746 }
747
748 #[rstest]
749 #[tokio::test]
750 async fn connect_forwards_reconnected_message_to_receiver() {
751 let addr = start_test_server().await;
752 let mut client = PolymarketWebSocketClient::new_market(
753 Some(format!("ws://{addr}/ws")),
754 false,
755 TransportBackend::default(),
756 );
757
758 client.connect().await.expect("connect websocket client");
759
760 let message =
761 tokio::time::timeout(tokio::time::Duration::from_secs(2), client.next_message())
762 .await
763 .expect("wait for websocket message");
764
765 assert!(matches!(
766 message,
767 Some(super::super::messages::PolymarketWsMessage::Reconnected { .. })
768 ));
769
770 client
771 .disconnect()
772 .await
773 .expect("disconnect websocket client");
774 }
775
776 #[rstest]
777 fn proxy_url_is_retained_for_market_and_user_clients() {
778 const MARKET_PROXY: &str = "http://market-user:market-proxy-secret@127.0.0.1:18086";
779 const USER_PROXY: &str = "https://user-user:user-proxy-secret@127.0.0.1:18087";
780 let market = PolymarketWebSocketClient::new_market_with_proxy(
781 Some("ws://market.example/ws".to_string()),
782 false,
783 TransportBackend::Tungstenite,
784 Some(ProxyUrl::parse(MARKET_PROXY).unwrap()),
785 );
786 let credential = crate::common::credential::Credential::new(
787 "fixture-key".into(),
788 "Zml4dHVyZQ==".into(),
789 "fixture-passphrase".into(),
790 )
791 .unwrap();
792 let user = PolymarketWebSocketClient::new_user_with_proxy(
793 Some("ws://user.example/ws".to_string()),
794 credential,
795 TransportBackend::Tungstenite,
796 Some(ProxyUrl::parse(USER_PROXY).unwrap()),
797 );
798 let market_config = market.websocket_config();
799 let user_config = user.websocket_config();
800 let market_debug = format!("{market:?}");
801 let user_debug = format!("{user:?}");
802 let assert_common = |config: &WebSocketConfig| {
803 assert_eq!(config.heartbeat_interval_secs, Some(10));
804 assert_eq!(config.connect_timeout_ms, Some(15_000));
805 assert_eq!(config.reconnect_delay_initial_ms, Some(250));
806 assert_eq!(config.reconnect_delay_max_ms, Some(5_000));
807 assert_eq!(config.reconnect_backoff_factor, Some(2.0));
808 assert_eq!(config.reconnect_jitter_ms, Some(200));
809 assert_eq!(config.reconnect_max_attempts, None);
810 assert_eq!(config.idle_timeout_ms, None);
813 assert_eq!(config.backend, TransportBackend::Tungstenite);
814 };
815
816 assert_eq!(market.proxy_url.as_ref().unwrap().expose(), MARKET_PROXY);
817 assert_eq!(user.proxy_url.as_ref().unwrap().expose(), USER_PROXY);
818 assert_eq!(market_config.url, "ws://market.example/ws");
819 assert_eq!(user_config.url, "ws://user.example/ws");
820 assert_eq!(market_config.proxy_url.as_deref(), Some(MARKET_PROXY));
821 assert_eq!(user_config.proxy_url.as_deref(), Some(USER_PROXY));
822 assert_eq!(market_config.heartbeat_payload, None);
823 assert_eq!(user_config.heartbeat_payload.as_deref(), Some("PING"));
824 assert_common(&market_config);
825 assert_common(&user_config);
826 assert!(!market_debug.contains("market-proxy-secret"));
827 assert!(!user_debug.contains("user-proxy-secret"));
828 }
829}