Skip to main content

nautilus_coinbase/websocket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket client for the Coinbase Advanced Trade API.
17//!
18//! Manages connection lifecycle, JWT-authenticated subscriptions, and dispatches
19//! parsed Nautilus messages through the [`FeedHandler`].
20
21use std::{
22    num::NonZeroU32,
23    str::FromStr,
24    sync::{
25        Arc, LazyLock,
26        atomic::{AtomicBool, AtomicU8, Ordering},
27    },
28    time::Duration,
29};
30
31use arc_swap::ArcSwap;
32#[cfg(test)]
33use nautilus_core::string::secret::REDACTED;
34use nautilus_core::{AtomicMap, string::secret::SecretString};
35use nautilus_live::{
36    SocketControl,
37    task::{TaskJoinOutcome, TaskSlot, finish_task},
38};
39use nautilus_model::{
40    data::BarType,
41    identifiers::{AccountId, InstrumentId},
42    instruments::{Instrument, InstrumentAny},
43};
44use nautilus_network::{
45    ConnectionUrl,
46    http::create_standard_nautilus_headers,
47    mode::ConnectionMode,
48    ratelimiter::quota::Quota,
49    websocket::{
50        SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
51        channel_message_handler,
52    },
53};
54use ustr::Ustr;
55
56use crate::{
57    common::{
58        consts::{
59            RECONNECT_BACKOFF_FACTOR, RECONNECT_BASE_BACKOFF, RECONNECT_JITTER_MS,
60            RECONNECT_MAX_BACKOFF, RECONNECT_TIMEOUT, WS_DISCONNECT_TIMEOUT, WS_HEARTBEAT_SECS,
61        },
62        credential::CoinbaseCredential,
63        enums::CoinbaseWsChannel,
64    },
65    websocket::{
66        handler::{FeedHandler, HandlerCommand, NautilusWsMessage},
67        messages::{CoinbaseWsAction, CoinbaseWsSubscription},
68    },
69};
70
71/// Coinbase WebSocket connection rate limit (8 per second per IP).
72pub static COINBASE_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
73    Quota::per_second(NonZeroU32::new(8).expect("non-zero")).expect("valid constant")
74});
75
76/// Coinbase WebSocket subscribe/unsubscribe rate limit (8 per second per IP).
77pub static COINBASE_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
78    Quota::per_second(NonZeroU32::new(8).expect("non-zero")).expect("valid constant")
79});
80
81/// Rate-limit key for subscribe/unsubscribe operations.
82pub const COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION: &str = "subscription";
83
84/// Pre-interned [`COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION`] slice.
85pub static COINBASE_WS_SUBSCRIPTION_KEYS: LazyLock<[Ustr; 1]> =
86    LazyLock::new(|| [Ustr::from(COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION)]);
87
88/// WebSocket client for Coinbase Advanced Trade market data and user streams.
89///
90/// Manages connection lifecycle, subscription state, and JWT authentication.
91/// Spawns a [`FeedHandler`] task that parses raw messages into Nautilus types.
92#[derive(Debug)]
93pub struct CoinbaseWebSocketClient {
94    url: ConnectionUrl,
95    connection_mode: Arc<ArcSwap<AtomicU8>>,
96    signal: Arc<AtomicBool>,
97    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
98    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
99    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
100    /// Maps a canonical wire `product_id` to the `product_id` the caller
101    /// subscribed or submitted with. Coinbase rewrites aliased products to
102    /// their canonical form on the wire (e.g. `BTC-USDC -> BTC-USD`), so
103    /// inbound messages must be re-keyed to the caller's id before parsing.
104    subscription_aliases: Arc<AtomicMap<Ustr, Ustr>>,
105    bar_types: ahash::AHashMap<String, BarType>,
106    subscriptions: SubscriptionState,
107    credential: Option<CoinbaseCredential>,
108    account_id: Option<AccountId>,
109    task_handle: TaskSlot<()>,
110    shutdown_errors: Vec<String>,
111    transport_backend: TransportBackend,
112    proxy_url: Option<SecretString>,
113    socket_control: Option<SocketControl>,
114}
115
116impl Clone for CoinbaseWebSocketClient {
117    fn clone(&self) -> Self {
118        Self {
119            url: self.url.clone(),
120            connection_mode: Arc::clone(&self.connection_mode),
121            signal: Arc::clone(&self.signal),
122            cmd_tx: Arc::clone(&self.cmd_tx),
123            out_rx: None,
124            instruments: Arc::clone(&self.instruments),
125            subscription_aliases: Arc::clone(&self.subscription_aliases),
126            bar_types: self.bar_types.clone(),
127            subscriptions: self.subscriptions.clone(),
128            credential: self.credential.clone(),
129            account_id: self.account_id,
130            task_handle: TaskSlot::new(),
131            shutdown_errors: Vec::new(),
132            transport_backend: self.transport_backend,
133            proxy_url: self.proxy_url.clone(),
134            socket_control: self.socket_control.clone(),
135        }
136    }
137}
138
139impl CoinbaseWebSocketClient {
140    /// Creates a new [`CoinbaseWebSocketClient`] for public market data.
141    pub fn new(url: &str, transport_backend: TransportBackend, proxy_url: Option<String>) -> Self {
142        let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
143
144        Self {
145            url: url.into(),
146            connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
147                ConnectionMode::Closed.as_u8(),
148            ))),
149            signal: Arc::new(AtomicBool::new(false)),
150            cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
151            out_rx: None,
152            instruments: Arc::new(AtomicMap::new()),
153            subscription_aliases: Arc::new(AtomicMap::new()),
154            bar_types: ahash::AHashMap::new(),
155            subscriptions: SubscriptionState::new('|'),
156            credential: None,
157            account_id: None,
158            task_handle: TaskSlot::new(),
159            shutdown_errors: Vec::new(),
160            transport_backend,
161            proxy_url: proxy_url.map(SecretString::from),
162            socket_control: None,
163        }
164    }
165
166    /// Configures socket state reporting and reconnect control.
167    #[must_use]
168    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
169        self.socket_control = Some(control);
170        self
171    }
172
173    /// Creates a new [`CoinbaseWebSocketClient`] with credentials for authenticated channels.
174    pub fn with_credential(
175        url: &str,
176        credential: CoinbaseCredential,
177        transport_backend: TransportBackend,
178        proxy_url: Option<String>,
179    ) -> Self {
180        let mut client = Self::new(url, transport_backend, proxy_url);
181        client.credential = Some(credential);
182        client
183    }
184
185    /// Sets the account ID used when emitting user-channel execution reports.
186    ///
187    /// Propagates to the feed handler when the connection is active so that
188    /// subsequent user events carry the correct account identifier.
189    pub async fn set_account_id(&mut self, account_id: AccountId) {
190        self.account_id = Some(account_id);
191
192        let cmd_tx = self.cmd_tx.read().await;
193        if let Err(e) = cmd_tx.send(HandlerCommand::SetAccountId(account_id)) {
194            log::debug!("Failed to send SetAccountId: {e}");
195        }
196    }
197
198    /// Bulk-populates the instrument cache.
199    ///
200    /// Safe to call before or after [`Self::connect`]. When called before
201    /// connect, instruments are picked up by the initial `InitializeInstruments`
202    /// command the client sends to the handler; when called after, a fresh
203    /// `InitializeInstruments` command is sent to refresh the handler's cache.
204    pub async fn initialize_instruments(&self, instruments: Vec<InstrumentAny>) {
205        let cmd_tx = self.cmd_tx.read().await;
206
207        if !instruments.is_empty() {
208            self.instruments.rcu(|cache| {
209                for instrument in &instruments {
210                    cache.insert(instrument.id(), instrument.clone());
211                }
212            });
213        }
214
215        if let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments)) {
216            log::debug!("Failed to send InitializeInstruments: {e}");
217        }
218    }
219
220    // Coinbase closes clients that idle without a subscribe inside 5s, and
221    // heartbeats keeps the connection alive when product topics are quiet.
222    // Marking before `resubscribe_all` replays it on every reconnect.
223    fn prime_default_subscriptions(&self) {
224        self.subscriptions
225            .mark_subscribe(CoinbaseWsChannel::Heartbeats.as_ref());
226    }
227
228    /// Establishes the WebSocket connection and spawns the feed handler.
229    pub async fn connect(&mut self) -> anyhow::Result<()> {
230        if self.is_active() || self.is_reconnecting() {
231            log::warn!("WebSocket already connected or reconnecting");
232            return Ok(());
233        }
234
235        if let Some(outcome) = finish_task(
236            &mut self.task_handle,
237            WS_DISCONNECT_TIMEOUT,
238            WS_DISCONNECT_TIMEOUT,
239        )
240        .await
241        {
242            match outcome {
243                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
244                TaskJoinOutcome::Failed(error) => {
245                    anyhow::bail!("Coinbase WebSocket handler failed: {error}");
246                }
247                TaskJoinOutcome::Incomplete => {
248                    anyhow::bail!("Coinbase WebSocket handler did not stop after abort");
249                }
250            }
251        }
252
253        // Clear stop signal from any previous disconnect
254        self.signal.store(false, Ordering::Relaxed);
255
256        let (message_handler, raw_rx) = channel_message_handler();
257        let headers = create_standard_nautilus_headers();
258
259        let cfg = WebSocketConfig {
260            url: self.url.expose().to_owned(),
261            headers,
262            // Coinbase uses TCP control-frame pings for transport keep-alive;
263            // application-layer liveness comes from the heartbeats channel.
264            heartbeat_interval_secs: Some(WS_HEARTBEAT_SECS),
265            heartbeat_payload: None,
266            connect_timeout_ms: Some(RECONNECT_TIMEOUT.as_millis() as u64),
267            reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
268            reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
269            reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
270            reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
271            reconnect_max_attempts: None,
272            heartbeat_timeout_secs: None,
273            idle_timeout_ms: None,
274            writer_capacity: None,
275            backend: self.transport_backend,
276            proxy_url: self
277                .proxy_url
278                .as_ref()
279                .map(|value| value.expose_secret().to_owned()),
280            max_message_size_bytes: None,
281            max_frame_size_bytes: None,
282        };
283
284        let keyed_quotas = vec![(
285            COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION.to_string(),
286            *COINBASE_WS_SUBSCRIPTION_QUOTA,
287        )];
288
289        let client = WebSocketClient::builder()
290            .config(cfg)
291            .message_handler(message_handler)
292            .keyed_quotas(keyed_quotas)
293            .default_quota(*COINBASE_WS_CONNECTION_QUOTA)
294            .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
295            .connect()
296            .await?;
297
298        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
299        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
300
301        self.out_rx = Some(out_rx);
302        let connection_mode = client.connection_mode_atomic();
303        let reconnect_handle = client.reconnect_handle();
304        log::debug!("Coinbase WebSocket connected: {}", self.url.origin());
305
306        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
307            anyhow::bail!("Failed to send SetClient command: {e}");
308        }
309
310        if let Some(control) = &self.socket_control {
311            control.register(move || reconnect_handle.request_reconnect());
312        }
313
314        {
315            let mut command_sender = self.cmd_tx.write().await;
316            let instruments_vec: Vec<InstrumentAny> =
317                self.instruments.load().values().cloned().collect();
318
319            if !instruments_vec.is_empty()
320                && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
321            {
322                log::error!("Failed to send InitializeInstruments: {e}");
323            }
324
325            // Restore bar type registrations from previous session
326            for (key, bar_type) in &self.bar_types {
327                if let Err(e) = cmd_tx.send(HandlerCommand::AddBarType {
328                    key: key.clone(),
329                    bar_type: *bar_type,
330                }) {
331                    log::error!("Failed to restore bar type {key}: {e}");
332                }
333            }
334
335            if let Some(account_id) = self.account_id
336                && let Err(e) = cmd_tx.send(HandlerCommand::SetAccountId(account_id))
337            {
338                log::error!("Failed to restore account_id: {e}");
339            }
340
341            self.prime_default_subscriptions();
342
343            // Replay retained subscriptions from previous session
344            resubscribe_all(
345                &self.subscriptions,
346                &self.credential,
347                &cmd_tx,
348                Some(&out_tx),
349            );
350
351            *command_sender = cmd_tx.clone();
352            self.connection_mode.store(connection_mode);
353        }
354
355        let signal = Arc::clone(&self.signal);
356        let subscriptions = self.subscriptions.clone();
357        let credential = self.credential.clone();
358        let cmd_tx_reconnect = cmd_tx.clone();
359        let aliases_for_handler = Arc::clone(&self.subscription_aliases);
360
361        if let Err(e) = self.task_handle.spawn(async move {
362            let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, aliases_for_handler);
363
364            loop {
365                match handler.next().await {
366                    Some(NautilusWsMessage::Reconnected) => {
367                        subscriptions.reset_after_reconnect();
368                        resubscribe_all(
369                            &subscriptions,
370                            &credential,
371                            &cmd_tx_reconnect,
372                            Some(&out_tx),
373                        );
374
375                        if let Err(e) = out_tx.send(NautilusWsMessage::Reconnected) {
376                            log::debug!("Output channel closed: {e}");
377                            break;
378                        }
379                    }
380                    Some(msg) => {
381                        if let Err(e) = out_tx.send(msg) {
382                            log::debug!("Output channel closed: {e}");
383                            break;
384                        }
385                    }
386                    None => {
387                        log::debug!("Feed handler stopped");
388                        break;
389                    }
390                }
391            }
392        }) {
393            self.out_rx = None;
394            anyhow::bail!("Failed to start Coinbase WebSocket handler task: {e}");
395        }
396
397        Ok(())
398    }
399
400    /// Subscribes to a channel for the given product IDs.
401    pub async fn subscribe(
402        &self,
403        channel: CoinbaseWsChannel,
404        product_ids: &[Ustr],
405    ) -> anyhow::Result<()> {
406        let jwt = if channel.requires_auth() {
407            let credential = self
408                .credential
409                .as_ref()
410                .ok_or_else(|| anyhow::anyhow!("Credentials required for {channel}"))?;
411            Some(credential.build_ws_jwt()?)
412        } else {
413            self.credential.as_ref().and_then(|c| c.build_ws_jwt().ok())
414        };
415
416        let sub = protect_subscription(CoinbaseWsSubscription {
417            msg_type: CoinbaseWsAction::Subscribe,
418            product_ids: product_ids.to_vec(),
419            channel,
420            jwt,
421        })?;
422
423        let channel_str = channel.as_ref();
424
425        if product_ids.is_empty() {
426            self.subscriptions.mark_subscribe(channel_str);
427        } else {
428            for product_id in product_ids {
429                let topic = format!("{channel_str}|{product_id}");
430                self.subscriptions.mark_subscribe(&topic);
431            }
432        }
433
434        let cmd_tx = self.cmd_tx.read().await;
435        cmd_tx
436            .send(HandlerCommand::Subscribe {
437                channel: sub.channel,
438                product_ids: sub.product_ids,
439                payload: sub.payload,
440            })
441            .map_err(|e| anyhow::anyhow!("Failed to send Subscribe command: {e}"))
442    }
443
444    /// Unsubscribes from a channel for the given product IDs.
445    pub async fn unsubscribe(
446        &self,
447        channel: CoinbaseWsChannel,
448        product_ids: &[Ustr],
449    ) -> anyhow::Result<()> {
450        let jwt = self.credential.as_ref().and_then(|c| c.build_ws_jwt().ok());
451
452        let unsub = protect_subscription(CoinbaseWsSubscription {
453            msg_type: CoinbaseWsAction::Unsubscribe,
454            product_ids: product_ids.to_vec(),
455            channel,
456            jwt,
457        })?;
458
459        let channel_str = channel.as_ref();
460
461        if product_ids.is_empty() {
462            self.subscriptions.mark_unsubscribe(channel_str);
463        } else {
464            for product_id in product_ids {
465                let topic = format!("{channel_str}|{product_id}");
466                self.subscriptions.mark_unsubscribe(&topic);
467            }
468        }
469
470        let cmd_tx = self.cmd_tx.read().await;
471        cmd_tx
472            .send(HandlerCommand::Unsubscribe {
473                channel: unsub.channel,
474                product_ids: unsub.product_ids,
475                payload: unsub.payload,
476            })
477            .map_err(|e| anyhow::anyhow!("Failed to send Unsubscribe command: {e}"))
478    }
479
480    /// Returns the next parsed message from the feed handler.
481    pub async fn next_message(&mut self) -> Option<NautilusWsMessage> {
482        self.out_rx.as_mut()?.recv().await
483    }
484
485    /// Disconnects the WebSocket and stops the feed handler.
486    pub(crate) fn begin_shutdown(&self) {
487        self.signal.store(true, Ordering::Release);
488    }
489
490    /// Disconnects the WebSocket and stops the feed handler.
491    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
492        // Send Disconnect command before setting the signal so the handler
493        // processes it and calls notify_closed() on the inner WebSocket client
494        let cmd_tx = self.cmd_tx.read().await;
495
496        if let Err(e) = cmd_tx.send(HandlerCommand::Disconnect) {
497            log::debug!("Failed to send Disconnect command: {e}");
498        }
499        drop(cmd_tx);
500
501        // Release pairs with the handler's Acquire load; fallback for when
502        // the command channel is full or closed.
503        self.begin_shutdown();
504
505        if let Some(outcome) = finish_task(
506            &mut self.task_handle,
507            WS_DISCONNECT_TIMEOUT,
508            WS_DISCONNECT_TIMEOUT,
509        )
510        .await
511        {
512            match outcome {
513                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
514                TaskJoinOutcome::Failed(error) => {
515                    self.shutdown_errors
516                        .push(format!("Coinbase WebSocket handler failed: {error}"));
517                }
518                TaskJoinOutcome::Incomplete => {
519                    self.shutdown_errors
520                        .push("Coinbase WebSocket handler did not stop after abort".to_string());
521                }
522            }
523        }
524
525        // Wait for the inner WebSocket's connection_mode atomic to reach Closed
526        // before returning. Without this, a subsequent connect() can observe a
527        // stale Active/Reconnect state and early-return, leaving out_rx unset
528        // and causing "WebSocket output receiver not available" on take.
529        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
530
531        loop {
532            let mode_ptr = self.connection_mode.load();
533
534            if ConnectionMode::from_u8(mode_ptr.load(Ordering::Relaxed)).is_closed() {
535                break;
536            }
537
538            if tokio::time::Instant::now() >= deadline {
539                self.shutdown_errors
540                    .push("Timed out waiting for WebSocket to reach Closed state".to_string());
541                break;
542            }
543
544            tokio::time::sleep(Duration::from_millis(20)).await;
545        }
546
547        if let Some(control) = &self.socket_control {
548            control.deregister();
549        }
550
551        if self.shutdown_errors.is_empty() {
552            Ok(())
553        } else {
554            let errors = std::mem::take(&mut self.shutdown_errors);
555            anyhow::bail!(errors.join("; "))
556        }
557    }
558
559    /// Returns true if the WebSocket connection is active.
560    #[must_use]
561    pub fn is_active(&self) -> bool {
562        let mode_ptr = self.connection_mode.load();
563        let mode_val = mode_ptr.load(Ordering::Relaxed);
564        ConnectionMode::from_u8(mode_val).is_active()
565    }
566
567    /// Returns true if the WebSocket is reconnecting after a transport drop.
568    #[must_use]
569    pub fn is_reconnecting(&self) -> bool {
570        let mode_ptr = self.connection_mode.load();
571        let mode_val = mode_ptr.load(Ordering::Relaxed);
572        ConnectionMode::from_u8(mode_val).is_reconnect()
573    }
574
575    /// Returns a reference to the instrument cache.
576    #[must_use]
577    pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
578        &self.instruments
579    }
580
581    /// Returns a reference to the canonical-to-subscribed alias map.
582    #[must_use]
583    pub fn subscription_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
584        &self.subscription_aliases
585    }
586
587    /// Records that inbound messages carrying `canonical` should be re-keyed to
588    /// `subscribed`. Caller is the data/exec client at subscribe or submit time
589    /// when the local product id differs from Coinbase's canonical alias.
590    pub fn register_subscription_alias(&self, canonical: Ustr, subscribed: Ustr) {
591        self.subscription_aliases.insert(canonical, subscribed);
592    }
593
594    /// Removes an alias registration. Safe to call if no entry exists.
595    pub fn unregister_subscription_alias(&self, canonical: &Ustr) {
596        self.subscription_aliases.remove(canonical);
597    }
598
599    /// Returns the subscription state.
600    #[must_use]
601    pub fn subscriptions(&self) -> &SubscriptionState {
602        &self.subscriptions
603    }
604
605    /// Updates an instrument in the cache and notifies the handler.
606    pub async fn update_instrument(&self, instrument: InstrumentAny) {
607        let cmd_tx = self.cmd_tx.read().await;
608        let id = instrument.id();
609        self.instruments.insert(id, instrument.clone());
610
611        if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(Box::new(instrument))) {
612            log::debug!("Failed to send UpdateInstrument: {e}");
613        }
614    }
615
616    pub(crate) async fn update_instruments(&self, instruments: &[InstrumentAny]) {
617        if instruments.is_empty() {
618            return;
619        }
620
621        let cmd_tx = self.cmd_tx.read().await;
622        self.instruments.rcu(|cache| {
623            for instrument in instruments {
624                cache.insert(instrument.id(), instrument.clone());
625            }
626        });
627
628        for instrument in instruments {
629            if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(Box::new(
630                instrument.clone(),
631            ))) {
632                log::debug!("Failed to send UpdateInstrument: {e}");
633            }
634        }
635    }
636
637    /// Takes the output message receiver, leaving `None` in its place.
638    ///
639    /// Used by the data client to move the receiver into a background consumption task.
640    pub fn take_out_rx(
641        &mut self,
642    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>> {
643        self.out_rx.take()
644    }
645
646    /// Registers a bar type locally without notifying the handler.
647    ///
648    /// Used by the data client to persist registrations on the original client
649    /// before cloning for async command dispatch.
650    pub fn register_bar_type(&mut self, key: String, bar_type: BarType) {
651        self.bar_types.insert(key, bar_type);
652    }
653
654    /// Registers a bar type for candle parsing.
655    pub async fn add_bar_type(&mut self, key: String, bar_type: BarType) {
656        self.bar_types.insert(key.clone(), bar_type);
657
658        let cmd_tx = self.cmd_tx.read().await;
659
660        if let Err(e) = cmd_tx.send(HandlerCommand::AddBarType { key, bar_type }) {
661            log::debug!("Failed to send AddBarType: {e}");
662        }
663    }
664}
665
666impl Drop for CoinbaseWebSocketClient {
667    fn drop(&mut self) {
668        if let Some(handle) = self.task_handle.as_ref() {
669            self.signal.store(true, Ordering::Release);
670            handle.abort();
671        }
672    }
673}
674
675struct ProtectedSubscription {
676    channel: CoinbaseWsChannel,
677    product_ids: Vec<Ustr>,
678    payload: SecretString,
679}
680
681fn protect_subscription(
682    mut subscription: CoinbaseWsSubscription,
683) -> Result<ProtectedSubscription, serde_json::Error> {
684    let payload = SecretString::from(serde_json::to_string(&subscription)?);
685
686    Ok(ProtectedSubscription {
687        channel: subscription.channel,
688        product_ids: std::mem::take(&mut subscription.product_ids),
689        payload,
690    })
691}
692
693fn resubscribe_all(
694    subscriptions: &SubscriptionState,
695    credential: &Option<CoinbaseCredential>,
696    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
697    out_tx: Option<&tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>>,
698) {
699    let topics = subscriptions.all_topics();
700
701    if topics.is_empty() {
702        log::debug!("No active subscriptions to restore");
703        return;
704    }
705
706    log::info!(
707        "Resubscribing to {} topics after reconnection",
708        topics.len()
709    );
710
711    for topic in topics {
712        let (channel, product_id) = match topic.split_once('|') {
713            Some((ch, pid)) => (ch, Some(pid)),
714            None => (topic.as_str(), None),
715        };
716
717        let channel_enum = match CoinbaseWsChannel::from_str(channel) {
718            Ok(ch) => ch,
719            Err(_) => {
720                log::warn!("Unknown channel in topic: {topic}");
721                continue;
722            }
723        };
724
725        let jwt = match credential.as_ref() {
726            Some(c) => match c.build_ws_jwt() {
727                Ok(token) => Some(token),
728                Err(e) => {
729                    if channel_enum.requires_auth() {
730                        let msg = format!(
731                            "JWT required for {channel} but build failed: {e}; topic {topic} not restored"
732                        );
733                        log::error!("{msg}");
734                        if let Some(tx) = out_tx {
735                            let _ = tx.send(NautilusWsMessage::Error(msg));
736                        }
737                        continue;
738                    }
739                    None
740                }
741            },
742            None => {
743                if channel_enum.requires_auth() {
744                    let msg = format!(
745                        "JWT required for {channel} but no credentials configured; topic {topic} not restored"
746                    );
747                    log::error!("{msg}");
748                    if let Some(tx) = out_tx {
749                        let _ = tx.send(NautilusWsMessage::Error(msg));
750                    }
751                    continue;
752                }
753                None
754            }
755        };
756
757        let product_ids = match product_id {
758            Some(pid) => vec![Ustr::from(pid)],
759            None => vec![],
760        };
761
762        let sub = CoinbaseWsSubscription {
763            msg_type: CoinbaseWsAction::Subscribe,
764            product_ids,
765            channel: channel_enum,
766            jwt,
767        };
768
769        let sub = match protect_subscription(sub) {
770            Ok(sub) => sub,
771            Err(e) => {
772                log::error!("Failed to serialize subscription for {topic}: {e}");
773                continue;
774            }
775        };
776
777        if let Err(e) = cmd_tx.send(HandlerCommand::Subscribe {
778            channel: sub.channel,
779            product_ids: sub.product_ids,
780            payload: sub.payload,
781        }) {
782            log::error!("Failed to resubscribe {topic}: {e}");
783        }
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use futures_util::StreamExt;
790    use nautilus_network::websocket::SubscriptionState;
791    use nautilus_testkit::connection::race_command_with_publication;
792    use rstest::rstest;
793
794    use super::*;
795
796    #[rstest]
797    #[case::single(false)]
798    #[case::bulk(true)]
799    #[tokio::test]
800    async fn instrument_updates_wait_for_sender_publication(#[case] bulk: bool) {
801        let ws_client =
802            CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
803        let instrument = InstrumentAny::CryptoPerpetual(
804            nautilus_model::instruments::stubs::crypto_perpetual_ethusdt(),
805        );
806        let mut sender = ws_client.cmd_tx.write().await;
807
808        let mut update = Box::pin(async {
809            if bulk {
810                ws_client
811                    .initialize_instruments(vec![instrument.clone()])
812                    .await;
813            } else {
814                ws_client.update_instrument(instrument.clone()).await;
815            }
816        });
817
818        assert!(futures_util::poll!(update.as_mut()).is_pending());
819        assert!(ws_client.instruments.load().is_empty());
820        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
821        *sender = cmd_tx;
822        drop(sender);
823        update.await;
824
825        assert_eq!(
826            ws_client.instruments.get_cloned(&instrument.id()),
827            Some(instrument.clone())
828        );
829
830        match (bulk, cmd_rx.try_recv().unwrap()) {
831            (true, HandlerCommand::InitializeInstruments(instruments)) => {
832                assert_eq!(instruments, vec![instrument]);
833            }
834            (false, HandlerCommand::UpdateInstrument(updated)) => assert_eq!(*updated, instrument),
835            (_, command) => panic!("unexpected cache command: {command:?}"),
836        }
837
838        assert!(cmd_rx.try_recv().is_err());
839    }
840
841    #[rstest]
842    #[case::initialize(true)]
843    #[case::update(false)]
844    #[tokio::test]
845    async fn batch_updates_merge_and_keep_command_order(#[case] initialize: bool) {
846        let ws_client =
847            CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
848        let retained = InstrumentAny::CryptoPerpetual(
849            nautilus_model::instruments::stubs::crypto_perpetual_ethusdt(),
850        );
851
852        let mut first = match retained.clone() {
853            InstrumentAny::CryptoPerpetual(instrument) => instrument,
854            _ => unreachable!(),
855        };
856
857        first.id = "BTCUSDT.COINBASE".into();
858        first.ts_event = 17.into();
859        let mut latest = first.clone();
860        latest.ts_event = 29.into();
861        let batch = vec![
862            InstrumentAny::CryptoPerpetual(first),
863            InstrumentAny::CryptoPerpetual(latest),
864        ];
865        ws_client
866            .initialize_instruments(vec![retained.clone()])
867            .await;
868        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
869        *ws_client.cmd_tx.write().await = sender;
870        if initialize {
871            ws_client.initialize_instruments(batch.clone()).await;
872        } else {
873            ws_client.update_instruments(&batch).await;
874        }
875
876        assert_eq!(ws_client.instruments.len(), 2);
877
878        for expected in [&retained, &batch[1]] {
879            assert_eq!(
880                serde_json::to_value(ws_client.instruments.get_cloned(&expected.id()).unwrap())
881                    .unwrap(),
882                serde_json::to_value(expected).unwrap()
883            );
884        }
885
886        if initialize {
887            let HandlerCommand::InitializeInstruments(actual) = receiver.try_recv().unwrap() else {
888                panic!("expected InitializeInstruments");
889            };
890
891            assert_eq!(
892                serde_json::to_value(actual).unwrap(),
893                serde_json::to_value(&batch).unwrap()
894            );
895        } else {
896            for expected in &batch {
897                let HandlerCommand::UpdateInstrument(actual) = receiver.try_recv().unwrap() else {
898                    panic!("expected UpdateInstrument");
899                };
900
901                assert_eq!(
902                    serde_json::to_value(actual).unwrap(),
903                    serde_json::to_value(expected).unwrap()
904                );
905            }
906        }
907
908        assert!(receiver.try_recv().is_err());
909        let before = ws_client.instruments.load();
910        ws_client.update_instruments(&[]).await;
911        assert!(Arc::ptr_eq(&before, &ws_client.instruments.load()));
912        assert!(receiver.try_recv().is_err());
913    }
914
915    #[tokio::test]
916    async fn command_during_sender_publication_reaches_initialized_handler() {
917        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
918        let addr = listener.local_addr().unwrap();
919        let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
920
921        let server = tokio::spawn(async move {
922            let (socket, _) = listener.accept().await.unwrap();
923            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
924            while let Some(Ok(message)) = socket.next().await {
925                if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
926                    let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
927                    let _ = received_tx.send(payload);
928                }
929            }
930        });
931
932        let url = format!("ws://{addr}");
933        let mut ws_client = CoinbaseWebSocketClient::new(&url, TransportBackend::default(), None);
934        let observer = ws_client.clone();
935        let expected = serde_json::json!({
936            "type": "subscribe",
937            "channel": "market_trades",
938            "product_ids": [
939                "BTC-USD"
940            ]
941        });
942
943        let command = HandlerCommand::Subscribe {
944            channel: CoinbaseWsChannel::MarketTrades,
945            product_ids: vec![Ustr::from("BTC-USD")],
946            payload: SecretString::from(expected.to_string()),
947        };
948
949        race_command_with_publication(
950            Arc::clone(&ws_client.cmd_tx),
951            ws_client.connect(),
952            move || command,
953            || observer.is_active(),
954        )
955        .await
956        .expect("connect failed");
957
958        let received = tokio::time::timeout(Duration::from_secs(2), async {
959            let heartbeat = received_rx.recv().await.unwrap();
960            let command = received_rx.recv().await.unwrap();
961            (heartbeat, command)
962        })
963        .await;
964
965        ws_client.disconnect().await.expect("disconnect failed");
966        server.abort();
967
968        let (heartbeat, payload) = received.expect("startup command must reach server");
969        assert_eq!(
970            heartbeat,
971            serde_json::json!({
972                "type": "subscribe",
973                "channel": "heartbeats"
974            })
975        );
976        assert_eq!(payload, expected);
977    }
978
979    #[rstest]
980    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
981    #[case(
982        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
983        "wss://stream.example.com:9443"
984    )]
985    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
986        let client = CoinbaseWebSocketClient::new(url, TransportBackend::default(), None);
987        let debug = format!("{client:?}");
988        assert!(debug.contains(&format!("url: {origin:?}")));
989        assert!(!debug.contains("SYN-"));
990        assert_eq!(client.url.expose(), url);
991    }
992
993    #[rstest]
994    fn test_debug_redacts_proxy_url() {
995        let proxy_url = "http://user:password@proxy.example:8080";
996        let client = CoinbaseWebSocketClient::new(
997            "wss://test",
998            TransportBackend::default(),
999            Some(proxy_url.to_string()),
1000        );
1001
1002        let debug = format!("{client:?}");
1003
1004        assert!(debug.contains(REDACTED));
1005        assert!(!debug.contains(proxy_url));
1006    }
1007
1008    #[rstest]
1009    fn test_drop_clone_does_not_signal_handler() {
1010        let client = CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
1011        let clone = client.clone();
1012
1013        drop(clone);
1014
1015        assert!(!client.signal.load(Ordering::Acquire));
1016    }
1017
1018    #[rstest]
1019    fn test_resubscribe_all_product_level_topic() {
1020        let subs = SubscriptionState::new('|');
1021        subs.mark_subscribe("level2|BTC-USD");
1022
1023        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1024        resubscribe_all(&subs, &None, &tx, None);
1025
1026        let cmd = rx.try_recv().unwrap();
1027
1028        match cmd {
1029            HandlerCommand::Subscribe {
1030                channel,
1031                product_ids,
1032                payload,
1033            } => {
1034                let payload: serde_json::Value =
1035                    serde_json::from_str(payload.expose_secret()).unwrap();
1036                assert_eq!(channel, CoinbaseWsChannel::Level2);
1037                assert_eq!(product_ids, vec![Ustr::from("BTC-USD")]);
1038                assert!(payload.get("jwt").is_none());
1039            }
1040            other => panic!("Expected Subscribe, was {other:?}"),
1041        }
1042    }
1043
1044    #[rstest]
1045    fn test_resubscribe_all_channel_level_topic() {
1046        let subs = SubscriptionState::new('|');
1047        subs.mark_subscribe("heartbeats");
1048
1049        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1050        resubscribe_all(&subs, &None, &tx, None);
1051
1052        let cmd = rx.try_recv().unwrap();
1053
1054        match cmd {
1055            HandlerCommand::Subscribe {
1056                channel,
1057                product_ids,
1058                ..
1059            } => {
1060                assert_eq!(channel, CoinbaseWsChannel::Heartbeats);
1061                assert!(product_ids.is_empty());
1062            }
1063            other => panic!("Expected Subscribe, was {other:?}"),
1064        }
1065    }
1066
1067    #[rstest]
1068    fn test_resubscribe_all_multiple_topics() {
1069        let subs = SubscriptionState::new('|');
1070        subs.mark_subscribe("market_trades|BTC-USD");
1071        subs.mark_subscribe("ticker|ETH-USD");
1072
1073        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1074        resubscribe_all(&subs, &None, &tx, None);
1075
1076        let cmd1 = rx.try_recv().unwrap();
1077        let cmd2 = rx.try_recv().unwrap();
1078
1079        assert!(matches!(cmd1, HandlerCommand::Subscribe { .. }));
1080        assert!(matches!(cmd2, HandlerCommand::Subscribe { .. }));
1081        assert!(rx.try_recv().is_err());
1082    }
1083
1084    #[rstest]
1085    fn test_resubscribe_all_empty_subscriptions() {
1086        let subs = SubscriptionState::new('|');
1087
1088        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1089        resubscribe_all(&subs, &None, &tx, None);
1090
1091        assert!(rx.try_recv().is_err());
1092    }
1093
1094    #[rstest]
1095    fn test_resubscribe_all_unknown_channel_skipped() {
1096        let subs = SubscriptionState::new('|');
1097        subs.mark_subscribe("nonexistent_channel|BTC-USD");
1098
1099        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1100        resubscribe_all(&subs, &None, &tx, None);
1101
1102        assert!(rx.try_recv().is_err());
1103    }
1104
1105    #[rstest]
1106    #[case("level2|BTC-USD", CoinbaseWsChannel::Level2)]
1107    #[case("market_trades|ETH-USD", CoinbaseWsChannel::MarketTrades)]
1108    #[case("ticker|BTC-USD", CoinbaseWsChannel::Ticker)]
1109    #[case("ticker_batch|BTC-USD", CoinbaseWsChannel::TickerBatch)]
1110    #[case("candles|BTC-USD", CoinbaseWsChannel::Candles)]
1111    #[case("heartbeats", CoinbaseWsChannel::Heartbeats)]
1112    #[case("status", CoinbaseWsChannel::Status)]
1113    fn test_resubscribe_all_channel_mapping(
1114        #[case] topic: &str,
1115        #[case] expected_channel: CoinbaseWsChannel,
1116    ) {
1117        let subs = SubscriptionState::new('|');
1118        subs.mark_subscribe(topic);
1119
1120        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1121        resubscribe_all(&subs, &None, &tx, None);
1122
1123        let cmd = rx.try_recv().unwrap();
1124
1125        match cmd {
1126            HandlerCommand::Subscribe { channel, .. } => {
1127                assert_eq!(channel, expected_channel);
1128            }
1129            other => panic!("Expected Subscribe, was {other:?}"),
1130        }
1131    }
1132
1133    #[rstest]
1134    fn test_protected_subscription_preserves_wire_jwt_and_redacts_command_debug() {
1135        let jwt = "jwt-secret-value";
1136        let subscription = CoinbaseWsSubscription {
1137            msg_type: CoinbaseWsAction::Subscribe,
1138            product_ids: vec![Ustr::from("BTC-USD")],
1139            channel: CoinbaseWsChannel::User,
1140            jwt: Some(SecretString::from(jwt)),
1141        };
1142        let subscription_debug = format!("{subscription:?}");
1143        let protected = protect_subscription(subscription).unwrap();
1144        let payload: serde_json::Value =
1145            serde_json::from_str(protected.payload.expose_secret()).unwrap();
1146        let command = HandlerCommand::Subscribe {
1147            channel: protected.channel,
1148            product_ids: protected.product_ids,
1149            payload: protected.payload,
1150        };
1151        let debug = format!("{command:?}");
1152
1153        assert_eq!(payload["jwt"], jwt);
1154        assert!(subscription_debug.contains(REDACTED));
1155        assert!(!subscription_debug.contains(jwt));
1156        assert!(!debug.contains(jwt));
1157    }
1158
1159    #[rstest]
1160    #[case("user|BTC-USD")]
1161    #[case("futures_balance_summary")]
1162    fn test_resubscribe_all_auth_channel_skipped_without_credentials(#[case] topic: &str) {
1163        let subs = SubscriptionState::new('|');
1164        subs.mark_subscribe(topic);
1165
1166        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1167        resubscribe_all(&subs, &None, &tx, None);
1168
1169        // Auth channels should be skipped when no credentials are provided
1170        assert!(rx.try_recv().is_err());
1171    }
1172
1173    #[rstest]
1174    #[case("user|BTC-USD", "user")]
1175    #[case("futures_balance_summary", "futures_balance_summary")]
1176    fn test_resubscribe_all_emits_error_for_auth_channel_without_credentials(
1177        #[case] topic: &str,
1178        #[case] channel: &str,
1179    ) {
1180        let subs = SubscriptionState::new('|');
1181        subs.mark_subscribe(topic);
1182
1183        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1184        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1185        resubscribe_all(&subs, &None, &cmd_tx, Some(&out_tx));
1186
1187        // No subscribe command should be sent for an unauthenticated auth channel.
1188        assert!(cmd_rx.try_recv().is_err());
1189
1190        let msg = out_rx
1191            .try_recv()
1192            .expect("Error event must be emitted when auth channel cannot resubscribe");
1193        match msg {
1194            NautilusWsMessage::Error(text) => {
1195                assert!(
1196                    text.contains(channel),
1197                    "error must mention the channel, was: {text}"
1198                );
1199                assert!(
1200                    text.contains(topic),
1201                    "error must mention the topic, was: {text}"
1202                );
1203            }
1204            other => panic!("expected Error variant, was {other:?}"),
1205        }
1206    }
1207
1208    #[rstest]
1209    fn test_resubscribe_all_emits_error_when_jwt_build_fails() {
1210        let subs = SubscriptionState::new('|');
1211        let topic = "user|BTC-USD";
1212        subs.mark_subscribe(topic);
1213
1214        // A credential with a malformed PEM secret causes build_ws_jwt() to fail
1215        // every time, exercising the JWT-build error branch.
1216        let bad_credential = Some(CoinbaseCredential::new(
1217            "organizations/test/apiKeys/test".to_string(),
1218            "not-a-pem-key".to_string(),
1219        ));
1220
1221        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1222        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1223        resubscribe_all(&subs, &bad_credential, &cmd_tx, Some(&out_tx));
1224
1225        assert!(cmd_rx.try_recv().is_err(), "no subscribe should be sent");
1226        let msg = out_rx
1227            .try_recv()
1228            .expect("Error event must be emitted when JWT build fails for an auth channel");
1229        match msg {
1230            NautilusWsMessage::Error(text) => {
1231                assert!(text.contains("user"), "error must mention channel: {text}");
1232                assert!(text.contains(topic), "error must mention topic: {text}");
1233            }
1234            other => panic!("expected Error variant, was {other:?}"),
1235        }
1236    }
1237
1238    #[rstest]
1239    fn test_prime_default_subscriptions_marks_heartbeats() {
1240        let client = CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
1241        assert!(client.subscriptions.all_topics().is_empty());
1242
1243        client.prime_default_subscriptions();
1244
1245        let topics = client.subscriptions.all_topics();
1246        assert!(topics.iter().any(|t| t == "heartbeats"), "{topics:?}");
1247    }
1248
1249    #[rstest]
1250    fn test_ws_quotas_match_documented_limits() {
1251        assert_eq!(COINBASE_WS_CONNECTION_QUOTA.burst_size().get(), 8);
1252        assert_eq!(COINBASE_WS_SUBSCRIPTION_QUOTA.burst_size().get(), 8);
1253    }
1254
1255    #[rstest]
1256    fn test_ws_subscription_rate_limit_key_is_stable() {
1257        assert_eq!(COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION, "subscription");
1258        assert_eq!(
1259            COINBASE_WS_SUBSCRIPTION_KEYS[0],
1260            COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION,
1261        );
1262    }
1263}