1use std::{
19 collections::VecDeque,
20 fmt::Debug,
21 future::Future,
22 pin::Pin,
23 sync::{
24 Arc,
25 atomic::{AtomicBool, Ordering},
26 },
27};
28
29use ahash::{AHashMap, AHashSet};
30use futures_util::{StreamExt, stream::FuturesUnordered};
31use nautilus_core::{AtomicTime, nanos::UnixNanos, time::get_atomic_clock_realtime};
32use nautilus_model::{identifiers::AccountId, instruments::InstrumentAny, types::Currency};
33use nautilus_network::{
34 RECONNECTED,
35 error::SendError,
36 retry::{RetryManager, create_websocket_retry_manager},
37 websocket::{SubscriptionState, WebSocketClient},
38};
39use rust_decimal::Decimal;
40use tokio_tungstenite::tungstenite::Message;
41use ustr::Ustr;
42
43use super::{
44 account_state::LighterAccountStateReconciler,
45 error::LighterWsError,
46 messages::{
47 AccountStream, ExecutionReport, LighterAsset, LighterPosition, LighterUserStats,
48 LighterWsCandle, LighterWsChannel, LighterWsChannelKind, LighterWsFrame,
49 LighterWsOrderBook, LighterWsRequest, NautilusWsMessage, SendTxRejectionSource,
50 },
51 parse::{
52 parse_ws_bar, parse_ws_funding_rate_update, parse_ws_index_price_update,
53 parse_ws_mark_price_update, parse_ws_order_book_deltas, parse_ws_order_book_depth10,
54 parse_ws_position_status_report, parse_ws_quote_tick, parse_ws_spot_index_price_update,
55 parse_ws_trade_tick,
56 },
57};
58use crate::{
59 common::{
60 consts::{
61 LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED, LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED,
62 LIGHTER_ERROR_CODE_TX_RANGE, LIGHTER_ERROR_CODE_WS_RATE_LIMITED,
63 LIGHTER_ERROR_CODE_WS_SUBSCRIBE_FAILED, LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL,
64 SUBSCRIBE_INFLIGHT_MAX, SUBSCRIBE_RETRY_BASE_BACKOFF, SUBSCRIBE_RETRY_MAX,
65 },
66 enums::LighterCandleResolution,
67 rate_limit::LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY,
68 },
69 http::models::{LighterOrder, LighterPriceLevel, LighterTrade},
70};
71
72const CTRL_TYPE_CONNECTED: &str = "connected";
76const CTRL_TYPE_SUBSCRIBED: &str = "subscribed";
77const CTRL_TYPE_UNSUBSCRIBED: &str = "unsubscribed";
78const CTRL_TYPE_PING: &str = "ping";
79const CTRL_TYPE_PONG: &str = "pong";
80const CTRL_TYPE_ERROR: &str = "error";
81const CTRL_TYPE_SEND_TX: &str = "jsonapi/sendtx";
82
83#[derive(serde::Deserialize)]
84struct LighterWsFrameHeader<'a> {
85 #[serde(rename = "type", borrow)]
86 kind: &'a str,
87 #[serde(borrow)]
88 channel: Option<&'a str>,
89}
90
91#[expect(
93 clippy::large_enum_variant,
94 reason = "commands are ephemeral and immediately consumed"
95)]
96pub enum HandlerCommand {
97 SetClient(WebSocketClient),
100 Disconnect,
102 Subscribe {
105 channel: LighterWsChannel,
106 auth: Option<String>,
107 response_tx: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
108 },
109 Unsubscribe { channel: LighterWsChannel },
111 ResubscribeOrderBook { market_index: i16 },
113 InitializeInstruments(Vec<(i16, InstrumentAny)>),
115 UpdateInstrument {
117 market_index: i16,
118 instrument: InstrumentAny,
119 },
120 SetBookDeltasSub { market_index: i16, subscribed: bool },
123 SetDepth10Sub { market_index: i16, subscribed: bool },
126 SetExecutionContext {
131 account_id: AccountId,
132 account_index: i64,
133 },
134 SendTx {
140 tx_type: u8,
141 tx_info: Box<serde_json::value::RawValue>,
142 connection_epoch: u64,
143 response_tx: tokio::sync::oneshot::Sender<Result<(), LighterWsError>>,
144 },
145}
146
147impl Debug for HandlerCommand {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 Self::SetClient(_) => f.write_str("SetClient(<WebSocketClient>)"),
155 Self::Disconnect => f.write_str("Disconnect"),
156 Self::Subscribe { channel, auth, .. } => f
157 .debug_struct(stringify!(Subscribe))
158 .field("channel", channel)
159 .field("authed", &auth.is_some())
160 .finish(),
161 Self::Unsubscribe { channel } => f
162 .debug_struct(stringify!(Unsubscribe))
163 .field("channel", channel)
164 .finish(),
165 Self::ResubscribeOrderBook { market_index } => f
166 .debug_struct(stringify!(ResubscribeOrderBook))
167 .field("market_index", market_index)
168 .finish(),
169 Self::InitializeInstruments(instruments) => f
170 .debug_tuple(stringify!(InitializeInstruments))
171 .field(&instruments.len())
172 .finish(),
173 Self::UpdateInstrument { market_index, .. } => f
174 .debug_struct(stringify!(UpdateInstrument))
175 .field("market_index", market_index)
176 .finish(),
177 Self::SetBookDeltasSub {
178 market_index,
179 subscribed,
180 } => f
181 .debug_struct(stringify!(SetBookDeltasSub))
182 .field("market_index", market_index)
183 .field("subscribed", subscribed)
184 .finish(),
185 Self::SetDepth10Sub {
186 market_index,
187 subscribed,
188 } => f
189 .debug_struct(stringify!(SetDepth10Sub))
190 .field("market_index", market_index)
191 .field("subscribed", subscribed)
192 .finish(),
193 Self::SetExecutionContext {
194 account_id,
195 account_index,
196 } => f
197 .debug_struct(stringify!(SetExecutionContext))
198 .field("account_id", account_id)
199 .field("account_index", account_index)
200 .finish(),
201 Self::SendTx { tx_type, .. } => f
202 .debug_struct(stringify!(SendTx))
203 .field("tx_type", tx_type)
204 .field("tx_info", &"<redacted>")
205 .finish(),
206 }
207 }
208}
209
210pub(super) struct FeedHandler {
217 clock: &'static AtomicTime,
218 signal: Arc<AtomicBool>,
219 inner: Option<WebSocketClient>,
220 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
221 cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
222 raw_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, Message)>,
223 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
224 subscriptions: SubscriptionState,
225 retry_manager: RetryManager<LighterWsError>,
226 pending_messages: VecDeque<NautilusWsMessage>,
227 pending_subs: VecDeque<(Ustr, u64)>,
228 inflight_subs: AHashMap<Ustr, u64>,
229 subscription_attempts: AHashMap<Ustr, SubscriptionAttempt>,
230 subscription_retries: FuturesUnordered<SubscriptionRetry>,
231 ignored_completions: AHashMap<Ustr, CompletionKind>,
232 next_subscription_generation: u64,
233 instruments: AHashMap<i16, InstrumentAny>,
234 book_delta_subs: AHashSet<i16>,
235 book_depth_10_subs: AHashSet<i16>,
236 book_snapshots_seen: AHashSet<i16>,
237 book_states: AHashMap<i16, CachedOrderBook>,
238 last_candles: AHashMap<(i16, LighterCandleResolution), LighterWsCandle>,
239 exec_account: Option<(AccountId, i64)>,
240 account_state_reconciler: LighterAccountStateReconciler,
241}
242
243type SubscriptionRetry = Pin<Box<dyn Future<Output = (Ustr, u64)> + Send + Sync + 'static>>;
244
245#[derive(Debug, Clone)]
246struct CachedOrderBook {
247 book: LighterWsOrderBook,
248 timestamp: u64,
249}
250
251struct SubscriptionAttempt {
252 channel: LighterWsChannel,
253 auth: Option<String>,
254 pending_auth: Option<String>,
255 generation: u64,
256 retries: u8,
257 response_txs: Vec<tokio::sync::oneshot::Sender<Result<(), String>>>,
258 pending_response_txs: Vec<tokio::sync::oneshot::Sender<Result<(), String>>>,
259}
260
261impl SubscriptionAttempt {
262 fn fold_pending_auth(&mut self) {
263 if let Some(auth) = self.pending_auth.take() {
264 self.auth = Some(auth);
265 self.response_txs.append(&mut self.pending_response_txs);
266 } else {
267 debug_assert!(self.pending_response_txs.is_empty());
268 }
269 }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280enum CompletionKind {
281 ControlAck,
282 Typed,
283 AlreadySubscribed,
284}
285
286impl FeedHandler {
287 #[cfg(test)]
288 pub(super) fn new(
289 signal: Arc<AtomicBool>,
290 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
291 raw_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, Message)>,
292 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
293 subscriptions: SubscriptionState,
294 ) -> Self {
295 Self::new_with_settlement_currency(
296 signal,
297 cmd_rx,
298 raw_rx,
299 out_tx,
300 subscriptions,
301 Currency::get_or_create_crypto("USDC"),
302 )
303 }
304
305 pub(super) fn new_with_settlement_currency(
306 signal: Arc<AtomicBool>,
307 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
308 raw_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, Message)>,
309 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
310 subscriptions: SubscriptionState,
311 settlement_currency: Currency,
312 ) -> Self {
313 Self {
314 clock: get_atomic_clock_realtime(),
315 signal,
316 inner: None,
317 cmd_rx,
318 cmd_tx: None,
319 raw_rx,
320 out_tx,
321 subscriptions,
322 retry_manager: create_websocket_retry_manager(),
323 pending_messages: VecDeque::new(),
324 pending_subs: VecDeque::new(),
325 inflight_subs: AHashMap::new(),
326 subscription_attempts: AHashMap::new(),
327 subscription_retries: FuturesUnordered::new(),
328 ignored_completions: AHashMap::new(),
329 next_subscription_generation: 1,
330 instruments: AHashMap::new(),
331 book_delta_subs: AHashSet::new(),
332 book_depth_10_subs: AHashSet::new(),
333 book_snapshots_seen: AHashSet::new(),
334 book_states: AHashMap::new(),
335 last_candles: AHashMap::new(),
336 exec_account: None,
337 account_state_reconciler: LighterAccountStateReconciler::new_with_settlement_currency(
338 settlement_currency,
339 ),
340 }
341 }
342
343 pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
344 self.out_tx
345 .send(msg)
346 .map_err(|e| format!("Failed to send message: {e}"))
347 }
348
349 pub(super) fn set_command_sender(
350 &mut self,
351 cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
352 ) {
353 self.cmd_tx = Some(cmd_tx);
354 }
355
356 pub(super) fn is_stopped(&self) -> bool {
357 self.signal.load(Ordering::Relaxed)
358 }
359
360 async fn send_with_retry(&self, payload: String) -> Result<(), LighterWsError> {
361 if let Some(client) = &self.inner {
362 self.retry_manager
363 .execute_with_retry(
364 "websocket_send",
365 || {
366 let payload = payload.clone();
367 async move {
368 client
369 .send_text(
370 payload,
371 Some(LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY.as_slice()),
372 )
373 .await
374 .map_err(LighterWsError::Transport)
375 }
376 },
377 should_retry_lighter_ws_error,
378 |e| create_lighter_ws_timeout_error(e.to_string()),
379 )
380 .await
381 } else {
382 Err(LighterWsError::Client(
383 "no active WebSocket client".to_string(),
384 ))
385 }
386 }
387
388 async fn send_once(
391 &self,
392 payload: String,
393 connection_epoch: u64,
394 ) -> Result<(), LighterWsError> {
395 if let Some(client) = &self.inner {
396 match client
397 .send_text_on_connection(payload, None, connection_epoch)
398 .await
399 {
400 Err(SendError::BrokenPipe(message)) => {
401 Err(LighterWsError::SendTxOutcomeUnknown(message))
402 }
403 result => result.map_err(LighterWsError::Transport),
404 }
405 } else {
406 Err(LighterWsError::Client(
407 "no active WebSocket client".to_string(),
408 ))
409 }
410 }
411
412 async fn dispatch_subscribe(
413 &self,
414 channel: LighterWsChannel,
415 auth: Option<String>,
416 ) -> Result<(), String> {
417 let topic = channel.topic_key();
418
419 let authed = auth.is_some();
420 let request = match auth {
421 Some(token) => LighterWsRequest::subscribe_auth(channel.subscription_channel(), token),
422 None => LighterWsRequest::subscribe(channel.subscription_channel()),
423 };
424
425 match serde_json::to_string(&request) {
426 Ok(payload) => {
427 log::debug!("Sending Lighter subscribe: topic={topic} authed={authed}");
430 if let Err(e) = self.send_with_retry(payload).await {
431 log::error!("Error subscribing to {topic}: {e}");
432 Err(e.to_string())
433 } else {
434 Ok(())
435 }
436 }
437 Err(e) => {
438 log::error!("Error serializing subscription for {topic}: {e}");
439 Err(format!("failed to serialize subscription for {topic}: {e}"))
440 }
441 }
442 }
443
444 async fn dispatch_send_tx(
445 &self,
446 tx_type: u8,
447 tx_info: Box<serde_json::value::RawValue>,
448 connection_epoch: u64,
449 ) -> Result<(), LighterWsError> {
450 let request = LighterWsRequest::SendTx {
451 data: super::messages::LighterWsSendTx { tx_type, tx_info },
452 };
453
454 match serde_json::to_string(&request) {
455 Ok(payload) => {
456 log::debug!(
457 "Sending Lighter sendTx: tx_type={tx_type} ({} bytes)",
458 payload.len(),
459 );
460
461 match self.send_once(payload, connection_epoch).await {
462 Ok(()) => Ok(()),
463 Err(e) => {
464 log::error!("Error dispatching Lighter sendTx (tx_type={tx_type}): {e}");
465 Err(e)
466 }
467 }
468 }
469 Err(e) => {
470 log::error!("Error serializing Lighter sendTx (tx_type={tx_type}): {e}");
471 Err(LighterWsError::Client(format!(
472 "failed to serialize Lighter sendTx: {e}"
473 )))
474 }
475 }
476 }
477
478 async fn dispatch_unsubscribe(&self, channel: LighterWsChannel) {
479 let topic = channel.topic_key();
480 self.subscriptions.mark_unsubscribe(&topic);
481
482 let request = LighterWsRequest::unsubscribe(channel.subscription_channel());
483 match serde_json::to_string(&request) {
484 Ok(payload) => {
485 log::debug!("Sending Lighter unsubscribe ({} bytes)", payload.len());
486 if let Err(e) = self.send_with_retry(payload).await {
487 log::error!("Error unsubscribing from {topic}: {e}");
488 }
489 }
490 Err(e) => {
491 log::error!("Error serializing unsubscription for {topic}: {e}");
492 }
493 }
494 }
495
496 pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
497 if let Some(msg) = self.pending_messages.pop_front() {
498 return Some(msg);
499 }
500
501 loop {
502 self.pump_pending_subscribes().await;
504
505 tokio::select! {
506 Some(cmd) = self.cmd_rx.recv() => {
507 match cmd {
508 HandlerCommand::SetClient(client) => {
509 log::debug!("Setting WebSocket client in Lighter handler");
510 self.inner = Some(client);
511 }
512 HandlerCommand::Disconnect => {
513 log::debug!("Lighter handler received disconnect");
514 if let Some(ref client) = self.inner {
515 client.disconnect().await;
516 }
517 self.signal.store(true, Ordering::SeqCst);
518 return None;
519 }
520 HandlerCommand::Subscribe {
521 channel,
522 auth,
523 response_tx,
524 } => {
525 self.queue_subscribe(channel, auth, response_tx);
526 }
527 HandlerCommand::Unsubscribe { channel } => {
528 let topic = channel.topic_key();
531 self.cancel_subscription_attempt(
532 &topic,
533 "subscription cancelled before venue acknowledgement",
534 );
535
536 if let LighterWsChannel::OrderBook(market_index) = &channel {
537 self.book_snapshots_seen.remove(market_index);
538 self.book_states.remove(market_index);
539 }
540 self.dispatch_unsubscribe(channel).await;
541 }
542 HandlerCommand::ResubscribeOrderBook { market_index } => {
543 self.resubscribe_order_book_stream(market_index).await;
544 }
545 HandlerCommand::InitializeInstruments(instruments) => {
546 self.instruments.clear();
547 for (market_index, inst) in instruments {
548 self.instruments.insert(market_index, inst);
549 }
550 }
551 HandlerCommand::UpdateInstrument { market_index, instrument } => {
552 self.instruments.insert(market_index, instrument);
553 }
554 HandlerCommand::SetBookDeltasSub { market_index, subscribed } => {
555 if subscribed {
556 let inserted = self.book_delta_subs.insert(market_index);
557 if inserted
558 && let Some(first) = self
559 .emit_cached_order_book_deltas_snapshot(market_index)
560 {
561 return Some(first);
562 }
563 } else {
564 self.book_delta_subs.remove(&market_index);
565 }
566 }
567 HandlerCommand::SetDepth10Sub { market_index, subscribed } => {
568 if subscribed {
569 let inserted = self.book_depth_10_subs.insert(market_index);
570 if inserted
571 && let Some(first) =
572 self.emit_cached_order_book_depth10_snapshot(market_index)
573 {
574 return Some(first);
575 }
576 } else {
577 self.book_depth_10_subs.remove(&market_index);
578 }
579 }
580 HandlerCommand::SetExecutionContext { account_id, account_index } => {
581 self.exec_account = Some((account_id, account_index));
582 }
583 HandlerCommand::SendTx {
584 tx_type,
585 tx_info,
586 connection_epoch,
587 response_tx,
588 } => {
589 let result = self
590 .dispatch_send_tx(tx_type, tx_info, connection_epoch)
591 .await;
592
593 if response_tx.send(result).is_err() {
594 log::debug!("Lighter sendTx result receiver dropped");
595 }
596 }
597 }
598 }
599 Some((topic, generation)) = self.subscription_retries.next(),
600 if !self.subscription_retries.is_empty() =>
601 {
602 self.queue_subscription_retry(topic, generation);
603 }
604 Some((connection_epoch, raw_msg)) = self.raw_rx.recv() => {
605 match raw_msg {
606 Message::Text(text) => {
607 if text == RECONNECTED {
608 log::debug!("Received Lighter WebSocket RECONNECTED sentinel");
609 self.book_snapshots_seen.clear();
610 self.book_states.clear();
611 self.last_candles.clear();
613 self.reset_subscription_attempts_after_reconnect();
617 self.account_state_reconciler.reset();
618 return Some(NautilusWsMessage::Reconnected {
619 connection_epoch,
620 });
621 }
622
623 let ts_init = self.clock.get_time_ns();
624
625 if let Ok(frame) = serde_json::from_str::<LighterWsFrame>(&text) {
626 if let Some(topic) = typed_subscribe_topic(&text) {
627 self.complete_subscription(topic, CompletionKind::Typed);
628 }
629 let messages = self
630 .handle_frame(frame, ts_init)
631 .into_iter()
632 .map(|msg| msg.with_connection_epoch(connection_epoch))
633 .collect();
634
635 if let Some(first) = self.dispatch_results(messages) {
636 return Some(first);
637 }
638 } else if let Ok(value) =
639 serde_json::from_str::<serde_json::Value>(&text)
640 {
641 let (matched, msg) = self.handle_control_value(&value);
642 if let Some(first) = msg {
643 return Some(first.with_connection_epoch(connection_epoch));
644 }
645
646 if !matched {
647 log::warn!("Lighter WS unparsed frame: {value}");
652 return Some(NautilusWsMessage::Raw(value));
653 }
654 } else {
655 log::warn!("Lighter WS non-JSON text: {text}");
656 }
657 }
658 Message::Ping(data) => {
659 if let Some(ref client) = self.inner
660 && let Err(e) = client.send_pong(data.to_vec()).await {
661 log::error!("Error sending Lighter pong: {e}");
662 }
663 }
664 Message::Close(frame) => {
665 log::debug!("Received Lighter WebSocket close frame: {frame:?}");
666 return None;
667 }
668 _ => {}
669 }
670 }
671 else => {
672 log::debug!("Lighter handler shutting down: stream ended or command channel closed");
673 return None;
674 }
675 }
676 }
677 }
678
679 fn dispatch_results(
680 &mut self,
681 mut messages: Vec<NautilusWsMessage>,
682 ) -> Option<NautilusWsMessage> {
683 if messages.is_empty() {
684 return None;
685 }
686 let first = messages.remove(0);
687 for extra in messages {
688 self.pending_messages.push_back(extra);
689 }
690 Some(first)
691 }
692
693 async fn pump_pending_subscribes(&mut self) {
696 while self.inflight_subs.len() < SUBSCRIBE_INFLIGHT_MAX {
697 let Some((topic, generation)) = self.pending_subs.pop_front() else {
698 break;
699 };
700
701 let Some(attempt) = self.subscription_attempts.get(&topic) else {
702 continue;
703 };
704
705 if attempt.generation != generation {
706 continue;
707 }
708
709 let channel = attempt.channel.clone();
710 let auth = attempt.auth.clone();
711 self.inflight_subs.insert(topic, generation);
712 if let Err(message) = self.dispatch_subscribe(channel, auth).await {
713 self.schedule_subscription_retry(topic, generation, &message);
714 }
715 }
716 }
717
718 fn queue_subscribe(
719 &mut self,
720 channel: LighterWsChannel,
721 auth: Option<String>,
722 response_tx: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
723 ) {
724 let topic = Ustr::from(channel.topic_key().as_str());
725 if let Some(attempt) = self.subscription_attempts.get_mut(&topic) {
726 attempt.channel = channel;
727 let effective_auth = attempt.pending_auth.as_ref().or(attempt.auth.as_ref());
728 let auth_changed = auth
729 .as_ref()
730 .is_some_and(|auth| Some(auth) != effective_auth);
731
732 if auth_changed {
733 let is_inflight = self.inflight_subs.get(&topic) == Some(&attempt.generation);
734 if is_inflight {
735 attempt.pending_auth = auth;
736 if let Some(response_tx) = response_tx {
737 attempt.pending_response_txs.push(response_tx);
738 }
739 } else {
740 debug_assert!(attempt.pending_auth.is_none());
741 debug_assert!(attempt.pending_response_txs.is_empty());
742 attempt.auth = auth;
743 if let Some(response_tx) = response_tx {
744 attempt.response_txs.push(response_tx);
745 }
746 }
747 } else if let Some(response_tx) = response_tx {
748 if auth.is_some() && attempt.pending_auth.is_some() {
749 attempt.pending_response_txs.push(response_tx);
750 } else {
751 attempt.response_txs.push(response_tx);
752 }
753 }
754 return;
755 }
756
757 let newly_pending = self.subscriptions.try_mark_subscribe(topic.as_str());
758 if !newly_pending
759 && auth.is_none()
760 && !self
761 .subscriptions
762 .pending_subscribe_topics()
763 .iter()
764 .any(|pending| pending == topic.as_str())
765 {
766 if let Some(response_tx) = response_tx {
767 let _ = response_tx.send(Ok(()));
768 }
769 return;
770 }
771
772 let generation = self.take_subscription_generation();
773 self.subscription_attempts.insert(
774 topic,
775 SubscriptionAttempt {
776 channel,
777 auth,
778 pending_auth: None,
779 generation,
780 retries: 0,
781 response_txs: response_tx.into_iter().collect(),
782 pending_response_txs: Vec::new(),
783 },
784 );
785 self.pending_subs.push_back((topic, generation));
786 }
787
788 fn queue_subscription_retry(&mut self, topic: Ustr, generation: u64) {
789 if self
790 .subscription_attempts
791 .get(&topic)
792 .is_some_and(|attempt| attempt.generation == generation)
793 && self.inflight_subs.get(&topic) != Some(&generation)
794 && !self
795 .pending_subs
796 .iter()
797 .any(|pending| *pending == (topic, generation))
798 {
799 self.pending_subs.push_back((topic, generation));
800 }
801 }
802
803 fn complete_subscription(&mut self, topic: &str, kind: CompletionKind) -> bool {
804 let topic = Ustr::from(topic);
805
806 if self.ignored_completions.get(&topic) == Some(&kind) {
811 self.ignored_completions.remove(&topic);
812 return false;
813 }
814
815 let Some(generation) = self.inflight_subs.get(&topic).copied() else {
816 return false;
817 };
818
819 if !self
820 .subscription_attempts
821 .get(&topic)
822 .is_some_and(|attempt| attempt.generation == generation)
823 {
824 return false;
825 }
826
827 self.inflight_subs.remove(&topic);
828 self.subscriptions.confirm_subscribe(topic.as_str());
829
830 if kind == CompletionKind::ControlAck {
834 self.ignored_completions
835 .insert(topic, CompletionKind::Typed);
836 }
837 let mut attempt = self
838 .subscription_attempts
839 .remove(&topic)
840 .expect("matching subscription attempt disappeared");
841 for response_tx in std::mem::take(&mut attempt.response_txs) {
842 let _ = response_tx.send(Ok(()));
843 }
844
845 if let Some(auth) = attempt.pending_auth.take() {
846 let generation = self.take_subscription_generation();
847 attempt.auth = Some(auth);
848 attempt.generation = generation;
849 attempt.retries = 0;
850 attempt.response_txs = std::mem::take(&mut attempt.pending_response_txs);
851 self.subscription_attempts.insert(topic, attempt);
852 self.pending_subs.push_back((topic, generation));
853 } else {
854 debug_assert!(attempt.pending_response_txs.is_empty());
855 }
856 true
857 }
858
859 fn retry_inflight_subscriptions(&mut self, message: &str) {
860 let mut inflight: Vec<(Ustr, u64)> = self
861 .inflight_subs
862 .iter()
863 .map(|(topic, generation)| (*topic, *generation))
864 .collect();
865 inflight.sort_unstable_by_key(|(topic, _)| *topic);
866
867 for (topic, generation) in inflight {
868 self.schedule_subscription_retry(topic, generation, message);
869 }
870 }
871
872 fn fail_inflight_subscriptions(&mut self, message: &str) {
873 let mut topics: Vec<Ustr> = self.inflight_subs.keys().copied().collect();
874 topics.sort_unstable();
875
876 for topic in topics {
877 self.fail_subscription_attempt(topic, message);
878 }
879 }
880
881 fn schedule_subscription_retry(&mut self, topic: Ustr, generation: u64, message: &str) {
882 if self.inflight_subs.get(&topic) != Some(&generation) {
883 return;
884 }
885 self.inflight_subs.remove(&topic);
886 self.subscriptions.mark_failure(topic.as_str());
887
888 let Some(attempt) = self.subscription_attempts.get_mut(&topic) else {
889 return;
890 };
891
892 if attempt.generation != generation {
893 return;
894 }
895
896 attempt.fold_pending_auth();
897 if attempt.retries >= SUBSCRIBE_RETRY_MAX {
898 self.fail_subscription_attempt(topic, message);
899 return;
900 }
901
902 attempt.retries += 1;
903 let retry = attempt.retries;
904 let next_generation = self.take_subscription_generation();
905 let attempt = self
906 .subscription_attempts
907 .get_mut(&topic)
908 .expect("subscription attempt disappeared before retry");
909 attempt.generation = next_generation;
910
911 let delay = SUBSCRIBE_RETRY_BASE_BACKOFF.saturating_mul(1_u32 << (retry - 1));
912 self.subscription_retries.push(Box::pin(async move {
913 tokio::time::sleep(delay).await;
914 (topic, next_generation)
915 }));
916 }
917
918 fn fail_subscription_attempt(&mut self, topic: Ustr, message: &str) {
919 self.inflight_subs.remove(&topic);
920 self.pending_subs.retain(|(pending, _)| *pending != topic);
921 self.subscriptions.mark_unsubscribe(topic.as_str());
922 self.subscriptions.confirm_unsubscribe(topic.as_str());
923
924 if let Some(attempt) = self.subscription_attempts.remove(&topic) {
925 let attempts = attempt.retries + 1;
926 for response_tx in attempt
927 .response_txs
928 .into_iter()
929 .chain(attempt.pending_response_txs)
930 {
931 let _ = response_tx.send(Err(format!(
932 "subscription {topic} failed after {attempts} attempts: {message}",
933 )));
934 }
935 }
936 }
937
938 fn cancel_subscription_attempt(&mut self, topic: &str, message: &str) {
939 let topic = Ustr::from(topic);
940 self.inflight_subs.remove(&topic);
941 self.pending_subs.retain(|(pending, _)| *pending != topic);
942 if let Some(attempt) = self.subscription_attempts.remove(&topic) {
943 for response_tx in attempt
944 .response_txs
945 .into_iter()
946 .chain(attempt.pending_response_txs)
947 {
948 let _ = response_tx.send(Err(format!("{message}: {topic}")));
949 }
950 }
951 }
952
953 fn reset_subscription_attempts_after_reconnect(&mut self) {
954 for topic in self.subscriptions.all_topics() {
955 self.subscriptions.mark_failure(&topic);
956 }
957 self.pending_subs.clear();
958 self.inflight_subs.clear();
959 self.ignored_completions.clear();
962
963 let mut topics: Vec<Ustr> = self.subscription_attempts.keys().copied().collect();
964 topics.sort_unstable();
965 for topic in topics {
966 let generation = self.take_subscription_generation();
967 let attempt = self
968 .subscription_attempts
969 .get_mut(&topic)
970 .expect("subscription attempt disappeared during reconnect");
971 attempt.fold_pending_auth();
972 attempt.generation = generation;
973 attempt.retries = 0;
974 self.pending_subs.push_back((topic, generation));
975 }
976 }
977
978 fn take_subscription_generation(&mut self) -> u64 {
979 let generation = self.next_subscription_generation;
980 self.next_subscription_generation =
981 self.next_subscription_generation.wrapping_add(1).max(1);
982 generation
983 }
984
985 fn handle_control_value(
990 &mut self,
991 value: &serde_json::Value,
992 ) -> (bool, Option<NautilusWsMessage>) {
993 if let Some(error) = already_subscribed_error(value) {
994 self.confirm_already_subscribed(error);
995 return (true, None);
996 }
997 let subscription_code = subscription_error_code(value);
998
999 if subscription_code == Some(LIGHTER_ERROR_CODE_WS_RATE_LIMITED) {
1000 self.retry_inflight_subscriptions(&format!(
1001 "venue rejected the WebSocket subscribe with code \
1002 {LIGHTER_ERROR_CODE_WS_RATE_LIMITED}",
1003 ));
1004 return (true, None);
1005 }
1006
1007 if subscription_code == Some(LIGHTER_ERROR_CODE_WS_SUBSCRIBE_FAILED) {
1008 self.fail_inflight_subscriptions(&format!(
1009 "venue rejected the WebSocket subscribe with code \
1010 {LIGHTER_ERROR_CODE_WS_SUBSCRIBE_FAILED}",
1011 ));
1012 return (true, None);
1013 }
1014
1015 let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
1016
1017 match kind {
1018 CTRL_TYPE_CONNECTED => {
1019 log::debug!("Lighter WebSocket handshake complete");
1020 (true, None)
1021 }
1022 CTRL_TYPE_PING | CTRL_TYPE_PONG => (true, None),
1023 CTRL_TYPE_SEND_TX => {
1024 let raw_code = value.get("code").and_then(|v| v.as_u64());
1025 match raw_code {
1026 Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) => {
1027 log_integrator_not_approved();
1028 (
1029 true,
1030 Some(send_tx_rejected_from_value(
1031 value,
1032 SendTxRejectionSource::Ack,
1033 )),
1034 )
1035 }
1036 Some(200) => {
1037 log::debug!("Lighter WebSocket sendTx ack: {value}");
1038 let tx_hash = value
1039 .get("tx_hash")
1040 .and_then(|v| v.as_str())
1041 .map(str::to_string);
1042 (
1043 true,
1044 Some(NautilusWsMessage::SendTxAck {
1045 connection_epoch: 0,
1046 tx_hash,
1047 code: 200,
1048 }),
1049 )
1050 }
1051 Some(_) => {
1052 log::error!("Lighter sendTx rejected: {value}");
1053 (
1054 true,
1055 Some(send_tx_rejected_from_value(
1056 value,
1057 SendTxRejectionSource::Ack,
1058 )),
1059 )
1060 }
1061 None => {
1062 log::warn!(
1063 "Ignoring malformed Lighter sendTx response without numeric code: {value}",
1064 );
1065 (true, None)
1066 }
1067 }
1068 }
1069 CTRL_TYPE_SUBSCRIBED | CTRL_TYPE_UNSUBSCRIBED => {
1070 if let Some(topic) = value.get("channel").and_then(|v| v.as_str()) {
1071 if kind == CTRL_TYPE_SUBSCRIBED {
1072 self.complete_subscription(topic, CompletionKind::ControlAck);
1073 } else {
1074 let was_pending_unsubscribe = self
1075 .subscriptions
1076 .pending_unsubscribe_topics()
1077 .iter()
1078 .any(|pending| pending == topic);
1079 self.subscriptions.confirm_unsubscribe(topic);
1080
1081 if was_pending_unsubscribe {
1082 if let Some(market_index) = order_book_market_index_from_topic(topic) {
1084 self.clear_cached_order_book(market_index);
1085 }
1086
1087 if let Some(key) = candle_market_and_resolution_from_topic(topic) {
1088 self.last_candles.remove(&key);
1089 }
1090 }
1091 }
1092 }
1093 (true, None)
1094 }
1095 CTRL_TYPE_ERROR => {
1096 let code = value.get("code").and_then(|v| v.as_u64());
1097 if code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
1098 log_integrator_not_approved();
1099 } else {
1100 log::warn!("Lighter WebSocket error frame: {value}");
1101 }
1102
1103 if is_sendtx_error_code(code) {
1104 (
1105 true,
1106 Some(send_tx_rejected_from_value(
1107 value,
1108 SendTxRejectionSource::BareError,
1109 )),
1110 )
1111 } else {
1112 (true, None)
1113 }
1114 }
1115 _ => {
1116 if let Some(error) = value.get("error") {
1117 let nested_code = error.get("code").and_then(|v| v.as_u64());
1118 if nested_code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
1119 log_integrator_not_approved();
1120 } else {
1121 log::warn!("Lighter WebSocket error frame: {value}");
1122 }
1123 let rejected = is_sendtx_error_code(nested_code).then(|| {
1124 send_tx_rejected_from_nested_error(error, SendTxRejectionSource::BareError)
1125 });
1126 return (true, rejected);
1127 }
1128 (false, None)
1129 }
1130 }
1131 }
1132
1133 fn confirm_already_subscribed(&mut self, error: &serde_json::Value) {
1134 let Some(topic) = error
1135 .get("message")
1136 .and_then(|value| value.as_str())
1137 .and_then(|message| message.strip_prefix("Already Subscribed to : "))
1138 else {
1139 log::debug!(
1140 "Lighter WebSocket subscription response: code={LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED}",
1141 );
1142 return;
1143 };
1144
1145 if !self.complete_subscription(topic, CompletionKind::AlreadySubscribed) {
1146 log::debug!(
1147 "Lighter WebSocket subscription response: code={LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED}",
1148 );
1149 return;
1150 }
1151
1152 log::debug!(
1153 "Lighter WebSocket subscription response: code={LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED}, topic={topic}",
1154 );
1155 }
1156
1157 fn handle_frame(
1158 &mut self,
1159 frame: LighterWsFrame,
1160 ts_init: UnixNanos,
1161 ) -> Vec<NautilusWsMessage> {
1162 match frame {
1163 LighterWsFrame::OrderBookSnapshot {
1164 channel,
1165 order_book,
1166 timestamp,
1167 ..
1168 } => self.handle_order_book(channel, &order_book, timestamp, true, ts_init),
1169 LighterWsFrame::OrderBook {
1170 channel,
1171 order_book,
1172 timestamp,
1173 ..
1174 } => self.handle_order_book(channel, &order_book, timestamp, false, ts_init),
1175 LighterWsFrame::TickerSnapshot {
1176 channel,
1177 ticker,
1178 timestamp,
1179 ..
1180 }
1181 | LighterWsFrame::Ticker {
1182 channel,
1183 ticker,
1184 timestamp,
1185 ..
1186 } => self.handle_ticker(channel, &ticker, timestamp, ts_init),
1187 LighterWsFrame::TradeSnapshot {
1188 trades,
1189 liquidation_trades,
1190 ..
1191 }
1192 | LighterWsFrame::Trade {
1193 trades,
1194 liquidation_trades,
1195 ..
1196 } => self.handle_trades(&trades, &liquidation_trades, ts_init),
1197 LighterWsFrame::AccountOrders { ref orders, .. }
1198 | LighterWsFrame::AccountAllOrders { ref orders, .. } => {
1199 if self.exec_account.is_none() {
1200 return raw_message(&frame);
1201 }
1202 let mut msgs = self.handle_account_orders(orders, ts_init);
1203 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
1204 AccountStream::Orders,
1205 ));
1206 msgs
1207 }
1208 LighterWsFrame::AccountAllTradesSnapshot { .. } => {
1216 if self.exec_account.is_none() {
1217 return raw_message(&frame);
1218 }
1219 log::debug!(
1220 "Skipping Lighter account_all_trades snapshot frame; \
1221 reconcile historical fills via HTTP",
1222 );
1223 vec![NautilusWsMessage::AccountStreamFirstFrame(
1224 AccountStream::Trades,
1225 )]
1226 }
1227 LighterWsFrame::AccountAllTrades { ref trades, .. } => {
1228 if self.exec_account.is_none() {
1229 return raw_message(&frame);
1230 }
1231 let mut msgs = self.handle_account_trades(trades.values().flatten(), ts_init);
1232 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
1233 AccountStream::Trades,
1234 ));
1235 msgs
1236 }
1237 LighterWsFrame::AccountAllPositionsSnapshot { ref positions, .. } => {
1238 if self.exec_account.is_none() {
1239 return raw_message(&frame);
1240 }
1241 let mut msgs =
1242 self.handle_account_positions(positions, ts_init, PositionFrameType::Snapshot);
1243 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
1244 AccountStream::Positions,
1245 ));
1246 msgs
1247 }
1248 LighterWsFrame::AccountAllPositions { ref positions, .. } => {
1249 if self.exec_account.is_none() {
1250 return raw_message(&frame);
1251 }
1252 self.handle_account_positions(positions, ts_init, PositionFrameType::Update)
1253 }
1254 LighterWsFrame::AccountAllAssets {
1255 ref assets,
1256 timestamp,
1257 ..
1258 } => {
1259 if self.exec_account.is_none() {
1260 return raw_message(&frame);
1261 }
1262 let mut msgs = self.handle_account_assets(assets, timestamp, ts_init);
1263 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
1264 AccountStream::Assets,
1265 ));
1266 msgs
1267 }
1268 LighterWsFrame::UserStats {
1269 ref stats,
1270 timestamp,
1271 ..
1272 } => {
1273 if self.exec_account.is_none() {
1274 return raw_message(&frame);
1275 }
1276 let mut msgs = self.handle_user_stats(stats, timestamp, ts_init);
1277 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
1278 AccountStream::UserStats,
1279 ));
1280 msgs
1281 }
1282 LighterWsFrame::MarketStats {
1283 ref market_stats,
1284 timestamp,
1285 ..
1286 } => self.handle_market_stats(market_stats, timestamp, ts_init),
1287 LighterWsFrame::SpotMarketStats {
1288 ref spot_market_stats,
1289 timestamp,
1290 ..
1291 } => self.handle_spot_market_stats(spot_market_stats, timestamp, ts_init),
1292 LighterWsFrame::CandleSnapshot {
1293 channel,
1294 ref candles,
1295 ..
1296 }
1297 | LighterWsFrame::Candle {
1298 channel,
1299 ref candles,
1300 ..
1301 } => self.handle_candles(channel, candles, ts_init),
1302 LighterWsFrame::Height { .. } => raw_message(&frame),
1303 }
1304 }
1305
1306 fn handle_order_book(
1307 &mut self,
1308 channel: Ustr,
1309 book: &super::messages::LighterWsOrderBook,
1310 timestamp: u64,
1311 is_snapshot: bool,
1312 ts_init: UnixNanos,
1313 ) -> Vec<NautilusWsMessage> {
1314 let market_index = match market_index_from_topic(channel.as_str()) {
1315 Some(index) => index,
1316 None => {
1317 log::debug!("Lighter order_book frame missing market index in channel '{channel}'");
1318 return Vec::new();
1319 }
1320 };
1321
1322 if !self.instruments.contains_key(&market_index) {
1323 log::debug!("No instrument cached for Lighter market_index={market_index}");
1324 return Vec::new();
1325 }
1326
1327 if !self.book_delta_subs.contains(&market_index)
1328 && !self.book_depth_10_subs.contains(&market_index)
1329 {
1330 return Vec::new();
1331 }
1332
1333 if !is_snapshot && !self.book_snapshots_seen.contains(&market_index) {
1337 log::warn!(
1338 "Dropping Lighter order_book update before snapshot for market_index={market_index}",
1339 );
1340 return Vec::new();
1341 }
1342
1343 if is_snapshot {
1344 self.book_snapshots_seen.insert(market_index);
1345 self.book_states.insert(
1346 market_index,
1347 CachedOrderBook {
1348 book: book.clone(),
1349 timestamp,
1350 },
1351 );
1352 } else if let Some(cached_nonce) = self
1353 .book_states
1354 .get(&market_index)
1355 .map(|state| state.book.nonce)
1356 {
1357 if book.begin_nonce != cached_nonce {
1358 log::warn!(
1359 "Dropping Lighter order_book update with nonce gap for \
1360 market_index={market_index}: begin_nonce={}, cached_nonce={cached_nonce}",
1361 book.begin_nonce,
1362 );
1363 self.clear_cached_order_book(market_index);
1364 self.queue_order_book_resync(market_index);
1365 return Vec::new();
1366 }
1367
1368 if let Some(state) = self.book_states.get_mut(&market_index) {
1369 apply_order_book_update(&mut state.book, book);
1370 state.timestamp = timestamp;
1371 }
1372 } else {
1373 log::warn!(
1374 "Dropping Lighter order_book update without cached state for \
1375 market_index={market_index}",
1376 );
1377 self.clear_cached_order_book(market_index);
1378 self.queue_order_book_resync(market_index);
1379 return Vec::new();
1380 }
1381
1382 self.order_book_messages(market_index, book, timestamp, is_snapshot, ts_init)
1383 }
1384
1385 fn clear_cached_order_book(&mut self, market_index: i16) {
1386 self.book_snapshots_seen.remove(&market_index);
1387 self.book_states.remove(&market_index);
1388 }
1389
1390 fn queue_order_book_resync(&self, market_index: i16) {
1391 if !self.order_book_stream_is_referenced(market_index) {
1392 log::debug!(
1393 "Skipping Lighter order_book resync: subscription cancelled, \
1394 market_index={market_index}",
1395 );
1396 return;
1397 }
1398
1399 let Some(cmd_tx) = &self.cmd_tx else {
1400 log::error!(
1401 "Cannot resync Lighter order_book stream without command sender: \
1402 market_index={market_index}",
1403 );
1404 return;
1405 };
1406
1407 if let Err(e) = cmd_tx.send(HandlerCommand::ResubscribeOrderBook { market_index }) {
1408 log::error!("Failed to queue Lighter order_book resync: {e}");
1409 }
1410 }
1411
1412 async fn resubscribe_order_book_stream(&mut self, market_index: i16) {
1413 if !self.order_book_stream_is_referenced(market_index) {
1414 log::debug!(
1415 "Skipping Lighter order_book resync: subscription cancelled before venue \
1416 unsubscribe, market_index={market_index}",
1417 );
1418 return;
1419 }
1420
1421 let channel = LighterWsChannel::OrderBook(market_index);
1422 self.dispatch_unsubscribe(channel.clone()).await;
1423
1424 if !self.order_book_stream_is_referenced(market_index) {
1425 log::debug!(
1426 "Skipping Lighter order_book resubscribe: subscription cancelled after venue \
1427 unsubscribe, market_index={market_index}",
1428 );
1429 return;
1430 }
1431
1432 let topic = Ustr::from(channel.topic_key().as_str());
1433 self.queue_subscribe(channel.clone(), None, None);
1434 self.pump_pending_subscribes().await;
1435
1436 if !self.order_book_stream_is_referenced(market_index) {
1437 log::debug!(
1438 "Cancelling Lighter order_book resync subscribe after user unsubscribe: \
1439 market_index={market_index}",
1440 );
1441 let attempted = self.inflight_subs.contains_key(&topic);
1442 self.cancel_subscription_attempt(topic.as_str(), "order book resubscription cancelled");
1443
1444 if attempted {
1445 self.dispatch_unsubscribe(channel).await;
1446 }
1447 }
1448 }
1449
1450 fn order_book_stream_is_referenced(&self, market_index: i16) -> bool {
1451 let channel = LighterWsChannel::OrderBook(market_index);
1452 self.subscriptions.get_reference_count(&channel.topic_key()) > 0
1453 && (self.book_delta_subs.contains(&market_index)
1454 || self.book_depth_10_subs.contains(&market_index))
1455 }
1456
1457 fn emit_cached_order_book_deltas_snapshot(
1458 &self,
1459 market_index: i16,
1460 ) -> Option<NautilusWsMessage> {
1461 let cached = self.book_states.get(&market_index)?.clone();
1462 let instrument = self.instruments.get(&market_index)?;
1463 let ts_init = self.clock.get_time_ns();
1464 match parse_ws_order_book_deltas(&cached.book, instrument, cached.timestamp, true, ts_init)
1465 {
1466 Ok(deltas) => Some(NautilusWsMessage::Deltas(deltas)),
1467 Err(e) => {
1468 log::error!("Error parsing cached Lighter order_book deltas: {e}");
1469 None
1470 }
1471 }
1472 }
1473
1474 fn emit_cached_order_book_depth10_snapshot(
1475 &self,
1476 market_index: i16,
1477 ) -> Option<NautilusWsMessage> {
1478 let cached = self.book_states.get(&market_index)?.clone();
1479 let instrument = self.instruments.get(&market_index)?;
1480 let ts_init = self.clock.get_time_ns();
1481 match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1482 Ok(depth) => Some(NautilusWsMessage::Depth10(Box::new(depth))),
1483 Err(e) => {
1484 log::error!("Error parsing cached Lighter order_book depth10: {e}");
1485 None
1486 }
1487 }
1488 }
1489
1490 fn order_book_messages(
1491 &self,
1492 market_index: i16,
1493 book: &LighterWsOrderBook,
1494 timestamp: u64,
1495 is_snapshot: bool,
1496 ts_init: UnixNanos,
1497 ) -> Vec<NautilusWsMessage> {
1498 let Some(instrument) = self.instruments.get(&market_index) else {
1499 log::debug!("No instrument cached for Lighter market_index={market_index}");
1500 return Vec::new();
1501 };
1502
1503 let mut messages = Vec::new();
1504
1505 if self.book_delta_subs.contains(&market_index) {
1506 match parse_ws_order_book_deltas(book, instrument, timestamp, is_snapshot, ts_init) {
1507 Ok(deltas) => messages.push(NautilusWsMessage::Deltas(deltas)),
1508 Err(e) => log::error!("Error parsing Lighter order_book deltas: {e}"),
1509 }
1510 }
1511
1512 if self.book_depth_10_subs.contains(&market_index)
1513 && let Some(cached) = self.book_states.get(&market_index)
1514 {
1515 match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1516 Ok(depth) => messages.push(NautilusWsMessage::Depth10(Box::new(depth))),
1517 Err(e) => log::error!("Error parsing Lighter order_book depth10: {e}"),
1518 }
1519 }
1520
1521 messages
1522 }
1523
1524 fn handle_ticker(
1525 &self,
1526 channel: Ustr,
1527 ticker: &super::messages::LighterTicker,
1528 timestamp: u64,
1529 ts_init: UnixNanos,
1530 ) -> Vec<NautilusWsMessage> {
1531 let Some(market_index) = market_index_from_topic(channel.as_str()) else {
1537 log::debug!("Lighter ticker frame missing market index in channel '{channel}'");
1538 return Vec::new();
1539 };
1540
1541 let Some(instrument) = self.instruments.get(&market_index) else {
1542 log::debug!("No instrument cached for Lighter ticker market_index={market_index}");
1543 return Vec::new();
1544 };
1545
1546 match parse_ws_quote_tick(ticker, instrument, timestamp, ts_init) {
1547 Ok(Some(quote)) => vec![NautilusWsMessage::Quote(quote)],
1548 Ok(None) => {
1549 log::debug!(
1550 "Skipping Lighter ticker for market_index={market_index}: one-sided book",
1551 );
1552 Vec::new()
1553 }
1554 Err(e) => {
1555 log::error!("Error parsing Lighter ticker frame: {e}");
1556 Vec::new()
1557 }
1558 }
1559 }
1560
1561 fn handle_trades(
1562 &self,
1563 trades: &[crate::http::models::LighterTrade],
1564 liquidation_trades: &[crate::http::models::LighterTrade],
1565 ts_init: UnixNanos,
1566 ) -> Vec<NautilusWsMessage> {
1567 let Some(market_index) = trades
1571 .first()
1572 .or_else(|| liquidation_trades.first())
1573 .map(|t| t.market_id)
1574 else {
1575 return Vec::new();
1576 };
1577
1578 let Some(instrument) = self.instruments.get(&market_index) else {
1579 log::debug!("No instrument cached for Lighter trade market_index={market_index}");
1580 return Vec::new();
1581 };
1582
1583 let mut ticks = Vec::with_capacity(trades.len() + liquidation_trades.len());
1584 for trade in trades.iter().chain(liquidation_trades.iter()) {
1585 match parse_ws_trade_tick(trade, instrument, ts_init) {
1586 Ok(tick) => ticks.push(tick),
1587 Err(e) => log::error!("Error parsing Lighter trade tick: {e}"),
1588 }
1589 }
1590
1591 if ticks.is_empty() {
1592 Vec::new()
1593 } else {
1594 vec![NautilusWsMessage::Trades(ticks)]
1595 }
1596 }
1597
1598 fn handle_market_stats(
1599 &self,
1600 payload: &super::messages::LighterMarketStatsPayload,
1601 timestamp: u64,
1602 ts_init: UnixNanos,
1603 ) -> Vec<NautilusWsMessage> {
1604 match payload {
1605 super::messages::LighterMarketStatsPayload::All(stats) => stats
1606 .values()
1607 .flat_map(|stats| self.handle_one_market_stats(stats, timestamp, ts_init))
1608 .collect(),
1609 super::messages::LighterMarketStatsPayload::One(stats) => {
1610 self.handle_one_market_stats(stats, timestamp, ts_init)
1611 }
1612 }
1613 }
1614
1615 fn handle_one_market_stats(
1616 &self,
1617 stats: &super::messages::LighterMarketStats,
1618 timestamp: u64,
1619 ts_init: UnixNanos,
1620 ) -> Vec<NautilusWsMessage> {
1621 let Some(instrument) = self.instruments.get(&stats.market_id) else {
1622 log::debug!(
1623 "No instrument cached for Lighter market_stats market_id={}",
1624 stats.market_id,
1625 );
1626 return Vec::new();
1627 };
1628
1629 let mut messages = Vec::with_capacity(3);
1630
1631 match parse_ws_mark_price_update(stats, instrument, timestamp, ts_init) {
1632 Ok(mark_price) => messages.push(NautilusWsMessage::MarkPrice(mark_price)),
1633 Err(e) => log::error!("Error parsing Lighter mark price: {e}"),
1634 }
1635
1636 match parse_ws_index_price_update(stats, instrument, timestamp, ts_init) {
1637 Ok(index_price) => messages.push(NautilusWsMessage::IndexPrice(index_price)),
1638 Err(e) => log::error!("Error parsing Lighter index price: {e}"),
1639 }
1640
1641 match parse_ws_funding_rate_update(stats, instrument, timestamp, ts_init) {
1642 Ok(funding_rate) => messages.push(NautilusWsMessage::FundingRate(funding_rate)),
1643 Err(e) => log::error!("Error parsing Lighter funding rate: {e}"),
1644 }
1645
1646 messages
1647 }
1648
1649 fn handle_spot_market_stats(
1650 &self,
1651 payload: &super::messages::LighterSpotMarketStatsPayload,
1652 timestamp: u64,
1653 ts_init: UnixNanos,
1654 ) -> Vec<NautilusWsMessage> {
1655 match payload {
1656 super::messages::LighterSpotMarketStatsPayload::All(stats) => stats
1657 .values()
1658 .filter_map(|stats| self.handle_one_spot_market_stats(stats, timestamp, ts_init))
1659 .collect(),
1660 super::messages::LighterSpotMarketStatsPayload::One(stats) => self
1661 .handle_one_spot_market_stats(stats, timestamp, ts_init)
1662 .into_iter()
1663 .collect(),
1664 }
1665 }
1666
1667 fn handle_one_spot_market_stats(
1668 &self,
1669 stats: &super::messages::LighterSpotMarketStats,
1670 timestamp: u64,
1671 ts_init: UnixNanos,
1672 ) -> Option<NautilusWsMessage> {
1673 let Some(instrument) = self.instruments.get(&stats.market_id) else {
1674 log::debug!(
1675 "No instrument cached for Lighter spot_market_stats market_id={}",
1676 stats.market_id,
1677 );
1678 return None;
1679 };
1680
1681 match parse_ws_spot_index_price_update(stats, instrument, timestamp, ts_init) {
1682 Ok(index_price) => Some(NautilusWsMessage::IndexPrice(index_price)),
1683 Err(e) => {
1684 log::error!("Error parsing Lighter spot index price: {e}");
1685 None
1686 }
1687 }
1688 }
1689
1690 fn handle_candles(
1691 &mut self,
1692 channel: Ustr,
1693 candles: &[LighterWsCandle],
1694 ts_init: UnixNanos,
1695 ) -> Vec<NautilusWsMessage> {
1696 let Some((market_index, resolution)) =
1697 candle_market_and_resolution_from_topic(channel.as_str())
1698 else {
1699 log::warn!("Lighter candle frame with unparsable channel `{channel}`");
1700 return Vec::new();
1701 };
1702
1703 let Some(instrument) = self.instruments.get(&market_index) else {
1704 log::debug!("No instrument cached for Lighter candle market_index={market_index}");
1705 return Vec::new();
1706 };
1707
1708 let key = (market_index, resolution);
1709 let mut emitted = Vec::new();
1710
1711 for candle in candles {
1712 let previous = self.last_candles.get(&key).cloned();
1713 match previous {
1714 None => {}
1715 Some(prev) if candle.t > prev.t => {
1716 match parse_ws_bar(instrument, &prev, resolution, ts_init) {
1718 Ok(bar) => emitted.push(NautilusWsMessage::Bar(bar)),
1719 Err(e) => log::error!("Error parsing Lighter candle bar: {e}"),
1720 }
1721 }
1722 Some(prev) if candle.t < prev.t => continue,
1723 Some(_) => {}
1724 }
1725 self.last_candles.insert(key, candle.clone());
1726 }
1727
1728 emitted
1729 }
1730
1731 fn handle_account_orders(
1732 &self,
1733 orders_by_market: &AHashMap<Ustr, Vec<LighterOrder>>,
1734 _ts_init: UnixNanos,
1735 ) -> Vec<NautilusWsMessage> {
1736 if self.exec_account.is_none() {
1737 log::debug!("Lighter account_orders frame skipped: no execution context set");
1738 return Vec::new();
1739 }
1740
1741 let mut reports = Vec::new();
1742
1743 for orders in orders_by_market.values() {
1744 for order in orders {
1745 if !self.instruments.contains_key(&order.market_index) {
1746 log::debug!(
1747 "No instrument cached for Lighter order market_index={}",
1748 order.market_index,
1749 );
1750 continue;
1751 }
1752
1753 reports.push(ExecutionReport::Order(order.clone()));
1754 }
1755 }
1756
1757 if reports.is_empty() {
1758 Vec::new()
1759 } else {
1760 vec![NautilusWsMessage::ExecutionReports(reports)]
1761 }
1762 }
1763
1764 fn handle_account_trades<'a>(
1765 &self,
1766 trades: impl IntoIterator<Item = &'a LighterTrade>,
1767 _ts_init: UnixNanos,
1768 ) -> Vec<NautilusWsMessage> {
1769 let Some((_account_id, account_index)) = self.exec_account else {
1770 log::debug!("Lighter account_trades frame skipped: no execution context set");
1771 return Vec::new();
1772 };
1773
1774 let mut reports = Vec::new();
1775
1776 for trade in trades {
1777 if !self.instruments.contains_key(&trade.market_id) {
1778 log::debug!(
1779 "No instrument cached for Lighter account trade market_id={}",
1780 trade.market_id,
1781 );
1782 continue;
1783 }
1784
1785 if trade.bid_account_id != account_index && trade.ask_account_id != account_index {
1789 continue;
1790 }
1791
1792 reports.push(ExecutionReport::Fill(trade.clone()));
1793 }
1794
1795 if reports.is_empty() {
1796 Vec::new()
1797 } else {
1798 vec![NautilusWsMessage::ExecutionReports(reports)]
1799 }
1800 }
1801
1802 fn handle_account_positions(
1803 &self,
1804 positions: &AHashMap<Ustr, LighterPosition>,
1805 ts_init: UnixNanos,
1806 frame_type: PositionFrameType,
1807 ) -> Vec<NautilusWsMessage> {
1808 let Some((account_id, _)) = self.exec_account else {
1809 log::debug!("Lighter account_positions frame skipped: no execution context set");
1810 return Vec::new();
1811 };
1812
1813 let ts_event = ts_init;
1816
1817 let mut reports = Vec::new();
1818 let mut skipped_market_ids = Vec::new();
1819 let mut closed_market_ids = Vec::new();
1820
1821 for position in positions.values() {
1822 if position.position.is_zero() {
1823 if matches!(frame_type, PositionFrameType::Update) {
1824 closed_market_ids.push(position.market_id);
1825 }
1826 continue;
1827 }
1828
1829 let Some(instrument) = self.instruments.get(&position.market_id) else {
1830 log::debug!(
1831 "No instrument cached for Lighter position market_id={}",
1832 position.market_id,
1833 );
1834
1835 skipped_market_ids.push(position.market_id);
1836 continue;
1837 };
1838
1839 match parse_ws_position_status_report(
1840 position, instrument, account_id, ts_event, ts_init,
1841 ) {
1842 Ok(report) => reports.push(report),
1843 Err(e) => {
1844 skipped_market_ids.push(position.market_id);
1845 log::error!("Error parsing Lighter position status report: {e}");
1846 }
1847 }
1848 }
1849
1850 match frame_type {
1851 PositionFrameType::Snapshot => {
1852 vec![NautilusWsMessage::PositionSnapshot {
1854 reports,
1855 skipped_market_ids,
1856 }]
1857 }
1858 PositionFrameType::Update => vec![NautilusWsMessage::PositionUpdate {
1859 reports,
1860 closed_market_ids,
1861 skipped_market_ids,
1862 }],
1863 }
1864 }
1865
1866 fn handle_account_assets(
1867 &self,
1868 assets: &AHashMap<Ustr, LighterAsset>,
1869 timestamp_ms: u64,
1870 ts_init: UnixNanos,
1871 ) -> Vec<NautilusWsMessage> {
1872 let Some((account_id, _)) = self.exec_account else {
1873 log::debug!("Lighter account_assets frame skipped: no execution context set");
1874 return Vec::new();
1875 };
1876
1877 let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1878 Ok(ts) => ts,
1879 Err(e) => {
1880 log::error!("Invalid Lighter account_assets timestamp {timestamp_ms}: {e}");
1881 return Vec::new();
1882 }
1883 };
1884
1885 self.account_state_reconciler.update_assets(assets);
1886 self.emit_unified_account_state(account_id, ts_event, ts_init)
1887 }
1888
1889 fn handle_user_stats(
1890 &self,
1891 stats: &LighterUserStats,
1892 timestamp_ms: u64,
1893 ts_init: UnixNanos,
1894 ) -> Vec<NautilusWsMessage> {
1895 let Some((account_id, _)) = self.exec_account else {
1896 log::debug!("Lighter user_stats frame skipped: no execution context set");
1897 return Vec::new();
1898 };
1899
1900 let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1901 Ok(ts) => ts,
1902 Err(e) => {
1903 log::error!("Invalid Lighter user_stats timestamp {timestamp_ms}: {e}");
1904 return Vec::new();
1905 }
1906 };
1907
1908 self.account_state_reconciler.update_user_stats(stats);
1909 self.emit_unified_account_state(account_id, ts_event, ts_init)
1910 }
1911
1912 fn emit_unified_account_state(
1917 &self,
1918 account_id: AccountId,
1919 ts_event: UnixNanos,
1920 ts_init: UnixNanos,
1921 ) -> Vec<NautilusWsMessage> {
1922 match self
1923 .account_state_reconciler
1924 .build_state(account_id, ts_event, ts_init)
1925 {
1926 Some(Ok(state)) => vec![NautilusWsMessage::AccountState(Box::new(state))],
1927 Some(Err(e)) => {
1928 log::error!("Error building unified Lighter account state: {e}");
1929 Vec::new()
1930 }
1931 None => Vec::new(),
1932 }
1933 }
1934}
1935
1936#[derive(Clone, Copy)]
1937enum PositionFrameType {
1938 Snapshot,
1939 Update,
1940}
1941
1942fn raw_message(frame: &LighterWsFrame) -> Vec<NautilusWsMessage> {
1943 let value = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null);
1944 vec![NautilusWsMessage::Raw(value)]
1945}
1946
1947fn apply_order_book_update(state: &mut LighterWsOrderBook, update: &LighterWsOrderBook) {
1948 apply_book_side_update(&mut state.bids, &update.bids, true);
1949 apply_book_side_update(&mut state.asks, &update.asks, false);
1950
1951 state.code = update.code;
1952 state.offset = update.offset;
1953 state.nonce = update.nonce;
1954 state.last_updated_at = update.last_updated_at;
1955 state.begin_nonce = update.begin_nonce;
1956}
1957
1958fn apply_book_side_update(
1959 levels: &mut Vec<LighterPriceLevel>,
1960 updates: &[LighterPriceLevel],
1961 bids: bool,
1962) {
1963 for update in updates {
1964 if update.price == Decimal::ZERO {
1965 continue;
1966 }
1967
1968 match find_book_level(levels, update.price, bids) {
1969 Ok(index) if update.size == Decimal::ZERO => {
1970 levels.remove(index);
1971 }
1972 Ok(index) => {
1973 levels[index] = update.clone();
1974 }
1975 Err(_) if update.size == Decimal::ZERO => {}
1976 Err(index) => {
1977 levels.insert(index, update.clone());
1978 }
1979 }
1980 }
1981}
1982
1983fn find_book_level(
1984 levels: &[LighterPriceLevel],
1985 price: Decimal,
1986 bids: bool,
1987) -> Result<usize, usize> {
1988 levels.binary_search_by(|level| {
1989 if bids {
1990 price.cmp(&level.price)
1991 } else {
1992 level.price.cmp(&price)
1993 }
1994 })
1995}
1996
1997fn is_sendtx_error_code(code: Option<u64>) -> bool {
2000 code.is_some_and(|c| LIGHTER_ERROR_CODE_TX_RANGE.contains(&c))
2001}
2002
2003fn already_subscribed_error(value: &serde_json::Value) -> Option<&serde_json::Value> {
2004 if value.get("code").and_then(|code| code.as_u64())
2005 == Some(LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED)
2006 {
2007 Some(value)
2008 } else {
2009 value.get("error").filter(|e| {
2010 e.get("code").and_then(|code| code.as_u64())
2011 == Some(LIGHTER_ERROR_CODE_ALREADY_SUBSCRIBED)
2012 })
2013 }
2014}
2015
2016fn subscription_error_code(value: &serde_json::Value) -> Option<u64> {
2017 value
2018 .get("code")
2019 .and_then(|code| code.as_u64())
2020 .or_else(|| {
2021 value
2022 .get("error")
2023 .and_then(|e| e.get("code"))
2024 .and_then(|code| code.as_u64())
2025 })
2026}
2027
2028fn typed_subscribe_topic(text: &str) -> Option<&str> {
2029 let header = serde_json::from_str::<LighterWsFrameHeader<'_>>(text).ok()?;
2030 header
2031 .kind
2032 .starts_with("subscribed/")
2033 .then_some(header.channel?)
2034}
2035
2036fn send_tx_rejected_from_value(
2039 value: &serde_json::Value,
2040 source: SendTxRejectionSource,
2041) -> NautilusWsMessage {
2042 let code = value.get("code").and_then(|v| v.as_i64());
2043 let message = value
2044 .get("message")
2045 .and_then(|v| v.as_str())
2046 .unwrap_or("")
2047 .to_string();
2048 let tx_hash = value
2049 .get("tx_hash")
2050 .and_then(|v| v.as_str())
2051 .map(str::to_string);
2052 NautilusWsMessage::SendTxRejected {
2053 connection_epoch: 0,
2054 source,
2055 code,
2056 message,
2057 tx_hash,
2058 }
2059}
2060
2061fn send_tx_rejected_from_nested_error(
2064 error: &serde_json::Value,
2065 source: SendTxRejectionSource,
2066) -> NautilusWsMessage {
2067 let code = error.get("code").and_then(|v| v.as_i64());
2068 let message = error
2069 .get("message")
2070 .and_then(|v| v.as_str())
2071 .unwrap_or("")
2072 .to_string();
2073 NautilusWsMessage::SendTxRejected {
2074 connection_epoch: 0,
2075 source,
2076 code,
2077 message,
2078 tx_hash: None,
2079 }
2080}
2081
2082fn log_integrator_not_approved() {
2083 log::error!(
2084 "Lighter venue rejected with code {LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED} \
2085 'integrator is not approved'.\n\
2086 Tagged orders require Nautilus integrator approval. \
2087 See: {LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL}",
2088 );
2089}
2090
2091fn market_index_from_topic(topic: &str) -> Option<i16> {
2092 let (_, rest) = topic.split_once(':')?;
2093 rest.parse::<i16>().ok()
2094}
2095
2096fn candle_market_and_resolution_from_topic(topic: &str) -> Option<(i16, LighterCandleResolution)> {
2097 let (channel, rest) = topic.split_once(':')?;
2098 if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::Candle) {
2099 return None;
2100 }
2101 let (market, res) = rest.split_once(':')?;
2102 let market_index = market.parse::<i16>().ok()?;
2103 let resolution = res.parse::<LighterCandleResolution>().ok()?;
2104 Some((market_index, resolution))
2105}
2106
2107fn order_book_market_index_from_topic(topic: &str) -> Option<i16> {
2108 let (channel, rest) = topic.split_once(':')?;
2109 if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::OrderBook) {
2110 return None;
2111 }
2112 rest.parse::<i16>().ok()
2113}
2114
2115pub(crate) fn should_retry_lighter_ws_error(error: &LighterWsError) -> bool {
2116 match error {
2117 LighterWsError::Network(_) => true,
2118 LighterWsError::Transport(send_error) => match send_error {
2123 SendError::Timeout => true,
2124 SendError::InvalidInput(_)
2125 | SendError::Closed
2126 | SendError::ConnectionChanged
2127 | SendError::BrokenPipe(_)
2128 | SendError::WriteTimeout => false,
2129 },
2130 LighterWsError::Authentication(_)
2131 | LighterWsError::Parse(_)
2132 | LighterWsError::Client(_)
2133 | LighterWsError::SendTxOutcomeUnknown(_) => false,
2134 }
2135}
2136
2137pub(crate) fn create_lighter_ws_timeout_error(_msg: String) -> LighterWsError {
2138 LighterWsError::Transport(SendError::Timeout)
2141}
2142
2143#[cfg(test)]
2144mod tests {
2145 use std::time::Duration;
2146
2147 use log::{Level, LevelFilter, Log, Metadata, Record};
2148 use nautilus_model::{
2149 enums::AccountType,
2150 identifiers::{InstrumentId, Symbol, Venue},
2151 instruments::{CryptoPerpetual, CurrencyPair},
2152 types::{Currency, Money, Price, Quantity},
2153 };
2154 use parking_lot::Mutex;
2155 use rstest::rstest;
2156 use rust_decimal::Decimal;
2157 use serde_json::json;
2158
2159 use super::*;
2160 use crate::{
2161 common::enums::{LighterCandleResolution, LighterTxType},
2162 websocket::messages::{LighterMarketSelection, LighterWsCandle, LighterWsChannel},
2163 };
2164
2165 const SECRET_MARKER: &str = "426426426";
2166
2167 struct OutboundLogCapture {
2168 messages: Mutex<Vec<String>>,
2169 }
2170
2171 static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
2172 messages: Mutex::new(Vec::new()),
2173 };
2174
2175 impl OutboundLogCapture {
2176 fn clear(&self) {
2177 self.messages.lock().clear();
2178 }
2179
2180 fn messages(&self) -> Vec<String> {
2181 self.messages.lock().clone()
2182 }
2183 }
2184
2185 impl Log for OutboundLogCapture {
2186 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
2187 metadata.level() == Level::Debug
2188 && metadata.target() == "nautilus_lighter::websocket::handler"
2189 }
2190
2191 fn log(&self, record: &Record<'_>) {
2192 if self.enabled(record.metadata()) {
2193 let message = record.args().to_string();
2194 if message.starts_with("Sending Lighter unsubscribe") {
2195 self.messages.lock().push(message);
2196 }
2197 }
2198 }
2199
2200 fn flush(&self) {}
2201 }
2202
2203 const WS_ACCOUNT_ORDERS_UPDATE: &str =
2204 include_str!("../../test_data/ws_account_orders_update.json");
2205 const WS_ACCOUNT_ALL_TRADES_UPDATE: &str =
2206 include_str!("../../test_data/ws_account_all_trades_update.json");
2207 const WS_ACCOUNT_ALL_POSITIONS_UPDATE: &str =
2208 include_str!("../../test_data/ws_account_all_positions_update.json");
2209 const WS_ACCOUNT_ALL_ASSETS_UPDATE: &str =
2210 include_str!("../../test_data/ws_account_all_assets_update.json");
2211 const WS_USER_STATS_UPDATE: &str = include_str!("../../test_data/ws_user_stats_update.json");
2212 const WS_ACCOUNT_ALL_ASSETS_WITH_POSITION: &str =
2213 include_str!("../../test_data/ws_account_all_assets_with_position.json");
2214 const WS_USER_STATS_WITH_POSITION: &str =
2215 include_str!("../../test_data/ws_user_stats_with_position.json");
2216 const WS_MARKET_STATS_UPDATE_SINGLE: &str =
2217 include_str!("../../test_data/ws_market_stats_update_single.json");
2218 const WS_MARKET_STATS_UPDATE_ALL: &str =
2219 include_str!("../../test_data/ws_market_stats_update_all.json");
2220 const WS_SPOT_MARKET_STATS_UPDATE_SINGLE: &str =
2221 include_str!("../../test_data/ws_spot_market_stats_update_single.json");
2222 const WS_SPOT_MARKET_STATS_UPDATE_ALL: &str =
2223 include_str!("../../test_data/ws_spot_market_stats_update_all.json");
2224 const WS_CANDLE_SUBSCRIBED: &str = include_str!("../../test_data/ws_candle_subscribed.json");
2225
2226 fn handle_control_text(
2227 handler: &mut FeedHandler,
2228 text: &str,
2229 ) -> (bool, Option<NautilusWsMessage>) {
2230 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
2231 return (false, None);
2232 };
2233 handler.handle_control_value(&value)
2234 }
2235
2236 fn stub_eth_perp_instrument() -> InstrumentAny {
2237 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
2238 InstrumentAny::CryptoPerpetual(
2239 CryptoPerpetual::builder()
2240 .instrument_id(instrument_id)
2241 .raw_symbol(Symbol::new("ETH-PERP"))
2242 .base_currency(Currency::from("ETH"))
2243 .quote_currency(Currency::from("USDC"))
2244 .settlement_currency(Currency::from("USDC"))
2245 .is_inverse(false)
2246 .price_precision(2)
2247 .size_precision(4)
2248 .price_increment(Price::from("0.01"))
2249 .size_increment(Quantity::from("0.0001"))
2250 .ts_event(UnixNanos::default())
2251 .ts_init(UnixNanos::default())
2252 .build()
2253 .unwrap(),
2254 )
2255 }
2256
2257 fn stub_eth_spot_instrument() -> InstrumentAny {
2258 let instrument_id = InstrumentId::new(Symbol::new("ETH-SPOT"), Venue::new("LIGHTER"));
2259 InstrumentAny::CurrencyPair(
2260 CurrencyPair::builder()
2261 .instrument_id(instrument_id)
2262 .raw_symbol(Symbol::new("ETH-SPOT"))
2263 .base_currency(Currency::from("ETH"))
2264 .quote_currency(Currency::from("USDC"))
2265 .price_precision(2)
2266 .size_precision(4)
2267 .price_increment(Price::from("0.01"))
2268 .size_increment(Quantity::from("0.0001"))
2269 .ts_event(UnixNanos::default())
2270 .ts_init(UnixNanos::default())
2271 .build()
2272 .unwrap(),
2273 )
2274 }
2275
2276 fn make_handler_with_account() -> FeedHandler {
2277 let signal = Arc::new(AtomicBool::new(false));
2278 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2279 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
2280 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2281 let mut handler =
2282 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2283 handler.instruments.insert(0, stub_eth_perp_instrument());
2284 handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
2285 handler
2286 }
2287
2288 #[rstest]
2289 #[tokio::test]
2290 async fn test_outbound_unsubscribe_log_omits_payload_body() {
2291 log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
2292 log::set_max_level(LevelFilter::Debug);
2293
2294 let handler = make_handler_with_account();
2295 let account_index = SECRET_MARKER.parse::<i64>().unwrap();
2296 let channel = LighterWsChannel::AccountAll(account_index);
2297 let payload_len = serde_json::to_string(&LighterWsRequest::unsubscribe(
2298 channel.subscription_channel(),
2299 ))
2300 .unwrap()
2301 .len();
2302 OUTBOUND_LOG_CAPTURE.clear();
2303
2304 handler.dispatch_unsubscribe(channel).await;
2305
2306 let messages = OUTBOUND_LOG_CAPTURE.messages();
2307
2308 assert!(
2309 messages
2310 .iter()
2311 .all(|message| !message.contains(SECRET_MARKER)),
2312 "outbound logs exposed the secret marker: {messages:?}"
2313 );
2314 assert!(
2315 messages.iter().any(|message| {
2316 message == &format!("Sending Lighter unsubscribe ({payload_len} bytes)")
2317 }),
2318 "unsubscribe metadata missing or inaccurate: {messages:?}"
2319 );
2320 }
2321
2322 fn mark_subscription_inflight(
2323 handler: &mut FeedHandler,
2324 channel: LighterWsChannel,
2325 response_tx: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
2326 ) -> (Ustr, u64) {
2327 handler.queue_subscribe(channel, None, response_tx);
2328 let (topic, generation) = handler
2329 .pending_subs
2330 .pop_front()
2331 .expect("subscription was not queued");
2332 handler.inflight_subs.insert(topic, generation);
2333 (topic, generation)
2334 }
2335
2336 fn saturate_subscription_gate(handler: &mut FeedHandler) {
2337 for i in 0..SUBSCRIBE_INFLIGHT_MAX {
2338 handler
2339 .inflight_subs
2340 .insert(Ustr::from(format!("dummy:{i}").as_str()), i as u64 + 1);
2341 }
2342 }
2343
2344 fn strip_account_marker(mut msgs: Vec<NautilusWsMessage>) -> Vec<NautilusWsMessage> {
2349 if matches!(
2350 msgs.last(),
2351 Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
2352 ) {
2353 msgs.pop();
2354 }
2355 msgs
2356 }
2357
2358 #[rstest]
2359 fn handle_frame_routes_account_orders_to_execution_reports() {
2360 let mut handler = make_handler_with_account();
2361 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2362
2363 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2364
2365 assert_eq!(messages.len(), 1);
2366 match &messages[0] {
2367 NautilusWsMessage::ExecutionReports(reports) => {
2368 assert_eq!(reports.len(), 1);
2369 match &reports[0] {
2370 super::ExecutionReport::Order(order) => {
2371 assert_eq!(order.order_id, "281476929510110");
2372 assert_eq!(order.client_order_id, "42");
2373 }
2374 other => panic!("expected order report, was {other:?}"),
2375 }
2376 }
2377 other => panic!("expected execution reports, was {other:?}"),
2378 }
2379 }
2380
2381 #[rstest]
2382 fn handle_frame_routes_account_trades_to_execution_reports() {
2383 let mut handler = make_handler_with_account();
2384 let frame: super::LighterWsFrame =
2385 serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
2386
2387 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2388
2389 assert_eq!(messages.len(), 1);
2390 match &messages[0] {
2391 NautilusWsMessage::ExecutionReports(reports) => {
2392 assert_eq!(reports.len(), 1);
2393 match &reports[0] {
2394 super::ExecutionReport::Fill(fill) => {
2395 assert_eq!(fill.bid_id_str.as_deref(), Some("562947905631053"),);
2401 }
2402 other => panic!("expected fill report, was {other:?}"),
2403 }
2404 }
2405 other => panic!("expected execution reports, was {other:?}"),
2406 }
2407 }
2408
2409 #[rstest]
2410 fn handle_frame_routes_account_positions_to_update_without_readiness_marker() {
2411 let mut handler = make_handler_with_account();
2412 let frame: super::LighterWsFrame =
2413 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2414
2415 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2416
2417 assert_eq!(messages.len(), 1);
2418 match &messages[0] {
2419 NautilusWsMessage::PositionUpdate {
2420 reports,
2421 closed_market_ids,
2422 skipped_market_ids,
2423 } => {
2424 assert!(closed_market_ids.is_empty());
2425 assert!(skipped_market_ids.is_empty());
2426 assert_eq!(reports.len(), 1);
2427 assert_eq!(reports[0].quantity, Quantity::from("1.5000"));
2428 }
2429 other => panic!("expected position update, was {other:?}"),
2430 }
2431 }
2432
2433 #[rstest]
2434 fn handle_frame_tolerates_unknown_position_margin_mode() {
2435 let mut handler = make_handler_with_account();
2436 let mut frame_json: serde_json::Value =
2437 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2438 frame_json["positions"]["0"]["margin_mode"] = json!(99);
2439 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2440
2441 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2442
2443 assert_eq!(messages.len(), 1);
2444 match &messages[0] {
2445 NautilusWsMessage::PositionUpdate {
2446 reports,
2447 closed_market_ids,
2448 skipped_market_ids,
2449 } => {
2450 assert!(closed_market_ids.is_empty());
2451 assert!(skipped_market_ids.is_empty());
2452 assert_eq!(reports.len(), 1);
2453 }
2454 other => panic!("expected position update, was {other:?}"),
2455 }
2456 }
2457
2458 #[rstest]
2459 fn handle_frame_routes_empty_account_positions_to_empty_update() {
2460 let mut handler = make_handler_with_account();
2461 let frame_json = serde_json::json!({
2462 "type": "update/account_all_positions",
2463 "channel": "account_all_positions:1234",
2464 "positions": {},
2465 "shares": [],
2466 "last_funding_round": null,
2467 "last_funding_discount": null,
2468 });
2469 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2470
2471 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2472
2473 assert_eq!(messages.len(), 1);
2474 match &messages[0] {
2475 NautilusWsMessage::PositionUpdate {
2476 reports,
2477 closed_market_ids,
2478 skipped_market_ids,
2479 } => {
2480 assert!(closed_market_ids.is_empty());
2481 assert!(skipped_market_ids.is_empty());
2482 assert!(reports.is_empty());
2483 }
2484 other => panic!("expected empty position update, was {other:?}"),
2485 }
2486 }
2487
2488 #[rstest]
2489 fn handle_frame_routes_zero_account_position_to_closed_update() {
2490 let mut handler = make_handler_with_account();
2491 let mut frame_json: serde_json::Value =
2492 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2493 frame_json["positions"]["0"]["position"] = json!("0.0000");
2494 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2495
2496 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2497
2498 assert_eq!(messages.len(), 1);
2499 match &messages[0] {
2500 NautilusWsMessage::PositionUpdate {
2501 reports,
2502 closed_market_ids,
2503 skipped_market_ids,
2504 } => {
2505 assert!(reports.is_empty());
2506 assert!(skipped_market_ids.is_empty());
2507 assert_eq!(closed_market_ids, &[0]);
2508 }
2509 other => panic!("expected closed position update, was {other:?}"),
2510 }
2511 }
2512
2513 #[rstest]
2514 fn handle_frame_marks_account_positions_incomplete_when_position_instrument_uncached() {
2515 let mut handler = make_handler_with_account();
2516 let mut frame_json: serde_json::Value =
2517 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2518 frame_json["type"] = json!("subscribed/account_all_positions");
2519 frame_json["positions"]["0"]["market_id"] = json!(999);
2520 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2521
2522 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2523
2524 assert_eq!(messages.len(), 1);
2525 match &messages[0] {
2526 NautilusWsMessage::PositionSnapshot {
2527 reports,
2528 skipped_market_ids,
2529 } => {
2530 assert_eq!(skipped_market_ids, &[999]);
2531 assert!(reports.is_empty());
2532 }
2533 other => panic!("expected incomplete position snapshot, was {other:?}"),
2534 }
2535 }
2536
2537 #[rstest]
2538 fn handle_frame_marks_account_positions_incomplete_when_position_parse_fails() {
2539 let mut handler = make_handler_with_account();
2540 let mut frame_json: serde_json::Value =
2541 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2542 frame_json["type"] = json!("subscribed/account_all_positions");
2543 frame_json["positions"]["0"]["position"] = json!("-1.5000");
2544 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2545
2546 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2547
2548 assert_eq!(messages.len(), 1);
2549 match &messages[0] {
2550 NautilusWsMessage::PositionSnapshot {
2551 reports,
2552 skipped_market_ids,
2553 } => {
2554 assert_eq!(skipped_market_ids, &[0]);
2555 assert!(reports.is_empty());
2556 }
2557 other => panic!("expected incomplete position snapshot, was {other:?}"),
2558 }
2559 }
2560
2561 #[rstest]
2562 fn handle_frame_marks_position_update_incomplete_when_position_parse_fails() {
2563 let mut handler = make_handler_with_account();
2564 let mut frame_json: serde_json::Value =
2565 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2566 frame_json["positions"]["0"]["position"] = json!("-1.5000");
2567 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2568
2569 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2570
2571 assert_eq!(messages.len(), 1);
2572 match &messages[0] {
2573 NautilusWsMessage::PositionUpdate {
2574 reports,
2575 closed_market_ids,
2576 skipped_market_ids,
2577 } => {
2578 assert!(reports.is_empty());
2579 assert!(closed_market_ids.is_empty());
2580 assert_eq!(skipped_market_ids, &[0]);
2581 }
2582 other => panic!("expected incomplete position update, was {other:?}"),
2583 }
2584 }
2585
2586 #[rstest]
2587 fn handle_frame_routes_subscribed_account_all_positions_snapshot() {
2588 let mut handler = make_handler_with_account();
2589 let frame_json = serde_json::json!({
2590 "type": "subscribed/account_all_positions",
2591 "channel": "account_all_positions:1234",
2592 "positions": {
2593 "0": {
2594 "allocated_margin": "0.000000",
2595 "avg_entry_price": "0.111230",
2596 "initial_margin_fraction": "10.00",
2597 "liquidation_price": "0.100598",
2598 "margin_mode": 0,
2599 "market_id": 0,
2600 "open_order_count": 0,
2601 "pending_order_count": 0,
2602 "position": "100",
2603 "position_tied_order_count": 0,
2604 "position_value": "11.123000",
2605 "realized_pnl": "0.000000",
2606 "sign": 1,
2607 "symbol": "ETH",
2608 "total_discount": "0.000000",
2609 "total_funding_paid_out": "0.000000",
2610 "unrealized_pnl": "0.000000"
2611 }
2612 },
2613 });
2614 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
2615
2616 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2617
2618 assert_eq!(messages.len(), 1);
2619 match &messages[0] {
2620 NautilusWsMessage::PositionSnapshot {
2621 reports,
2622 skipped_market_ids,
2623 } => {
2624 assert!(skipped_market_ids.is_empty());
2625 assert_eq!(reports.len(), 1);
2626 assert_eq!(reports[0].quantity, Quantity::from("100"));
2627 }
2628 other => panic!("expected position snapshot, was {other:?}"),
2629 }
2630 }
2631
2632 #[rstest]
2633 #[case::connected(serde_json::json!({"type": "connected", "session_id": "x"}), true, false)]
2634 #[case::ping(serde_json::json!({"type": "ping"}), true, false)]
2635 #[case::pong(serde_json::json!({"type": "pong"}), true, false)]
2636 #[case::send_tx_ack(
2637 serde_json::json!({"type": "jsonapi/sendtx", "code": 200, "tx_hash": "abc"}),
2638 true,
2639 true,
2640 )]
2641 #[case::send_tx_without_code(
2642 serde_json::json!({"type": "jsonapi/sendtx", "tx_hash": "abc"}),
2643 true,
2644 false,
2645 )]
2646 #[case::send_tx_with_nonnumeric_code(
2647 serde_json::json!({"type": "jsonapi/sendtx", "code": "200", "tx_hash": "abc"}),
2648 true,
2649 false,
2650 )]
2651 #[case::error_frame(
2652 serde_json::json!({"type": "error", "code": 21727, "message": "invalid client order index"}),
2653 true,
2654 true,
2655 )]
2656 #[case::error_frame_integrator_not_approved(
2657 serde_json::json!({"type": "error", "code": 21149, "message": "integrator is not approved"}),
2658 true,
2659 true,
2660 )]
2661 #[case::send_tx_ack_integrator_not_approved(
2662 serde_json::json!({"type": "jsonapi/sendtx", "code": 21149, "message": "integrator is not approved"}),
2663 true,
2664 true,
2665 )]
2666 #[case::wrapped_error_integrator_not_approved(
2667 serde_json::json!({"error": {"code": 21149, "message": "integrator is not approved"}}),
2668 true,
2669 true,
2670 )]
2671 #[case::subscription_error_frame(
2672 serde_json::json!({"type": "error", "code": 30003, "message": "Already Subscribed to : ticker:3"}),
2673 true,
2674 false,
2675 )]
2676 #[case::wrapped_subscription_error(
2677 serde_json::json!({"error": {"code": 30003, "message": "Already Subscribed to : ticker:3"}}),
2678 true,
2679 false,
2680 )]
2681 #[case::codeless_error_frame(
2682 serde_json::json!({"type": "error", "message": "unclassifiable"}),
2683 true,
2684 false,
2685 )]
2686 #[case::unknown_type(
2687 serde_json::json!({"type": "something_unexpected", "payload": "x"}),
2688 false,
2689 false,
2690 )]
2691 #[case::no_type_field(
2692 serde_json::json!({"error": {"code": 21702, "message": "invalid price"}}),
2693 true,
2694 true,
2695 )]
2696 fn handle_control_text_tri_state(
2697 #[case] payload: serde_json::Value,
2698 #[case] expected_matched: bool,
2699 #[case] expected_has_msg: bool,
2700 ) {
2701 let mut handler = make_handler_with_account();
2708 let text = payload.to_string();
2709 let (matched, msg) = handle_control_text(&mut handler, &text);
2710 assert_eq!(matched, expected_matched, "matched flag");
2711 assert_eq!(msg.is_some(), expected_has_msg, "msg presence");
2712 }
2713
2714 #[rstest]
2715 fn handle_control_text_sendtx_success_emits_typed_ack() {
2716 let mut handler = make_handler_with_account();
2717 let payload = serde_json::json!({
2718 "type": "jsonapi/sendtx",
2719 "code": 200,
2720 "tx_hash": "0000abcd",
2721 })
2722 .to_string();
2723
2724 let (_, msg) = handle_control_text(&mut handler, &payload);
2725
2726 match msg.expect("SendTxAck emitted") {
2727 NautilusWsMessage::SendTxAck { tx_hash, code, .. } => {
2728 assert_eq!(code, 200);
2729 assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2730 }
2731 other => panic!("expected SendTxAck, was {other:?}"),
2732 }
2733 }
2734
2735 #[rstest]
2736 fn handle_control_text_sendtx_failure_emits_ack_sourced_rejection() {
2737 let mut handler = make_handler_with_account();
2738 let payload = serde_json::json!({
2739 "type": "jsonapi/sendtx",
2740 "code": 21727,
2741 "message": "invalid client order index",
2742 })
2743 .to_string();
2744
2745 let (_, msg) = handle_control_text(&mut handler, &payload);
2746
2747 match msg.expect("SendTxRejected emitted") {
2748 NautilusWsMessage::SendTxRejected {
2749 source,
2750 code,
2751 message,
2752 tx_hash,
2753 ..
2754 } => {
2755 assert_eq!(source, SendTxRejectionSource::Ack);
2756 assert_eq!(code, Some(21727));
2757 assert_eq!(message, "invalid client order index");
2758 assert_eq!(tx_hash, None);
2759 }
2760 other => panic!("expected SendTxRejected, was {other:?}"),
2761 }
2762 }
2763
2764 #[rstest]
2765 fn handle_control_text_sendtx_failure_carries_echoed_tx_hash() {
2766 let mut handler = make_handler_with_account();
2767 let payload = serde_json::json!({
2768 "type": "jsonapi/sendtx",
2769 "code": 21727,
2770 "message": "invalid client order index",
2771 "tx_hash": "0000abcd",
2772 })
2773 .to_string();
2774
2775 let (_, msg) = handle_control_text(&mut handler, &payload);
2776
2777 match msg.expect("SendTxRejected emitted") {
2778 NautilusWsMessage::SendTxRejected { tx_hash, .. } => {
2779 assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2780 }
2781 other => panic!("expected SendTxRejected, was {other:?}"),
2782 }
2783 }
2784
2785 #[rstest]
2786 fn handle_control_text_bare_error_frame_emits_bare_error_rejection() {
2787 let mut handler = make_handler_with_account();
2788 let payload = serde_json::json!({
2789 "type": "error",
2790 "code": 21702,
2791 "message": "invalid price",
2792 })
2793 .to_string();
2794
2795 let (_, msg) = handle_control_text(&mut handler, &payload);
2796
2797 match msg.expect("SendTxRejected emitted") {
2798 NautilusWsMessage::SendTxRejected {
2799 source,
2800 code,
2801 message,
2802 tx_hash,
2803 ..
2804 } => {
2805 assert_eq!(source, SendTxRejectionSource::BareError);
2806 assert_eq!(code, Some(21702));
2807 assert_eq!(message, "invalid price");
2808 assert_eq!(tx_hash, None);
2809 }
2810 other => panic!("expected SendTxRejected, was {other:?}"),
2811 }
2812 }
2813
2814 #[rstest]
2815 fn handle_control_text_wrapped_error_emits_bare_error_rejection() {
2816 let mut handler = make_handler_with_account();
2818 let payload = serde_json::json!({
2819 "error": {"code": 21149, "message": "integrator is not approved"},
2820 })
2821 .to_string();
2822
2823 let (_, msg) = handle_control_text(&mut handler, &payload);
2824
2825 match msg.expect("SendTxRejected emitted") {
2826 NautilusWsMessage::SendTxRejected {
2827 source,
2828 code,
2829 message,
2830 tx_hash,
2831 ..
2832 } => {
2833 assert_eq!(source, SendTxRejectionSource::BareError);
2834 assert_eq!(code, Some(21149));
2835 assert_eq!(message, "integrator is not approved");
2836 assert_eq!(tx_hash, None);
2837 }
2838 other => panic!("expected SendTxRejected, was {other:?}"),
2839 }
2840 }
2841
2842 #[rstest]
2843 fn handle_frame_emits_no_account_state_until_both_streams_seen() {
2844 let mut handler = make_handler_with_account();
2848 let assets_only: super::LighterWsFrame =
2849 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2850
2851 let messages = strip_account_marker(handler.handle_frame(assets_only, UnixNanos::from(11)));
2852
2853 assert!(
2854 messages.is_empty(),
2855 "expected no AccountState before user_stats arrives, received {messages:?}"
2856 );
2857 }
2858
2859 #[rstest]
2860 fn handle_frame_routes_account_assets_and_user_stats_to_unified_state() {
2861 let mut handler = make_handler_with_account();
2868 let assets_frame: super::LighterWsFrame =
2869 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2870 let user_stats_frame: super::LighterWsFrame =
2871 serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2872
2873 let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2874 let messages =
2875 strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2876
2877 assert_eq!(messages.len(), 1);
2878 match &messages[0] {
2879 NautilusWsMessage::AccountState(state) => {
2880 let usdc = Currency::get_or_create_crypto("USDC");
2881 assert_eq!(state.account_type, AccountType::Margin);
2882 assert_eq!(state.base_currency, None);
2883 assert_eq!(state.balances.len(), 1);
2884 assert_eq!(state.balances[0].currency, usdc);
2885 assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2888 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2889 assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2890 assert_eq!(state.margins.len(), 1);
2891 assert_eq!(state.margins[0].currency, usdc);
2892 assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2893 assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2894 assert!(state.margins[0].instrument_id.is_none());
2895 assert!(state.is_reported);
2896 }
2897 other => panic!("expected account state, was {other:?}"),
2898 }
2899 }
2900
2901 #[rstest]
2902 fn handle_frame_unified_state_reflects_open_position() {
2903 let mut handler = make_handler_with_account();
2914 let assets_frame: super::LighterWsFrame =
2915 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_WITH_POSITION).unwrap();
2916 let user_stats_frame: super::LighterWsFrame =
2917 serde_json::from_str(WS_USER_STATS_WITH_POSITION).unwrap();
2918
2919 let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2920 let messages =
2921 strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2922
2923 assert_eq!(messages.len(), 1);
2924 match &messages[0] {
2925 NautilusWsMessage::AccountState(state) => {
2926 assert_eq!(state.account_type, AccountType::Margin);
2927 assert_eq!(state.base_currency, None);
2928 assert_eq!(state.balances.len(), 1);
2929 assert_eq!(state.balances[0].total, Money::from("49.99536956 USDC"));
2933 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2934 assert_eq!(state.balances[0].free, Money::from("49.99536956 USDC"));
2935 assert_eq!(state.margins.len(), 1);
2936 assert_eq!(state.margins[0].initial, Money::from("0.82705500 USDC"));
2938 assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2939 assert!(state.margins[0].instrument_id.is_none());
2940 }
2941 other => panic!("expected account state, was {other:?}"),
2942 }
2943 }
2944
2945 #[rstest]
2946 fn handle_frame_emits_account_stream_first_frame_marker_per_variant() {
2947 let mut handler = make_handler_with_account();
2953 let orders_frame: super::LighterWsFrame =
2954 serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2955 let trades_frame: super::LighterWsFrame =
2956 serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
2957 let positions_frame: super::LighterWsFrame = serde_json::from_value({
2958 let mut value: serde_json::Value =
2959 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2960 value["type"] = json!("subscribed/account_all_positions");
2961 value
2962 })
2963 .unwrap();
2964 let assets_frame: super::LighterWsFrame =
2965 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2966 let user_stats_frame: super::LighterWsFrame =
2967 serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2968
2969 let cases = [
2970 (orders_frame, AccountStream::Orders),
2971 (trades_frame, AccountStream::Trades),
2972 (positions_frame, AccountStream::Positions),
2973 (assets_frame, AccountStream::Assets),
2974 (user_stats_frame, AccountStream::UserStats),
2975 ];
2976
2977 for (frame, expected) in cases {
2978 let msgs = handler.handle_frame(frame, UnixNanos::from(11));
2979 let marker = msgs
2980 .iter()
2981 .find(|m| matches!(m, NautilusWsMessage::AccountStreamFirstFrame(_)))
2982 .unwrap_or_else(|| panic!("missing marker for {expected:?}"));
2983 match marker {
2984 NautilusWsMessage::AccountStreamFirstFrame(stream) => {
2985 assert_eq!(*stream, expected);
2986 }
2987 other => panic!("expected AccountStreamFirstFrame, was {other:?}"),
2988 }
2989 assert!(
2992 matches!(
2993 msgs.last(),
2994 Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
2995 ),
2996 "marker must trail typed reports for {expected:?}",
2997 );
2998 }
2999 }
3000
3001 #[rstest]
3002 fn handle_frame_account_orders_without_context_falls_back_to_raw() {
3003 let signal = Arc::new(AtomicBool::new(false));
3004 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3005 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3006 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3007 let mut handler =
3008 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3009 handler.instruments.insert(0, stub_eth_perp_instrument());
3010 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
3014 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3015
3016 assert_eq!(messages.len(), 1);
3017 match &messages[0] {
3018 NautilusWsMessage::Raw(value) => {
3019 assert_eq!(value["type"], "update/account_orders");
3020 }
3021 other => panic!("expected raw fallback, was {other:?}"),
3022 }
3023 }
3024
3025 #[rstest]
3026 fn handle_frame_account_orders_skips_unknown_market() {
3027 let signal = Arc::new(AtomicBool::new(false));
3033 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3034 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3035 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3036 let mut handler =
3037 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3038 handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
3039 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
3042 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
3043
3044 assert!(messages.is_empty());
3045 }
3046
3047 #[rstest]
3048 fn handle_frame_account_assets_invalid_timestamp_returns_empty() {
3049 let mut handler = make_handler_with_account();
3050 let frame_json = r#"{
3057 "type": "update/account_all_assets",
3058 "channel": "account_all_assets:1234",
3059 "timestamp": 18446744073709551615,
3060 "assets": {
3061 "0": {
3062 "symbol": "USDC",
3063 "asset_id": 0,
3064 "balance": "100.000000",
3065 "locked_balance": "1.000000"
3066 }
3067 }
3068 }"#;
3069 let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
3070
3071 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
3072
3073 assert!(messages.is_empty());
3074 }
3075
3076 #[rstest]
3077 fn handle_frame_account_all_orders_routes_to_execution_reports() {
3078 let mut handler = make_handler_with_account();
3082 let frame_json = r#"{
3083 "type": "update/account_all_orders",
3084 "channel": "account_all_orders:1234",
3085 "orders": {
3086 "0": [{
3087 "order_index": 281476929510110,
3088 "client_order_index": 42,
3089 "order_id": "281476929510110",
3090 "client_order_id": "42",
3091 "market_index": 0,
3092 "owner_account_index": 1234,
3093 "initial_base_amount": "0.0050",
3094 "price": "2352.74",
3095 "nonce": 9182390020,
3096 "remaining_base_amount": "0.0050",
3097 "is_ask": true,
3098 "base_size": 50,
3099 "base_price": 235274,
3100 "filled_base_amount": "0.0000",
3101 "filled_quote_amount": "0.000000",
3102 "side": "sell",
3103 "type": "limit",
3104 "time_in_force": "good-till-time",
3105 "reduce_only": false,
3106 "trigger_price": "0.00",
3107 "order_expiry": 1780360584479,
3108 "status": "open",
3109 "trigger_status": "na",
3110 "trigger_time": 0,
3111 "parent_order_index": 0,
3112 "parent_order_id": "0",
3113 "to_trigger_order_id_0": "0",
3114 "to_trigger_order_id_1": "0",
3115 "to_cancel_order_id_0": "0",
3116 "integrator_fee_collector_index": "0",
3117 "integrator_taker_fee": "0",
3118 "integrator_maker_fee": "0",
3119 "block_height": 227535532,
3120 "timestamp": 1777941383576,
3121 "created_at": 1777941383576,
3122 "updated_at": 1777941383576,
3123 "transaction_time": 1777941383576735
3124 }]
3125 }
3126 }"#;
3127 let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
3128
3129 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
3130
3131 assert_eq!(messages.len(), 1);
3132 match &messages[0] {
3133 NautilusWsMessage::ExecutionReports(reports) => {
3134 assert_eq!(reports.len(), 1);
3135 match &reports[0] {
3136 super::ExecutionReport::Order(order) => {
3137 assert_eq!(order.order_id, "281476929510110");
3138 }
3139 other => panic!("expected order report, was {other:?}"),
3140 }
3141 }
3142 other => panic!("expected execution reports, was {other:?}"),
3143 }
3144 }
3145
3146 fn snapshot_trade_frame_json() -> &'static str {
3147 r#"{
3148 "type": "subscribed/account_all_trades",
3149 "channel": "account_all_trades:1234",
3150 "trades": [{
3151 "trade_id": 19209006902,
3152 "trade_id_str": "19209006902",
3153 "tx_hash": "000000128b1ee814",
3154 "type": "trade",
3155 "market_id": 0,
3156 "size": "0.1336",
3157 "price": "2352.73",
3158 "usd_amount": "314.324728",
3159 "ask_id": 281476929510102,
3160 "bid_id": 562947905631053,
3161 "ask_client_id": 0,
3162 "bid_client_id": 7001011966,
3163 "ask_account_id": 91249,
3164 "bid_account_id": 1234,
3165 "is_maker_ask": true,
3166 "block_height": 227535535,
3167 "timestamp": 1777941384181,
3168 "transaction_time": 1777941384181586
3169 }],
3170 "total_volume": "100.0",
3171 "monthly_volume": "100.0",
3172 "weekly_volume": "100.0",
3173 "daily_volume": "100.0"
3174 }"#
3175 }
3176
3177 #[rstest]
3178 fn handle_frame_account_all_trades_snapshot_is_dropped_with_context() {
3179 let mut handler = make_handler_with_account();
3180 let frame: super::LighterWsFrame =
3185 serde_json::from_str(snapshot_trade_frame_json()).unwrap();
3186
3187 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
3188
3189 assert!(messages.is_empty());
3190 }
3191
3192 #[rstest]
3193 fn handle_frame_account_all_trades_snapshot_falls_back_to_raw_without_context() {
3194 let signal = Arc::new(AtomicBool::new(false));
3195 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3196 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3197 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3198 let mut handler =
3199 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3200 handler.instruments.insert(0, stub_eth_perp_instrument());
3201 let frame: super::LighterWsFrame =
3205 serde_json::from_str(snapshot_trade_frame_json()).unwrap();
3206 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3207
3208 assert_eq!(messages.len(), 1);
3209 match &messages[0] {
3210 NautilusWsMessage::Raw(value) => {
3211 assert_eq!(value["type"], "subscribed/account_all_trades");
3212 }
3213 other => panic!("expected raw fallback, was {other:?}"),
3214 }
3215 }
3216
3217 #[rstest]
3218 fn handle_frame_market_stats_emits_mark_index_and_funding_updates() {
3219 let mut handler = make_handler_with_account();
3220 let frame: super::LighterWsFrame =
3221 serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE).unwrap();
3222
3223 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3224
3225 assert_eq!(messages.len(), 3);
3226 match &messages[0] {
3227 NautilusWsMessage::MarkPrice(update) => {
3228 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
3229 assert_eq!(update.value, Price::from("2064.47"));
3230 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
3231 }
3232 event => panic!("expected mark price update, was {event:?}"),
3233 }
3234
3235 match &messages[1] {
3236 NautilusWsMessage::IndexPrice(update) => {
3237 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
3238 assert_eq!(update.value, Price::from("2064.48"));
3239 }
3240 event => panic!("expected index price update, was {event:?}"),
3241 }
3242
3243 match &messages[2] {
3244 NautilusWsMessage::FundingRate(update) => {
3245 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
3246 assert_eq!(update.rate, Decimal::new(1, 6));
3247 assert_eq!(update.next_funding_ns, None);
3248 }
3249 event => panic!("expected funding rate update, was {event:?}"),
3250 }
3251 }
3252
3253 #[rstest]
3254 fn handle_frame_market_stats_all_emits_mark_index_and_funding_updates() {
3255 let mut handler = make_handler_with_account();
3256 let frame: super::LighterWsFrame =
3257 serde_json::from_str(WS_MARKET_STATS_UPDATE_ALL).unwrap();
3258
3259 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3260
3261 assert_eq!(messages.len(), 3);
3262 assert!(matches!(&messages[0], NautilusWsMessage::MarkPrice(_)));
3263 assert!(matches!(&messages[1], NautilusWsMessage::IndexPrice(_)));
3264 assert!(matches!(&messages[2], NautilusWsMessage::FundingRate(_)));
3265
3266 match &messages[0] {
3267 NautilusWsMessage::MarkPrice(update) => {
3268 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
3269 assert_eq!(update.value, Price::from("2064.47"));
3270 }
3271 event => panic!("expected mark price update, was {event:?}"),
3272 }
3273
3274 match &messages[2] {
3275 NautilusWsMessage::FundingRate(update) => {
3276 assert_eq!(update.rate, Decimal::new(1, 6));
3277 assert_eq!(update.next_funding_ns, None);
3278 }
3279 event => panic!("expected funding rate update, was {event:?}"),
3280 }
3281 }
3282
3283 #[rstest]
3284 fn handle_frame_spot_market_stats_emits_index_update() {
3285 let signal = Arc::new(AtomicBool::new(false));
3286 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3287 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3288 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3289 let mut handler =
3290 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3291 handler.instruments.insert(2048, stub_eth_spot_instrument());
3292 let frame: super::LighterWsFrame =
3293 serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE).unwrap();
3294
3295 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3296
3297 assert_eq!(messages.len(), 1);
3298 match &messages[0] {
3299 NautilusWsMessage::IndexPrice(update) => {
3300 assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
3301 assert_eq!(update.value, Price::from("1.00"));
3302 }
3303 event => panic!("expected spot index price update, was {event:?}"),
3304 }
3305 }
3306
3307 #[rstest]
3308 fn handle_frame_spot_market_stats_all_emits_index_update() {
3309 let signal = Arc::new(AtomicBool::new(false));
3310 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3311 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3312 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3313 let mut handler =
3314 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3315 handler.instruments.insert(2048, stub_eth_spot_instrument());
3316 let frame: super::LighterWsFrame =
3317 serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_ALL).unwrap();
3318
3319 let messages = handler.handle_frame(frame, UnixNanos::from(11));
3320
3321 assert_eq!(messages.len(), 1);
3322 match &messages[0] {
3323 NautilusWsMessage::IndexPrice(update) => {
3324 assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
3325 assert_eq!(update.value, Price::from("1.00"));
3326 }
3327 event => panic!("expected spot index price update, was {event:?}"),
3328 }
3329 }
3330
3331 #[rstest]
3332 #[case(LighterWsChannel::OrderBook(0), "order_book:0", "order_book/0")]
3333 #[case(LighterWsChannel::Trade(7), "trade:7", "trade/7")]
3334 #[case(LighterWsChannel::Ticker(2), "ticker:2", "ticker/2")]
3335 #[case(LighterWsChannel::Height, "height", "height")]
3336 #[case(
3337 LighterWsChannel::MarketStats(LighterMarketSelection::All),
3338 "market_stats:all",
3339 "market_stats/all"
3340 )]
3341 #[case(
3342 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(2048)),
3343 "spot_market_stats:2048",
3344 "spot_market_stats/2048"
3345 )]
3346 #[case(
3347 LighterWsChannel::AccountOrders { market_index: 0, account_index: 1234 },
3348 "account_orders:0:1234",
3349 "account_orders/0/1234",
3350 )]
3351 fn topic_and_subscription_round_trip(
3352 #[case] channel: LighterWsChannel,
3353 #[case] expected_topic: &str,
3354 #[case] expected_subscription: &str,
3355 ) {
3356 assert_eq!(channel.topic_key(), expected_topic);
3357 assert_eq!(channel.subscription_channel(), expected_subscription);
3358 }
3359
3360 #[rstest]
3361 #[case("order_book:0", Some(0))]
3362 #[case("trade:42", Some(42))]
3363 #[case("height", None)]
3364 #[case("malformed", None)]
3365 fn market_index_extraction(#[case] topic: &str, #[case] expected: Option<i16>) {
3366 assert_eq!(market_index_from_topic(topic), expected);
3367 }
3368
3369 #[rstest]
3370 #[case("order_book:0", Some(0))]
3371 #[case("order_book:42", Some(42))]
3372 #[case("trade:42", None)]
3373 #[case("ticker:2", None)]
3374 #[case("market_stats:0", None)]
3375 #[case("height", None)]
3376 #[case("order_book:not-an-int", None)]
3377 fn order_book_market_index_only_matches_order_book_channel(
3378 #[case] topic: &str,
3379 #[case] expected: Option<i16>,
3380 ) {
3381 assert_eq!(order_book_market_index_from_topic(topic), expected);
3382 }
3383
3384 #[rstest]
3385 #[case(LighterWsChannel::AccountAll(1234), true)]
3386 #[case(LighterWsChannel::OrderBook(0), false)]
3387 #[case(LighterWsChannel::AccountAllPositions(1), true)]
3388 #[case(LighterWsChannel::Trade(0), false)]
3389 fn requires_auth_classification(#[case] channel: LighterWsChannel, #[case] expected: bool) {
3390 assert_eq!(channel.requires_auth(), expected);
3391 }
3392
3393 #[rstest]
3394 fn handler_command_subscribe_debug_redacts_auth_token() {
3395 let token = "schnorr-signature-bytes-do-not-leak";
3396 let cmd = HandlerCommand::Subscribe {
3397 channel: LighterWsChannel::AccountAll(1234),
3398 auth: Some(token.to_string()),
3399 response_tx: None,
3400 };
3401
3402 let dbg = format!("{cmd:?}");
3403
3404 assert!(
3405 !dbg.contains(token),
3406 "Debug output must not contain the auth token, found: {dbg}",
3407 );
3408 assert!(dbg.contains("authed"), "Debug should include authed flag");
3409 }
3410
3411 #[tokio::test]
3412 async fn send_tx_command_returns_handler_send_error_without_active_client() {
3413 let signal = Arc::new(AtomicBool::new(false));
3414 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3415 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3416 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3417 let mut handler =
3418 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3419 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
3420
3421 cmd_tx
3422 .send(HandlerCommand::SendTx {
3423 tx_type: LighterTxType::CreateOrder as u8,
3424 tx_info: serde_json::value::RawValue::from_string(
3425 r#"{"AccountIndex":12345,"Nonce":42}"#.to_string(),
3426 )
3427 .unwrap(),
3428 connection_epoch: 0,
3429 response_tx,
3430 })
3431 .unwrap();
3432 drop(cmd_tx);
3433 drop(raw_tx);
3434
3435 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3436 .await
3437 .expect("timed out waiting for handler to drain command");
3438 let result = response_rx.await.expect("sendTx response channel closed");
3439
3440 assert!(next.is_none());
3441 let Err(LighterWsError::Client(message)) = result else {
3442 panic!("expected client send error, was {result:?}");
3443 };
3444 assert!(message.contains("no active WebSocket client"));
3445 }
3446
3447 #[tokio::test]
3448 async fn resubscribe_order_book_command_skips_when_reference_removed() {
3449 let signal = Arc::new(AtomicBool::new(false));
3450 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3451 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3452 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3453 let subscriptions = SubscriptionState::new(':');
3454 let topic = LighterWsChannel::OrderBook(0).topic_key();
3455 assert!(subscriptions.add_reference(&topic));
3456 assert!(subscriptions.remove_reference(&topic));
3457
3458 let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, subscriptions.clone());
3459 handler.book_delta_subs.insert(0);
3460
3461 cmd_tx
3462 .send(HandlerCommand::ResubscribeOrderBook { market_index: 0 })
3463 .expect("queue resync");
3464 drop(cmd_tx);
3465 drop(raw_tx);
3466
3467 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3468 .await
3469 .expect("timed out waiting for handler to drain command");
3470
3471 assert!(next.is_none());
3472 assert!(subscriptions.pending_subscribe_topics().is_empty());
3473 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
3474 }
3475
3476 #[tokio::test]
3477 async fn resubscribe_order_book_queues_when_inflight_is_at_cap() {
3478 let signal = Arc::new(AtomicBool::new(false));
3479 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3480 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3481 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3482 let subscriptions = SubscriptionState::new(':');
3483 let topic = LighterWsChannel::OrderBook(0).topic_key();
3484 assert!(subscriptions.add_reference(&topic));
3485
3486 let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, subscriptions);
3487 handler.book_delta_subs.insert(0);
3488 saturate_subscription_gate(&mut handler);
3489
3490 handler.resubscribe_order_book_stream(0).await;
3491
3492 assert_eq!(handler.inflight_subs.len(), SUBSCRIBE_INFLIGHT_MAX);
3493 assert!(
3494 handler
3495 .pending_subs
3496 .iter()
3497 .any(|(topic, _)| *topic == Ustr::from("order_book:0")),
3498 );
3499 }
3500
3501 fn stub_candle(
3502 t: i64,
3503 open: i64,
3504 high: i64,
3505 low: i64,
3506 close: i64,
3507 volume_ticks: i64,
3508 ) -> LighterWsCandle {
3509 LighterWsCandle {
3510 t,
3511 o: Decimal::new(open, 2),
3512 h: Decimal::new(high, 2),
3513 l: Decimal::new(low, 2),
3514 c: Decimal::new(close, 2),
3515 v: Decimal::new(volume_ticks, 4),
3516 quote_volume: Decimal::ZERO,
3517 i: 0,
3518 }
3519 }
3520
3521 fn candle_frame(channel: &str, candle: LighterWsCandle, is_snapshot: bool) -> LighterWsFrame {
3522 if is_snapshot {
3523 LighterWsFrame::CandleSnapshot {
3524 channel: Ustr::from(channel),
3525 candles: vec![candle],
3526 timestamp: 0,
3527 }
3528 } else {
3529 LighterWsFrame::Candle {
3530 channel: Ustr::from(channel),
3531 candles: vec![candle],
3532 timestamp: 0,
3533 }
3534 }
3535 }
3536
3537 #[rstest]
3538 fn handle_candles_first_observation_caches_without_emit() {
3539 let mut handler = make_handler_with_account();
3540 let frame = candle_frame(
3541 "candle:0:1m",
3542 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
3543 true,
3544 );
3545
3546 let messages = handler.handle_frame(frame, UnixNanos::from(99));
3547
3548 assert!(messages.is_empty(), "first observation must not emit");
3549 let key = (0_i16, LighterCandleResolution::OneMinute);
3550 assert_eq!(handler.last_candles.get(&key).map(|c| c.t), Some(1_000_000));
3551 }
3552
3553 #[rstest]
3554 fn handle_candles_t_advance_emits_bar_for_previous_candle() {
3555 let mut handler = make_handler_with_account();
3556 let prev = stub_candle(1_000_000, 10_000, 11_000, 9_900, 10_500, 10_000);
3557 let next = stub_candle(1_060_000, 10_500, 10_600, 10_450, 10_550, 20_000);
3558 let next_t = next.t;
3559 handler.handle_frame(candle_frame("candle:0:1m", prev, true), UnixNanos::from(1));
3560
3561 let messages =
3562 handler.handle_frame(candle_frame("candle:0:1m", next, false), UnixNanos::from(2));
3563
3564 assert_eq!(messages.len(), 1);
3565 match &messages[0] {
3566 NautilusWsMessage::Bar(bar) => {
3567 assert_eq!(bar.open, Price::from("100.00"));
3569 assert_eq!(bar.high, Price::from("110.00"));
3570 assert_eq!(bar.low, Price::from("99.00"));
3571 assert_eq!(bar.close, Price::from("105.00"));
3572 assert_eq!(bar.volume, Quantity::from("1.0000"));
3573 assert_eq!(bar.ts_event, UnixNanos::from(1_000_000 * 1_000_000));
3574 }
3575 other => panic!("expected Bar message, was {other:?}"),
3576 }
3577 let cached = handler
3578 .last_candles
3579 .get(&(0_i16, LighterCandleResolution::OneMinute))
3580 .expect("cache populated");
3581 assert_eq!(cached.t, next_t);
3582 }
3583
3584 #[rstest]
3585 fn handle_candles_same_t_updates_cache_without_emit() {
3586 let mut handler = make_handler_with_account();
3587 let initial = stub_candle(1_000_000, 10_000, 10_050, 9_950, 10_025, 5_000);
3588 let same_t_updated = stub_candle(1_000_000, 10_000, 10_100, 9_950, 10_075, 7_500);
3589 let same_t_h = same_t_updated.h;
3590 let same_t_c = same_t_updated.c;
3591 handler.handle_frame(
3592 candle_frame("candle:0:1m", initial, true),
3593 UnixNanos::from(1),
3594 );
3595
3596 let messages = handler.handle_frame(
3597 candle_frame("candle:0:1m", same_t_updated, false),
3598 UnixNanos::from(2),
3599 );
3600
3601 assert!(messages.is_empty(), "same-`t` update must not emit");
3602 let cached = handler
3603 .last_candles
3604 .get(&(0_i16, LighterCandleResolution::OneMinute))
3605 .expect("cache populated");
3606 assert_eq!(cached.h, same_t_h);
3607 assert_eq!(cached.c, same_t_c);
3608 }
3609
3610 #[rstest]
3611 fn handle_candles_regressed_t_is_skipped() {
3612 let mut handler = make_handler_with_account();
3613 let initial = stub_candle(2_000_000, 10_000, 10_000, 10_000, 10_000, 5_000);
3614 let regressed = stub_candle(1_000_000, 9_000, 9_000, 9_000, 9_000, 5_000);
3615 let initial_t = initial.t;
3616 handler.handle_frame(
3617 candle_frame("candle:0:1m", initial, true),
3618 UnixNanos::from(1),
3619 );
3620
3621 let messages = handler.handle_frame(
3622 candle_frame("candle:0:1m", regressed, false),
3623 UnixNanos::from(2),
3624 );
3625
3626 assert!(messages.is_empty(), "regressed `t` must not emit");
3627 let cached = handler
3628 .last_candles
3629 .get(&(0_i16, LighterCandleResolution::OneMinute))
3630 .expect("cache populated");
3631 assert_eq!(cached.t, initial_t);
3633 }
3634
3635 #[rstest]
3636 fn handle_candles_unknown_market_returns_empty() {
3637 let mut handler = make_handler_with_account();
3638 let frame = candle_frame(
3639 "candle:99:1m",
3640 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 5_000),
3641 true,
3642 );
3643
3644 let messages = handler.handle_frame(frame, UnixNanos::from(1));
3645
3646 assert!(messages.is_empty());
3647 }
3648
3649 #[rstest]
3650 fn handle_unsubscribe_ack_clears_only_matching_candle_key() {
3651 let mut handler = make_handler_with_account();
3652 handler.last_candles.insert(
3653 (0, LighterCandleResolution::OneMinute),
3654 stub_candle(1, 0, 0, 0, 0, 0),
3655 );
3656 handler.last_candles.insert(
3657 (0, LighterCandleResolution::FiveMinute),
3658 stub_candle(2, 0, 0, 0, 0, 0),
3659 );
3660 handler.subscriptions.mark_unsubscribe("candle:0:1m");
3661
3662 let payload = json!({"type": "unsubscribed", "channel": "candle:0:1m"});
3663 let (matched, _) = handle_control_text(&mut handler, &payload.to_string());
3664
3665 assert!(matched);
3666 assert!(
3667 handler
3668 .last_candles
3669 .get(&(0, LighterCandleResolution::OneMinute))
3670 .is_none(),
3671 );
3672 assert!(
3673 handler
3674 .last_candles
3675 .get(&(0, LighterCandleResolution::FiveMinute))
3676 .is_some(),
3677 );
3678 }
3679
3680 #[rstest]
3681 #[case::well_formed("candle:0:1m", Some((0, LighterCandleResolution::OneMinute)))]
3682 #[case::weekly("candle:3:1w", Some((3, LighterCandleResolution::OneWeek)))]
3683 #[case::other_kind("order_book:0", None)]
3684 #[case::missing_resolution("candle:0", None)]
3685 #[case::bad_market("candle:notanint:1m", None)]
3686 #[case::bad_resolution("candle:0:bogus", None)]
3687 fn test_candle_market_and_resolution_from_topic(
3688 #[case] topic: &str,
3689 #[case] expected: Option<(i16, LighterCandleResolution)>,
3690 ) {
3691 assert_eq!(candle_market_and_resolution_from_topic(topic), expected);
3692 }
3693
3694 #[rstest]
3695 #[case::network_retries(LighterWsError::Network("disconnected".into()), true)]
3696 #[case::auth_does_not_retry(LighterWsError::Authentication("bad token".into()), false)]
3697 #[case::parse_does_not_retry(LighterWsError::Parse("bad json".into()), false)]
3698 #[case::client_does_not_retry(LighterWsError::Client("no active WebSocket client".into()), false)]
3699 #[case::transport_closed_does_not_retry(LighterWsError::Transport(SendError::Closed), false)]
3700 #[case::transport_invalid_input_does_not_retry(
3701 LighterWsError::Transport(SendError::InvalidInput("pong payload too large".into())),
3702 false
3703 )]
3704 #[case::transport_connection_changed_does_not_retry(
3705 LighterWsError::Transport(SendError::ConnectionChanged),
3706 false
3707 )]
3708 #[case::transport_timeout_retries(LighterWsError::Transport(SendError::Timeout), true)]
3709 #[case::transport_write_timeout_does_not_retry(
3710 LighterWsError::Transport(SendError::WriteTimeout),
3711 false
3712 )]
3713 #[case::transport_broken_pipe_does_not_retry(
3714 LighterWsError::Transport(SendError::BrokenPipe(
3715 "writer closed".into(),
3716 )),
3717 false,
3718 )]
3719 fn test_should_retry_lighter_ws_error(#[case] error: LighterWsError, #[case] expected: bool) {
3720 assert_eq!(should_retry_lighter_ws_error(&error), expected);
3721 }
3722
3723 #[rstest]
3725 #[case::closed(SendError::Closed)]
3726 #[case::invalid_input(SendError::InvalidInput("pong payload too large".into()))]
3727 #[case::timeout(SendError::Timeout)]
3728 #[case::write_timeout(SendError::WriteTimeout)]
3729 #[case::connection_changed(SendError::ConnectionChanged)]
3730 #[case::broken_pipe(SendError::BrokenPipe("writer dropped".into()))]
3731 fn send_error_converts_into_transport_variant(#[case] send_error: SendError) {
3732 let err: LighterWsError = send_error.into();
3733 assert!(
3734 matches!(err, LighterWsError::Transport(_)),
3735 "expected Transport variant, was {err:?}",
3736 );
3737 }
3738
3739 #[tokio::test]
3740 async fn subscribe_command_parks_in_pending_subs_when_inflight_at_cap() {
3741 let signal = Arc::new(AtomicBool::new(false));
3742 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3743 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3744 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3745 let mut handler =
3746 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3747
3748 saturate_subscription_gate(&mut handler);
3750
3751 cmd_tx
3752 .send(HandlerCommand::Subscribe {
3753 channel: LighterWsChannel::Candle {
3754 market_index: 0,
3755 resolution: LighterCandleResolution::OneMinute,
3756 },
3757 auth: None,
3758 response_tx: None,
3759 })
3760 .expect("queue subscribe");
3761 drop(cmd_tx);
3762 drop(raw_tx);
3763
3764 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3765 .await
3766 .expect("timed out waiting for handler to drain command");
3767
3768 assert!(next.is_none());
3769 assert_eq!(handler.inflight_subs.len(), SUBSCRIBE_INFLIGHT_MAX);
3770 assert_eq!(handler.pending_subs.len(), 1);
3771 assert_eq!(handler.subscription_attempts.len(), 1);
3772 assert_eq!(handler.pending_subs[0].0, Ustr::from("candle:0:1m"));
3773 }
3774
3775 #[tokio::test]
3776 async fn unsubscribe_drops_queued_subscribe_while_gate_full() {
3777 let signal = Arc::new(AtomicBool::new(false));
3778 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3779 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3780 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3781 let mut handler =
3782 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3783
3784 saturate_subscription_gate(&mut handler);
3786
3787 cmd_tx
3788 .send(HandlerCommand::Subscribe {
3789 channel: LighterWsChannel::Trade(0),
3790 auth: None,
3791 response_tx: None,
3792 })
3793 .expect("queue subscribe");
3794 cmd_tx
3795 .send(HandlerCommand::Unsubscribe {
3796 channel: LighterWsChannel::Trade(0),
3797 })
3798 .expect("queue unsubscribe");
3799 drop(cmd_tx);
3800 drop(raw_tx);
3801
3802 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3803 .await
3804 .expect("timed out waiting for handler to drain commands");
3805
3806 assert!(next.is_none());
3807 assert!(handler.pending_subs.is_empty());
3808 assert!(handler.subscription_attempts.is_empty());
3809 }
3810
3811 #[tokio::test]
3812 async fn reconnect_requeues_attempt_with_new_generation() {
3813 let signal = Arc::new(AtomicBool::new(false));
3814 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3815 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3816 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3817 let mut handler =
3818 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3819
3820 saturate_subscription_gate(&mut handler);
3821 handler.queue_subscribe(LighterWsChannel::Trade(0), None, None);
3822 let old_generation = handler.pending_subs[0].1;
3823
3824 raw_tx
3825 .send((7, Message::Text(RECONNECTED.to_string().into())))
3826 .expect("queue reconnect sentinel");
3827
3828 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3829 .await
3830 .expect("timed out waiting for reconnect");
3831
3832 assert!(matches!(next, Some(NautilusWsMessage::Reconnected { .. })));
3833 assert!(handler.inflight_subs.is_empty());
3834 assert_eq!(handler.pending_subs.len(), 1);
3835 assert_eq!(handler.pending_subs[0].0, Ustr::from("trade:0"));
3836 assert_ne!(handler.pending_subs[0].1, old_generation);
3837 assert_eq!(
3838 handler.subscription_attempts[&Ustr::from("trade:0")].generation,
3839 handler.pending_subs[0].1,
3840 );
3841 }
3842
3843 #[rstest]
3844 #[tokio::test]
3845 async fn pump_releases_inflight_slot_and_schedules_send_failure_retry() {
3846 let signal = Arc::new(AtomicBool::new(false));
3847 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3848 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
3849 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3850 let mut handler =
3851 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3852
3853 for market_index in 0..3 {
3855 handler.queue_subscribe(LighterWsChannel::Trade(market_index), None, None);
3856 }
3857
3858 handler.pump_pending_subscribes().await;
3859
3860 assert!(handler.pending_subs.is_empty());
3861 assert!(handler.inflight_subs.is_empty());
3862 assert_eq!(handler.subscription_attempts.len(), 3);
3863 assert!(
3864 handler
3865 .subscription_attempts
3866 .values()
3867 .all(|attempt| attempt.retries == 1),
3868 );
3869 assert_eq!(handler.subscription_retries.len(), 3);
3870 }
3871
3872 #[rstest]
3873 fn subscribed_control_frame_releases_inflight_slot() {
3874 let mut handler = make_handler_with_account();
3875 mark_subscription_inflight(
3876 &mut handler,
3877 LighterWsChannel::Candle {
3878 market_index: 0,
3879 resolution: LighterCandleResolution::OneMinute,
3880 },
3881 None,
3882 );
3883
3884 let (matched, msg) = handle_control_text(
3885 &mut handler,
3886 r#"{"type":"subscribed","channel":"candle:0:1m"}"#,
3887 );
3888
3889 assert!(matched);
3890 assert!(msg.is_none());
3891 assert!(
3892 !handler
3893 .inflight_subs
3894 .contains_key(&Ustr::from("candle:0:1m"))
3895 );
3896 assert!(handler.subscription_attempts.is_empty());
3897 }
3898
3899 #[rstest]
3900 #[case::top_level(
3901 r#"{"type":"error","code":30003,"message":"Already Subscribed to : account_all_orders:12345"}"#
3902 )]
3903 #[case::nested(
3904 r#"{"error":{"code":30003,"message":"Already Subscribed to : account_all_orders:12345"}}"#
3905 )]
3906 #[case::nested_typed(
3907 r#"{"type":"error","error":{"code":30003,"message":"Already Subscribed to : account_all_orders:12345"}}"#
3908 )]
3909 fn already_subscribed_confirms_matching_inflight_topic(#[case] payload: &str) {
3910 let mut handler = make_handler_with_account();
3911 mark_subscription_inflight(
3912 &mut handler,
3913 LighterWsChannel::AccountAllOrders(12345),
3914 None,
3915 );
3916
3917 let (matched, msg) = handle_control_text(&mut handler, payload);
3918
3919 assert!(matched);
3920 assert!(msg.is_none());
3921 assert!(handler.inflight_subs.is_empty());
3922 assert!(handler.subscriptions.pending_subscribe_topics().is_empty());
3923 assert_eq!(handler.subscriptions.len(), 1);
3924 }
3925
3926 #[rstest]
3927 fn already_subscribed_does_not_confirm_unmatched_topic() {
3928 let mut handler = make_handler_with_account();
3929 let inflight = "account_all_orders:12345";
3930 mark_subscription_inflight(
3931 &mut handler,
3932 LighterWsChannel::AccountAllOrders(12345),
3933 None,
3934 );
3935
3936 let (matched, msg) = handle_control_text(
3937 &mut handler,
3938 r#"{"type":"error","code":30003,"message":"Already Subscribed to : account_all_trades:12345"}"#,
3939 );
3940
3941 assert!(matched);
3942 assert!(msg.is_none());
3943 assert!(handler.inflight_subs.contains_key(&Ustr::from(inflight)));
3944 assert_eq!(
3945 handler.subscriptions.pending_subscribe_topics(),
3946 vec![inflight]
3947 );
3948 assert_eq!(handler.subscriptions.len(), 0);
3949 }
3950
3951 #[tokio::test]
3952 async fn duplicate_queued_and_inflight_topics_share_one_generation() {
3953 let mut handler = make_handler_with_account();
3954 let (response_tx_1, mut response_rx_1) = tokio::sync::oneshot::channel();
3955 let (response_tx_2, mut response_rx_2) = tokio::sync::oneshot::channel();
3956 let (response_tx_3, mut response_rx_3) = tokio::sync::oneshot::channel();
3957 let channel = LighterWsChannel::Trade(7);
3958
3959 handler.queue_subscribe(channel.clone(), None, Some(response_tx_1));
3960 let generation = handler.pending_subs[0].1;
3961 handler.queue_subscribe(channel.clone(), None, Some(response_tx_2));
3962
3963 assert_eq!(handler.pending_subs.len(), 1);
3964 assert_eq!(handler.subscription_attempts.len(), 1);
3965 assert_eq!(
3966 handler.subscription_attempts[&Ustr::from("trade:7")]
3967 .response_txs
3968 .len(),
3969 2,
3970 );
3971
3972 let (topic, queued_generation) = handler.pending_subs.pop_front().unwrap();
3973 handler.inflight_subs.insert(topic, queued_generation);
3974 handler.queue_subscribe(channel, None, Some(response_tx_3));
3975
3976 assert_eq!(queued_generation, generation);
3977 assert!(handler.pending_subs.is_empty());
3978 assert_eq!(handler.inflight_subs.len(), 1);
3979 assert_eq!(
3980 handler.subscription_attempts[&Ustr::from("trade:7")]
3981 .response_txs
3982 .len(),
3983 3,
3984 );
3985 assert!(matches!(
3986 response_rx_1.try_recv(),
3987 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
3988 ));
3989 assert!(matches!(
3990 response_rx_2.try_recv(),
3991 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
3992 ));
3993 assert!(matches!(
3994 response_rx_3.try_recv(),
3995 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
3996 ));
3997
3998 handle_control_text(&mut handler, r#"{"type":"subscribed","channel":"trade:7"}"#);
3999
4000 assert_eq!(response_rx_1.await.unwrap(), Ok(()));
4001 assert_eq!(response_rx_2.await.unwrap(), Ok(()));
4002 assert_eq!(response_rx_3.await.unwrap(), Ok(()));
4003 assert!(handler.inflight_subs.is_empty());
4004 assert!(handler.subscription_attempts.is_empty());
4005 }
4006
4007 #[tokio::test]
4008 async fn changed_inflight_auth_waits_for_serialized_successor() {
4009 let mut handler = make_handler_with_account();
4010 let channel = LighterWsChannel::AccountAllOrders(12345);
4011 let topic = Ustr::from(channel.topic_key().as_str());
4012 let (old_tx, old_rx) = tokio::sync::oneshot::channel();
4013 let (fresh_tx, mut fresh_rx) = tokio::sync::oneshot::channel();
4014
4015 handler.queue_subscribe(channel.clone(), Some("old-token".to_string()), Some(old_tx));
4016 let (queued_topic, old_generation) = handler.pending_subs.pop_front().unwrap();
4017 handler.inflight_subs.insert(queued_topic, old_generation);
4018 handler.queue_subscribe(channel, Some("fresh-token".to_string()), Some(fresh_tx));
4019
4020 let attempt = &handler.subscription_attempts[&topic];
4021 assert_eq!(attempt.auth.as_deref(), Some("old-token"));
4022 assert_eq!(attempt.pending_auth.as_deref(), Some("fresh-token"));
4023 assert_eq!(attempt.response_txs.len(), 1);
4024 assert_eq!(attempt.pending_response_txs.len(), 1);
4025
4026 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4027 assert_eq!(old_rx.await.unwrap(), Ok(()));
4028 assert!(matches!(
4029 fresh_rx.try_recv(),
4030 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4031 ));
4032 let attempt = &handler.subscription_attempts[&topic];
4033 assert_ne!(attempt.generation, old_generation);
4034 assert_eq!(attempt.auth.as_deref(), Some("fresh-token"));
4035 assert!(attempt.pending_auth.is_none());
4036 assert_eq!(
4037 handler.pending_subs.front(),
4038 Some(&(topic, attempt.generation)),
4039 );
4040
4041 let (_, fresh_generation) = handler.pending_subs.pop_front().unwrap();
4042 handler.inflight_subs.insert(topic, fresh_generation);
4043
4044 assert!(!handler.complete_subscription(topic.as_str(), CompletionKind::Typed));
4047 assert!(matches!(
4048 fresh_rx.try_recv(),
4049 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4050 ));
4051 assert_eq!(
4052 handler.subscription_attempts[&topic].generation,
4053 fresh_generation,
4054 );
4055
4056 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4057 assert_eq!(fresh_rx.await.unwrap(), Ok(()));
4058 assert!(handler.subscription_attempts.is_empty());
4059 }
4060
4061 #[tokio::test]
4062 async fn stale_typed_frame_does_not_complete_fresh_attempt_after_predecessor_removed() {
4063 let mut handler = make_handler_with_account();
4064 let channel = LighterWsChannel::AccountAllOrders(12345);
4065 let topic = Ustr::from(channel.topic_key().as_str());
4066 let (old_tx, old_rx) = tokio::sync::oneshot::channel();
4067 let (fresh_tx, mut fresh_rx) = tokio::sync::oneshot::channel();
4068
4069 handler.queue_subscribe(channel.clone(), Some("old-token".to_string()), Some(old_tx));
4072 let (queued_topic, old_generation) = handler.pending_subs.pop_front().unwrap();
4073 handler.inflight_subs.insert(queued_topic, old_generation);
4074 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4075 assert_eq!(old_rx.await.unwrap(), Ok(()));
4076 assert!(handler.subscription_attempts.is_empty());
4077
4078 handler.queue_subscribe(channel, Some("fresh-token".to_string()), Some(fresh_tx));
4080 let (_, fresh_generation) = handler.pending_subs.pop_front().unwrap();
4081 handler.inflight_subs.insert(topic, fresh_generation);
4082
4083 assert!(!handler.complete_subscription(topic.as_str(), CompletionKind::Typed));
4085 assert!(matches!(
4086 fresh_rx.try_recv(),
4087 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4088 ));
4089 assert_eq!(
4090 handler.subscription_attempts[&topic].generation,
4091 fresh_generation,
4092 );
4093
4094 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4095 assert_eq!(fresh_rx.await.unwrap(), Ok(()));
4096 assert!(handler.subscription_attempts.is_empty());
4097 }
4098
4099 #[tokio::test]
4100 async fn changed_queued_auth_updates_existing_generation_before_dispatch() {
4101 let mut handler = make_handler_with_account();
4102 let channel = LighterWsChannel::AccountAllOrders(12345);
4103 let topic = Ustr::from(channel.topic_key().as_str());
4104 let (old_tx, old_rx) = tokio::sync::oneshot::channel();
4105 let (fresh_tx, fresh_rx) = tokio::sync::oneshot::channel();
4106
4107 handler.queue_subscribe(channel.clone(), Some("old-token".to_string()), Some(old_tx));
4108 let generation = handler.pending_subs[0].1;
4109 handler.queue_subscribe(channel, Some("fresh-token".to_string()), Some(fresh_tx));
4110
4111 assert_eq!(handler.pending_subs.len(), 1);
4112 assert_eq!(handler.pending_subs[0], (topic, generation));
4113 let attempt = &handler.subscription_attempts[&topic];
4114 assert_eq!(attempt.generation, generation);
4115 assert_eq!(attempt.auth.as_deref(), Some("fresh-token"));
4116 assert!(attempt.pending_auth.is_none());
4117 assert_eq!(attempt.response_txs.len(), 2);
4118 assert!(attempt.pending_response_txs.is_empty());
4119
4120 handler.pending_subs.pop_front();
4121 handler.inflight_subs.insert(topic, generation);
4122 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4123 assert_eq!(old_rx.await.unwrap(), Ok(()));
4124 assert_eq!(fresh_rx.await.unwrap(), Ok(()));
4125 }
4126
4127 #[rstest]
4128 fn second_reconnect_folds_pending_auth_into_requeued_generation() {
4129 let mut handler = make_handler_with_account();
4130 let channel = LighterWsChannel::AccountAllOrders(12345);
4131 let topic = Ustr::from(channel.topic_key().as_str());
4132 let (old_tx, mut old_rx) = tokio::sync::oneshot::channel();
4133 let (fresh_tx, mut fresh_rx) = tokio::sync::oneshot::channel();
4134
4135 handler.queue_subscribe(channel.clone(), Some("old-token".to_string()), Some(old_tx));
4136 handler.reset_subscription_attempts_after_reconnect();
4137 let (_, replay_generation) = handler.pending_subs.pop_front().unwrap();
4138 handler.inflight_subs.insert(topic, replay_generation);
4139 handler.queue_subscribe(channel, Some("fresh-token".to_string()), Some(fresh_tx));
4140
4141 let attempt = &handler.subscription_attempts[&topic];
4142 assert_eq!(attempt.auth.as_deref(), Some("old-token"));
4143 assert_eq!(attempt.pending_auth.as_deref(), Some("fresh-token"));
4144 assert_eq!(attempt.response_txs.len(), 1);
4145 assert_eq!(attempt.pending_response_txs.len(), 1);
4146
4147 handler.reset_subscription_attempts_after_reconnect();
4148
4149 let attempt = &handler.subscription_attempts[&topic];
4150 assert_ne!(attempt.generation, replay_generation);
4151 assert_eq!(attempt.auth.as_deref(), Some("fresh-token"));
4152 assert!(attempt.pending_auth.is_none());
4153 assert_eq!(attempt.response_txs.len(), 2);
4154 assert!(attempt.pending_response_txs.is_empty());
4155 assert_eq!(handler.pending_subs.len(), 1);
4156 assert_eq!(handler.pending_subs[0], (topic, attempt.generation));
4157 assert!(handler.inflight_subs.is_empty());
4158 assert!(matches!(
4159 old_rx.try_recv(),
4160 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4161 ));
4162 assert!(matches!(
4163 fresh_rx.try_recv(),
4164 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4165 ));
4166
4167 let (_, generation) = handler.pending_subs.pop_front().unwrap();
4168 handler.inflight_subs.insert(topic, generation);
4169 assert!(handler.complete_subscription(topic.as_str(), CompletionKind::ControlAck));
4170
4171 assert_eq!(old_rx.try_recv(), Ok(Ok(())));
4172 assert_eq!(fresh_rx.try_recv(), Ok(Ok(())));
4173 assert!(handler.subscription_attempts.is_empty());
4174 }
4175
4176 #[tokio::test]
4177 async fn retry_folds_pending_auth_without_resetting_retry_budget() {
4178 let mut handler = make_handler_with_account();
4179 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4180 handler.set_command_sender(cmd_tx);
4181 let channel = LighterWsChannel::AccountAllOrders(12345);
4182 let topic = Ustr::from(channel.topic_key().as_str());
4183 let (old_tx, mut old_rx) = tokio::sync::oneshot::channel();
4184 let (fresh_tx, mut fresh_rx) = tokio::sync::oneshot::channel();
4185
4186 handler.queue_subscribe(channel.clone(), Some("old-token".to_string()), Some(old_tx));
4187 let (_, generation) = handler.pending_subs.pop_front().unwrap();
4188 handler.inflight_subs.insert(topic, generation);
4189 handler
4190 .subscription_attempts
4191 .get_mut(&topic)
4192 .unwrap()
4193 .retries = 2;
4194 handler.queue_subscribe(channel, Some("fresh-token".to_string()), Some(fresh_tx));
4195
4196 handler.schedule_subscription_retry(topic, generation, "retry");
4197
4198 let attempt = &handler.subscription_attempts[&topic];
4199 assert_eq!(attempt.retries, 3);
4200 assert_ne!(attempt.generation, generation);
4201 assert_eq!(attempt.auth.as_deref(), Some("fresh-token"));
4202 assert!(attempt.pending_auth.is_none());
4203 assert_eq!(attempt.response_txs.len(), 2);
4204 assert!(attempt.pending_response_txs.is_empty());
4205 assert!(handler.pending_subs.is_empty());
4206 assert!(handler.inflight_subs.is_empty());
4207 assert_eq!(handler.subscription_retries.len(), 1);
4208 assert!(matches!(
4209 old_rx.try_recv(),
4210 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4211 ));
4212 assert!(matches!(
4213 fresh_rx.try_recv(),
4214 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4215 ));
4216 }
4217
4218 #[tokio::test]
4219 async fn confirmed_authenticated_topic_opens_new_generation() {
4220 let mut handler = make_handler_with_account();
4221 let channel = LighterWsChannel::AccountAllOrders(12345);
4222 let topic = channel.topic_key();
4223 handler.subscriptions.mark_subscribe(&topic);
4224 handler.subscriptions.confirm_subscribe(&topic);
4225 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
4226
4227 handler.queue_subscribe(
4228 channel,
4229 Some("rotated-auth-token".to_string()),
4230 Some(response_tx),
4231 );
4232
4233 assert_eq!(handler.pending_subs.len(), 1);
4234 assert_eq!(handler.subscription_attempts.len(), 1);
4235 assert_eq!(handler.subscriptions.len(), 1);
4236 assert!(handler.subscriptions.pending_subscribe_topics().is_empty());
4237
4238 let (topic, generation) = handler.pending_subs.pop_front().unwrap();
4239 handler.inflight_subs.insert(topic, generation);
4240 handle_control_text(
4241 &mut handler,
4242 r#"{"type":"subscribed","channel":"account_all_orders:12345"}"#,
4243 );
4244
4245 assert_eq!(response_rx.await.unwrap(), Ok(()));
4246 assert!(handler.subscription_attempts.is_empty());
4247 assert_eq!(handler.subscriptions.len(), 1);
4248 }
4249
4250 #[tokio::test]
4251 async fn typed_update_before_ack_does_not_complete_subscription_generation() {
4252 let mut handler = make_handler_with_account();
4253 let (response_tx, mut response_rx) = tokio::sync::oneshot::channel();
4254 let (topic, generation) = mark_subscription_inflight(
4255 &mut handler,
4256 LighterWsChannel::Candle {
4257 market_index: 0,
4258 resolution: LighterCandleResolution::OneMinute,
4259 },
4260 Some(response_tx),
4261 );
4262
4263 handler.handle_frame(
4264 candle_frame(
4265 "candle:0:1m",
4266 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
4267 false,
4268 ),
4269 UnixNanos::from(1),
4270 );
4271
4272 assert_eq!(
4273 handler
4274 .last_candles
4275 .get(&(0, LighterCandleResolution::OneMinute))
4276 .map(|candle| candle.t),
4277 Some(1_000_000),
4278 );
4279 assert_eq!(handler.inflight_subs.get(&topic), Some(&generation));
4280 assert_eq!(
4281 handler.subscriptions.pending_subscribe_topics(),
4282 vec!["candle:0:1m"],
4283 );
4284 assert_eq!(handler.subscriptions.len(), 0);
4285 assert!(matches!(
4286 response_rx.try_recv(),
4287 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4288 ));
4289
4290 handle_control_text(
4291 &mut handler,
4292 r#"{"type":"subscribed","channel":"candle:0:1m"}"#,
4293 );
4294
4295 assert_eq!(response_rx.await.unwrap(), Ok(()));
4296 assert_eq!(handler.subscriptions.len(), 1);
4297 }
4298
4299 #[tokio::test]
4300 async fn typed_subscribed_frame_completes_subscription_generation() {
4301 let signal = Arc::new(AtomicBool::new(false));
4302 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
4303 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u64, Message)>();
4304 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
4305 let mut handler =
4306 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
4307 handler.instruments.insert(0, stub_eth_perp_instrument());
4308 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
4309 mark_subscription_inflight(
4310 &mut handler,
4311 LighterWsChannel::Candle {
4312 market_index: 0,
4313 resolution: LighterCandleResolution::OneMinute,
4314 },
4315 Some(response_tx),
4316 );
4317
4318 raw_tx
4319 .send((7, Message::Text(WS_CANDLE_SUBSCRIBED.into())))
4320 .expect("typed subscribed frame");
4321 drop(raw_tx);
4322 drop(cmd_tx);
4323
4324 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
4325 .await
4326 .expect("handler did not process typed subscribed frame");
4327
4328 assert!(next.is_none());
4329 assert_eq!(response_rx.await.unwrap(), Ok(()));
4330 assert!(handler.inflight_subs.is_empty());
4331 assert!(handler.subscription_attempts.is_empty());
4332 assert_eq!(handler.subscriptions.len(), 1);
4333 }
4334
4335 #[tokio::test]
4336 async fn failed_subscribe_error_fails_every_waiter_for_the_generation() {
4337 let mut handler = make_handler_with_account();
4338 let (response_tx_1, response_rx_1) = tokio::sync::oneshot::channel();
4339 let (response_tx_2, response_rx_2) = tokio::sync::oneshot::channel();
4340 let channel = LighterWsChannel::MarketStats(LighterMarketSelection::Market(0));
4341 mark_subscription_inflight(&mut handler, channel.clone(), Some(response_tx_1));
4342 handler.queue_subscribe(channel, None, Some(response_tx_2));
4343
4344 let (matched, msg) = handle_control_text(
4345 &mut handler,
4346 r#"{"type":"error","code":30012,"message":"failed to subscribe"}"#,
4347 );
4348
4349 assert!(matched);
4350 assert!(msg.is_none());
4351 let error_1 = response_rx_1.await.unwrap().unwrap_err();
4352 let error_2 = response_rx_2.await.unwrap().unwrap_err();
4353 assert!(error_1.contains("market_stats:0"));
4354 assert!(error_1.contains("30012"));
4355 assert_eq!(error_1, error_2);
4356 assert!(handler.inflight_subs.is_empty());
4357 assert!(handler.subscription_attempts.is_empty());
4358 assert!(handler.subscriptions.is_empty());
4359 }
4360
4361 #[tokio::test]
4362 async fn rate_limit_retry_exhaustion_fails_waiter_and_clears_intent() {
4363 let mut handler = make_handler_with_account();
4364 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
4365 let (topic, _) =
4366 mark_subscription_inflight(&mut handler, LighterWsChannel::Trade(7), Some(response_tx));
4367 handler
4368 .subscription_attempts
4369 .get_mut(&topic)
4370 .expect("subscription attempt")
4371 .retries = SUBSCRIBE_RETRY_MAX;
4372
4373 let (matched, msg) = handle_control_text(
4374 &mut handler,
4375 r#"{"type":"error","code":30009,"message":"rate limit exceeded"}"#,
4376 );
4377
4378 assert!(matched);
4379 assert!(msg.is_none());
4380 assert_eq!(
4381 response_rx.await.unwrap(),
4382 Err(
4383 "subscription trade:7 failed after 6 attempts: venue rejected the WebSocket \
4384 subscribe with code 30009"
4385 .to_string(),
4386 ),
4387 );
4388 assert!(handler.inflight_subs.is_empty());
4389 assert!(handler.subscription_attempts.is_empty());
4390 assert!(handler.subscriptions.is_empty());
4391 }
4392
4393 #[tokio::test]
4394 async fn rate_limit_error_retries_each_inflight_topic_with_new_generation() {
4395 let mut handler = make_handler_with_account();
4396 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4397 handler.set_command_sender(cmd_tx);
4398 let (trade_tx, mut trade_rx) = tokio::sync::oneshot::channel();
4399 let (candle_tx, mut candle_rx) = tokio::sync::oneshot::channel();
4400 let (trade_topic, trade_generation) =
4401 mark_subscription_inflight(&mut handler, LighterWsChannel::Trade(7), Some(trade_tx));
4402 let (candle_topic, candle_generation) = mark_subscription_inflight(
4403 &mut handler,
4404 LighterWsChannel::Candle {
4405 market_index: 0,
4406 resolution: LighterCandleResolution::OneMinute,
4407 },
4408 Some(candle_tx),
4409 );
4410
4411 let (matched, msg) = handle_control_text(
4412 &mut handler,
4413 r#"{"type":"error","code":30009,"message":"rate limit exceeded"}"#,
4414 );
4415
4416 assert!(matched);
4417 assert!(msg.is_none());
4418 assert!(handler.inflight_subs.is_empty());
4419 assert!(handler.pending_subs.is_empty());
4420 let trade_retry = &handler.subscription_attempts[&trade_topic];
4421 let candle_retry = &handler.subscription_attempts[&candle_topic];
4422 assert_eq!(trade_retry.retries, 1);
4423 assert_eq!(candle_retry.retries, 1);
4424 assert_ne!(trade_retry.generation, trade_generation);
4425 assert_ne!(candle_retry.generation, candle_generation);
4426 assert_eq!(handler.subscription_retries.len(), 2);
4427 assert!(matches!(
4428 trade_rx.try_recv(),
4429 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4430 ));
4431 assert!(matches!(
4432 candle_rx.try_recv(),
4433 Err(tokio::sync::oneshot::error::TryRecvError::Empty),
4434 ));
4435
4436 handle_control_text(&mut handler, r#"{"type":"subscribed","channel":"trade:7"}"#);
4437 assert_eq!(handler.subscription_attempts.len(), 2);
4438 assert_eq!(handler.subscriptions.len(), 0);
4439
4440 let retry_generations = [
4441 (
4442 trade_topic,
4443 handler.subscription_attempts[&trade_topic].generation,
4444 ),
4445 (
4446 candle_topic,
4447 handler.subscription_attempts[&candle_topic].generation,
4448 ),
4449 ];
4450
4451 for (topic, generation) in retry_generations {
4452 handler.queue_subscription_retry(topic, generation);
4453 let queued = handler.pending_subs.pop_front().unwrap();
4454 assert_eq!(queued, (topic, generation));
4455 handler.inflight_subs.insert(topic, generation);
4456 }
4457
4458 handle_control_text(&mut handler, r#"{"type":"subscribed","channel":"trade:7"}"#);
4459 handle_control_text(
4460 &mut handler,
4461 r#"{"type":"subscribed","channel":"candle:0:1m"}"#,
4462 );
4463
4464 assert_eq!(trade_rx.await.unwrap(), Ok(()));
4465 assert_eq!(candle_rx.await.unwrap(), Ok(()));
4466 assert!(handler.subscription_attempts.is_empty());
4467 assert_eq!(handler.subscriptions.len(), 2);
4468 }
4469}