Skip to main content

nautilus_lighter/websocket/
handler.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Inner WebSocket feed handler running on a dedicated tokio task.
17
18use 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
72// Lighter control-frame `type` field values that fall outside the typed
73// `LighterWsFrame` variants. These are protocol-level frames the handler
74// inspects via the typed-frame parse fallback in `handle_control_value`.
75const 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/// Commands sent from the outer client to the inner feed handler.
92#[expect(
93    clippy::large_enum_variant,
94    reason = "commands are ephemeral and immediately consumed"
95)]
96pub enum HandlerCommand {
97    /// Hand the live `WebSocketClient` to the handler after the outer client
98    /// completes the network connect.
99    SetClient(WebSocketClient),
100    /// Drain the queue and shut the handler down.
101    Disconnect,
102    /// Subscribe to a channel. `auth` carries an optional auth token for the
103    /// account-scoped channels.
104    Subscribe {
105        channel: LighterWsChannel,
106        auth: Option<String>,
107        response_tx: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
108    },
109    /// Unsubscribe from a channel.
110    Unsubscribe { channel: LighterWsChannel },
111    /// Resubscribe to the venue `order_book` stream after a continuity gap.
112    ResubscribeOrderBook { market_index: i16 },
113    /// Replace the handler's instrument cache (used on initial connect).
114    InitializeInstruments(Vec<(i16, InstrumentAny)>),
115    /// Insert or replace a single instrument by `market_index`.
116    UpdateInstrument {
117        market_index: i16,
118        instrument: InstrumentAny,
119    },
120    /// Toggle whether `update/order_book` frames for `market_index` should
121    /// be emitted as [`NautilusWsMessage::Deltas`].
122    SetBookDeltasSub { market_index: i16, subscribed: bool },
123    /// Toggle whether `update/order_book` frames for `market_index` should
124    /// also be emitted as a [`NautilusWsMessage::Depth10`] snapshot.
125    SetDepth10Sub { market_index: i16, subscribed: bool },
126    /// Provide the execution context (`AccountId` and venue `account_index`)
127    /// the handler stamps onto reports parsed from `account_*` frames.
128    /// Without this context the handler cannot construct typed reports and
129    /// account frames are forwarded as raw JSON instead.
130    SetExecutionContext {
131        account_id: AccountId,
132        account_index: i64,
133    },
134    /// Dispatch a signed L2 transaction over the WebSocket.
135    ///
136    /// The handler serializes a [`LighterWsRequest::SendTx`] frame; the venue
137    /// confirms acceptance via the `account_*` streams (and reports any
138    /// rejection via a control-plane `error` frame).
139    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    /// Custom `Debug` that redacts the `auth` field of `Subscribe`. The
149    /// command is routed in-process via mpsc and may be printed by error
150    /// paths or trace logging; deriving `Debug` would otherwise leak the
151    /// venue bearer token verbatim.
152    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
210/// Inner feed handler. Owns the [`WebSocketClient`] exclusively and routes
211/// raw frames into the venue-message channel.
212///
213/// Subscribe dispatch is gated by a closed-loop inflight count: the venue caps
214/// unacknowledged client messages per connection, and acknowledgement latency
215/// is multi-second, so queued subscribes are held back until acks free a slot.
216pub(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/// Which venue frame produced a subscription completion.
273///
274/// The venue answers a subscribe with a generic control ack followed by a
275/// typed `subscribed/<channel>` snapshot, and both complete by topic. The kind
276/// lets `complete_subscription` swallow the predecessor's trailing frame so a
277/// same-topic generation dispatched between the two cannot be completed by a
278/// response that belongs to the earlier request.
279#[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    // Single-shot: sendTx payloads carry a signed nonce; transport-layer
389    // retry could double-submit if the original landed and only the ack was lost.
390    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                // Avoid logging the serialized payload for authenticated channels;
428                // it embeds a live Lighter L2 bearer token.
429                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            // Pump each iteration: the select below drains acks that free slots
503            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                            // Drop a queued-but-unsent subscribe for this channel so a freed
529                            // slot cannot resubscribe after the caller unsubscribed.
530                            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                                // Resubscribe replays a fresh `subscribed/candle`; pre-disconnect cache is stale.
612                                self.last_candles.clear();
613                                // The new socket starts with zero inflight and restore_subscriptions
614                                // re-queues every topic. Retire stale generations before accepting
615                                // replay commands so late work cannot complete a replacement attempt.
616                                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                                    // Neither a typed frame nor a recognized
648                                    // control type. Surface raw so venue
649                                    // errors (bad signature, margin rejection,
650                                    // ...) don't drop silently.
651                                    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    // Non-blocking count check, not a permit await: this task also drains the
694    // acks that free slots, so awaiting a permit here would deadlock.
695    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        // Swallow the predecessor's trailing frame before the inflight guard,
807        // so it cannot complete a same-topic generation dispatched between the
808        // venue's control ack and its typed snapshot - whether that generation
809        // is a serialized successor, a fresh attempt, queued, or in backoff.
810        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        // The venue contract is control ack first, typed snapshot second, so a
831        // control-ack completion owes one trailing typed frame. Typed and
832        // already-subscribed completions have no established trailing frame.
833        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        // Completion frames from the old connection must not be applied to -
960        // or swallowed from - the new connection.
961        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    /// Returns `(matched, msg)` where `matched=true` means the frame's
986    /// `type` was recognized as a known control type (whether or not a
987    /// message is emitted); `matched=false` lets the caller surface the
988    /// raw frame so venue errors with unknown shapes aren't lost.
989    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                            // Only matched unsubscribe ACKs should reset stream state
1083                            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            // Lighter publishes the historical fill replay as `subscribed/`
1209            // rather than `update/`. When typed execution routing is active
1210            // we drop the snapshot to avoid re-emitting prior executions as
1211            // fresh fill reports on every reconnect (HTTP reconciliation
1212            // owns historical state recovery). When no execution context is
1213            // set we still forward as `Raw` to preserve the prior contract
1214            // for unauthenticated subscribers.
1215            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        // The venue tags the initial book as `subscribed/order_book` and follows
1334        // up with `update/order_book` for incrementals. An incremental cannot
1335        // seed the book because it only carries changed levels.
1336        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        // Resolve the instrument from the channel's `ticker:N` market index,
1532        // not the ticker payload's `s` symbol. Lighter sends the unsuffixed
1533        // venue symbol (e.g. "ETH"), while cached `raw_symbol` carries the
1534        // -PERP / -SPOT suffix, so a symbol-name match never resolves and
1535        // would conflate spot vs perp listings of the same asset.
1536        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        // Lighter splits trade prints across `trades` and `liquidation_trades`
1568        // on the same `update/trade` frame; both share the LighterTrade model
1569        // and are public executions, so emit them together.
1570        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                    // `t` advanced; previous candle is closed.
1717                    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            // The venue shares the channel with crossed pairs the account is
1786            // not part of; gate the cheap account check at the handler so
1787            // unrelated traffic never reaches the execution consumer.
1788            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        // The position frame carries no top-level event timestamp, so stamp
1814        // ts_event with the wall-clock time the handler observed the frame.
1815        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                // Emit even when empty: signals the last position closed.
1853                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    /// Asks the reconciler for a merged [`AccountState`] and wraps it as a
1913    /// `NautilusWsMessage`. Returns an empty vec when the second stream
1914    /// hasn't arrived yet; the AccountStreamFirstFrame marker still gets
1915    /// pushed by the caller so the startup gate progresses.
1916    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
1997// Codes outside the transaction range (e.g. 30003 "Already Subscribed")
1998// would falsely reject a live order; see `LIGHTER_ERROR_CODE_TX_RANGE`.
1999fn 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
2036// SendTxRejected from a top-level `{code, message}` frame: non-200 sendTx
2037// ACK or `{"type":"error",...}`.
2038fn 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
2061// SendTxRejected from a nested `{"error":{"code":N,"message":...}}` frame;
2062// these frames never carry a `tx_hash`.
2063fn 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        // Closed and BrokenPipe are terminal on this client; only Timeout
2119        // (wait_for_active) can recover if the connection comes up. WriteTimeout
2120        // is not retryable: the write was cancelled after it began, so the peer
2121        // may already have the message and a retry could duplicate it.
2122        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    // Structured variant so the classifier retries; the retry manager
2139    // already logs the textual timeout context.
2140    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    /// Strip the trailing `AccountStreamFirstFrame` marker from a handler
2345    /// emission so account-stream tests can assert on the typed payload
2346    /// alone. The marker contract is pinned in
2347    /// `handle_frame_emits_account_stream_first_frame_marker_per_variant`.
2348    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                        // Bid side is the user's account in this fixture
2396                        // (see handle_frame_routes_account_trades_to_execution_reports
2397                        // fixture: bid_account_id == 1234). The raw payload
2398                        // carries the bid order id as the venue id; the
2399                        // execution loop derives `venue_order_id` from it.
2400                        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        // Pins the (matched, msg) contract that lets the WS receive loop
2702        // distinguish "known control type, consume silently" from
2703        // "surface a typed/Raw message to the consumer". sendTx success ACKs
2704        // and venue-error frames now produce typed SendTxAck/SendTxRejected
2705        // variants instead of Raw, so the execution loop can correlate them
2706        // back to in-flight cloids.
2707        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        // No top-level `type` field; the rejection sits under `error.{code,message}`.
2817        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        // Reconciler refuses to emit until both `account_all_assets` and
2845        // `user_stats` have delivered. An assets frame alone produces only
2846        // the AccountStreamFirstFrame marker, no AccountState payload.
2847        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        // Fixture is the captured production no-position payload (10 USDC
2862        // on spot, 40 USDC pledged as perp collateral, no resting orders).
2863        // Lighter runs unified margin: both legs are deployable equity, so
2864        // total = balance + margin_balance, locked = locked_balance only,
2865        // and MarginBalance.initial = 0 because user_stats.collateral ==
2866        // available_balance (no margin in use).
2867        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                // balance 10 + margin_balance 40 = 50 total; locked = 0
2886                // (no resting spot orders); free = 50 (all deployable).
2887                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        // Fixture pair captured live with one open ETH-LONG position:
2904        //   account_all_assets[USDC] = {balance:10, locked:0, margin_balance:39.995369556}
2905        //   user_stats              = {collateral:39.995369, available:39.168314, margin_usage:2.07}
2906        //
2907        // The 5 mUSDC haircut on margin_balance is the entry fee. The
2908        // 0.827055 USDC gap between collateral and available_balance is
2909        // the initial margin pledged to the open position. AccountBalance
2910        // is unchanged in shape; perp-margin-in-use lives on
2911        // MarginBalance, not in `locked`. Maintenance is zero because
2912        // Lighter doesn't publish a maintenance value on user_stats.
2913        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                // total = 10 + 39.995369556 = 49.99536956 (Money truncates
2930                // the trailing digit to 8 decimals, matching the
2931                // production log).
2932                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                // initial = collateral 39.995369 - available 39.168314 = 0.827055
2937                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        // Pins the strict-await wiring: the handler appends an
2948        // `AccountStreamFirstFrame` marker after any typed reports so the
2949        // execution consumption loop can mark readiness only after the
2950        // accompanying state has been applied. The marker fires even for
2951        // empty content frames that produce no typed reports.
2952        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            // The marker must trail any typed reports the handler emitted
2990            // so the consumption loop applies state first.
2991            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        // exec_account intentionally left unset; the handler must preserve
3011        // the prior `Raw` forwarding contract for unauthenticated subscribers.
3012
3013        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        // Build a handler with the execution context but no instrument
3028        // cached for the order's market_index; the handler should log and
3029        // emit no execution reports (the trailing readiness marker still
3030        // fires so `connect()` does not stall when the venue resubscribes
3031        // before instrument bootstrap completes).
3032        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        // No instrument inserted for market_index=0.
3040
3041        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        // u64::MAX millis overflows the seconds-to-nanos conversion in
3051        // `parse_millis_to_nanos`; the handler must log and emit no typed
3052        // account state rather than surface a partially constructed one.
3053        // The trailing readiness marker is still emitted so `connect()`
3054        // does not stall if the venue ever sends a malformed timestamp on
3055        // the initial frame.
3056        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        // The `update/account_all_orders` variant lacks the per-account
3079        // top-level `account` and `nonce` fields that `account_orders`
3080        // carries, but the handler should treat them identically.
3081        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        // With execution context active the snapshot must be dropped to
3181        // avoid replaying historical fills as live FillReports on reconnect.
3182        // The trailing readiness marker is still emitted so `connect()`
3183        // observes the trades stream as having delivered.
3184        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        // No exec_account: preserve prior Raw forwarding so unauthenticated
3202        // subscribers still see the snapshot frame.
3203
3204        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                // The emitted bar reflects the previous (closed) candle, not the new one.
3568                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        // Regressed frame is skipped entirely; cache stays on the original entry.
3632        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    // Pins the `#[from] SendError` derive that `.map_err(LighterWsError::Transport)` relies on.
3724    #[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 the gate so the pump cannot dispatch the queued subscribe
3749        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 the gate so the queued subscribe cannot dispatch before the unsubscribe
3785        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        // No active client, so every dispatch fails; the gate must not leak slots
3854        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        // The predecessor's trailing typed snapshot must not complete the
4045        // dispatched successor.
4046        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        // Predecessor completes on its control ack and is removed before any
4070        // pending auth exists.
4071        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        // The fresh command then creates and dispatches a brand-new attempt.
4079        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        // The predecessor's trailing typed snapshot must not complete it.
4084        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}