1use std::{
25 fmt::Debug,
26 sync::{
27 Arc,
28 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
29 },
30 time::Duration,
31};
32
33use alloy::signers::local::PrivateKeySigner;
34use arc_swap::ArcSwap;
35use dashmap::DashMap;
36use nautilus_common::live::get_runtime;
37use nautilus_core::UUID4;
38use nautilus_network::{
39 mode::ConnectionMode,
40 ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota},
41 websocket::{
42 AuthTracker, TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
43 },
44};
45use serde::{Serialize, de::DeserializeOwned};
46use serde_json::Value;
47use ustr::Ustr;
48
49use super::{
50 error::{DeriveWsError, Result},
51 handler::{
52 DeriveWsMessage, FeedHandler, HandlerCommand, orderbook_subscribe_params,
53 ticker_subscribe_params, trades_subscribe_params,
54 },
55 messages::{
56 DeriveWsChannel, WsLoginParams, WsLoginResult, WsSubscribeParams, WsSubscribeResult,
57 WsUnsubscribeParams, WsUnsubscribeResult, methods, orderbook_channel, rate_limit_key_for,
58 ticker_channel, trades_channel,
59 },
60};
61use crate::{
62 common::{
63 consts::{
64 RECONNECT_BACKOFF_FACTOR, RECONNECT_BASE_BACKOFF, RECONNECT_JITTER_MS,
65 RECONNECT_MAX_BACKOFF, RECONNECT_TIMEOUT, WS_HEARTBEAT_SECS, WS_REQUEST_TIMEOUT,
66 },
67 enums::DeriveEnvironment,
68 rate_limit::{self, DERIVE_MATCHING_RATE_KEY},
69 urls,
70 },
71 http::{
72 models::{
73 DeriveEmptyResult, DeriveOpenOrdersResult, DeriveOrder, DeriveOrderResult,
74 DeriveReplaceResult,
75 },
76 query::{
77 DeriveCancelAllParams, DeriveCancelParams, DeriveCancelTriggerOrderParams,
78 DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams,
79 DeriveTriggerOrderParams,
80 },
81 },
82 signing::auth::build_ws_login,
83};
84
85#[derive(Clone)]
88pub struct DeriveWsCredentials {
89 pub wallet_address: String,
91 pub signer: PrivateKeySigner,
93}
94
95impl DeriveWsCredentials {
96 pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
102 let signer: PrivateKeySigner = session_key_hex
103 .parse()
104 .map_err(|e| DeriveWsError::transport(format!("invalid session key: {e}")))?;
105 Ok(Self {
106 wallet_address: wallet_address.into(),
107 signer,
108 })
109 }
110}
111
112impl Debug for DeriveWsCredentials {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct(stringify!(DeriveWsCredentials))
115 .field("wallet_address", &self.wallet_address)
116 .field("signer", &"***redacted***")
117 .finish()
118 }
119}
120
121type WsRateLimiter = RateLimiter<Ustr, MonotonicClock>;
125
126#[derive(Debug)]
132pub struct DeriveWebSocketClient {
133 url: String,
134 transport_backend: TransportBackend,
135 proxy_url: Option<String>,
136 connection_mode: Arc<ArcSwap<AtomicU8>>,
137 signal: Arc<AtomicBool>,
138 auth_tracker: AuthTracker,
139 credentials: Option<DeriveWsCredentials>,
140 next_id: Arc<AtomicU64>,
141 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
142 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>>,
143 subscriptions: Arc<DashMap<String, ()>>,
144 task_handle: Option<tokio::task::JoinHandle<()>>,
145 request_timeout: Duration,
146 conn_id: Arc<ArcSwap<String>>,
147 rate_limiter: Arc<WsRateLimiter>,
148}
149
150#[derive(Debug, Clone)]
152pub struct DeriveWebSocketSubscriptionHandle {
153 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
154 subscriptions: Arc<DashMap<String, ()>>,
155 request_timeout: Duration,
156 rate_limiter: Arc<WsRateLimiter>,
157}
158
159#[derive(Debug, Clone)]
168pub struct DeriveWsExecutionHandle {
169 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
170 request_timeout: Duration,
171 conn_id: Arc<ArcSwap<String>>,
172 rate_limiter: Arc<WsRateLimiter>,
173}
174
175impl DeriveWebSocketClient {
176 #[must_use]
179 pub fn new(
180 url: Option<String>,
181 environment: DeriveEnvironment,
182 transport_backend: TransportBackend,
183 proxy_url: Option<String>,
184 ) -> Self {
185 let url = url.unwrap_or_else(|| urls::ws_url(environment).to_string());
186 Self::build(url, transport_backend, proxy_url, None, None)
187 }
188
189 #[must_use]
196 pub fn with_credentials(
197 url: Option<String>,
198 environment: DeriveEnvironment,
199 transport_backend: TransportBackend,
200 proxy_url: Option<String>,
201 credentials: DeriveWsCredentials,
202 max_matching_requests_per_second: Option<u32>,
203 ) -> Self {
204 let url = url.unwrap_or_else(|| urls::ws_url(environment).to_string());
205 let matching_quota = rate_limit::matching_quota(max_matching_requests_per_second);
206 Self::build(
207 url,
208 transport_backend,
209 proxy_url,
210 Some(credentials),
211 Some(matching_quota),
212 )
213 }
214
215 fn build(
216 url: String,
217 transport_backend: TransportBackend,
218 proxy_url: Option<String>,
219 credentials: Option<DeriveWsCredentials>,
220 matching_quota: Option<Quota>,
221 ) -> Self {
222 let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
223 ConnectionMode::Closed as u8,
224 ))));
225 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
227 let mut keyed_quotas: Vec<(Ustr, Quota)> = Vec::new();
232 if let Some(quota) = matching_quota {
233 keyed_quotas.push((Ustr::from(DERIVE_MATCHING_RATE_KEY), quota));
234 }
235 let rate_limiter = Arc::new(RateLimiter::new_with_quota(
236 Some(rate_limit::non_matching_quota()),
237 keyed_quotas,
238 ));
239 Self {
240 url,
241 transport_backend,
242 proxy_url,
243 connection_mode,
244 signal: Arc::new(AtomicBool::new(false)),
245 auth_tracker: AuthTracker::new(),
246 credentials,
247 next_id: Arc::new(AtomicU64::new(1)),
248 cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
249 out_rx: None,
250 subscriptions: Arc::new(DashMap::new()),
251 task_handle: None,
252 request_timeout: WS_REQUEST_TIMEOUT,
253 conn_id: Arc::new(ArcSwap::from_pointee(UUID4::new().to_string())),
254 rate_limiter,
255 }
256 }
257
258 #[must_use]
260 pub fn url(&self) -> &str {
261 &self.url
262 }
263
264 #[must_use]
267 pub fn is_authenticated(&self) -> bool {
268 self.auth_tracker.is_authenticated()
269 }
270
271 #[must_use]
273 pub fn is_active(&self) -> bool {
274 self.connection_mode.load().load(Ordering::Relaxed) == ConnectionMode::Active as u8
275 }
276
277 pub async fn connect(&mut self) -> Result<()> {
288 let auth_ok = self.credentials.is_none() || self.is_authenticated();
291 if self.is_active() && auth_ok && self.task_handle.is_some() {
292 log::warn!("Derive WebSocket already connected");
293 return Ok(());
294 }
295
296 if self.task_handle.is_some() {
298 log::debug!("Tearing down stale Derive WebSocket state before connect");
299 self.teardown().await;
300 }
301
302 let (message_handler, raw_rx) = channel_message_handler();
303 let cfg = WebSocketConfig {
304 url: self.url.clone(),
305 headers: vec![],
306 heartbeat: Some(WS_HEARTBEAT_SECS),
307 heartbeat_msg: None,
308 reconnect_timeout_ms: Some(RECONNECT_TIMEOUT.as_millis() as u64),
309 reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
310 reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
311 reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
312 reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
313 reconnect_max_attempts: None,
314 idle_timeout_ms: None,
315 backend: self.transport_backend,
316 proxy_url: self.proxy_url.clone(),
317 };
318 let client = WebSocketClient::connect(cfg, Some(message_handler), None, None, vec![], None)
322 .await
323 .map_err(|e| DeriveWsError::transport(e.to_string()))?;
324
325 client.set_auth_tracker(self.auth_tracker.clone(), false);
329
330 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
331 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<DeriveWsMessage>();
332
333 *self.cmd_tx.write().await = cmd_tx.clone();
334 self.out_rx = Some(out_rx);
335 self.conn_id.store(Arc::new(UUID4::new().to_string()));
336
337 self.connection_mode.store(client.connection_mode_atomic());
338 log::debug!("Derive WebSocket connected: {}", self.url);
339
340 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
341 return Err(DeriveWsError::transport(format!(
342 "failed to send SetClient command: {e}",
343 )));
344 }
345
346 let signal = Arc::clone(&self.signal);
347 let auth_tracker = self.auth_tracker.clone();
348 let next_id = Arc::clone(&self.next_id);
349 let credentials = self.credentials.clone();
350 let subscriptions = Arc::clone(&self.subscriptions);
351 let conn_id = Arc::clone(&self.conn_id);
352 let cmd_tx_for_loop = cmd_tx.clone();
353 let rate_limiter = Arc::clone(&self.rate_limiter);
354 let request_timeout = self.request_timeout;
355
356 let stream_handle = get_runtime().spawn(async move {
357 let mut handler =
358 FeedHandler::new(signal, cmd_rx, raw_rx, next_id, auth_tracker.clone());
359
360 loop {
361 match handler.next().await {
362 Some(DeriveWsMessage::Reconnected) => {
363 log::info!("Derive WebSocket re-establishing session after reconnect");
364 conn_id.store(Arc::new(UUID4::new().to_string()));
365
366 if out_tx.send(DeriveWsMessage::Reconnected).is_err() {
367 log::debug!("Derive outer receiver dropped, exiting stream loop");
368 break;
369 }
370
371 let cmd_tx_async = cmd_tx_for_loop.clone();
374 let auth_tracker_async = auth_tracker.clone();
375 let creds_async = credentials.clone();
376 let subs_async = Arc::clone(&subscriptions);
377 let rate_limiter_async = Arc::clone(&rate_limiter);
378
379 get_runtime().spawn(async move {
380 if let Some(creds) = creds_async
381 && let Err(e) = login_via_handler(
382 &rate_limiter_async,
383 &cmd_tx_async,
384 &auth_tracker_async,
385 &creds,
386 request_timeout,
387 )
388 .await
389 {
390 log::error!("Derive WebSocket re-login failed: {e}");
391 }
392 let channels: Vec<String> =
396 subs_async.iter().map(|e| e.key().clone()).collect();
397 for channel in channels {
398 if let Err(e) = subscribe_via_handler(
399 &rate_limiter_async,
400 &cmd_tx_async,
401 vec![channel.clone()],
402 request_timeout,
403 )
404 .await
405 {
406 log::error!(
407 "Derive WebSocket resubscribe failed for {channel}: {e}",
408 );
409 }
410 }
411 });
412 }
413 Some(msg) => {
414 if out_tx.send(msg).is_err() {
415 log::debug!("Derive outer receiver dropped, exiting stream loop");
416 break;
417 }
418 }
419 None => {
420 log::debug!("Derive handler task ended");
421 break;
422 }
423 }
424 }
425 });
426 self.task_handle = Some(stream_handle);
427
428 if let Some(creds) = self.credentials.clone()
429 && let Err(e) = login_via_handler(
430 &self.rate_limiter,
431 &cmd_tx,
432 &self.auth_tracker,
433 &creds,
434 self.request_timeout,
435 )
436 .await
437 {
438 log::warn!("Derive WebSocket login failed; tearing down transport: {e}");
441 self.teardown().await;
442 return Err(e);
443 }
444
445 Ok(())
446 }
447
448 async fn teardown(&mut self) {
452 self.signal.store(true, Ordering::Relaxed);
453
454 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
455 log::debug!(
456 "Failed to enqueue Disconnect command (handler may already be shut down): {e}",
457 );
458 }
459
460 if let Some(handle) = self.task_handle.take() {
461 let abort_handle = handle.abort_handle();
462 tokio::select! {
463 result = handle => match result {
464 Ok(()) => log::debug!("Derive WebSocket task completed"),
465 Err(e) if e.is_cancelled() => log::debug!("Derive WebSocket task cancelled"),
466 Err(e) => log::error!("Derive WebSocket task error: {e:?}"),
467 },
468 () = tokio::time::sleep(Duration::from_secs(2)) => {
469 log::warn!("Timeout waiting for Derive WebSocket task, aborting");
470 abort_handle.abort();
471 }
472 }
473 }
474
475 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
478 *self.cmd_tx.write().await = placeholder_tx;
479 self.out_rx = None;
480 self.connection_mode
481 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
482 self.auth_tracker.invalidate();
483 self.subscriptions.clear();
484 self.signal.store(false, Ordering::Relaxed);
485 }
486
487 pub async fn disconnect(&mut self) -> Result<()> {
494 log::debug!("Disconnecting Derive WebSocket");
495 self.teardown().await;
496 Ok(())
497 }
498
499 pub async fn subscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
507 self.subscription_handle()
508 .subscribe_ticker(instrument_name, interval)
509 .await
510 }
511
512 pub async fn unsubscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
519 self.subscription_handle()
520 .unsubscribe_ticker(instrument_name, interval)
521 .await
522 }
523
524 pub async fn subscribe_orderbook(
531 &self,
532 instrument_name: &str,
533 group: &str,
534 depth: &str,
535 ) -> Result<()> {
536 self.subscription_handle()
537 .subscribe_orderbook(instrument_name, group, depth)
538 .await
539 }
540
541 pub async fn unsubscribe_orderbook(
548 &self,
549 instrument_name: &str,
550 group: &str,
551 depth: &str,
552 ) -> Result<()> {
553 self.subscription_handle()
554 .unsubscribe_orderbook(instrument_name, group, depth)
555 .await
556 }
557
558 pub async fn subscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
565 self.subscription_handle()
566 .subscribe_trades(instrument_type, currency)
567 .await
568 }
569
570 pub async fn unsubscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
577 self.subscription_handle()
578 .unsubscribe_trades(instrument_type, currency)
579 .await
580 }
581
582 pub async fn subscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
593 where
594 C: Into<DeriveWsChannel>,
595 {
596 self.subscription_handle()
597 .subscribe_channels(channels)
598 .await
599 }
600
601 pub async fn unsubscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
609 where
610 C: Into<DeriveWsChannel>,
611 {
612 self.subscription_handle()
613 .unsubscribe_channels(channels)
614 .await
615 }
616
617 pub async fn next_event(&mut self) -> Option<DeriveWsMessage> {
619 if let Some(rx) = self.out_rx.as_mut() {
620 rx.recv().await
621 } else {
622 None
623 }
624 }
625
626 #[must_use]
629 pub fn subscription_count(&self) -> usize {
630 self.subscriptions.len()
631 }
632
633 #[must_use]
635 pub fn subscription_handle(&self) -> DeriveWebSocketSubscriptionHandle {
636 DeriveWebSocketSubscriptionHandle {
637 cmd_tx: Arc::clone(&self.cmd_tx),
638 subscriptions: Arc::clone(&self.subscriptions),
639 request_timeout: self.request_timeout,
640 rate_limiter: Arc::clone(&self.rate_limiter),
641 }
642 }
643
644 #[must_use]
651 pub fn execution_handle(&self) -> DeriveWsExecutionHandle {
652 DeriveWsExecutionHandle {
653 cmd_tx: Arc::clone(&self.cmd_tx),
654 request_timeout: self.request_timeout,
655 conn_id: Arc::clone(&self.conn_id),
656 rate_limiter: Arc::clone(&self.rate_limiter),
657 }
658 }
659
660 pub fn take_event_receiver(
665 &mut self,
666 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>> {
667 self.out_rx.take()
668 }
669}
670
671impl DeriveWebSocketSubscriptionHandle {
672 pub async fn subscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
679 let channel = ticker_channel(instrument_name, interval);
680 let params = ticker_subscribe_params(instrument_name, interval);
681 self.send_subscribe(channel, ¶ms).await
682 }
683
684 pub async fn unsubscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
691 let channel = ticker_channel(instrument_name, interval);
692 self.send_unsubscribe(channel).await
693 }
694
695 pub async fn subscribe_orderbook(
702 &self,
703 instrument_name: &str,
704 group: &str,
705 depth: &str,
706 ) -> Result<()> {
707 let channel = orderbook_channel(instrument_name, group, depth);
708 let params = orderbook_subscribe_params(instrument_name, group, depth);
709 self.send_subscribe(channel, ¶ms).await
710 }
711
712 pub async fn unsubscribe_orderbook(
719 &self,
720 instrument_name: &str,
721 group: &str,
722 depth: &str,
723 ) -> Result<()> {
724 let channel = orderbook_channel(instrument_name, group, depth);
725 self.send_unsubscribe(channel).await
726 }
727
728 pub async fn subscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
735 let channel = trades_channel(instrument_type, currency);
736 let params = trades_subscribe_params(instrument_type, currency);
737 self.send_subscribe(channel, ¶ms).await
738 }
739
740 pub async fn unsubscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
747 let channel = trades_channel(instrument_type, currency);
748 self.send_unsubscribe(channel).await
749 }
750
751 pub async fn subscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
758 where
759 C: Into<DeriveWsChannel>,
760 {
761 let channels = channels.into_iter().map(Into::into).collect::<Vec<_>>();
762 if channels.is_empty() {
763 return Ok(());
764 }
765 let topics = channel_topics(&channels);
766 let params = WsSubscribeParams { channels };
767 let cmd_tx = self.cmd_tx.read().await.clone();
768 let _: WsSubscribeResult = send_request(
769 &self.rate_limiter,
770 &cmd_tx,
771 methods::PUBLIC_SUBSCRIBE,
772 ¶ms,
773 self.request_timeout,
774 )
775 .await?;
776
777 for channel in topics {
778 self.subscriptions.insert(channel, ());
779 }
780 Ok(())
781 }
782
783 pub async fn unsubscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
791 where
792 C: Into<DeriveWsChannel>,
793 {
794 let channels = channels.into_iter().map(Into::into).collect::<Vec<_>>();
795 if channels.is_empty() {
796 return Ok(());
797 }
798 let topics = channel_topics(&channels);
799 let params = WsUnsubscribeParams { channels };
800 let cmd_tx = self.cmd_tx.read().await.clone();
801 let _: WsUnsubscribeResult = send_request(
802 &self.rate_limiter,
803 &cmd_tx,
804 methods::PUBLIC_UNSUBSCRIBE,
805 ¶ms,
806 self.request_timeout,
807 )
808 .await?;
809
810 for channel in topics {
811 self.subscriptions.remove(&channel);
812 }
813 Ok(())
814 }
815
816 async fn send_subscribe(&self, channel: String, params: &WsSubscribeParams) -> Result<()> {
817 let cmd_tx = self.cmd_tx.read().await.clone();
818 let _: WsSubscribeResult = send_request(
819 &self.rate_limiter,
820 &cmd_tx,
821 methods::PUBLIC_SUBSCRIBE,
822 params,
823 self.request_timeout,
824 )
825 .await?;
826 self.subscriptions.insert(channel, ());
827 Ok(())
828 }
829
830 async fn send_unsubscribe(&self, channel: String) -> Result<()> {
831 let params = WsUnsubscribeParams {
832 channels: vec![DeriveWsChannel::from(channel.clone())],
833 };
834 let cmd_tx = self.cmd_tx.read().await.clone();
835 let _: WsUnsubscribeResult = send_request(
836 &self.rate_limiter,
837 &cmd_tx,
838 methods::PUBLIC_UNSUBSCRIBE,
839 ¶ms,
840 self.request_timeout,
841 )
842 .await?;
843 self.subscriptions.remove(&channel);
844 Ok(())
845 }
846}
847
848impl DeriveWsExecutionHandle {
849 #[must_use]
851 pub fn conn_id(&self) -> String {
852 self.conn_id.load_full().as_ref().clone()
853 }
854
855 pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
867 let cmd_tx = self.cmd_tx.read().await.clone();
868 let result: DeriveOrderResult = send_request_typed(
869 &self.rate_limiter,
870 &cmd_tx,
871 methods::PRIVATE_ORDER,
872 params,
873 self.request_timeout,
874 )
875 .await?;
876 Ok(result.order)
877 }
878
879 pub async fn submit_trigger_order(
887 &self,
888 params: &DeriveTriggerOrderParams,
889 ) -> Result<DeriveOrder> {
890 let cmd_tx = self.cmd_tx.read().await.clone();
891 let result: DeriveOrderResult = send_request_typed(
892 &self.rate_limiter,
893 &cmd_tx,
894 methods::PRIVATE_TRIGGER_ORDER,
895 params,
896 self.request_timeout,
897 )
898 .await?;
899 Ok(result.order)
900 }
901
902 pub async fn modify_order(&self, params: &DeriveReplaceParams) -> Result<DeriveOrder> {
912 let cmd_tx = self.cmd_tx.read().await.clone();
913 let result: DeriveReplaceResult = send_request_typed(
914 &self.rate_limiter,
915 &cmd_tx,
916 methods::PRIVATE_REPLACE,
917 params,
918 self.request_timeout,
919 )
920 .await?;
921 Ok(result.order)
922 }
923
924 pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<()> {
932 let cmd_tx = self.cmd_tx.read().await.clone();
933 let _: DeriveEmptyResult = send_request(
934 &self.rate_limiter,
935 &cmd_tx,
936 methods::PRIVATE_CANCEL,
937 params,
938 self.request_timeout,
939 )
940 .await?;
941 Ok(())
942 }
943
944 pub async fn cancel_trigger_order(
952 &self,
953 params: &DeriveCancelTriggerOrderParams,
954 ) -> Result<DeriveOrder> {
955 let cmd_tx = self.cmd_tx.read().await.clone();
956 send_request_typed(
957 &self.rate_limiter,
958 &cmd_tx,
959 methods::PRIVATE_CANCEL_TRIGGER_ORDER,
960 params,
961 self.request_timeout,
962 )
963 .await
964 }
965
966 pub async fn get_trigger_orders(
975 &self,
976 params: &DeriveGetTriggerOrdersParams,
977 ) -> Result<DeriveOpenOrdersResult> {
978 let cmd_tx = self.cmd_tx.read().await.clone();
979 send_request_typed(
980 &self.rate_limiter,
981 &cmd_tx,
982 methods::PRIVATE_GET_TRIGGER_ORDERS,
983 params,
984 self.request_timeout,
985 )
986 .await
987 }
988
989 pub async fn cancel_all_orders(&self, params: &DeriveCancelAllParams) -> Result<()> {
998 let cmd_tx = self.cmd_tx.read().await.clone();
999 let _: DeriveEmptyResult = send_request(
1000 &self.rate_limiter,
1001 &cmd_tx,
1002 methods::PRIVATE_CANCEL_ALL,
1003 params,
1004 self.request_timeout,
1005 )
1006 .await?;
1007 Ok(())
1008 }
1009}
1010
1011async fn send_raw<P>(
1015 rate_limiter: &WsRateLimiter,
1016 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1017 method: &'static str,
1018 params: &P,
1019 timeout: Duration,
1020) -> Result<Value>
1021where
1022 P: Serialize + ?Sized,
1023{
1024 let params = serde_json::to_value(params)?;
1025
1026 let rate_keys = [rate_limit_key_for(method)];
1037 rate_limiter.await_keys_ready(Some(&rate_keys)).await;
1038
1039 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
1040 cmd_tx
1041 .send(HandlerCommand::Request {
1042 method,
1043 params,
1044 response_tx,
1045 })
1046 .map_err(|e| DeriveWsError::transport(format!("failed to enqueue `{method}`: {e}")))?;
1047
1048 match tokio::time::timeout(timeout, response_rx).await {
1052 Ok(Ok(outcome)) => outcome,
1053 Ok(Err(_)) => Err(DeriveWsError::RequestCancelled {
1054 method: method.to_owned(),
1055 }),
1056 Err(_) => Err(DeriveWsError::Timeout {
1057 method: method.to_owned(),
1058 }),
1059 }
1060}
1061
1062async fn send_request<P, R>(
1065 rate_limiter: &WsRateLimiter,
1066 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1067 method: &'static str,
1068 params: &P,
1069 timeout: Duration,
1070) -> Result<R>
1071where
1072 P: Serialize + ?Sized,
1073 R: Default + DeserializeOwned,
1074{
1075 let value = send_raw(rate_limiter, cmd_tx, method, params, timeout).await?;
1076 let typed = if value.is_null() {
1077 R::default()
1078 } else {
1079 serde_json::from_value(value)?
1080 };
1081 Ok(typed)
1082}
1083
1084async fn send_request_typed<P, R>(
1087 rate_limiter: &WsRateLimiter,
1088 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1089 method: &'static str,
1090 params: &P,
1091 timeout: Duration,
1092) -> Result<R>
1093where
1094 P: Serialize + ?Sized,
1095 R: DeserializeOwned,
1096{
1097 let value = send_raw(rate_limiter, cmd_tx, method, params, timeout).await?;
1098 Ok(serde_json::from_value(value)?)
1099}
1100
1101fn channel_topics(channels: &[DeriveWsChannel]) -> Vec<String> {
1102 channels.iter().map(ToString::to_string).collect()
1103}
1104
1105async fn login_via_handler(
1106 rate_limiter: &WsRateLimiter,
1107 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1108 auth_tracker: &AuthTracker,
1109 creds: &DeriveWsCredentials,
1110 timeout: Duration,
1111) -> Result<()> {
1112 let login = build_ws_login(&creds.wallet_address, &creds.signer)?;
1113 let params = WsLoginParams {
1114 wallet: login.wallet,
1115 timestamp: login.timestamp,
1116 signature: login.signature,
1117 };
1118 let _receiver = auth_tracker.begin();
1119
1120 match send_request::<_, WsLoginResult>(
1121 rate_limiter,
1122 cmd_tx,
1123 methods::PUBLIC_LOGIN,
1124 ¶ms,
1125 timeout,
1126 )
1127 .await
1128 {
1129 Ok(_) => {
1130 auth_tracker.succeed();
1131 log::debug!("Derive WebSocket authenticated");
1132 Ok(())
1133 }
1134 Err(e) => {
1135 auth_tracker.fail(e.to_string());
1136 Err(e)
1137 }
1138 }
1139}
1140
1141async fn subscribe_via_handler(
1142 rate_limiter: &WsRateLimiter,
1143 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1144 channels: Vec<String>,
1145 timeout: Duration,
1146) -> Result<()> {
1147 let params = WsSubscribeParams {
1148 channels: channels.into_iter().map(DeriveWsChannel::from).collect(),
1149 };
1150 let _: WsSubscribeResult = send_request(
1151 rate_limiter,
1152 cmd_tx,
1153 methods::PUBLIC_SUBSCRIBE,
1154 ¶ms,
1155 timeout,
1156 )
1157 .await?;
1158 Ok(())
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use rstest::rstest;
1164
1165 use super::*;
1166
1167 #[rstest]
1168 fn test_public_client_defaults_to_environment_url() {
1169 let client = DeriveWebSocketClient::new(
1170 None,
1171 DeriveEnvironment::Mainnet,
1172 TransportBackend::default(),
1173 None,
1174 );
1175 assert!(client.url().starts_with("wss://"));
1176 assert!(client.url().contains("api.lyra.finance"));
1177 assert!(!client.is_authenticated());
1178 assert!(!client.is_active());
1179 assert_eq!(client.subscription_count(), 0);
1180 }
1181
1182 #[rstest]
1183 fn test_testnet_client_routes_to_demo_url() {
1184 let client = DeriveWebSocketClient::new(
1185 None,
1186 DeriveEnvironment::Testnet,
1187 TransportBackend::default(),
1188 None,
1189 );
1190 assert!(client.url().contains("demo"));
1191 }
1192
1193 #[rstest]
1194 fn test_credentials_constructor_parses_session_key() {
1195 let creds = DeriveWsCredentials::new(
1196 "0x000000000000000000000000000000000000aaaa",
1197 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
1198 )
1199 .unwrap();
1200 assert!(creds.wallet_address.starts_with("0x"));
1201 let client = DeriveWebSocketClient::with_credentials(
1202 None,
1203 DeriveEnvironment::Testnet,
1204 TransportBackend::default(),
1205 None,
1206 creds,
1207 None,
1208 );
1209 assert!(client.url().contains("demo"));
1210 assert!(!client.is_authenticated());
1211 }
1212
1213 #[rstest]
1214 fn test_credentials_debug_redacts_signer() {
1215 let creds = DeriveWsCredentials::new(
1216 "0xWALLET",
1217 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
1218 )
1219 .unwrap();
1220 let debug = format!("{creds:?}");
1221 assert!(debug.contains("redacted"));
1222 assert!(debug.contains("0xWALLET"));
1223 assert!(!debug.contains("2ae8be44"));
1224 }
1225
1226 #[rstest]
1227 fn test_credentials_constructor_rejects_invalid_session_key() {
1228 let err = DeriveWsCredentials::new("0xWALLET", "not-a-hex-key").unwrap_err();
1229 assert!(err.to_string().contains("invalid session key"));
1230 }
1231
1232 #[rstest]
1233 #[tokio::test]
1234 async fn test_send_raw_times_out_when_no_response_arrives() {
1235 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1238 let rate_limiter: WsRateLimiter = RateLimiter::new_with_quota(None, Vec::new());
1239 let err = send_raw(
1240 &rate_limiter,
1241 &cmd_tx,
1242 methods::PRIVATE_ORDER,
1243 &serde_json::json!({}),
1244 Duration::from_millis(50),
1245 )
1246 .await
1247 .expect_err("must time out");
1248
1249 match err {
1250 DeriveWsError::Timeout { method } => assert_eq!(method, methods::PRIVATE_ORDER),
1251 other => panic!("expected Timeout, was {other:?}"),
1252 }
1253 }
1254
1255 #[rstest]
1256 #[tokio::test]
1257 async fn test_send_request_typed_rejects_null_result() {
1258 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1262 tokio::spawn(async move {
1263 if let Some(HandlerCommand::Request { response_tx, .. }) = cmd_rx.recv().await {
1264 let _ = response_tx.send(Ok(Value::Null));
1265 }
1266 });
1267 let rate_limiter: WsRateLimiter = RateLimiter::new_with_quota(None, Vec::new());
1268 let result: Result<DeriveOrderResult> = send_request_typed(
1269 &rate_limiter,
1270 &cmd_tx,
1271 methods::PRIVATE_ORDER,
1272 &serde_json::json!({}),
1273 Duration::from_secs(1),
1274 )
1275 .await;
1276 assert!(matches!(result, Err(DeriveWsError::Serde(_))));
1277 }
1278}