1use std::{
19 collections::HashMap,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, AtomicU8, Ordering},
23 },
24};
25
26use arc_swap::ArcSwap;
27#[cfg(test)]
28use nautilus_core::string::secret::REDACTED;
29use nautilus_core::{
30 AtomicMap,
31 string::secret::{SecretString, zeroize_json_value},
32};
33use nautilus_live::{
34 SocketControl,
35 task::{TaskGroup, TaskShutdownError},
36};
37use nautilus_model::{
38 identifiers::{
39 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
40 },
41 instruments::{Instrument, InstrumentAny},
42};
43use nautilus_network::{
44 ConnectionUrl,
45 http::create_standard_nautilus_headers,
46 mode::ConnectionMode,
47 websocket::{
48 AUTHENTICATION_TIMEOUT_SECS, AuthTracker, SubscriptionState, TransportBackend,
49 WebSocketClient, WebSocketConfig, channel_message_handler,
50 },
51};
52use parking_lot::RwLock;
53use tokio_util::sync::CancellationToken;
54use zeroize::Zeroizing;
55
56use super::{
57 handler::{FuturesFeedHandler, FuturesHandlerCommand},
58 messages::{
59 KrakenFuturesChallengeRequest, KrakenFuturesEvent, KrakenFuturesFeed,
60 KrakenFuturesPrivateSubscribeRequest, KrakenFuturesRequest, KrakenFuturesWsMessage,
61 },
62};
63use crate::{
64 common::{
65 consts::{KRAKEN_FUTURES_WS_SUBSCRIPTION_QUOTA, KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION},
66 credential::KrakenCredential,
67 parse::truncate_cl_ord_id,
68 },
69 websocket::error::KrakenWsError,
70};
71
72pub const KRAKEN_FUTURES_WS_TOPIC_DELIMITER: char = ':';
76
77#[derive(Debug)]
79pub struct KrakenFuturesWebSocketClient {
80 url: ConnectionUrl,
81 heartbeat_secs: u64,
82 auth_timeout_secs: u64,
83 signal: Arc<AtomicBool>,
84 connection_mode: Arc<ArcSwap<AtomicU8>>,
85 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<FuturesHandlerCommand>>>,
86 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<KrakenFuturesWsMessage>>>,
87 handler_tasks: Arc<TaskGroup>,
88 connect_lock: Arc<tokio::sync::Mutex<()>>,
89 subscriptions: SubscriptionState,
90 subscription_payloads: Arc<tokio::sync::RwLock<HashMap<String, SecretString>>>,
91 auth_tracker: AuthTracker,
92 cancellation_token: CancellationToken,
93 credential: Option<KrakenCredential>,
94 original_challenge: Arc<tokio::sync::RwLock<Option<SecretString>>>,
95 signed_challenge: Arc<tokio::sync::RwLock<Option<SecretString>>>,
96 account_id: Arc<RwLock<Option<AccountId>>>,
97 truncated_id_map: Arc<AtomicMap<String, ClientOrderId>>,
98 order_instrument_map: Arc<AtomicMap<String, InstrumentId>>,
99 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
100 transport_backend: TransportBackend,
101 proxy_url: Option<SecretString>,
102 socket_control: Option<SocketControl>,
103}
104
105impl Clone for KrakenFuturesWebSocketClient {
106 fn clone(&self) -> Self {
107 Self {
108 url: self.url.clone(),
109 heartbeat_secs: self.heartbeat_secs,
110 auth_timeout_secs: self.auth_timeout_secs,
111 signal: Arc::clone(&self.signal),
112 connection_mode: Arc::clone(&self.connection_mode),
113 cmd_tx: Arc::clone(&self.cmd_tx),
114 out_rx: self.out_rx.clone(),
115 handler_tasks: Arc::clone(&self.handler_tasks),
116 connect_lock: Arc::clone(&self.connect_lock),
117 subscriptions: self.subscriptions.clone(),
118 subscription_payloads: Arc::clone(&self.subscription_payloads),
119 auth_tracker: self.auth_tracker.clone(),
120 cancellation_token: self.cancellation_token.clone(),
121 credential: self.credential.clone(),
122 original_challenge: Arc::clone(&self.original_challenge),
123 signed_challenge: Arc::clone(&self.signed_challenge),
124 account_id: Arc::clone(&self.account_id),
125 truncated_id_map: Arc::clone(&self.truncated_id_map),
126 order_instrument_map: Arc::clone(&self.order_instrument_map),
127 instruments: Arc::clone(&self.instruments),
128 transport_backend: self.transport_backend,
129 proxy_url: self.proxy_url.clone(),
130 socket_control: self.socket_control.clone(),
131 }
132 }
133}
134
135impl KrakenFuturesWebSocketClient {
136 #[must_use]
138 pub fn new(url: String, heartbeat_secs: u64, proxy_url: Option<String>) -> Self {
139 Self::with_credentials(
140 url,
141 heartbeat_secs,
142 None,
143 None,
144 TransportBackend::default(),
145 proxy_url,
146 )
147 }
148
149 #[must_use]
151 pub fn with_credentials(
152 url: String,
153 heartbeat_secs: u64,
154 credential: Option<KrakenCredential>,
155 auth_timeout_secs: Option<u64>,
156 transport_backend: TransportBackend,
157 proxy_url: Option<String>,
158 ) -> Self {
159 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<FuturesHandlerCommand>();
160 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
161 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
162
163 Self {
164 url: url.into(),
165 heartbeat_secs,
166 auth_timeout_secs: auth_timeout_secs.unwrap_or(AUTHENTICATION_TIMEOUT_SECS),
167 signal: Arc::new(AtomicBool::new(false)),
168 connection_mode,
169 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
170 out_rx: None,
171 handler_tasks: Arc::new(TaskGroup::new()),
172 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
173 subscriptions: SubscriptionState::new(KRAKEN_FUTURES_WS_TOPIC_DELIMITER),
174 subscription_payloads: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
175 auth_tracker: AuthTracker::new(),
176 cancellation_token: CancellationToken::new(),
177 credential,
178 original_challenge: Arc::new(tokio::sync::RwLock::new(None)),
179 signed_challenge: Arc::new(tokio::sync::RwLock::new(None)),
180 account_id: Arc::new(RwLock::new(None)),
181 truncated_id_map: Arc::new(AtomicMap::new()),
182 order_instrument_map: Arc::new(AtomicMap::new()),
183 instruments: Arc::new(AtomicMap::new()),
184 transport_backend,
185 proxy_url: proxy_url.map(SecretString::from),
186 socket_control: None,
187 }
188 }
189
190 pub(crate) fn begin_shutdown(&self) {
191 self.handler_tasks.begin_shutdown();
192 self.cancellation_token.cancel();
193 self.signal.store(true, Ordering::Relaxed);
194 }
195
196 #[must_use]
198 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
199 self.socket_control = Some(control);
200 self
201 }
202
203 #[must_use]
205 pub fn has_credentials(&self) -> bool {
206 self.credential.is_some()
207 }
208
209 #[must_use]
211 pub fn url(&self) -> &str {
212 self.url.expose()
213 }
214
215 #[must_use]
217 pub fn is_closed(&self) -> bool {
218 ConnectionMode::from_u8(self.connection_mode.load().load(Ordering::Relaxed))
219 == ConnectionMode::Closed
220 }
221
222 #[must_use]
224 pub fn is_active(&self) -> bool {
225 ConnectionMode::from_u8(self.connection_mode.load().load(Ordering::Relaxed))
226 == ConnectionMode::Active
227 }
228
229 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), KrakenWsError> {
231 let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
232
233 tokio::time::timeout(timeout, async {
234 while !self.is_active() {
235 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
236 }
237 })
238 .await
239 .map_err(|_| {
240 KrakenWsError::ConnectionError(format!(
241 "WebSocket connection timeout after {timeout_secs} seconds"
242 ))
243 })?;
244
245 Ok(())
246 }
247
248 #[must_use]
250 pub fn is_authenticated(&self) -> bool {
251 self.auth_tracker.is_authenticated()
252 }
253
254 pub async fn wait_until_authenticated(&self, timeout_secs: f64) -> Result<(), KrakenWsError> {
258 let timeout = tokio::time::Duration::from_secs_f64(timeout_secs);
259 if self.auth_tracker.wait_for_authenticated(timeout).await {
260 Ok(())
261 } else {
262 Err(KrakenWsError::AuthenticationError(format!(
263 "Authentication not completed within {timeout_secs} seconds"
264 )))
265 }
266 }
267
268 pub async fn authenticate(&self) -> Result<(), KrakenWsError> {
274 let credential = self.credential.as_ref().ok_or_else(|| {
275 KrakenWsError::AuthenticationError("API credentials required".to_string())
276 })?;
277
278 let payload = build_challenge_payload(credential)
279 .map_err(|e| KrakenWsError::JsonError(e.to_string()))?;
280
281 let receiver = self.auth_tracker.begin();
282
283 self.cmd_tx
284 .read()
285 .await
286 .send(FuturesHandlerCommand::RequestChallenge { payload })
287 .map_err(|e| KrakenWsError::ChannelError(e.to_string()))?;
288
289 self.auth_tracker
290 .wait_for_result::<KrakenWsError>(
291 tokio::time::Duration::from_secs(self.auth_timeout_secs),
292 receiver,
293 )
294 .await?;
295
296 log::debug!("Futures WebSocket authentication successful");
297 Ok(())
298 }
299
300 pub async fn connect(&mut self) -> Result<(), KrakenWsError> {
302 let connect_lock = Arc::clone(&self.connect_lock);
303 let _connect_guard = connect_lock.lock().await;
304
305 log::debug!("Connecting to Futures WebSocket: {}", self.url.origin());
306
307 if !self.handler_tasks.is_open() || !self.handler_tasks.is_empty() {
308 self.disconnect_locked().await?;
309 self.handler_tasks.start_generation().map_err(|e| {
310 KrakenWsError::ConnectionError(format!(
311 "Failed to start WebSocket handler task generation: {e}"
312 ))
313 })?;
314 }
315 let handler_spawner = self.handler_tasks.spawner().map_err(|e| {
316 KrakenWsError::ConnectionError(format!(
317 "Failed to acquire WebSocket handler task spawner: {e}"
318 ))
319 })?;
320
321 if self.cancellation_token.is_cancelled() {
322 self.cancellation_token = CancellationToken::new();
323 }
324
325 self.signal.store(false, Ordering::Relaxed);
326
327 let (raw_handler, raw_rx) = channel_message_handler();
328 let headers = create_standard_nautilus_headers();
329
330 let ws_config = WebSocketConfig {
331 url: self.url.expose().to_owned(),
332 headers,
333 heartbeat_interval_secs: Some(self.heartbeat_secs),
334 heartbeat_payload: None, connect_timeout_ms: Some(5_000),
336 reconnect_delay_initial_ms: Some(500),
337 reconnect_delay_max_ms: Some(5_000),
338 reconnect_backoff_factor: Some(1.5),
339 reconnect_jitter_ms: Some(250),
340 reconnect_max_attempts: None,
341 heartbeat_timeout_secs: None,
342 idle_timeout_ms: None,
343 writer_capacity: None,
344 backend: self.transport_backend,
345 proxy_url: self
346 .proxy_url
347 .as_ref()
348 .map(|value| value.expose_secret().to_owned()),
349 max_message_size_bytes: None,
350 max_frame_size_bytes: None,
351 };
352
353 let keyed_quotas = vec![(
354 KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION[0].to_string(),
355 *KRAKEN_FUTURES_WS_SUBSCRIPTION_QUOTA,
356 )];
357
358 let ws_client = WebSocketClient::builder()
359 .config(ws_config)
360 .message_handler(raw_handler)
361 .keyed_quotas(keyed_quotas)
362 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
363 .connect()
364 .await
365 .map_err(|e| KrakenWsError::ConnectionError(e.to_string()))?;
366
367 let connection_mode = ws_client.connection_mode_atomic();
368 let reconnect_handle = ws_client.reconnect_handle();
369
370 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<KrakenFuturesWsMessage>();
371 self.out_rx = Some(Arc::new(out_rx));
372
373 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<FuturesHandlerCommand>();
374
375 if let Err(e) = cmd_tx.send(FuturesHandlerCommand::SetClient(ws_client)) {
376 return Err(KrakenWsError::ConnectionError(format!(
377 "Failed to send WebSocketClient to handler: {e}"
378 )));
379 }
380
381 {
382 let mut command_sender = self.cmd_tx.write().await;
383 *command_sender = cmd_tx.clone();
384 self.connection_mode.store(connection_mode);
385 }
386
387 if let Some(control) = &self.socket_control {
388 control.register(move || reconnect_handle.request_reconnect());
389 }
390
391 let signal = self.signal.clone();
392 let subscriptions = self.subscriptions.clone();
393 let subscription_payloads = self.subscription_payloads.clone();
394 let cmd_tx_for_reconnect = cmd_tx.clone();
395 let credential_for_reconnect = self.credential.clone();
396 let original_challenge_for_reconnect = self.original_challenge.clone();
397 let signed_challenge_for_reconnect = self.signed_challenge.clone();
398 let auth_tracker_for_reconnect = self.auth_tracker.clone();
399
400 let handler_task = async move {
401 let mut handler =
402 FuturesFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions.clone());
403 let mut pending_resubscribe = false;
404
405 loop {
406 match handler.next().await {
407 Some(KrakenFuturesWsMessage::Reconnected) => {
408 if signal.load(Ordering::Relaxed) {
409 continue;
410 }
411 log::info!("WebSocket reconnected");
412
413 subscriptions.reset_after_reconnect();
414
415 auth_tracker_for_reconnect.invalidate();
416 *original_challenge_for_reconnect.write().await = None;
417 *signed_challenge_for_reconnect.write().await = None;
418
419 let payloads = subscription_payloads.read().await.clone();
420
421 resubscribe_public(&cmd_tx_for_reconnect, &subscriptions, &payloads);
424
425 let has_private =
426 payloads.keys().any(|k| k == "open_orders" || k == "fills");
427
428 pending_resubscribe = false;
429
430 if has_private {
431 if let Some(ref cred) = credential_for_reconnect {
432 match build_challenge_payload(cred) {
433 Ok(payload) => {
434 let _rx = auth_tracker_for_reconnect.begin();
435
436 if let Err(e) = cmd_tx_for_reconnect.send(
437 FuturesHandlerCommand::RequestChallenge { payload },
438 ) {
439 log::error!("Failed to queue reconnect challenge: {e}");
440 } else {
441 pending_resubscribe = true;
442 }
443 }
444 Err(e) => {
445 log::error!("Failed to serialize reconnect challenge: {e}");
446 }
447 }
448 } else {
449 log::warn!(
450 "Private subscriptions exist but no credentials available"
451 );
452 }
453 }
454
455 if let Err(e) = out_tx.send(KrakenFuturesWsMessage::Reconnected) {
456 log::debug!("Output channel closed: {e}");
457 break;
458 }
459 }
460 Some(KrakenFuturesWsMessage::Challenge(challenge)) => {
461 let Some(ref cred) = credential_for_reconnect else {
462 log::warn!("Challenge received but no credentials configured");
463 auth_tracker_for_reconnect.fail("no credentials");
464 continue;
465 };
466
467 let challenge = SecretString::from(challenge);
468 match cred
469 .sign_ws_challenge(challenge.expose_secret())
470 .map(SecretString::from)
471 {
472 Ok(signed) => {
473 *original_challenge_for_reconnect.write().await =
474 Some(challenge.clone());
475 *signed_challenge_for_reconnect.write().await =
476 Some(signed.clone());
477 auth_tracker_for_reconnect.succeed();
478 log::debug!("Signed WebSocket challenge");
479
480 if pending_resubscribe {
481 let payloads = subscription_payloads.read().await;
482 resubscribe_private(
483 &cmd_tx_for_reconnect,
484 &subscriptions,
485 &payloads,
486 cred,
487 challenge.expose_secret(),
488 signed.expose_secret(),
489 );
490 pending_resubscribe = false;
491 }
492 }
493 Err(e) => {
494 log::error!("Failed to sign challenge: {e}");
495 auth_tracker_for_reconnect.fail(e.to_string());
496 pending_resubscribe = false;
497 }
498 }
499 }
500 Some(msg) => {
501 if let Err(e) = out_tx.send(msg) {
502 log::debug!("Output channel closed: {e}");
503 break;
504 }
505 }
506 None => {
507 log::debug!("Handler stream ended");
508 break;
509 }
510 }
511 }
512
513 log::debug!("Futures handler task exiting");
514 };
515
516 if let Err(e) = handler_spawner.spawn(handler_task) {
517 if let Some(control) = &self.socket_control {
518 control.deregister();
519 }
520 self.out_rx = None;
521 return Err(KrakenWsError::ConnectionError(format!(
522 "Failed to register WebSocket handler task: {e}"
523 )));
524 }
525
526 log::debug!("Futures WebSocket connected successfully");
527 Ok(())
528 }
529
530 pub async fn disconnect(&mut self) -> Result<(), KrakenWsError> {
532 let connect_lock = Arc::clone(&self.connect_lock);
533 let _connect_guard = connect_lock.lock().await;
534 self.disconnect_locked().await
535 }
536
537 async fn disconnect_locked(&self) -> Result<(), KrakenWsError> {
538 log::debug!("Disconnecting Futures WebSocket");
539
540 self.handler_tasks.begin_shutdown();
541 self.signal.store(true, Ordering::Relaxed);
542
543 if let Err(e) = self
544 .cmd_tx
545 .read()
546 .await
547 .send(FuturesHandlerCommand::Disconnect)
548 {
549 log::debug!(
550 "Failed to send disconnect command (handler may already be shut down): {e}"
551 );
552 }
553
554 let task_result = self
555 .handler_tasks
556 .finish_shutdown(
557 tokio::time::Duration::from_secs(2),
558 tokio::time::Duration::from_secs(2),
559 )
560 .await;
561
562 self.subscriptions.clear();
563 self.subscription_payloads.write().await.clear();
564 self.auth_tracker.fail("Disconnected");
565
566 if let Some(control) = &self.socket_control {
567 control.deregister();
568 }
569
570 match task_result {
571 Ok(()) => Ok(()),
572 Err(error @ TaskShutdownError::Timeout { .. }) => Err(KrakenWsError::Timeout(format!(
573 "Futures WebSocket handler shutdown timed out: {error}"
574 ))),
575 Err(e) => Err(KrakenWsError::Disconnected(format!(
576 "Futures WebSocket handler shutdown failed: {e}"
577 ))),
578 }
579 }
580
581 pub async fn close(&mut self) -> Result<(), KrakenWsError> {
583 self.disconnect().await
584 }
585
586 pub async fn subscribe_mark_price(
588 &self,
589 instrument_id: InstrumentId,
590 ) -> Result<(), KrakenWsError> {
591 let symbol = instrument_id.symbol;
592 let key = format!("mark:{symbol}");
593
594 if !self.subscriptions.add_reference(&key) {
595 return Ok(());
596 }
597
598 self.subscriptions.mark_subscribe(&key);
599 self.subscriptions.confirm_subscribe(&key);
600 self.ensure_ticker_subscribed(symbol).await
601 }
602
603 pub async fn unsubscribe_mark_price(
605 &self,
606 instrument_id: InstrumentId,
607 ) -> Result<(), KrakenWsError> {
608 let symbol = instrument_id.symbol;
609 let key = format!("mark:{symbol}");
610
611 if !self.subscriptions.remove_reference(&key) {
612 return Ok(());
613 }
614
615 self.subscriptions.mark_unsubscribe(&key);
616 self.subscriptions.confirm_unsubscribe(&key);
617 self.maybe_unsubscribe_ticker(symbol).await
618 }
619
620 pub async fn subscribe_index_price(
622 &self,
623 instrument_id: InstrumentId,
624 ) -> Result<(), KrakenWsError> {
625 let symbol = instrument_id.symbol;
626 let key = format!("index:{symbol}");
627
628 if !self.subscriptions.add_reference(&key) {
629 return Ok(());
630 }
631
632 self.subscriptions.mark_subscribe(&key);
633 self.subscriptions.confirm_subscribe(&key);
634 self.ensure_ticker_subscribed(symbol).await
635 }
636
637 pub async fn unsubscribe_index_price(
639 &self,
640 instrument_id: InstrumentId,
641 ) -> Result<(), KrakenWsError> {
642 let symbol = instrument_id.symbol;
643 let key = format!("index:{symbol}");
644
645 if !self.subscriptions.remove_reference(&key) {
646 return Ok(());
647 }
648
649 self.subscriptions.mark_unsubscribe(&key);
650 self.subscriptions.confirm_unsubscribe(&key);
651 self.maybe_unsubscribe_ticker(symbol).await
652 }
653
654 pub async fn subscribe_funding_rate(
656 &self,
657 instrument_id: InstrumentId,
658 ) -> Result<(), KrakenWsError> {
659 let symbol = instrument_id.symbol;
660 let key = format!("funding:{symbol}");
661
662 if !self.subscriptions.add_reference(&key) {
663 return Ok(());
664 }
665
666 self.subscriptions.mark_subscribe(&key);
667 self.subscriptions.confirm_subscribe(&key);
668 self.ensure_ticker_subscribed(symbol).await
669 }
670
671 pub async fn unsubscribe_funding_rate(
673 &self,
674 instrument_id: InstrumentId,
675 ) -> Result<(), KrakenWsError> {
676 let symbol = instrument_id.symbol;
677 let key = format!("funding:{symbol}");
678
679 if !self.subscriptions.remove_reference(&key) {
680 return Ok(());
681 }
682
683 self.subscriptions.mark_unsubscribe(&key);
684 self.subscriptions.confirm_unsubscribe(&key);
685 self.maybe_unsubscribe_ticker(symbol).await
686 }
687
688 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
692 let symbol = instrument_id.symbol;
693 let key = format!("quotes:{symbol}");
694
695 if !self.subscriptions.add_reference(&key) {
696 return Ok(());
697 }
698
699 self.subscriptions.mark_subscribe(&key);
700 self.subscriptions.confirm_subscribe(&key);
701
702 self.ensure_book_subscribed(symbol).await
704 }
705
706 pub async fn unsubscribe_quotes(
708 &self,
709 instrument_id: InstrumentId,
710 ) -> Result<(), KrakenWsError> {
711 let symbol = instrument_id.symbol;
712 let key = format!("quotes:{symbol}");
713
714 if !self.subscriptions.remove_reference(&key) {
715 return Ok(());
716 }
717
718 self.subscriptions.mark_unsubscribe(&key);
719 self.subscriptions.confirm_unsubscribe(&key);
720 self.maybe_unsubscribe_book(symbol).await
721 }
722
723 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
725 let symbol = instrument_id.symbol;
726 let key = format!("trades:{symbol}");
727
728 if !self.subscriptions.add_reference(&key) {
729 return Ok(());
730 }
731
732 self.subscriptions.mark_subscribe(&key);
733 let payload = self
734 .send_subscribe_feed(KrakenFuturesFeed::Trade, vec![symbol.to_string()])
735 .await?;
736 self.subscriptions.confirm_subscribe(&key);
737 self.subscription_payloads
738 .write()
739 .await
740 .insert(key, payload);
741 Ok(())
742 }
743
744 pub async fn unsubscribe_trades(
746 &self,
747 instrument_id: InstrumentId,
748 ) -> Result<(), KrakenWsError> {
749 let symbol = instrument_id.symbol;
750 let key = format!("trades:{symbol}");
751
752 if !self.subscriptions.remove_reference(&key) {
753 return Ok(());
754 }
755
756 self.subscriptions.mark_unsubscribe(&key);
757 self.send_unsubscribe_feed(KrakenFuturesFeed::Trade, vec![symbol.to_string()])
758 .await?;
759 self.subscriptions.confirm_unsubscribe(&key);
760 self.subscription_payloads.write().await.remove(&key);
761 Ok(())
762 }
763
764 pub async fn subscribe_book(
769 &self,
770 instrument_id: InstrumentId,
771 _depth: Option<u32>,
772 ) -> Result<(), KrakenWsError> {
773 let symbol = instrument_id.symbol;
774
775 let deltas_key = format!("deltas:{symbol}");
776 self.subscriptions.add_reference(&deltas_key);
777 self.subscriptions.mark_subscribe(&deltas_key);
778 self.subscriptions.confirm_subscribe(&deltas_key);
779
780 self.ensure_book_subscribed(symbol).await
781 }
782
783 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> Result<(), KrakenWsError> {
785 let symbol = instrument_id.symbol;
786
787 let deltas_key = format!("deltas:{symbol}");
788 self.subscriptions.remove_reference(&deltas_key);
789 self.subscriptions.mark_unsubscribe(&deltas_key);
790 self.subscriptions.confirm_unsubscribe(&deltas_key);
791
792 self.maybe_unsubscribe_book(symbol).await
793 }
794
795 async fn ensure_ticker_subscribed(&self, symbol: Symbol) -> Result<(), KrakenWsError> {
796 let ticker_key = format!("ticker:{symbol}");
797
798 if !self.subscriptions.add_reference(&ticker_key) {
799 return Ok(());
800 }
801
802 self.subscriptions.mark_subscribe(&ticker_key);
803 let payload = self
804 .send_subscribe_feed(KrakenFuturesFeed::Ticker, vec![symbol.to_string()])
805 .await?;
806 self.subscriptions.confirm_subscribe(&ticker_key);
807 self.subscription_payloads
808 .write()
809 .await
810 .insert(ticker_key, payload);
811 Ok(())
812 }
813
814 async fn maybe_unsubscribe_ticker(&self, symbol: Symbol) -> Result<(), KrakenWsError> {
815 let ticker_key = format!("ticker:{symbol}");
816
817 if !self.subscriptions.remove_reference(&ticker_key) {
818 return Ok(());
819 }
820
821 self.subscriptions.mark_unsubscribe(&ticker_key);
822 self.send_unsubscribe_feed(KrakenFuturesFeed::Ticker, vec![symbol.to_string()])
823 .await?;
824 self.subscriptions.confirm_unsubscribe(&ticker_key);
825 self.subscription_payloads.write().await.remove(&ticker_key);
826 Ok(())
827 }
828
829 async fn ensure_book_subscribed(&self, symbol: Symbol) -> Result<(), KrakenWsError> {
830 let book_key = format!("book:{symbol}");
831
832 if !self.subscriptions.add_reference(&book_key) {
833 return Ok(());
834 }
835
836 self.subscriptions.mark_subscribe(&book_key);
837 let payload = self
838 .send_subscribe_feed(KrakenFuturesFeed::Book, vec![symbol.to_string()])
839 .await?;
840 self.subscriptions.confirm_subscribe(&book_key);
841 self.subscription_payloads
842 .write()
843 .await
844 .insert(book_key, payload);
845 Ok(())
846 }
847
848 async fn maybe_unsubscribe_book(&self, symbol: Symbol) -> Result<(), KrakenWsError> {
849 let book_key = format!("book:{symbol}");
850
851 if !self.subscriptions.remove_reference(&book_key) {
852 return Ok(());
853 }
854
855 self.subscriptions.mark_unsubscribe(&book_key);
856 self.send_unsubscribe_feed(KrakenFuturesFeed::Book, vec![symbol.to_string()])
857 .await?;
858 self.subscriptions.confirm_unsubscribe(&book_key);
859 self.subscription_payloads.write().await.remove(&book_key);
860 Ok(())
861 }
862
863 pub fn take_output_rx(
865 &mut self,
866 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<KrakenFuturesWsMessage>> {
867 self.out_rx.take().and_then(|arc| Arc::try_unwrap(arc).ok())
868 }
869
870 pub async fn set_auth_credentials(
872 &self,
873 original_challenge: String,
874 signed_challenge: String,
875 ) -> Result<(), KrakenWsError> {
876 let _credential = self.credential.as_ref().ok_or_else(|| {
877 KrakenWsError::AuthenticationError("API credentials required".to_string())
878 })?;
879
880 let original_challenge = SecretString::from(original_challenge);
881 let signed_challenge = SecretString::from(signed_challenge);
882 *self.original_challenge.write().await = Some(original_challenge);
883 *self.signed_challenge.write().await = Some(signed_challenge);
884 self.auth_tracker.succeed();
885
886 Ok(())
887 }
888
889 pub fn sign_challenge(&self, challenge: &str) -> Result<String, KrakenWsError> {
893 let credential = self.credential.as_ref().ok_or_else(|| {
894 KrakenWsError::AuthenticationError("API credentials required".to_string())
895 })?;
896
897 credential.sign_ws_challenge(challenge).map_err(|e| {
898 KrakenWsError::AuthenticationError(format!("Failed to sign challenge: {e}"))
899 })
900 }
901
902 pub async fn authenticate_with_challenge(&self, challenge: &str) -> Result<(), KrakenWsError> {
904 let credential = self.credential.as_ref().ok_or_else(|| {
905 KrakenWsError::AuthenticationError("API credentials required".to_string())
906 })?;
907
908 let signed_challenge = credential.sign_ws_challenge(challenge).map_err(|e| {
909 KrakenWsError::AuthenticationError(format!("Failed to sign challenge: {e}"))
910 })?;
911
912 self.set_auth_credentials(challenge.to_string(), signed_challenge)
913 .await
914 }
915
916 pub fn set_account_id(&self, account_id: AccountId) {
918 *self.account_id.write() = Some(account_id);
919 }
920
921 #[must_use]
923 pub fn account_id(&self) -> Option<AccountId> {
924 *self.account_id.read()
925 }
926
927 #[must_use]
929 pub fn account_id_shared(&self) -> &Arc<RwLock<Option<AccountId>>> {
930 &self.account_id
931 }
932
933 #[must_use]
935 pub fn truncated_id_map(&self) -> &Arc<AtomicMap<String, ClientOrderId>> {
936 &self.truncated_id_map
937 }
938
939 #[must_use]
941 pub fn order_instrument_map(&self) -> &Arc<AtomicMap<String, InstrumentId>> {
942 &self.order_instrument_map
943 }
944
945 #[must_use]
947 pub fn instruments_shared(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
948 &self.instruments
949 }
950
951 #[must_use]
953 pub fn subscriptions(&self) -> &SubscriptionState {
954 &self.subscriptions
955 }
956
957 pub fn cache_instrument(&self, instrument: InstrumentAny) {
959 self.instruments.insert(instrument.id(), instrument);
960 }
961
962 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
964 self.instruments.rcu(|m| {
965 for instrument in instruments {
966 m.insert(instrument.id(), instrument.clone());
967 }
968 });
969 }
970
971 pub fn cache_client_order(
977 &self,
978 client_order_id: ClientOrderId,
979 venue_order_id: Option<VenueOrderId>,
980 instrument_id: InstrumentId,
981 _trader_id: TraderId,
982 _strategy_id: StrategyId,
983 ) {
984 let truncated = truncate_cl_ord_id(&client_order_id);
985
986 if truncated != client_order_id.as_str() {
987 self.truncated_id_map.insert(truncated, client_order_id);
988 }
989
990 if let Some(venue_id) = venue_order_id {
991 self.order_instrument_map
992 .insert(venue_id.to_string(), instrument_id);
993 }
994 }
995
996 pub async fn subscribe_open_orders(&self) -> Result<(), KrakenWsError> {
998 let key = "open_orders";
999 if !self.subscriptions.add_reference(key) {
1000 return Ok(());
1001 }
1002
1003 self.subscriptions.mark_subscribe(key);
1004 let payload = self
1005 .send_private_subscribe_feed(KrakenFuturesFeed::OpenOrders)
1006 .await?;
1007 self.subscriptions.confirm_subscribe(key);
1008 self.subscription_payloads
1009 .write()
1010 .await
1011 .insert(key.to_string(), payload);
1012 Ok(())
1013 }
1014
1015 pub async fn subscribe_fills(&self) -> Result<(), KrakenWsError> {
1017 let key = "fills";
1018 if !self.subscriptions.add_reference(key) {
1019 return Ok(());
1020 }
1021
1022 self.subscriptions.mark_subscribe(key);
1023 let payload = self
1024 .send_private_subscribe_feed(KrakenFuturesFeed::Fills)
1025 .await?;
1026 self.subscriptions.confirm_subscribe(key);
1027 self.subscription_payloads
1028 .write()
1029 .await
1030 .insert(key.to_string(), payload);
1031 Ok(())
1032 }
1033
1034 pub async fn subscribe_executions(&self) -> Result<(), KrakenWsError> {
1036 self.subscribe_open_orders().await?;
1037 self.subscribe_fills().await?;
1038 Ok(())
1039 }
1040
1041 async fn send_subscribe_feed(
1042 &self,
1043 feed: KrakenFuturesFeed,
1044 product_ids: Vec<String>,
1045 ) -> Result<SecretString, KrakenWsError> {
1046 let request = KrakenFuturesRequest {
1047 event: KrakenFuturesEvent::Subscribe,
1048 feed,
1049 product_ids,
1050 };
1051 let payload = SecretString::from(
1052 serde_json::to_string(&request).map_err(|e| KrakenWsError::JsonError(e.to_string()))?,
1053 );
1054 self.cmd_tx
1055 .read()
1056 .await
1057 .send(FuturesHandlerCommand::Subscribe {
1058 payload: payload.clone(),
1059 })
1060 .map_err(|e| KrakenWsError::ChannelError(e.to_string()))?;
1061 Ok(payload)
1062 }
1063
1064 async fn send_unsubscribe_feed(
1065 &self,
1066 feed: KrakenFuturesFeed,
1067 product_ids: Vec<String>,
1068 ) -> Result<(), KrakenWsError> {
1069 let request = KrakenFuturesRequest {
1070 event: KrakenFuturesEvent::Unsubscribe,
1071 feed,
1072 product_ids,
1073 };
1074 let payload = SecretString::from(
1075 serde_json::to_string(&request).map_err(|e| KrakenWsError::JsonError(e.to_string()))?,
1076 );
1077 self.cmd_tx
1078 .read()
1079 .await
1080 .send(FuturesHandlerCommand::Unsubscribe { payload })
1081 .map_err(|e| KrakenWsError::ChannelError(e.to_string()))?;
1082 Ok(())
1083 }
1084
1085 async fn send_private_subscribe_feed(
1086 &self,
1087 feed: KrakenFuturesFeed,
1088 ) -> Result<SecretString, KrakenWsError> {
1089 let credential = self.credential.as_ref().ok_or_else(|| {
1090 KrakenWsError::AuthenticationError("API credentials required".to_string())
1091 })?;
1092 let original_challenge = self
1093 .original_challenge
1094 .read()
1095 .await
1096 .clone()
1097 .ok_or_else(|| {
1098 KrakenWsError::AuthenticationError(
1099 "Must authenticate before subscribing to private feeds".to_string(),
1100 )
1101 })?;
1102 let signed_challenge = self.signed_challenge.read().await.clone().ok_or_else(|| {
1103 KrakenWsError::AuthenticationError(
1104 "Must authenticate before subscribing to private feeds".to_string(),
1105 )
1106 })?;
1107
1108 let request = Zeroizing::new(KrakenFuturesPrivateSubscribeRequest {
1109 event: KrakenFuturesEvent::Subscribe,
1110 feed,
1111 api_key: credential.api_key().into(),
1112 original_challenge,
1113 signed_challenge,
1114 });
1115 let payload = SecretString::from(
1116 serde_json::to_string(&*request)
1117 .map_err(|e| KrakenWsError::JsonError(e.to_string()))?,
1118 );
1119 drop(request);
1120 self.cmd_tx
1121 .read()
1122 .await
1123 .send(FuturesHandlerCommand::Subscribe {
1124 payload: payload.clone(),
1125 })
1126 .map_err(|e| KrakenWsError::ChannelError(e.to_string()))?;
1127 Ok(payload)
1128 }
1129}
1130
1131fn update_private_payload_credentials(
1132 payload: &str,
1133 api_key: &str,
1134 original_challenge: &str,
1135 signed_challenge: &str,
1136) -> Option<SecretString> {
1137 let mut value: serde_json::Value = serde_json::from_str(payload).ok()?;
1138 let obj = value.as_object_mut()?;
1139 obj.insert(
1140 "api_key".to_string(),
1141 serde_json::Value::String(api_key.to_string()),
1142 );
1143 obj.insert(
1144 "original_challenge".to_string(),
1145 serde_json::Value::String(original_challenge.to_string()),
1146 );
1147 obj.insert(
1148 "signed_challenge".to_string(),
1149 serde_json::Value::String(signed_challenge.to_string()),
1150 );
1151 let payload = serde_json::to_string(&value).ok().map(SecretString::from);
1152 zeroize_json_value(&mut value);
1153 payload
1154}
1155
1156fn build_challenge_payload(credential: &KrakenCredential) -> serde_json::Result<SecretString> {
1157 let request = Zeroizing::new(KrakenFuturesChallengeRequest {
1158 event: KrakenFuturesEvent::Challenge,
1159 api_key: credential.api_key().into(),
1160 });
1161 serde_json::to_string(&*request).map(SecretString::from)
1162}
1163
1164fn is_private_feed_key(key: &str) -> bool {
1165 key == "open_orders" || key == "fills"
1166}
1167
1168fn resubscribe_public(
1169 cmd_tx: &tokio::sync::mpsc::UnboundedSender<FuturesHandlerCommand>,
1170 subscriptions: &SubscriptionState,
1171 payloads: &HashMap<String, SecretString>,
1172) {
1173 for (key, payload) in payloads {
1174 if is_private_feed_key(key) {
1175 continue;
1176 }
1177
1178 if let Err(e) = cmd_tx.send(FuturesHandlerCommand::Subscribe {
1179 payload: payload.clone(),
1180 }) {
1181 log::error!("Failed to send resubscribe: error={e}, topic={key}");
1182 continue;
1183 }
1184
1185 subscriptions.mark_subscribe(key);
1186 }
1187}
1188
1189fn resubscribe_private(
1190 cmd_tx: &tokio::sync::mpsc::UnboundedSender<FuturesHandlerCommand>,
1191 subscriptions: &SubscriptionState,
1192 payloads: &HashMap<String, SecretString>,
1193 credential: &KrakenCredential,
1194 original_challenge: &str,
1195 signed_challenge: &str,
1196) {
1197 for (key, payload) in payloads {
1198 if !is_private_feed_key(key) {
1199 continue;
1200 }
1201
1202 let Some(updated) = update_private_payload_credentials(
1203 payload.expose_secret(),
1204 credential.api_key(),
1205 original_challenge,
1206 signed_challenge,
1207 ) else {
1208 log::error!("Failed to update private payload for {key}");
1209 continue;
1210 };
1211
1212 if let Err(e) = cmd_tx.send(FuturesHandlerCommand::Subscribe { payload: updated }) {
1213 log::error!("Failed to send resubscribe: error={e}, topic={key}");
1214 continue;
1215 }
1216
1217 subscriptions.mark_subscribe(key);
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use std::time::Duration;
1224
1225 use base64::{Engine, engine::general_purpose::STANDARD};
1226 use futures_util::StreamExt;
1227 use nautilus_network::websocket::AuthTracker;
1228 use nautilus_testkit::connection::race_command_with_publication;
1229 use rstest::rstest;
1230
1231 use super::*;
1232
1233 struct DropSignal(Arc<AtomicBool>);
1234
1235 impl Drop for DropSignal {
1236 fn drop(&mut self) {
1237 self.0.store(true, Ordering::Release);
1238 }
1239 }
1240
1241 fn test_credential() -> KrakenCredential {
1242 let secret = STANDARD.encode(b"test_secret_key_24bytes!");
1243 KrakenCredential::new("test_key", secret)
1244 }
1245
1246 #[tokio::test]
1247 async fn command_during_sender_publication_reaches_initialized_handler() {
1248 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1249 let addr = listener.local_addr().unwrap();
1250 let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
1251
1252 let server = tokio::spawn(async move {
1253 let (socket, _) = listener.accept().await.unwrap();
1254 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1255 while let Some(Ok(message)) = socket.next().await {
1256 if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
1257 let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
1258 let _ = received_tx.send(payload);
1259 }
1260 }
1261 });
1262
1263 let url = format!("ws://{addr}");
1264 let mut ws_client = KrakenFuturesWebSocketClient::new(url, 30, None);
1265 let observer = ws_client.clone();
1266 let expected = serde_json::json!({
1267 "event": "subscribe",
1268 "feed": "trade",
1269 "product_ids": [
1270 "PI_XBTUSD"
1271 ]
1272 });
1273
1274 let command = FuturesHandlerCommand::Subscribe {
1275 payload: SecretString::from(expected.to_string()),
1276 };
1277
1278 race_command_with_publication(
1279 Arc::clone(&ws_client.cmd_tx),
1280 ws_client.connect(),
1281 move || command,
1282 || observer.is_active(),
1283 )
1284 .await
1285 .expect("connect failed");
1286 let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
1287 ws_client.close().await.expect("disconnect failed");
1288 server.abort();
1289
1290 let payload = received
1291 .expect("startup command must reach server")
1292 .unwrap();
1293 assert_eq!(payload, expected);
1294 }
1295
1296 #[rstest]
1297 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
1298 #[case(
1299 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
1300 "wss://stream.example.com:9443"
1301 )]
1302 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
1303 let client = KrakenFuturesWebSocketClient::new(url.to_string(), 30, None);
1304 let debug = format!("{client:?}");
1305 assert!(debug.contains(&format!("url: {origin:?}")));
1306 assert!(!debug.contains("SYN-"));
1307 assert_eq!(client.url.expose(), url);
1308 }
1309
1310 #[rstest]
1311 #[tokio::test]
1312 async fn test_debug_redacts_auth_state_and_proxy_url() {
1313 let client = KrakenFuturesWebSocketClient::with_credentials(
1314 "wss://test".to_string(),
1315 30,
1316 Some(test_credential()),
1317 None,
1318 TransportBackend::default(),
1319 Some("http://user:proxy-secret@localhost".to_string()),
1320 );
1321 client
1322 .set_auth_credentials(
1323 "original-challenge".to_string(),
1324 "signed-challenge".to_string(),
1325 )
1326 .await
1327 .unwrap();
1328
1329 let debug = format!("{client:?}");
1330
1331 assert!(debug.contains(REDACTED));
1332 assert!(!debug.contains("proxy-secret"));
1333 assert!(!debug.contains("original-challenge"));
1334 assert!(!debug.contains("signed-challenge"));
1335 }
1336
1337 #[tokio::test]
1338 async fn test_last_client_owner_drop_aborts_handler_task() {
1339 let client = KrakenFuturesWebSocketClient::new("wss://test".to_string(), 30, None);
1340 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1341 let dropped = Arc::new(AtomicBool::new(false));
1342 let drop_signal = DropSignal(Arc::clone(&dropped));
1343 client
1344 .handler_tasks
1345 .spawn(async move {
1346 let _drop_signal = drop_signal;
1347 started_tx.send(()).expect("started receiver");
1348 std::future::pending::<()>().await;
1349 })
1350 .expect("handler task should register");
1351 started_rx.await.expect("handler task started");
1352 let clone = client.clone();
1353
1354 drop(client);
1355 assert!(!dropped.load(Ordering::Acquire));
1356 drop(clone);
1357
1358 tokio::time::timeout(tokio::time::Duration::from_secs(1), async {
1359 while !dropped.load(Ordering::Acquire) {
1360 tokio::task::yield_now().await;
1361 }
1362 })
1363 .await
1364 .expect("handler task aborted");
1365 }
1366
1367 #[rstest]
1368 fn test_build_challenge_payload_emits_expected_event() {
1369 let credential = test_credential();
1370 let payload = build_challenge_payload(&credential).expect("serializes");
1371 assert!(payload.expose_secret().contains(r#""event":"challenge""#));
1372 assert!(payload.expose_secret().contains(r#""api_key":"test_key""#));
1373 }
1374
1375 #[rstest]
1376 #[tokio::test]
1377 async fn test_resubscribe_public_skips_private_feeds() {
1378 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<FuturesHandlerCommand>();
1379 let subscriptions = SubscriptionState::new(KRAKEN_FUTURES_WS_TOPIC_DELIMITER);
1380
1381 let mut payloads = HashMap::new();
1382 payloads.insert(
1383 "trades:PI_XBTUSD".to_string(),
1384 SecretString::from(
1385 r#"{"event":"subscribe","feed":"trade","product_ids":["PI_XBTUSD"]}"#.to_string(),
1386 ),
1387 );
1388 payloads.insert(
1389 "open_orders".to_string(),
1390 SecretString::from(r#"{"event":"subscribe","feed":"open_orders"}"#.to_string()),
1391 );
1392
1393 resubscribe_public(&cmd_tx, &subscriptions, &payloads);
1394
1395 let mut subscribed = Vec::new();
1396 while let Ok(FuturesHandlerCommand::Subscribe { payload }) = cmd_rx.try_recv() {
1397 subscribed.push(payload);
1398 }
1399
1400 assert_eq!(
1401 subscribed.len(),
1402 1,
1403 "only the public feed should resubscribe"
1404 );
1405 assert!(subscribed[0].expose_secret().contains("PI_XBTUSD"));
1406 }
1407
1408 #[rstest]
1409 #[tokio::test]
1410 async fn test_resubscribe_public_restores_publics_even_with_credentialed_client() {
1411 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<FuturesHandlerCommand>();
1414 let subscriptions = SubscriptionState::new(KRAKEN_FUTURES_WS_TOPIC_DELIMITER);
1415
1416 let mut payloads = HashMap::new();
1417 payloads.insert(
1418 "trades:PI_XBTUSD".to_string(),
1419 SecretString::from(
1420 r#"{"event":"subscribe","feed":"trade","product_ids":["PI_XBTUSD"]}"#.to_string(),
1421 ),
1422 );
1423
1424 resubscribe_public(&cmd_tx, &subscriptions, &payloads);
1425
1426 match cmd_rx.try_recv().expect("public subscribe expected") {
1427 FuturesHandlerCommand::Subscribe { payload } => {
1428 assert!(payload.expose_secret().contains("PI_XBTUSD"));
1429 }
1430 other => panic!("expected Subscribe, was {other:?}"),
1431 }
1432 }
1433
1434 #[rstest]
1435 #[tokio::test]
1436 async fn test_resubscribe_private_patches_credentials() {
1437 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<FuturesHandlerCommand>();
1438 let subscriptions = SubscriptionState::new(KRAKEN_FUTURES_WS_TOPIC_DELIMITER);
1439 let credential = test_credential();
1440
1441 let mut payloads = HashMap::new();
1442 payloads.insert(
1443 "open_orders".to_string(),
1444 SecretString::from(
1445 r#"{"event":"subscribe","feed":"open_orders","api_key":"","original_challenge":"","signed_challenge":""}"#
1446 .to_string(),
1447 ),
1448 );
1449 payloads.insert(
1450 "trades:PI_XBTUSD".to_string(),
1451 SecretString::from(
1452 r#"{"event":"subscribe","feed":"trade","product_ids":["PI_XBTUSD"]}"#.to_string(),
1453 ),
1454 );
1455
1456 resubscribe_private(
1457 &cmd_tx,
1458 &subscriptions,
1459 &payloads,
1460 &credential,
1461 "server-challenge",
1462 "signed-value",
1463 );
1464
1465 let mut subscribed = Vec::new();
1466 while let Ok(FuturesHandlerCommand::Subscribe { payload }) = cmd_rx.try_recv() {
1467 subscribed.push(payload);
1468 }
1469
1470 assert_eq!(
1471 subscribed.len(),
1472 1,
1473 "only the private feed should resubscribe"
1474 );
1475 let value: serde_json::Value =
1476 serde_json::from_str(subscribed[0].expose_secret()).expect("payload is valid JSON");
1477 assert_eq!(value["event"], "subscribe");
1478 assert_eq!(value["feed"], "open_orders");
1479 assert_eq!(value["api_key"], "test_key");
1480 assert_eq!(value["original_challenge"], "server-challenge");
1481 assert_eq!(value["signed_challenge"], "signed-value");
1482 }
1483
1484 #[rstest]
1485 #[tokio::test]
1486 async fn test_auth_tracker_succeed_completes_wait_for_result() {
1487 let tracker = AuthTracker::new();
1488 let receiver = tracker.begin();
1489
1490 let tracker_for_responder = tracker.clone();
1491
1492 tokio::spawn(async move {
1493 tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
1494 tracker_for_responder.succeed();
1495 });
1496
1497 tracker
1498 .wait_for_result::<KrakenWsError>(tokio::time::Duration::from_secs(1), receiver)
1499 .await
1500 .expect("auth should succeed");
1501
1502 assert!(tracker.is_authenticated());
1503 }
1504
1505 #[rstest]
1506 #[tokio::test]
1507 async fn test_auth_tracker_wait_for_result_times_out() {
1508 let tracker = AuthTracker::new();
1509 let receiver = tracker.begin();
1510
1511 let err = tracker
1512 .wait_for_result::<KrakenWsError>(tokio::time::Duration::from_millis(20), receiver)
1513 .await
1514 .expect_err("should time out");
1515
1516 assert!(matches!(err, KrakenWsError::AuthenticationError(_)));
1517 assert!(!tracker.is_authenticated());
1518 }
1519
1520 #[rstest]
1521 #[tokio::test]
1522 async fn test_authenticate_without_credentials_errors() {
1523 let client = KrakenFuturesWebSocketClient::new(
1524 "wss://futures.kraken.com/ws/v1".to_string(),
1525 60,
1526 None,
1527 );
1528
1529 let err = client.authenticate().await.expect_err("should fail");
1530 assert!(
1531 matches!(err, KrakenWsError::AuthenticationError(ref msg) if msg.contains("API credentials required")),
1532 "unexpected error: {err:?}"
1533 );
1534 }
1535
1536 #[rstest]
1537 #[tokio::test]
1538 async fn test_set_auth_credentials_marks_tracker_authenticated() {
1539 let client = KrakenFuturesWebSocketClient::with_credentials(
1540 "wss://futures.kraken.com/ws/v1".to_string(),
1541 60,
1542 Some(test_credential()),
1543 None,
1544 TransportBackend::default(),
1545 None,
1546 );
1547
1548 assert!(!client.is_authenticated());
1549
1550 client
1551 .set_auth_credentials("orig-challenge".to_string(), "signed-challenge".to_string())
1552 .await
1553 .expect("should succeed");
1554
1555 assert!(client.is_authenticated());
1556 client
1557 .wait_until_authenticated(0.05)
1558 .await
1559 .expect("should return immediately");
1560 }
1561
1562 #[rstest]
1563 #[tokio::test]
1564 async fn test_set_auth_credentials_without_credentials_errors() {
1565 let client = KrakenFuturesWebSocketClient::new(
1566 "wss://futures.kraken.com/ws/v1".to_string(),
1567 60,
1568 None,
1569 );
1570
1571 let err = client
1572 .set_auth_credentials("orig".to_string(), "signed".to_string())
1573 .await
1574 .expect_err("should fail");
1575 assert!(matches!(err, KrakenWsError::AuthenticationError(_)));
1576 assert!(!client.is_authenticated());
1577 }
1578
1579 #[rstest]
1580 #[tokio::test]
1581 async fn test_authenticate_with_challenge_updates_state() {
1582 let client = KrakenFuturesWebSocketClient::with_credentials(
1583 "wss://futures.kraken.com/ws/v1".to_string(),
1584 60,
1585 Some(test_credential()),
1586 None,
1587 TransportBackend::default(),
1588 None,
1589 );
1590
1591 client
1592 .authenticate_with_challenge("server-challenge")
1593 .await
1594 .expect("should succeed");
1595
1596 assert!(client.is_authenticated());
1597 }
1598
1599 #[rstest]
1600 #[tokio::test]
1601 async fn test_wait_until_authenticated_resolves_after_success() {
1602 let client = KrakenFuturesWebSocketClient::with_credentials(
1603 "wss://futures.kraken.com/ws/v1".to_string(),
1604 60,
1605 Some(test_credential()),
1606 None,
1607 TransportBackend::default(),
1608 None,
1609 );
1610
1611 let client_for_responder = client.clone();
1612
1613 tokio::spawn(async move {
1614 tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
1615 client_for_responder
1616 .set_auth_credentials("orig".to_string(), "signed".to_string())
1617 .await
1618 .expect("succeeds");
1619 });
1620
1621 client
1622 .wait_until_authenticated(1.0)
1623 .await
1624 .expect("should resolve once credentials are set");
1625 }
1626
1627 #[rstest]
1628 #[tokio::test]
1629 async fn test_wait_until_authenticated_times_out() {
1630 let client = KrakenFuturesWebSocketClient::with_credentials(
1631 "wss://futures.kraken.com/ws/v1".to_string(),
1632 60,
1633 Some(test_credential()),
1634 None,
1635 TransportBackend::default(),
1636 None,
1637 );
1638
1639 let err = client
1640 .wait_until_authenticated(0.05)
1641 .await
1642 .expect_err("should time out");
1643 assert!(matches!(err, KrakenWsError::AuthenticationError(_)));
1644 }
1645}