Skip to main content

nautilus_lighter/
execution.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//! Live execution client for the Lighter adapter.
17//!
18//! This module hosts the [`LighterExecutionClient`] that wires the platform
19//! execution engine to the Lighter L2 sequencer. Order submission,
20//! cancellation, and modification use signed WebSocket trading transactions.
21//! Reconciliation and report generation combine Lighter's account WebSocket
22//! streams with the HTTP read endpoints.
23//!
24//! Auth-token rotation is owned by this execution client and refreshes the
25//! private account-stream subscriptions on
26//! [`crate::websocket::client::LighterWebSocketClient`].
27
28use std::{
29    collections::{BTreeMap, BTreeSet},
30    future::Future,
31    sync::{
32        Arc,
33        atomic::{AtomicBool, AtomicU64, Ordering},
34    },
35    time::Duration,
36};
37
38use ahash::AHashSet;
39use anyhow::Context;
40use async_trait::async_trait;
41#[cfg(test)]
42use nautilus_common::live::get_runtime;
43use nautilus_common::{
44    clients::ExecutionClient,
45    enums::{LogColor, LogLevel},
46    live::runner::get_exec_event_sender,
47    log_debug,
48    messages::execution::{
49        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
50        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
51        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
52    },
53};
54use nautilus_core::{
55    UUID4, UnixNanos,
56    datetime::unix_nanos_to_iso8601,
57    params::Params,
58    time::{AtomicTime, get_atomic_clock_realtime},
59};
60use nautilus_live::{
61    ExecutionClientCore, ExecutionEventEmitter, SocketControlFactory,
62    execution::failure::CommandFailure,
63    task::{TaskGroup, TaskGroupGuard, TaskJoinOutcome, TaskSlot, TaskSpawner, finish_task},
64};
65use nautilus_model::{
66    accounts::AccountAny,
67    enums::{AccountType, OmsType, OrderSide, OrderType, PositionSide},
68    events::{OrderAccepted, OrderDeniedReason, OrderEventAny},
69    identifiers::{
70        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TraderId, Venue, VenueOrderId,
71    },
72    instruments::{Instrument, InstrumentAny},
73    orders::{Order, OrderAny},
74    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
75    types::{AccountBalance, MarginBalance, Quantity},
76};
77use nautilus_network::error::SendError;
78use parking_lot::Mutex;
79use rust_decimal::Decimal;
80use serde::Deserialize;
81use tokio_util::sync::CancellationToken;
82use zeroize::Zeroizing;
83
84use crate::{
85    common::{
86        consts::{DISCONNECT_TIMEOUT, LIGHTER_ERROR_CODE_INVALID_NONCE, LIGHTER_MAX_BATCH_TX},
87        credential::{Credential, scrub_auth},
88        deployment,
89        enums::{
90            LighterAccountTier, LighterPositionMarginMode, LighterProductType, LighterTxStatus,
91            LighterTxType,
92        },
93        rate_limit::{LighterTxRateLimiter, await_tx_quota, build_tx_rate_limiter, resolve_quota},
94        symbol::{MarketRegistry, product_type_from_instrument_id},
95    },
96    config::LighterExecutionClientConfig,
97    http::{
98        client::{LIGHTER_REST_PAGE_SIZE, LighterHttpClient, LighterRawHttpClient},
99        error::LighterHttpError,
100        models::{LighterAccountDetail, LighterSendTxRequest},
101        query::{
102            LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
103            LighterSortDirection, LighterTradeSortBy, LighterTradesQuery,
104        },
105    },
106    signing::{
107        auth_token::{build_auth_token_for, fresh_k},
108        nonce::NonceError,
109        tx::{
110            ApproveIntegratorTxInfo, CancelOrderTxInfo, CreateOrderTxInfo, L2TxAttributes,
111            ModifyOrderTxInfo, OrderInfo, TxContext, TxInfoJson, UpdateLeverageTxInfo, sign_tx,
112        },
113    },
114    websocket::{
115        LighterWsError, USER_STREAMS_ENDPOINT,
116        client::{LighterWebSocketClient, RetainedTaskSlot, TaskRetentionGuard},
117        dispatch::{
118            LIGHTER_INSTRUMENT_CACHE, MAX_RECONCILIATION_PAGES, OrderIdentity, PendingOrderAction,
119            PendingSendTx, PendingSendTxKind, TradeDedupSource, WsDispatchState,
120            cache_instruments_for_reports, derive_market_order_price_ticks,
121            evict_terminal_mappings, lookup_create_order_status_report, lookup_order_status_report,
122            nautilus_to_lighter_order_type, nautilus_to_lighter_tif, order_expiry_for,
123            parse_http_order_to_report, price_to_ticks, quantity_to_ticks,
124        },
125        messages::{
126            AccountStream, ExecutionReport, LighterWsChannel, NautilusWsMessage,
127            SendTxRejectionSource,
128        },
129        parse::{
130            LighterCommissionError, OpenFrameContext, ParsedOrderEvent, lighter_order_shape,
131            parse_lighter_order_event, parse_lighter_order_filled, parse_lighter_trade_id,
132            parse_ws_fill_report, parse_ws_order_status_report,
133        },
134    },
135};
136
137/// Default `expired_at` window applied to a signed tx if the order does not
138/// supply its own GTD expiry: 5 minutes from wall-clock at submission time.
139const DEFAULT_TX_EXPIRY_MS: i64 = 5 * 60 * 1_000;
140
141/// Delay between venue lookups for an acknowledged order.
142const ACKED_ORDER_LOOKUP_DELAY: Duration = Duration::from_secs(2);
143const ACKED_CREATE_PROBE_ATTEMPTS: usize = 3;
144
145const STRATEGY_REASON_MAX_CHARS: usize = 512;
146
147/// Refresh the auth token this far before its issuance deadline. The
148/// [`crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS`] is 7 hours;
149/// rotating at 6 hours leaves an hour of headroom for transient refresh
150/// failures.
151const AUTH_TOKEN_REFRESH_INTERVAL: std::time::Duration =
152    std::time::Duration::from_secs(6 * 60 * 60);
153
154// Refresh interval must stay below the token TTL, or rotation hands out already-expired tokens
155const _: () = assert!(
156    AUTH_TOKEN_REFRESH_INTERVAL.as_secs()
157        < crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS as u64,
158    "AUTH_TOKEN_REFRESH_INTERVAL must stay below DEFAULT_AUTH_TOKEN_TTL_SECS",
159);
160
161// Retry budget after a scheduled rotation failure: 7 h TTL minus the 6 h
162// refresh cadence leaves one hour before the old token expires.
163const AUTH_TOKEN_REFRESH_RETRY_WINDOW: Duration = Duration::from_secs(60 * 60);
164const AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(30);
165
166// Also used as the cadence after a retry window is exhausted
167const AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY: Duration = Duration::from_secs(5 * 60);
168const AUTH_TOKEN_REFRESH_BACKOFF: AuthTokenRefreshBackoff = AuthTokenRefreshBackoff {
169    initial_delay: AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY,
170    max_delay: AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY,
171    window: AUTH_TOKEN_REFRESH_RETRY_WINDOW,
172};
173const NONCE_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(1);
174const NONCE_REFRESH_RETRY_MAX_DELAY: Duration = Duration::from_secs(30);
175// Bounds the startup account snapshot so a slow or failing `/account`
176// endpoint cannot stall connect for the HTTP retry budget.
177const ACCOUNT_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
178const REFERRAL_ATTRIBUTION_TIMEOUT: Duration = Duration::from_secs(10);
179
180/// Attribution window for a bare venue error frame. The frame carries no
181/// `tx_hash` or cloid; if the oldest pending sendTx was submitted within
182/// this window we attribute and emit `OrderRejected`. Outside the window
183/// the existing submit-timeout drives expiry.
184const SENDTX_BARE_ERROR_WINDOW_MS: u64 = 1_000;
185const INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS: i64 = 5 * 365 * 24 * 60 * 60 * 1_000;
186const INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK: u32 = 0;
187const NONCE_CONNECTION_EPOCH_UNAVAILABLE: u64 = u64::MAX;
188
189#[derive(Debug)]
190pub struct LighterExecutionClient {
191    core: ExecutionClientCore,
192    clock: &'static AtomicTime,
193    config: LighterExecutionClientConfig,
194    account_tier: Option<LighterAccountTier>,
195    emitter: ExecutionEventEmitter,
196    credential: Option<Credential>,
197    http_client: LighterHttpClient,
198    ws_client: LighterWebSocketClient,
199    tx_rate_limiter: Arc<LighterTxRateLimiter>,
200    tx_send_sequencer: TxSendSequencer,
201    nonce_submission_gate: Arc<tokio::sync::RwLock<()>>,
202    nonce_ready_connection_epoch: Arc<AtomicU64>,
203    registry: Arc<MarketRegistry>,
204    socket_factory: SocketControlFactory,
205    pending_tasks: TaskGroup,
206    ws_stream_handle: TaskSlot<()>,
207    ws_disconnect_handle: TaskSlot<Result<(), LighterWsError>>,
208    ws_handler_retained: Arc<RetainedTaskSlot>,
209    auth_refresh_handle: TaskSlot<()>,
210    shutdown_errors: Vec<String>,
211    cancellation_token: CancellationToken,
212    dispatch: WsDispatchState,
213    nonce_recovery_inflight: Arc<AtomicBool>,
214    auth_refresh_notify: Arc<tokio::sync::Notify>,
215}
216
217impl LighterExecutionClient {
218    fn log_report_receipt(count: usize, report_type: &str, log_level: LogLevel) {
219        let plural = if count == 1 { "" } else { "s" };
220        let message = format!("Received {count} {report_type}{plural}");
221
222        match log_level {
223            LogLevel::Off => {}
224            LogLevel::Trace => log::trace!("{message}"),
225            LogLevel::Debug => log::debug!("{message}"),
226            LogLevel::Info => log::info!("{message}"),
227            LogLevel::Warning => log::warn!("{message}"),
228            LogLevel::Error => log::error!("{message}"),
229        }
230    }
231
232    /// Creates a new [`LighterExecutionClient`] instance.
233    ///
234    /// Resolves credentials from `config` or the matching environment
235    /// variables (see [`crate::common::credential`]). Missing credentials
236    /// degrade to an unauthenticated client that can bootstrap instruments
237    /// but cannot submit transactions; the constructor returns an error if
238    /// supplied values are malformed.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the HTTP client fails to initialize or if any
243    /// supplied credential value cannot be parsed.
244    pub fn new(
245        core: ExecutionClientCore,
246        config: LighterExecutionClientConfig,
247    ) -> anyhow::Result<Self> {
248        anyhow::ensure!(
249            core.venue == config.resolved_venue(),
250            "Lighter execution core venue {} does not match configured venue {}",
251            core.venue,
252            config.resolved_venue(),
253        );
254
255        anyhow::ensure!(
256            config.account_id.get_issuer() == core.venue,
257            "Lighter account ID issuer {} does not match configured venue {}",
258            config.account_id.get_issuer(),
259            core.venue,
260        );
261
262        let credential = Credential::resolve_for_deployment(
263            config.private_key.clone(),
264            config.account_index,
265            config.api_key_index,
266            config.deployment,
267            config.environment,
268        )
269        .context("failed to resolve Lighter credentials")?;
270
271        let registry = Arc::new(MarketRegistry::new_with_venue_and_settlement_currency(
272            core.venue,
273            config.settlement_currency(),
274        ));
275        let socket_factory = SocketControlFactory::new(core.client_id, Some(core.venue));
276
277        // One transaction limiter shared across the HTTP and WebSocket sendTx
278        // paths so their combined rate honours the single per-account venue bucket.
279        let tx_rate_limiter = build_tx_rate_limiter(config.sendtx_quota_per_min);
280
281        let raw_http = LighterRawHttpClient::new_with_quotas(
282            config.environment,
283            Some(config.http_url()),
284            config.http_timeout_secs,
285            config.proxy_url.clone(),
286            resolve_quota(config.rest_quota_per_min),
287            Some(Arc::clone(&tx_rate_limiter)),
288        )
289        .context("failed to construct Lighter raw HTTP client")?;
290
291        let http_client =
292            LighterHttpClient::from_raw_with_registry(raw_http, Arc::clone(&registry));
293
294        let ws_client = Self::create_ws_client(&config, Arc::clone(&registry), &socket_factory);
295
296        let clock = get_atomic_clock_realtime();
297        let emitter = ExecutionEventEmitter::new(
298            clock,
299            core.trader_id,
300            core.account_id,
301            AccountType::Margin,
302            None,
303        );
304        let pending_tasks = TaskGroup::new();
305
306        Ok(Self {
307            core,
308            clock,
309            config,
310            account_tier: None,
311            emitter,
312            credential,
313            http_client,
314            ws_client,
315            tx_rate_limiter,
316            tx_send_sequencer: TxSendSequencer::new(),
317            nonce_submission_gate: Arc::new(tokio::sync::RwLock::new(())),
318            nonce_ready_connection_epoch: Arc::new(AtomicU64::new(0)),
319            registry,
320            socket_factory,
321            cancellation_token: pending_tasks.cancellation_token(),
322            pending_tasks,
323            ws_stream_handle: TaskSlot::new(),
324            ws_disconnect_handle: TaskSlot::new(),
325            ws_handler_retained: Arc::new(RetainedTaskSlot::new()),
326            auth_refresh_handle: TaskSlot::new(),
327            shutdown_errors: Vec::new(),
328            dispatch: WsDispatchState::new(),
329            nonce_recovery_inflight: Arc::new(AtomicBool::new(false)),
330            auth_refresh_notify: Arc::new(tokio::sync::Notify::new()),
331        })
332    }
333
334    /// Returns a reference to the configuration.
335    #[must_use]
336    pub fn config(&self) -> &LighterExecutionClientConfig {
337        &self.config
338    }
339
340    /// Returns `true` when the client holds resolved Lighter credentials.
341    #[must_use]
342    pub fn has_credentials(&self) -> bool {
343        self.credential.is_some()
344    }
345
346    /// Returns `true` when every retained background task has completed.
347    /// Useful in tests to wait for fire-and-forget HTTP work.
348    #[must_use]
349    pub fn pending_tasks_all_finished(&self) -> bool {
350        self.pending_tasks.all_finished()
351    }
352
353    fn create_ws_client(
354        config: &LighterExecutionClientConfig,
355        registry: Arc<MarketRegistry>,
356        socket_factory: &SocketControlFactory,
357    ) -> LighterWebSocketClient {
358        let ws_client = LighterWebSocketClient::new(
359            Some(config.ws_url()),
360            config.environment,
361            registry,
362            config.transport_backend,
363            config.ws_timeout_secs,
364            config.proxy_url.clone(),
365        );
366
367        ws_client.with_socket_control(socket_factory.control(USER_STREAMS_ENDPOINT))
368    }
369
370    fn take_ws_client(&mut self) -> LighterWebSocketClient {
371        let cached_instruments = self.ws_client.instruments_cache();
372        let ws_cache = cached_instruments
373            .iter()
374            .map(|entry| (*entry.key(), entry.value().clone()))
375            .collect();
376        let replacement = Self::create_ws_client(
377            &self.config,
378            Arc::clone(&self.registry),
379            &self.socket_factory,
380        );
381        replacement.cache_instruments(ws_cache);
382
383        std::mem::replace(&mut self.ws_client, replacement)
384    }
385
386    fn spawn_task<F>(&self, description: &'static str, fut: F)
387    where
388        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
389    {
390        let future = async move {
391            if let Err(e) = fut.await {
392                log::warn!("{description} failed: {e:?}");
393            }
394        };
395
396        if let Err(e) = self.pending_tasks.spawn(future) {
397            log::warn!("Skipping Lighter {description} after shutdown began: {e}");
398        }
399    }
400
401    fn begin_session_shutdown(&mut self) {
402        self.pending_tasks.begin_shutdown();
403        self.cancellation_token.cancel();
404        self.ws_client.begin_shutdown();
405        self.core.set_disconnected();
406
407        if self.ws_disconnect_handle.is_none()
408            && (self.ws_client.is_active() || self.ws_stream_handle.is_some())
409        {
410            let ws_client = self.take_ws_client();
411            let retained = Arc::clone(&self.ws_handler_retained);
412
413            if let Err(e) = self
414                .ws_disconnect_handle
415                .spawn(ws_client.disconnect_with_task_retention(retained))
416            {
417                log::error!("Failed to start Lighter WebSocket disconnect task: {e}");
418            }
419        }
420    }
421
422    fn session_tasks_finished(&self) -> bool {
423        self.ws_stream_handle.is_none()
424            && self.ws_disconnect_handle.is_none()
425            && self.ws_handler_retained.is_empty()
426            && self.auth_refresh_handle.is_none()
427            && self.pending_tasks.is_empty()
428            && self.shutdown_errors.is_empty()
429    }
430
431    async fn finish_session_shutdown(&mut self) -> anyhow::Result<()> {
432        self.pending_tasks.begin_shutdown();
433
434        Self::finish_disconnect_task(
435            &mut self.ws_disconnect_handle,
436            "WebSocket disconnect",
437            &mut self.shutdown_errors,
438        )
439        .await;
440
441        if let Err(e) = self.ws_handler_retained.finish().await {
442            self.shutdown_errors.push(e.to_string());
443        }
444        Self::finish_owned_task(
445            &mut self.ws_stream_handle,
446            "execution consumer",
447            &mut self.shutdown_errors,
448        )
449        .await;
450        Self::finish_owned_task(
451            &mut self.auth_refresh_handle,
452            "auth-token refresh",
453            &mut self.shutdown_errors,
454        )
455        .await;
456
457        if let Err(e) = self
458            .pending_tasks
459            .finish_shutdown(Duration::from_secs(1), DISCONNECT_TIMEOUT)
460            .await
461        {
462            self.shutdown_errors
463                .push(format!("client-owned tasks failed: {e}"));
464        }
465
466        let stale = self.dispatch.take_pending_sendtx();
467        warn_pending_sendtx_unknown(&stale, "session shutdown");
468        self.nonce_recovery_inflight.store(false, Ordering::Release);
469
470        if !self.shutdown_errors.is_empty() {
471            let errors = std::mem::take(&mut self.shutdown_errors);
472            anyhow::bail!("Failed to terminate Lighter tasks: {}", errors.join("; "));
473        }
474        Ok(())
475    }
476
477    async fn finish_owned_task(
478        slot: &mut TaskSlot<()>,
479        description: &str,
480        errors: &mut Vec<String>,
481    ) {
482        let Some(outcome) = finish_task(slot, DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
483            return;
484        };
485
486        match outcome {
487            TaskJoinOutcome::Completed(()) => {
488                log::debug!("Lighter {description} task completed");
489            }
490            TaskJoinOutcome::Aborted => {
491                log::debug!("Lighter {description} task cancelled");
492            }
493            TaskJoinOutcome::Failed(e) => {
494                errors.push(format!("{description} task failed: {e}"));
495            }
496            TaskJoinOutcome::Incomplete => {
497                errors.push(format!("{description} task did not stop after abort"));
498            }
499        }
500    }
501
502    async fn finish_disconnect_task(
503        slot: &mut TaskSlot<Result<(), LighterWsError>>,
504        description: &str,
505        errors: &mut Vec<String>,
506    ) {
507        let Some(outcome) = finish_task(slot, DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
508            return;
509        };
510
511        match outcome {
512            TaskJoinOutcome::Completed(Ok(())) | TaskJoinOutcome::Aborted => {}
513            TaskJoinOutcome::Completed(Err(e)) => {
514                errors.push(format!("{description} failed: {e}"));
515            }
516            TaskJoinOutcome::Failed(e) => {
517                errors.push(format!("{description} task failed: {e}"));
518            }
519            TaskJoinOutcome::Incomplete => {
520                errors.push(format!("{description} task did not stop after abort"));
521            }
522        }
523    }
524
525    async fn ensure_instruments_initialized_async(&self) -> anyhow::Result<()> {
526        if self.core.instruments_initialized() {
527            return Ok(());
528        }
529
530        let instruments = self
531            .http_client
532            .request_instruments()
533            .await
534            .context("failed to request Lighter instruments")?;
535
536        let ws_cache: Vec<(i16, InstrumentAny)> = instruments
537            .iter()
538            .filter_map(|instrument| {
539                self.registry
540                    .market_index(&instrument.id())
541                    .map(|market_index| (market_index, instrument.clone()))
542            })
543            .collect();
544        self.ws_client.cache_instruments(ws_cache);
545        cache_instruments_for_reports(&instruments);
546
547        log::debug!(
548            "Bootstrapped {} Lighter instruments ({} registry entries)",
549            instruments.len(),
550            self.registry.len(),
551        );
552
553        self.core.set_instruments_initialized();
554        Ok(())
555    }
556
557    async fn await_account_streams_ready(&self, timeout_secs: f64) -> anyhow::Result<()> {
558        let timeout = Duration::from_secs_f64(timeout_secs);
559        self.dispatch.account_streams_ready.await_all(timeout).await
560    }
561
562    async fn refresh_nonce(&self) -> anyhow::Result<()> {
563        let Some(credential) = &self.credential else {
564            return Ok(());
565        };
566        self.nonce_ready_connection_epoch
567            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
568        let _refresh_guard = self.nonce_submission_gate.write().await;
569
570        let response = self
571            .http_client
572            .get_next_nonce(credential.account_index(), credential.api_key_index())
573            .await
574            .context("failed to fetch Lighter nextNonce")?;
575
576        self.dispatch.nonce_manager.refresh(
577            credential.account_index(),
578            credential.api_key_index(),
579            response.nonce,
580        );
581
582        // Release the latch in case a disconnect aborted recovery mid-task
583        self.nonce_recovery_inflight.store(false, Ordering::Release);
584
585        log::debug!(
586            "Refreshed Lighter nonce baseline: account_index={}, api_key_index={}, next_nonce={}",
587            credential.account_index(),
588            credential.api_key_index(),
589            response.nonce,
590        );
591        Ok(())
592    }
593
594    async fn sync_nonce_from_venue(&self) -> anyhow::Result<()> {
595        let Some(credential) = &self.credential else {
596            return Ok(());
597        };
598
599        let _refresh_guard = self.nonce_submission_gate.write().await;
600        let response = self
601            .http_client
602            .get_next_nonce(credential.account_index(), credential.api_key_index())
603            .await
604            .context("failed to fetch Lighter nextNonce")?;
605        self.dispatch.nonce_manager.sync_from_venue(
606            credential.account_index(),
607            credential.api_key_index(),
608            response.nonce,
609        )?;
610        Ok(())
611    }
612
613    async fn fetch_account_detail(&self) -> Option<LighterAccountDetail> {
614        let Some(credential) = &self.credential else {
615            return None;
616        };
617        let account_index = credential.account_index();
618
619        match tokio::time::timeout(
620            ACCOUNT_SNAPSHOT_TIMEOUT,
621            self.http_client.get_account_detail(account_index),
622        )
623        .await
624        {
625            Ok(Ok(detail)) => Some(detail),
626            Ok(Err(e)) => {
627                log::warn!(
628                    "Failed to fetch Lighter account detail for account_index={account_index}; \
629                     continuing startup without account metadata: {e}"
630                );
631                None
632            }
633            Err(_) => {
634                log::warn!(
635                    "Lighter account detail timed out after {}s for account_index={account_index}; \
636                     continuing startup without account metadata",
637                    ACCOUNT_SNAPSHOT_TIMEOUT.as_secs()
638                );
639                None
640            }
641        }
642    }
643
644    // Logs the venue-reported account tier in blue. Informational only: the
645    // active quotas are resolved from config at construction, never raised here
646    // (the higher venue limits require registering the caller IP, so the tier
647    // alone does not guarantee them).
648    fn detect_account_tier(&self, detail: &LighterAccountDetail) -> LighterAccountTier {
649        let account_index = detail.account_index;
650        let code = detail.account_type;
651        let tier = LighterAccountTier::from_code(code);
652        let standard_rest = LighterAccountTier::Standard
653            .documented_rest_quota_per_min()
654            .unwrap_or(60);
655        let (active_rest, cross_check) =
656            tier_quota_report(tier, self.config.rest_quota_per_min, standard_rest);
657
658        log_debug!(
659            "Lighter execution account {account_index} reported tier {tier} \
660             (account_type={code}); active REST quota {active_rest} req/min",
661            color = LogColor::Blue
662        );
663
664        match cross_check {
665            Some(TierCrossCheck::AboveTier { documented }) => {
666                log::warn!(
667                    "Configured Lighter rest_quota_per_min={active_rest} exceeds the {tier} tier \
668                     limit of {documented} req/min; the venue may reject requests unless the \
669                     caller IP is registered for the higher limit"
670                );
671            }
672            Some(TierCrossCheck::RaiseHint { documented }) => {
673                log_debug!(
674                    "Lighter {tier} tier permits up to {documented} REST req/min; set \
675                     rest_quota_per_min (and register the caller IP with Lighter) to use it",
676                    color = LogColor::Blue
677                );
678            }
679            None => {}
680        }
681
682        tier
683    }
684
685    fn integrator_account_index(&self) -> Option<u64> {
686        if matches!(
687            self.account_tier,
688            Some(LighterAccountTier::Plus | LighterAccountTier::Premium)
689        ) {
690            deployment::integrator_account_index(self.config.deployment, self.config.environment)
691        } else {
692            None
693        }
694    }
695
696    async fn apply_referral_attribution(
697        &self,
698        detail: &LighterAccountDetail,
699    ) -> anyhow::Result<()> {
700        let Some(referral_code) =
701            deployment::referral_code(self.config.deployment, self.config.environment)
702        else {
703            return Ok(());
704        };
705
706        let Some(credential) = &self.credential else {
707            return Ok(());
708        };
709
710        anyhow::ensure!(
711            !detail.l1_address.trim().is_empty(),
712            "Lighter account detail returned an empty L1 address"
713        );
714
715        let auth_token = Zeroizing::new(
716            build_auth_token_for(credential)
717                .context("failed to mint Lighter auth token for referral attribution")?,
718        );
719        let referral_code = Zeroizing::new(referral_code.to_string());
720
721        self.http_client
722            .use_referral(
723                &detail.l1_address,
724                referral_code.as_str(),
725                auth_token.as_str(),
726            )
727            .await
728            .context("failed to apply Lighter referral code")?;
729
730        log::debug!("Applied Robinhood Chain referral attribution");
731        Ok(())
732    }
733
734    /// Returns `Ok(true)` if this credential's `api_key_index` is maker-only.
735    /// Maker-only keys cannot submit `ApproveIntegrator`, so the caller skips
736    /// the integrator auto-approval when `true`.
737    async fn is_maker_only_api_key(&self, credential: &Credential) -> anyhow::Result<bool> {
738        let auth_token = build_auth_token_for(credential)
739            .context("failed to mint Lighter auth token for maker-only check")?;
740        let response = self
741            .http_client
742            .get_maker_only_api_keys(credential.account_index(), auth_token)
743            .await
744            .context("failed to query getMakerOnlyApiKeys")?;
745        let api_key_index = i64::from(credential.api_key_index());
746        Ok(response.api_key_indexes.contains(&api_key_index))
747    }
748
749    async fn submit_integrator_auto_approval(&self) -> anyhow::Result<()> {
750        let Some(integrator_account_index) = self.integrator_account_index() else {
751            return Ok(());
752        };
753
754        let Some(credential) = &self.credential else {
755            return Ok(());
756        };
757
758        let mut maker_only_check_failed = false;
759
760        match self.is_maker_only_api_key(credential).await {
761            Ok(true) => {
762                log::warn!(
763                    "Skipping Lighter integrator auto-approval: api_key_index={} is maker-only; \
764                     ensure the account has been approved by a non-maker-only key",
765                    credential.api_key_index(),
766                );
767                return Ok(());
768            }
769            Ok(false) => {}
770            Err(e) => {
771                maker_only_check_failed = true;
772
773                log::debug!(
774                    "Unable to determine whether the Lighter API key is maker-only; proceeding \
775                     with integrator auto-approval: {e:?}"
776                );
777            }
778        }
779
780        let mut approval =
781            self.prepare_integrator_auto_approval(credential, integrator_account_index)?;
782
783        let request = LighterSendTxRequest::new(
784            LighterTxType::ApproveIntegrator as u8,
785            approval.tx_info.clone(),
786        );
787
788        approval.send_reservation.wait_for_turn().await;
789
790        let response = match self.http_client.send_tx(&request).await {
791            Ok(response) => response,
792            Err(e) => {
793                if lighter_http_error_is_definite_api_rejection(&e) {
794                    let _ = self.dispatch.nonce_manager.ack_failure_if_latest(
795                        credential.account_index(),
796                        approval.api_key_index,
797                        approval.nonce,
798                    );
799                }
800                approval.send_reservation.release();
801                let hint = if maker_only_check_failed {
802                    " (maker-only pre-flight check failed earlier; venue may reject with 62007 \
803                     if this key is maker-only)"
804                } else {
805                    ""
806                };
807                return Err(anyhow::Error::new(e).context(format!(
808                    "failed to submit Lighter integrator approval nonce={} api_key_index={}{hint}",
809                    approval.nonce, approval.api_key_index,
810                )));
811            }
812        };
813
814        let _ = self.dispatch.nonce_manager.ack_success(
815            credential.account_index(),
816            approval.api_key_index,
817            approval.nonce,
818        );
819        approval.send_reservation.release();
820
821        log::debug!(
822            "Submitted Lighter integrator approval: integrator={}, nonce={}, \
823             api_key_index={}, approval_expiry={}, tx_hash={}",
824            integrator_account_index,
825            approval.nonce,
826            approval.api_key_index,
827            approval.approval_expiry,
828            response.tx_hash,
829        );
830        Ok(())
831    }
832
833    fn prepare_integrator_auto_approval(
834        &self,
835        credential: &Credential,
836        integrator_account_index: u64,
837    ) -> anyhow::Result<PreparedIntegratorApproval> {
838        let ReservedTxContext {
839            context,
840            send_reservation,
841        } = self.build_tx_context(credential)?;
842
843        let now_ms = (self.clock.get_time_ns().as_u64() as i64) / 1_000_000;
844        let approval_expiry = now_ms.saturating_add(INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS);
845        let nonce = context.nonce;
846        let api_key_index = context.api_key_index;
847
848        let tx = ApproveIntegratorTxInfo {
849            context,
850            integrator_account_index: integrator_account_index as i64,
851            max_perps_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
852            max_perps_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
853            max_spot_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
854            max_spot_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
855            approval_expiry,
856            skip_nonce: 0,
857        };
858
859        let signed = sign_tx(
860            &tx,
861            self.config.chain_id(),
862            &credential.private_key()?,
863            fresh_k(),
864        );
865
866        let tx_info = TxInfoJson::approve_integrator(&tx, &signed, "");
867
868        Ok(PreparedIntegratorApproval {
869            tx_info,
870            nonce,
871            api_key_index,
872            approval_expiry,
873            send_reservation,
874        })
875    }
876
877    async fn spawn_ws_consumer(&mut self) -> anyhow::Result<()> {
878        // Local clone owns the handler `task_handle` until post-connect
879        // setup succeeds. Transferring earlier would leave failures unable
880        // to drain the task through the clone's `disconnect()`.
881        let mut ws_guard = TaskRetentionGuard::new(
882            self.ws_client.clone(),
883            Arc::clone(&self.ws_handler_retained),
884        );
885        ws_guard
886            .client_mut()
887            .connect_with_cancellation(self.cancellation_token.clone())
888            .await
889            .context("failed to connect to Lighter WebSocket")?;
890
891        // Wrapped so any failure routes through the clone's `disconnect()`
892        // (which still owns the handler task); mirrors Hyperliquid's
893        // `post_ws` block.
894        let post_connect = async {
895            ws_guard
896                .client_mut()
897                .wait_until_active()
898                .await
899                .context("Lighter WebSocket did not reach active state")?;
900
901            if let Some(credential) = &self.credential {
902                let auth_token = build_auth_token_for(credential)
903                    .context("failed to mint Lighter auth token")?;
904                let account_index = credential.account_index();
905
906                ws_guard
907                    .client_mut()
908                    .set_execution_context(self.core.account_id, account_index)
909                    .await
910                    .map_err(|e| anyhow::anyhow!("failed to set Lighter execution context: {e}"))?;
911
912                // Subscribe to the five account-scoped streams the consumption
913                // loop converts into typed reports. The handler merges
914                // `account_all_assets` and `user_stats` into a single
915                // AccountState (see websocket/account_state.rs).
916                let channels = [
917                    LighterWsChannel::AccountAllOrders(account_index),
918                    LighterWsChannel::AccountAllTrades(account_index),
919                    LighterWsChannel::AccountAllPositions(account_index),
920                    LighterWsChannel::AccountAllAssets(account_index),
921                    LighterWsChannel::UserStats(account_index),
922                ];
923
924                for channel in channels {
925                    ws_guard
926                        .client_mut()
927                        .subscribe_account(channel.clone(), auth_token.clone())
928                        .await
929                        .map_err(|e| {
930                            anyhow::anyhow!(
931                                "failed to subscribe to Lighter account channel {channel:?}: {e}",
932                            )
933                        })?;
934                }
935
936                log::debug!("Subscribed to Lighter account streams: account_index={account_index}",);
937            } else {
938                log::warn!(
939                    "Lighter execution client has no credentials: account streams not subscribed; \
940                     typed execution reports will not flow"
941                );
942            }
943
944            Ok::<(), anyhow::Error>(())
945        };
946
947        if let Err(e) = post_connect.await {
948            log::warn!("Lighter post-connect setup failed, tearing down WS: {e}");
949            let ws_client = ws_guard.disarm();
950            let mut rollback_errors = Vec::new();
951
952            if let Err(e) = ws_client
953                .disconnect_with_task_retention(Arc::clone(&self.ws_handler_retained))
954                .await
955            {
956                rollback_errors.push(e.to_string());
957            }
958
959            if let Err(e) = self.ws_handler_retained.finish().await {
960                rollback_errors.push(e.to_string());
961            }
962
963            if rollback_errors.is_empty() {
964                return Err(e);
965            }
966            return Err(e.context(format!(
967                "Lighter post-connect rollback failed: {}",
968                rollback_errors.join("; ")
969            )));
970        }
971
972        let mut ws_client = ws_guard.disarm();
973        self.ws_client.set_task_slot(ws_client.take_task_slot());
974
975        let cancellation_token = self.cancellation_token.clone();
976        let emitter = self.emitter.clone();
977        let dispatch = self.dispatch.clone();
978        let credential_for_loop = self.credential.clone();
979        let http_client_for_loop = self.http_client.clone();
980        let registry_for_loop = Arc::clone(&self.registry);
981        let auth_refresh_notify = Arc::clone(&self.auth_refresh_notify);
982        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
983        let nonce_ready_connection_epoch = Arc::clone(&self.nonce_ready_connection_epoch);
984        let pending_tasks_for_loop = self
985            .pending_tasks
986            .spawner()
987            .context("Lighter task admission is closed")?;
988        let account_id_for_loop = self.core.account_id;
989        let clock_for_loop = self.clock;
990        let nonce_refresh_retry = NonceRefreshRetry {
991            http_client: self.http_client.clone(),
992            dispatch: self.dispatch.clone(),
993            credential: self.credential.clone(),
994            submission_gate: Arc::clone(&self.nonce_submission_gate),
995            ready_connection_epoch: Arc::clone(&self.nonce_ready_connection_epoch),
996            ws_client: ws_client.clone(),
997            cancellation_token: self.cancellation_token.clone(),
998            pending_tasks: pending_tasks_for_loop.clone(),
999        };
1000
1001        self.ws_stream_handle.spawn(async move {
1002            log::debug!("Lighter execution WebSocket consumption loop started");
1003
1004            loop {
1005                tokio::select! {
1006                    () = cancellation_token.cancelled() => {
1007                        log::debug!("Lighter execution consumption loop cancelled");
1008                        break;
1009                    }
1010                    msg_opt = ws_client.next_event() => {
1011                        match msg_opt {
1012                            Some(NautilusWsMessage::ExecutionReports(reports)) => {
1013                                let mut order_count = 0_usize;
1014                                let mut fill_count = 0_usize;
1015                                let trader_id = emitter.trader_id();
1016                                let account_index = credential_for_loop
1017                                    .as_ref()
1018                                    .map(|c| c.account_index());
1019
1020                                for report in reports {
1021                                    match report {
1022                                        ExecutionReport::Order(order) => {
1023                                            order_count += 1;
1024                                            dispatch_lighter_order(
1025                                                &order,
1026                                                &dispatch,
1027                                                &emitter,
1028                                                &registry_for_loop,
1029                                                account_id_for_loop,
1030                                                trader_id,
1031                                                clock_for_loop.get_time_ns(),
1032                                            );
1033                                        }
1034                                        ExecutionReport::Fill(trade) => {
1035                                            fill_count += 1;
1036                                            dispatch_lighter_trade(
1037                                                &trade,
1038                                                &dispatch,
1039                                                &emitter,
1040                                                &registry_for_loop,
1041                                                account_id_for_loop,
1042                                                trader_id,
1043                                                account_index,
1044                                                clock_for_loop.get_time_ns(),
1045                                            );
1046                                        }
1047                                    }
1048                                }
1049                                log::debug!(
1050                                    "Lighter execution batch: orders={order_count} fills={fill_count}",
1051                                );
1052                            }
1053                            Some(NautilusWsMessage::PositionSnapshot {
1054                                reports,
1055                                skipped_market_ids,
1056                            }) => {
1057                                // Replace even when empty, but keep rows the
1058                                // handler could not parse or map.
1059                                for r in &reports {
1060                                    if let Some(idx) =
1061                                        registry_for_loop.market_index(&r.instrument_id)
1062                                    {
1063                                        dispatch.note_active_market(idx);
1064                                    }
1065                                }
1066                                let position_count = reports.len();
1067                                let retained_positions: Vec<InstrumentId> = skipped_market_ids
1068                                    .iter()
1069                                    .filter_map(|market_id| {
1070                                        registry_for_loop.instrument_id(*market_id)
1071                                    })
1072                                    .collect();
1073                                let removed = dispatch.replace_position_snapshot(
1074                                    &reports,
1075                                    &retained_positions,
1076                                    &skipped_market_ids,
1077                                );
1078                                log::debug!(
1079                                    "Lighter position snapshot: positions={position_count}, skipped_markets={}, removed={}",
1080                                    skipped_market_ids.len(),
1081                                    removed.len(),
1082                                );
1083                                emit_lighter_position_reports(
1084                                    reports,
1085                                    removed,
1086                                    &emitter,
1087                                    account_id_for_loop,
1088                                    clock_for_loop.get_time_ns(),
1089                                );
1090                            }
1091                            Some(NautilusWsMessage::PositionUpdate {
1092                                reports,
1093                                closed_market_ids,
1094                                skipped_market_ids,
1095                            }) => {
1096                                let mut covered_market_ids = closed_market_ids.clone();
1097
1098                                for report in &reports {
1099                                    if let Some(market_id) =
1100                                        registry_for_loop.market_index(&report.instrument_id)
1101                                    {
1102                                        dispatch.note_active_market(market_id);
1103                                        covered_market_ids.push(market_id);
1104                                    }
1105                                }
1106                                let position_count = reports.len();
1107                                let closed_positions: Vec<InstrumentId> = closed_market_ids
1108                                    .iter()
1109                                    .filter_map(|market_id| {
1110                                        registry_for_loop.instrument_id(*market_id)
1111                                    })
1112                                    .collect();
1113                                let removed = dispatch.apply_position_update(
1114                                    &reports,
1115                                    &closed_positions,
1116                                    &covered_market_ids,
1117                                    &skipped_market_ids,
1118                                );
1119                                log::debug!(
1120                                    "Lighter position update: positions={position_count}, closed_markets={}, skipped_markets={}, removed={}",
1121                                    closed_market_ids.len(),
1122                                    skipped_market_ids.len(),
1123                                    removed.len(),
1124                                );
1125                                emit_lighter_position_reports(
1126                                    reports,
1127                                    removed,
1128                                    &emitter,
1129                                    account_id_for_loop,
1130                                    clock_for_loop.get_time_ns(),
1131                                );
1132                            }
1133                            Some(NautilusWsMessage::AccountState(state)) => {
1134                                log::debug!(
1135                                    "Lighter AccountState: balances={} margins={}",
1136                                    state.balances.len(),
1137                                    state.margins.len(),
1138                                );
1139                                // Cache so query_account can serve a recent
1140                                // snapshot without a REST round-trip; Lighter
1141                                // does not currently expose a REST account
1142                                // endpoint that would make a fresh fetch
1143                                // possible.
1144                                dispatch.cache_account_state((*state).clone());
1145                                emitter.send_account_state(*state);
1146                            }
1147                            Some(NautilusWsMessage::Reconnected { connection_epoch }) => {
1148                                dispatch.invalidate_position_snapshot();
1149                                nonce_ready_connection_epoch.store(
1150                                    NONCE_CONNECTION_EPOCH_UNAVAILABLE,
1151                                    Ordering::Release,
1152                                );
1153                                // Subscriptions are restored by
1154                                // `LighterWebSocketClient`'s reconnect logic;
1155                                // the execution context is preserved by the
1156                                // handler across reconnects. Refresh the nonce
1157                                // baseline since the venue's expected next
1158                                // nonce may have advanced while we were
1159                                // disconnected.
1160                                log::debug!("Lighter WebSocket reconnected (execution stream)");
1161                                auth_refresh_notify.notify_one();
1162
1163                                // No cache touch here: the next venue
1164                                // `account_all_positions` snapshot is
1165                                // authoritative and drives the diff. A
1166                                // synthetic flat from the lifecycle would
1167                                // produce a false close+reopen on a healthy
1168                                // flap. Trade-off: between reconnect and the
1169                                // next snapshot (~<1s typically),
1170                                // `generate_position_status_reports` may
1171                                // return stale data.
1172
1173                                // Drained creates have unknown outcomes;
1174                                // reconciliation resolves them, so warn
1175                                // rather than emit rejections.
1176                                let _refresh_guard = nonce_submission_gate.write().await;
1177                                let disconnected_epoch = connection_epoch.saturating_sub(1);
1178                                let stale = dispatch.drain_pending_sendtx(disconnected_epoch);
1179                                warn_pending_sendtx_unknown(&stale, "reconnect");
1180
1181                                if let Some(credential) = &credential_for_loop {
1182                                    match http_client_for_loop
1183                                        .get_next_nonce(
1184                                            credential.account_index(),
1185                                            credential.api_key_index(),
1186                                        )
1187                                        .await
1188                                    {
1189                                        Ok(response) => {
1190                                            dispatch.nonce_manager.refresh(
1191                                                credential.account_index(),
1192                                                credential.api_key_index(),
1193                                                response.nonce,
1194                                            );
1195                                            nonce_ready_connection_epoch
1196                                                .store(connection_epoch, Ordering::Release);
1197                                            log::debug!(
1198                                                "Refreshed Lighter nonce after reconnect: \
1199                                                 account_index={}, next_nonce={}",
1200                                                credential.account_index(),
1201                                                response.nonce,
1202                                            );
1203                                        }
1204                                        Err(e) => {
1205                                            log::error!(
1206                                                "Failed to refresh Lighter nonce after reconnect: {e}",
1207                                            );
1208                                            nonce_refresh_retry
1209                                                .clone()
1210                                                .spawn(connection_epoch);
1211                                        }
1212                                    }
1213                                }
1214                            }
1215                            Some(NautilusWsMessage::SendTxAck {
1216                                connection_epoch,
1217                                tx_hash,
1218                                code,
1219                            }) => {
1220                                let account_index = credential_for_loop
1221                                    .as_ref()
1222                                    .map(|c| c.account_index());
1223                                let acked = handle_send_tx_ack_for_connection(
1224                                    &dispatch,
1225                                    account_index,
1226                                    connection_epoch,
1227                                    code,
1228                                    tx_hash.as_deref(),
1229                                );
1230
1231                                if let (Some(pending), Some(credential)) =
1232                                    (acked, credential_for_loop.clone())
1233                                {
1234                                    spawn_acked_order_probe(
1235                                        &pending,
1236                                        AckedOrderProbeContext {
1237                                            http_client: http_client_for_loop.clone(),
1238                                            registry: Arc::clone(&registry_for_loop),
1239                                            credential,
1240                                            dispatch: dispatch.clone(),
1241                                            account_id: account_id_for_loop,
1242                                            clock: clock_for_loop,
1243                                            emitter: emitter.clone(),
1244                                            connection_epoch: ws_client.connection_epoch_atomic(),
1245                                            cancellation_token: cancellation_token.clone(),
1246                                            pending_tasks: pending_tasks_for_loop.clone(),
1247                                        },
1248                                    );
1249                                }
1250                            }
1251                            Some(NautilusWsMessage::SendTxRejected {
1252                                connection_epoch,
1253                                source,
1254                                code,
1255                                message,
1256                                tx_hash,
1257                            }) => {
1258                                let account_index = credential_for_loop
1259                                    .as_ref()
1260                                    .map(|c| c.account_index());
1261                                let needs_nonce_resync = handle_send_tx_rejection_for_connection(
1262                                    &dispatch,
1263                                    &emitter,
1264                                    account_index,
1265                                    connection_epoch,
1266                                    clock_for_loop.get_time_ns(),
1267                                    source,
1268                                    code,
1269                                    &message,
1270                                    tx_hash.as_deref(),
1271                                );
1272
1273                                // Invalid nonce means the sequential stream is
1274                                // wedged on a burned nonce; only a hard refresh
1275                                // moves allocation back down.
1276                                if needs_nonce_resync
1277                                    && let Some(credential) = &credential_for_loop
1278                                {
1279                                    if ws_client.connection_epoch() != connection_epoch {
1280                                        log::warn!(
1281                                            "Skipping stale Lighter nonce rejection from connection \
1282                                             epoch {connection_epoch}",
1283                                        );
1284                                        continue;
1285                                    }
1286                                    nonce_ready_connection_epoch.store(
1287                                        NONCE_CONNECTION_EPOCH_UNAVAILABLE,
1288                                        Ordering::Release,
1289                                    );
1290                                    let _refresh_guard = nonce_submission_gate.write().await;
1291
1292                                    match http_client_for_loop
1293                                        .get_next_nonce(
1294                                            credential.account_index(),
1295                                            credential.api_key_index(),
1296                                        )
1297                                        .await
1298                                    {
1299                                        Ok(response) => {
1300                                            dispatch.nonce_manager.refresh(
1301                                                credential.account_index(),
1302                                                credential.api_key_index(),
1303                                                response.nonce,
1304                                            );
1305                                            nonce_ready_connection_epoch
1306                                                .store(connection_epoch, Ordering::Release);
1307                                            log::debug!(
1308                                                "Hard-refreshed Lighter nonce after invalid-nonce \
1309                                                 rejection: account_index={}, next_nonce={}",
1310                                                credential.account_index(),
1311                                                response.nonce,
1312                                            );
1313                                        }
1314                                        Err(e) => {
1315                                            log::error!(
1316                                                "Failed to refresh Lighter nonce after \
1317                                                 invalid-nonce rejection: {e}",
1318                                            );
1319                                            nonce_refresh_retry
1320                                                .clone()
1321                                                .spawn(connection_epoch);
1322                                        }
1323                                    }
1324                                }
1325                            }
1326                            Some(NautilusWsMessage::Raw(value)) => {
1327                                log::debug!("Unhandled Lighter raw frame on execution stream: {value}");
1328                            }
1329                            Some(NautilusWsMessage::AccountStreamFirstFrame(stream)) => {
1330                                // FIFO with preceding reports on the same
1331                                // channel: any typed reports the handler
1332                                // emitted for this frame have already been
1333                                // applied by the cases above, so marking
1334                                // here is safe to unblock `await_all`.
1335                                match stream {
1336                                    AccountStream::Orders => {
1337                                        dispatch.account_streams_ready.mark_orders();
1338                                    }
1339                                    AccountStream::Trades => {
1340                                        dispatch.account_streams_ready.mark_trades();
1341                                    }
1342                                    AccountStream::Positions => {
1343                                        dispatch.account_streams_ready.mark_positions();
1344                                    }
1345                                    AccountStream::Assets => {
1346                                        dispatch.account_streams_ready.mark_assets();
1347                                    }
1348                                    AccountStream::UserStats => {
1349                                        dispatch.account_streams_ready.mark_user_stats();
1350                                    }
1351                                }
1352                            }
1353                            // Public market data variants reach the execution
1354                            // stream only if the user shares a websocket clone
1355                            // with the data client (no production caller does).
1356                            Some(
1357                                NautilusWsMessage::Trades(_)
1358                                | NautilusWsMessage::Quote(_)
1359                                | NautilusWsMessage::Deltas(_)
1360                                | NautilusWsMessage::Depth10(_)
1361                                | NautilusWsMessage::Bar(_)
1362                                | NautilusWsMessage::MarkPrice(_)
1363                                | NautilusWsMessage::IndexPrice(_)
1364                                | NautilusWsMessage::FundingRate(_),
1365                            ) => {}
1366                            None => {
1367                                log::debug!("Lighter execution next_event returned None");
1368                                tokio::select! {
1369                                    () = cancellation_token.cancelled() => {
1370                                        log::debug!(
1371                                            "Lighter execution consumption loop cancelled"
1372                                        );
1373                                        break;
1374                                    }
1375                                    () = tokio::time::sleep(Duration::from_secs(1)) => {}
1376                                }
1377                            }
1378                        }
1379                    }
1380                }
1381            }
1382
1383            log::debug!("Lighter execution WebSocket consumption loop finished");
1384        })?;
1385
1386        if let Some(credential) = &self.credential {
1387            self.spawn_auth_token_refresh(credential.clone())?;
1388        }
1389
1390        Ok(())
1391    }
1392
1393    fn spawn_auth_token_refresh(&mut self, credential: Credential) -> anyhow::Result<()> {
1394        let ws_client = self.ws_client.clone();
1395        let cancellation_token = self.cancellation_token.clone();
1396        let account_index = credential.account_index();
1397        let channels = auth_token_rotation_channels(account_index);
1398        let refresh_notify = Arc::clone(&self.auth_refresh_notify);
1399
1400        self.auth_refresh_handle.spawn(async move {
1401            log::debug!(
1402                "Lighter auth-token refresh task started: interval={}s, account_index={account_index}",
1403                AUTH_TOKEN_REFRESH_INTERVAL.as_secs(),
1404            );
1405
1406            let mut next_refresh_delay = AUTH_TOKEN_REFRESH_INTERVAL;
1407
1408            loop {
1409                tokio::select! {
1410                    biased;
1411                    () = cancellation_token.cancelled() => {
1412                        log::debug!("Lighter auth-token refresh task cancelled");
1413                        break;
1414                    }
1415                    () = refresh_notify.notified() => {
1416                        log::debug!(
1417                            "Refreshing Lighter auth token immediately after reconnect: account_index={account_index}",
1418                        );
1419                    }
1420                    () = tokio::time::sleep(next_refresh_delay) => {}
1421                }
1422
1423                let outcome = refresh_auth_token_until_rotated(
1424                    &credential,
1425                    &channels,
1426                    &cancellation_token,
1427                    AUTH_TOKEN_REFRESH_BACKOFF,
1428                    |credential| -> anyhow::Result<String> { build_auth_token_for(credential) },
1429                    |channel, token| {
1430                        let ws_client = ws_client.clone();
1431                        async move { ws_client.subscribe_account(channel, token).await }
1432                    },
1433                )
1434                .await;
1435
1436                if let Some(delay) = auth_token_refresh_next_delay(outcome) {
1437                    if outcome == AuthTokenRefreshOutcome::Exhausted {
1438                        log::error!(
1439                            "Lighter auth-token rotation exhausted retry window; retrying again in {}s: account_index={account_index}",
1440                            delay.as_secs(),
1441                        );
1442                    }
1443                    next_refresh_delay = delay;
1444                } else {
1445                    log::debug!("Lighter auth-token refresh task cancelled");
1446                    break;
1447                }
1448            }
1449        })?;
1450        Ok(())
1451    }
1452
1453    // Per-order `params["market_order_slippage_bps"]` overrides the config default.
1454    fn resolve_slippage_bps(&self, params: Option<&Params>) -> u32 {
1455        params
1456            .and_then(|p| p.get_u64("market_order_slippage_bps"))
1457            .map_or(self.config.market_order_slippage_bps, |v| v as u32)
1458    }
1459
1460    fn build_tx_context(&self, credential: &Credential) -> anyhow::Result<ReservedTxContext> {
1461        let connection_epoch = self.ws_client.connection_epoch();
1462        anyhow::ensure!(
1463            self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
1464            "Lighter nonce refresh is pending for connection epoch {connection_epoch}",
1465        );
1466        let nonce_guard = Arc::clone(&self.nonce_submission_gate)
1467            .try_read_owned()
1468            .context("Lighter nonce refresh is in progress")?;
1469        anyhow::ensure!(
1470            self.ws_client.connection_epoch() == connection_epoch
1471                && self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
1472            "Lighter connection or nonce state changed during transaction preparation",
1473        );
1474        let nonce = match self
1475            .dispatch
1476            .nonce_manager
1477            .next_nonce(credential.account_index(), credential.api_key_index())
1478        {
1479            Ok(nonce) => nonce,
1480            Err(e @ NonceError::SkipWindowExhausted { .. }) => {
1481                // Lost acks leave the baseline stale; resync from the venue so
1482                // later commands recover. The fetch is async; this command fails.
1483                self.spawn_nonce_window_recovery(credential);
1484                anyhow::bail!("failed to allocate Lighter nonce: {e}");
1485            }
1486            Err(e) => anyhow::bail!("failed to allocate Lighter nonce: {e}"),
1487        };
1488
1489        let now_ns = self.clock.get_time_ns().as_u64() as i64;
1490        let expired_at = (now_ns / 1_000_000).saturating_add(DEFAULT_TX_EXPIRY_MS);
1491        let send_reservation = self
1492            .tx_send_sequencer
1493            .reserve(
1494                credential.account_index(),
1495                credential.api_key_index(),
1496                nonce,
1497            )
1498            .with_nonce_ownership(nonce_guard, connection_epoch);
1499
1500        let context = TxContext {
1501            account_index: credential.account_index(),
1502            api_key_index: credential.api_key_index(),
1503            nonce,
1504            expired_at,
1505        };
1506
1507        Ok(ReservedTxContext {
1508            context,
1509            send_reservation,
1510        })
1511    }
1512
1513    fn spawn_nonce_window_recovery(&self, credential: &Credential) {
1514        if self.nonce_recovery_inflight.swap(true, Ordering::AcqRel) {
1515            return;
1516        }
1517
1518        let inflight = Arc::clone(&self.nonce_recovery_inflight);
1519        let http_client = self.http_client.clone();
1520        let dispatch = self.dispatch.clone();
1521        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
1522        let account_index = credential.account_index();
1523        let api_key_index = credential.api_key_index();
1524
1525        self.spawn_task("nonce_window_recovery", async move {
1526            let _refresh_guard = nonce_submission_gate.write().await;
1527            let result = http_client
1528                .get_next_nonce(account_index, api_key_index)
1529                .await;
1530            inflight.store(false, Ordering::Release);
1531
1532            match result {
1533                Ok(response) => {
1534                    // Monotonic sync, not `refresh`: a hard reset could
1535                    // reissue nonces already signed into in-flight txs.
1536                    let _ = dispatch.nonce_manager.sync_from_venue(
1537                        account_index,
1538                        api_key_index,
1539                        response.nonce,
1540                    );
1541                    log::debug!(
1542                        "Resynced Lighter nonce baseline after skip-window exhaustion: \
1543                         account_index={account_index}, api_key_index={api_key_index}, \
1544                         next_nonce={}",
1545                        response.nonce,
1546                    );
1547                }
1548                Err(e) => {
1549                    log::error!(
1550                        "Failed to resync Lighter nonce after skip-window exhaustion: {e}",
1551                    );
1552                }
1553            }
1554            Ok(())
1555        });
1556    }
1557
1558    fn dispatch_create_order_plan(
1559        &self,
1560        plan: CreateOrderPlan,
1561        credential: &Credential,
1562    ) -> anyhow::Result<()> {
1563        let context = self.fanout_dispatch_context(credential)?;
1564        let prepared = context.sign_create_order(plan)?;
1565        self.spawn_task("submit_order", async move {
1566            context.send_create_order(prepared).await;
1567            Ok(())
1568        });
1569
1570        Ok(())
1571    }
1572
1573    fn prepare_create_order_plan(
1574        &self,
1575        order: &OrderAny,
1576        slippage_bps: u32,
1577    ) -> anyhow::Result<CreateOrderPlan> {
1578        let instrument_id = order.instrument_id();
1579        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1580            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
1581        })?;
1582
1583        let instrument = self.core.cache().try_instrument(&instrument_id)?.clone();
1584
1585        let order_kind = nautilus_to_lighter_order_type(order.order_type())?;
1586
1587        let tif = nautilus_to_lighter_tif(
1588            order.order_type(),
1589            order.time_in_force(),
1590            order.is_post_only(),
1591        )?;
1592        let now_ms = (self.clock.get_time_ns().as_u64() / 1_000_000) as i64;
1593        let order_expiry = order_expiry_for(
1594            order.order_type(),
1595            &order.time_in_force(),
1596            order.expire_time(),
1597            now_ms,
1598        )?;
1599
1600        let base_amount = quantity_to_ticks(&order.quantity(), instrument.size_precision())?;
1601
1602        // `quantity_to_ticks` floors sub-precision quantities to 0.
1603        anyhow::ensure!(
1604            base_amount > 0,
1605            "quantity `{}` rounds to 0 ticks at size_precision {}",
1606            order.quantity(),
1607            instrument.size_precision(),
1608        );
1609        let price_precision = instrument.price_precision();
1610        let is_buy = matches!(order.order_side(), OrderSide::Buy);
1611
1612        // Lighter requires `price` on market-style orders as the worst
1613        // acceptable cap; derive it from far-side quote or trigger.
1614        let price_ticks = match order.order_type() {
1615            OrderType::Market => {
1616                let quote = self
1617                    .core
1618                    .cache()
1619                    .quote(&instrument_id)
1620                    .copied()
1621                    .ok_or_else(|| {
1622                        anyhow::anyhow!(
1623                            "no cached quote for {instrument_id}: subscribe to quotes before submitting MARKET orders",
1624                        )
1625                    })?;
1626                let base = if is_buy {
1627                    quote.ask_price.as_decimal()
1628                } else {
1629                    quote.bid_price.as_decimal()
1630                };
1631                derive_market_order_price_ticks(base, is_buy, price_precision, slippage_bps)?
1632            }
1633            OrderType::StopMarket | OrderType::MarketIfTouched => {
1634                let trigger = order.trigger_price().ok_or_else(|| {
1635                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type(),)
1636                })?;
1637                derive_market_order_price_ticks(
1638                    trigger.as_decimal(),
1639                    is_buy,
1640                    price_precision,
1641                    slippage_bps,
1642                )?
1643            }
1644            _ => order
1645                .price()
1646                .map(|p| price_to_ticks(&p, price_precision))
1647                .transpose()?
1648                .unwrap_or(0),
1649        };
1650
1651        let trigger_price_ticks = order
1652            .trigger_price()
1653            .map(|p| price_to_ticks(&p, price_precision))
1654            .transpose()?
1655            .unwrap_or(0);
1656
1657        // Conditional types: `price_to_ticks` floors sub-tick triggers to 0,
1658        // which Lighter would then reject.
1659        if matches!(
1660            order.order_type(),
1661            OrderType::StopMarket
1662                | OrderType::StopLimit
1663                | OrderType::MarketIfTouched
1664                | OrderType::LimitIfTouched
1665        ) {
1666            anyhow::ensure!(
1667                trigger_price_ticks > 0,
1668                "trigger_price `{:?}` rounds to 0 ticks at precision {price_precision}",
1669                order.trigger_price(),
1670            );
1671        }
1672        validate_order_amount(&instrument, order.quantity(), price_ticks, price_precision)?;
1673
1674        Ok(CreateOrderPlan {
1675            order: order.clone(),
1676            market_index,
1677            base_amount,
1678            price: price_ticks,
1679            order_type: order_kind as u8,
1680            time_in_force: tif as u8,
1681            trigger_price: trigger_price_ticks,
1682            order_expiry,
1683        })
1684    }
1685
1686    fn fanout_dispatch_context(
1687        &self,
1688        credential: &Credential,
1689    ) -> anyhow::Result<FanoutDispatchContext> {
1690        let pending_tasks = self
1691            .pending_tasks
1692            .spawner()
1693            .context("Lighter task admission is closed")?;
1694        Ok(FanoutDispatchContext {
1695            clock: self.clock,
1696            chain_id: self.config.chain_id(),
1697            integrator_account_index: self.integrator_account_index(),
1698            emitter: self.emitter.clone(),
1699            credential: credential.clone(),
1700            http_client: self.http_client.clone(),
1701            ws_client: self.ws_client.clone(),
1702            tx_rate_limiter: Arc::clone(&self.tx_rate_limiter),
1703            tx_send_sequencer: self.tx_send_sequencer.clone(),
1704            nonce_submission_gate: Arc::clone(&self.nonce_submission_gate),
1705            nonce_ready_connection_epoch: Arc::clone(&self.nonce_ready_connection_epoch),
1706            dispatch: self.dispatch.clone(),
1707            nonce_recovery_inflight: Arc::clone(&self.nonce_recovery_inflight),
1708            pending_tasks,
1709        })
1710    }
1711
1712    fn dispatch_signed_cancel_order(&self, cmd: &CancelOrder, credential: &Credential) {
1713        let context = match self.fanout_dispatch_context(credential) {
1714            Ok(context) => context,
1715            Err(e) => {
1716                log::warn!("Skipping Lighter cancel_order after shutdown began: {e}");
1717                return;
1718            }
1719        };
1720        self.dispatch
1721            .set_pending_order_action(cmd.client_order_id, PendingOrderAction::Cancel);
1722        let emit_cancel_rejected = self.can_emit_order_cancel_rejected(&cmd.client_order_id);
1723        if !emit_cancel_rejected {
1724            self.dispatch
1725                .clear_pending_order_action_if(&cmd.client_order_id, PendingOrderAction::Cancel);
1726        }
1727
1728        let prepared = match self
1729            .prepare_cancel_order_plan(cmd)
1730            .and_then(|plan| context.sign_cancel_order(&plan))
1731        {
1732            Ok(prepared) => prepared,
1733            Err(e) => {
1734                self.dispatch.clear_pending_order_action_if(
1735                    &cmd.client_order_id,
1736                    PendingOrderAction::Cancel,
1737                );
1738                let reason = format!("Lighter cancel_order failed: {e}");
1739                if emit_cancel_rejected {
1740                    log::warn!("{reason} for {}", cmd.client_order_id);
1741                    self.emitter.emit_order_cancel_rejected_event(
1742                        cmd.strategy_id,
1743                        cmd.instrument_id,
1744                        cmd.client_order_id,
1745                        cmd.venue_order_id,
1746                        &reason,
1747                        self.clock.get_time_ns(),
1748                    );
1749                } else {
1750                    log::warn!(
1751                        "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
1752                        cmd.client_order_id,
1753                    );
1754                }
1755                return;
1756            }
1757        };
1758
1759        self.spawn_task("cancel_order", async move {
1760            context
1761                .send_cancel_order(prepared, emit_cancel_rejected)
1762                .await;
1763            Ok(())
1764        });
1765    }
1766
1767    fn can_emit_order_cancel_rejected(&self, client_order_id: &ClientOrderId) -> bool {
1768        self.core
1769            .cache()
1770            .order(client_order_id)
1771            .is_none_or(|order| order.is_pending_cancel())
1772    }
1773
1774    fn prepare_cancel_order_plan(&self, cmd: &CancelOrder) -> anyhow::Result<CancelOrderPlan> {
1775        let market_index = self
1776            .registry
1777            .market_index(&cmd.instrument_id)
1778            .ok_or_else(|| {
1779                anyhow::anyhow!(
1780                    "no Lighter market_index registered for instrument {}",
1781                    cmd.instrument_id,
1782                )
1783            })?;
1784
1785        self.core.cache().try_order(&cmd.client_order_id)?;
1786
1787        // Lighter cancel_order targets a single order by venue order_id.
1788        // The map is populated on the first OrderStatusReport for the cloid.
1789        let voi = cmd
1790            .venue_order_id
1791            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
1792            .ok_or_else(|| {
1793                anyhow::anyhow!(
1794                    "cannot cancel Lighter order {}: venue order_id not yet known \
1795                     (await OrderAccepted before issuing cancel)",
1796                    cmd.client_order_id,
1797                )
1798            })?;
1799
1800        let venue_index: i64 = voi
1801            .as_str()
1802            .parse()
1803            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
1804
1805        Ok(CancelOrderPlan {
1806            client_order_id: cmd.client_order_id,
1807            strategy_id: cmd.strategy_id,
1808            instrument_id: cmd.instrument_id,
1809            venue_order_id: Some(voi),
1810            market_index,
1811            venue_index,
1812        })
1813    }
1814
1815    fn dispatch_signed_modify_order(&self, cmd: &ModifyOrder, credential: &Credential) {
1816        self.dispatch
1817            .set_pending_order_action(cmd.client_order_id, PendingOrderAction::Modify);
1818        let prepared = match self.prepare_signed_modify_order(cmd, credential) {
1819            Ok(prepared) => prepared,
1820            Err(e) => {
1821                self.dispatch.clear_pending_order_action_if(
1822                    &cmd.client_order_id,
1823                    PendingOrderAction::Modify,
1824                );
1825                let reason = format!("Lighter modify_order failed: {e}");
1826                log::warn!("{reason} for {}", cmd.client_order_id);
1827                self.emitter.emit_order_modify_rejected_event(
1828                    cmd.strategy_id,
1829                    cmd.instrument_id,
1830                    cmd.client_order_id,
1831                    cmd.venue_order_id,
1832                    &reason,
1833                    self.clock.get_time_ns(),
1834                );
1835                return;
1836            }
1837        };
1838        let PreparedModifyOrder {
1839            client_order_id,
1840            strategy_id,
1841            instrument_id,
1842            venue_order_id,
1843            tx_info,
1844            nonce,
1845            api_key_index,
1846            tx_hash,
1847            mut send_reservation,
1848        } = prepared;
1849        let connection_epoch = send_reservation.connection_epoch;
1850
1851        let ws_client = self.ws_client.clone();
1852        let dispatch = self.dispatch.clone();
1853        let credential = credential.clone();
1854        let emitter = self.emitter.clone();
1855        let clock = self.clock;
1856
1857        let tx_rate_limiter = self.tx_rate_limiter.clone();
1858
1859        self.spawn_task("modify_order", async move {
1860            send_reservation.wait_for_turn().await;
1861            await_tx_quota(&tx_rate_limiter).await;
1862            dispatch.enqueue_pending_sendtx(PendingSendTx {
1863                connection_epoch,
1864                kind: PendingSendTxKind::Modify {
1865                    strategy_id,
1866                    instrument_id,
1867                    client_order_id,
1868                    venue_order_id,
1869                },
1870                submitted_at: clock.get_time_ns(),
1871                nonce,
1872                api_key_index,
1873                tx_hash,
1874            });
1875
1876            if let Err(e) = ws_client
1877                .send_tx_on_connection(LighterTxType::ModifyOrder as u8, tx_info, connection_epoch)
1878                .await
1879            {
1880                let failure = classify_lighter_ws_command_failure("modify_order", &e);
1881                let reason = command_failure_reason(&failure);
1882                if matches!(&failure, CommandFailure::Ambiguous(_)) {
1883                    log::warn!(
1884                        "Lighter modify_order dispatch outcome unknown for {client_order_id}: {reason}; \
1885                         retaining pending state for venue reconciliation; diagnostic={e:?}",
1886                    );
1887                } else {
1888                    log::error!("{reason} for {client_order_id}; diagnostic={e:?}");
1889                    dispatch.remove_pending_sendtx_by_nonce(connection_epoch, nonce);
1890                    dispatch.clear_pending_order_action_if(
1891                        &client_order_id,
1892                        PendingOrderAction::Modify,
1893                    );
1894                    rollback_tx_dispatch(&dispatch, &credential, None, nonce);
1895                    emitter.emit_order_modify_rejected_event(
1896                        strategy_id,
1897                        instrument_id,
1898                        client_order_id,
1899                        venue_order_id,
1900                        reason,
1901                        clock.get_time_ns(),
1902                    );
1903                }
1904            }
1905            send_reservation.release();
1906            Ok(())
1907        });
1908    }
1909
1910    fn prepare_signed_modify_order(
1911        &self,
1912        cmd: &ModifyOrder,
1913        credential: &Credential,
1914    ) -> anyhow::Result<PreparedModifyOrder> {
1915        let market_index = self
1916            .registry
1917            .market_index(&cmd.instrument_id)
1918            .ok_or_else(|| {
1919                anyhow::anyhow!(
1920                    "no Lighter market_index registered for instrument {}",
1921                    cmd.instrument_id,
1922                )
1923            })?;
1924
1925        let voi = cmd
1926            .venue_order_id
1927            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
1928            .ok_or_else(|| {
1929                anyhow::anyhow!(
1930                    "cannot modify Lighter order {}: venue order_id not yet known \
1931                     (await OrderAccepted before issuing modify)",
1932                    cmd.client_order_id,
1933                )
1934            })?;
1935
1936        let venue_index: i64 = voi
1937            .as_str()
1938            .parse()
1939            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
1940
1941        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1942        let instrument = self
1943            .core
1944            .cache()
1945            .try_instrument(&cmd.instrument_id)?
1946            .clone();
1947
1948        let new_qty = cmd.quantity.unwrap_or(order.quantity());
1949        let price_precision = instrument.price_precision();
1950        let new_trigger = cmd.trigger_price.or(order.trigger_price());
1951
1952        // Market-style stops carry no limit price; Lighter still needs a worst-
1953        // acceptable `price` cap, derived from the trigger and slippage like submit.
1954        let price_ticks = match order.order_type() {
1955            OrderType::StopMarket | OrderType::MarketIfTouched => {
1956                let trigger = new_trigger.ok_or_else(|| {
1957                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type())
1958                })?;
1959                let is_buy = matches!(order.order_side(), OrderSide::Buy);
1960                let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
1961
1962                derive_market_order_price_ticks(
1963                    trigger.as_decimal(),
1964                    is_buy,
1965                    price_precision,
1966                    slippage_bps,
1967                )?
1968            }
1969            _ => {
1970                let new_price = cmd.price.or(order.price()).ok_or_else(|| {
1971                    anyhow::anyhow!("modify_order requires a price (none on order or command)")
1972                })?;
1973
1974                price_to_ticks(&new_price, price_precision)?
1975            }
1976        };
1977
1978        let base_amount = quantity_to_ticks(&new_qty, instrument.size_precision())?;
1979        anyhow::ensure!(
1980            base_amount > 0,
1981            "quantity `{new_qty}` rounds to 0 ticks at size_precision {}",
1982            instrument.size_precision(),
1983        );
1984        let trigger_price_ticks = match new_trigger {
1985            Some(trigger) if trigger.raw != 0 => price_to_ticks(&trigger, price_precision)?,
1986            _ => 0,
1987        };
1988
1989        if matches!(
1990            order.order_type(),
1991            OrderType::StopMarket
1992                | OrderType::StopLimit
1993                | OrderType::MarketIfTouched
1994                | OrderType::LimitIfTouched
1995        ) {
1996            anyhow::ensure!(
1997                trigger_price_ticks > 0,
1998                "trigger_price `{new_trigger:?}` rounds to 0 ticks at precision {price_precision}",
1999            );
2000        }
2001        validate_order_amount(&instrument, new_qty, price_ticks, price_precision)?;
2002
2003        let ReservedTxContext {
2004            context,
2005            send_reservation,
2006        } = self.build_tx_context(credential)?;
2007
2008        let captured_nonce = context.nonce;
2009        let captured_api_key_index = context.api_key_index;
2010
2011        let mut rollback_guard =
2012            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
2013
2014        let tx = ModifyOrderTxInfo {
2015            context,
2016            market_index,
2017            index: venue_index,
2018            base_amount,
2019            price: price_ticks,
2020            trigger_price: trigger_price_ticks,
2021            attributes: integrator_attributes(self.integrator_account_index()),
2022        };
2023
2024        let signed = sign_tx(
2025            &tx,
2026            self.config.chain_id(),
2027            &credential.private_key()?,
2028            fresh_k(),
2029        );
2030
2031        let tx_info_str = TxInfoJson::modify_order(&tx, &signed);
2032        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
2033            .context("failed to wrap signed Lighter modify tx_info JSON")?;
2034
2035        rollback_guard.disarm();
2036
2037        Ok(PreparedModifyOrder {
2038            client_order_id: cmd.client_order_id,
2039            strategy_id: cmd.strategy_id,
2040            instrument_id: cmd.instrument_id,
2041            venue_order_id: Some(voi),
2042            tx_info,
2043            nonce: captured_nonce,
2044            api_key_index: captured_api_key_index,
2045            tx_hash: signed.tx_hash_hex(),
2046            send_reservation,
2047        })
2048    }
2049
2050    /// Submit Lighter's native `UpdateLeverage` tx (`tx_type = 20`).
2051    ///
2052    /// Changes the initial margin fraction and the position margin mode for
2053    /// the given market. `initial_margin_fraction` is in venue ticks
2054    /// (1e-4 fraction): `500` = 5% initial margin = 20x leverage,
2055    /// `1000` = 10% = 10x, etc. Valid range is `1..=10_000`
2056    /// (the upstream `MarginFractionTick` cap).
2057    ///
2058    /// Nautilus does not expose a `set_leverage` command on the execution
2059    /// trait, so this method is callable directly from strategy or bootstrap
2060    /// code.
2061    ///
2062    /// # Errors
2063    ///
2064    /// Returns an error if credentials are missing, the instrument is not
2065    /// registered, `initial_margin_fraction` is outside `1..=10_000`, or
2066    /// the dispatch pre-flight (nonce allocation, signing) fails. Transport
2067    /// errors after dispatch are logged but not returned synchronously.
2068    pub fn update_leverage(
2069        &self,
2070        instrument_id: InstrumentId,
2071        initial_margin_fraction: u16,
2072        margin_mode: LighterPositionMarginMode,
2073    ) -> anyhow::Result<()> {
2074        let credential = self.credential.as_ref().ok_or_else(|| {
2075            anyhow::anyhow!("Lighter execution client cannot update leverage without credentials")
2076        })?;
2077
2078        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
2079            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
2080        })?;
2081
2082        anyhow::ensure!(
2083            (1..=10_000).contains(&initial_margin_fraction),
2084            "initial_margin_fraction must be in 1..=10_000, was {initial_margin_fraction}",
2085        );
2086
2087        let ReservedTxContext {
2088            context,
2089            mut send_reservation,
2090        } = self.build_tx_context(credential)?;
2091
2092        let connection_epoch = send_reservation.connection_epoch;
2093
2094        let captured_nonce = context.nonce;
2095        let captured_api_key_index = context.api_key_index;
2096        let mut rollback_guard =
2097            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
2098
2099        let tx = UpdateLeverageTxInfo {
2100            context,
2101            market_index,
2102            initial_margin_fraction,
2103            margin_mode: margin_mode as u8,
2104            skip_nonce: 0,
2105        };
2106
2107        let signed = sign_tx(
2108            &tx,
2109            self.config.chain_id(),
2110            &credential.private_key()?,
2111            fresh_k(),
2112        );
2113
2114        let tx_info_str = TxInfoJson::update_leverage(&tx, &signed);
2115        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
2116            .context("failed to wrap signed Lighter update_leverage tx_info JSON")?;
2117
2118        rollback_guard.disarm();
2119        let captured_tx_hash = signed.tx_hash_hex();
2120
2121        let ws_client = self.ws_client.clone();
2122        let dispatch = self.dispatch.clone();
2123        let credential = credential.clone();
2124        let clock = self.clock;
2125
2126        let tx_rate_limiter = self.tx_rate_limiter.clone();
2127
2128        self.spawn_task("update_leverage", async move {
2129            send_reservation.wait_for_turn().await;
2130            await_tx_quota(&tx_rate_limiter).await;
2131            dispatch.enqueue_pending_sendtx(PendingSendTx {
2132                connection_epoch,
2133                kind: PendingSendTxKind::Other,
2134                submitted_at: clock.get_time_ns(),
2135                nonce: captured_nonce,
2136                api_key_index: captured_api_key_index,
2137                tx_hash: captured_tx_hash,
2138            });
2139
2140            if let Err(e) = ws_client
2141                .send_tx_on_connection(
2142                    LighterTxType::UpdateLeverage as u8,
2143                    tx_info,
2144                    connection_epoch,
2145                )
2146                .await
2147            {
2148                let failure = classify_lighter_ws_command_failure("update_leverage", &e);
2149                let reason = command_failure_reason(&failure);
2150                if matches!(&failure, CommandFailure::Ambiguous(_)) {
2151                    log::warn!(
2152                        "Lighter update_leverage dispatch outcome unknown for {instrument_id}: \
2153                         {reason}; retaining pending nonce for venue reconciliation; diagnostic={e:?}",
2154                    );
2155                } else {
2156                    log::error!("{reason} for {instrument_id}; diagnostic={e:?}");
2157                    dispatch.remove_pending_sendtx_by_nonce(connection_epoch, captured_nonce);
2158                    rollback_tx_dispatch(&dispatch, &credential, None, captured_nonce);
2159                }
2160            }
2161            send_reservation.release();
2162            Ok(())
2163        });
2164
2165        Ok(())
2166    }
2167}
2168
2169fn emit_lighter_position_reports(
2170    reports: Vec<PositionStatusReport>,
2171    removed: Vec<InstrumentId>,
2172    emitter: &ExecutionEventEmitter,
2173    account_id: AccountId,
2174    now: UnixNanos,
2175) {
2176    for report in reports {
2177        log::debug!(
2178            "Lighter PositionStatusReport: instrument={} side={:?} qty={}",
2179            report.instrument_id,
2180            report.position_side,
2181            report.quantity,
2182        );
2183        emitter.send_position_report(report);
2184    }
2185
2186    // Emit Flat so the engine observes positions the venue reports as closed
2187    for instrument_id in removed {
2188        let flat = PositionStatusReport::new(
2189            account_id,
2190            instrument_id,
2191            PositionSide::Flat,
2192            Quantity::zero(0),
2193            now,
2194            now,
2195            Some(UUID4::new()),
2196            None,
2197            None,
2198        );
2199        emitter.send_position_report(flat);
2200    }
2201}
2202
2203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2204enum AuthTokenRefreshOutcome {
2205    Rotated,
2206    Cancelled,
2207    Exhausted,
2208}
2209
2210#[derive(Debug, Clone, Copy)]
2211struct AuthTokenRefreshBackoff {
2212    initial_delay: Duration,
2213    max_delay: Duration,
2214    window: Duration,
2215}
2216
2217#[derive(Clone)]
2218struct NonceRefreshRetry {
2219    http_client: LighterHttpClient,
2220    dispatch: WsDispatchState,
2221    credential: Option<Credential>,
2222    submission_gate: Arc<tokio::sync::RwLock<()>>,
2223    ready_connection_epoch: Arc<AtomicU64>,
2224    ws_client: LighterWebSocketClient,
2225    cancellation_token: CancellationToken,
2226    pending_tasks: TaskSpawner,
2227}
2228
2229impl NonceRefreshRetry {
2230    fn spawn(self, connection_epoch: u64) {
2231        let pending_tasks = self.pending_tasks.clone();
2232
2233        let future = async move {
2234            let Some(credential) = self.credential else {
2235                return;
2236            };
2237            let mut retry_delay = NONCE_REFRESH_RETRY_INITIAL_DELAY;
2238
2239            loop {
2240                tokio::select! {
2241                    () = self.cancellation_token.cancelled() => return,
2242                    () = tokio::time::sleep(retry_delay) => {}
2243                }
2244
2245                if self.ws_client.connection_epoch() != connection_epoch
2246                    || self.ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
2247                {
2248                    return;
2249                }
2250
2251                let _refresh_guard = tokio::select! {
2252                    () = self.cancellation_token.cancelled() => return,
2253                    guard = self.submission_gate.write() => guard,
2254                };
2255
2256                if self.ws_client.connection_epoch() != connection_epoch
2257                    || self.ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
2258                {
2259                    return;
2260                }
2261
2262                let result = tokio::select! {
2263                    () = self.cancellation_token.cancelled() => return,
2264                    result = self.http_client.get_next_nonce(
2265                        credential.account_index(),
2266                        credential.api_key_index(),
2267                    ) => result,
2268                };
2269
2270                match result {
2271                    Ok(response) => {
2272                        if self.ws_client.connection_epoch() != connection_epoch {
2273                            return;
2274                        }
2275                        self.dispatch.nonce_manager.refresh(
2276                            credential.account_index(),
2277                            credential.api_key_index(),
2278                            response.nonce,
2279                        );
2280                        self.ready_connection_epoch
2281                            .store(connection_epoch, Ordering::Release);
2282                        log::info!(
2283                            "Recovered Lighter nonce refresh: account_index={}, \
2284                             connection_epoch={connection_epoch}, next_nonce={}",
2285                            credential.account_index(),
2286                            response.nonce,
2287                        );
2288                        return;
2289                    }
2290                    Err(e) => {
2291                        log::error!(
2292                            "Failed to retry Lighter nonce refresh for connection epoch \
2293                             {connection_epoch}: {e}",
2294                        );
2295                    }
2296                }
2297
2298                retry_delay = retry_delay
2299                    .saturating_mul(2)
2300                    .min(NONCE_REFRESH_RETRY_MAX_DELAY);
2301            }
2302        };
2303
2304        if let Err(e) = pending_tasks.spawn(future) {
2305            log::debug!("Skipping Lighter nonce refresh retry during shutdown: {e}");
2306        }
2307    }
2308}
2309
2310fn auth_token_rotation_channels(account_index: i64) -> [LighterWsChannel; 5] {
2311    [
2312        LighterWsChannel::AccountAllOrders(account_index),
2313        LighterWsChannel::AccountAllTrades(account_index),
2314        LighterWsChannel::AccountAllPositions(account_index),
2315        LighterWsChannel::AccountAllAssets(account_index),
2316        LighterWsChannel::UserStats(account_index),
2317    ]
2318}
2319
2320async fn refresh_auth_token_until_rotated<MintToken, Subscribe, SubscribeFuture>(
2321    credential: &Credential,
2322    channels: &[LighterWsChannel],
2323    cancellation_token: &CancellationToken,
2324    backoff: AuthTokenRefreshBackoff,
2325    mut mint_token: MintToken,
2326    mut subscribe: Subscribe,
2327) -> AuthTokenRefreshOutcome
2328where
2329    MintToken: FnMut(&Credential) -> anyhow::Result<String>,
2330    Subscribe: FnMut(LighterWsChannel, String) -> SubscribeFuture,
2331    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
2332{
2333    let retry_started = tokio::time::Instant::now();
2334    let mut retry_delay = backoff.initial_delay.min(backoff.max_delay);
2335    let mut attempt = 1_u32;
2336
2337    loop {
2338        match rotate_auth_token_once(credential, channels, &mut mint_token, &mut subscribe).await {
2339            Ok(()) => {
2340                log::debug!(
2341                    "Lighter auth-token rotated for account_index={}, attempts={attempt}",
2342                    credential.account_index(),
2343                );
2344                return AuthTokenRefreshOutcome::Rotated;
2345            }
2346            Err(e) => {
2347                log::warn!("Lighter auth-token rotation attempt {attempt} failed: {e:#}");
2348            }
2349        }
2350
2351        let Some(remaining) = backoff.window.checked_sub(retry_started.elapsed()) else {
2352            log::error!(
2353                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
2354                credential.account_index(),
2355            );
2356            return AuthTokenRefreshOutcome::Exhausted;
2357        };
2358
2359        if remaining.as_nanos() == 0 {
2360            log::error!(
2361                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
2362                credential.account_index(),
2363            );
2364            return AuthTokenRefreshOutcome::Exhausted;
2365        }
2366
2367        let delay = retry_delay.min(remaining);
2368        log::warn!(
2369            "Retrying Lighter auth-token rotation in {:.3}s: account_index={}, attempts={attempt}",
2370            delay.as_secs_f64(),
2371            credential.account_index(),
2372        );
2373
2374        if !sleep_or_auth_token_refresh_cancelled(delay, cancellation_token).await {
2375            return AuthTokenRefreshOutcome::Cancelled;
2376        }
2377
2378        retry_delay = next_auth_token_refresh_retry_delay(retry_delay, backoff.max_delay);
2379        attempt = attempt.saturating_add(1);
2380    }
2381}
2382
2383async fn rotate_auth_token_once<MintToken, Subscribe, SubscribeFuture>(
2384    credential: &Credential,
2385    channels: &[LighterWsChannel],
2386    mint_token: &mut MintToken,
2387    subscribe: &mut Subscribe,
2388) -> anyhow::Result<()>
2389where
2390    MintToken: FnMut(&Credential) -> anyhow::Result<String>,
2391    Subscribe: FnMut(LighterWsChannel, String) -> SubscribeFuture,
2392    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
2393{
2394    let token =
2395        mint_token(credential).context("failed to mint Lighter auth token during rotation")?;
2396    let mut first_error = None;
2397
2398    for channel in channels {
2399        if let Err(e) = subscribe(channel.clone(), token.clone()).await {
2400            log::debug!("Lighter auth-token rotation: re-subscribe failed for {channel:?}: {e}",);
2401            first_error.get_or_insert_with(|| format!("{channel:?}: {e}"));
2402        }
2403    }
2404
2405    if let Some(error) = first_error {
2406        anyhow::bail!("failed to re-subscribe Lighter account channels: {error}");
2407    }
2408
2409    Ok(())
2410}
2411
2412async fn sleep_or_auth_token_refresh_cancelled(
2413    duration: Duration,
2414    cancellation_token: &CancellationToken,
2415) -> bool {
2416    tokio::select! {
2417        () = cancellation_token.cancelled() => false,
2418        () = tokio::time::sleep(duration) => true,
2419    }
2420}
2421
2422fn auth_token_refresh_next_delay(outcome: AuthTokenRefreshOutcome) -> Option<Duration> {
2423    match outcome {
2424        AuthTokenRefreshOutcome::Rotated => Some(AUTH_TOKEN_REFRESH_INTERVAL),
2425        AuthTokenRefreshOutcome::Cancelled => None,
2426        AuthTokenRefreshOutcome::Exhausted => Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY),
2427    }
2428}
2429
2430fn next_auth_token_refresh_retry_delay(current: Duration, max: Duration) -> Duration {
2431    current.checked_mul(2).unwrap_or(max).min(max)
2432}
2433
2434#[derive(Debug)]
2435struct ReservedTxContext {
2436    context: TxContext,
2437    send_reservation: TxSendReservation,
2438}
2439
2440#[derive(Debug, Clone)]
2441struct TxSendSequencer {
2442    state: Arc<Mutex<TxSendSequencerState>>,
2443    version: Arc<AtomicU64>,
2444    changed: tokio::sync::watch::Sender<u64>,
2445}
2446
2447impl TxSendSequencer {
2448    fn new() -> Self {
2449        let (changed, _) = tokio::sync::watch::channel(0);
2450        Self {
2451            state: Arc::new(Mutex::new(TxSendSequencerState::default())),
2452            version: Arc::new(AtomicU64::new(0)),
2453            changed,
2454        }
2455    }
2456
2457    fn reserve(&self, account_index: i64, api_key_index: u8, nonce: i64) -> TxSendReservation {
2458        let key = TxSendKey {
2459            account_index,
2460            api_key_index,
2461        };
2462        self.state
2463            .lock()
2464            .pending
2465            .entry(key)
2466            .or_default()
2467            .insert(nonce);
2468        self.notify_waiters();
2469
2470        TxSendReservation {
2471            sequencer: self.clone(),
2472            key,
2473            nonce,
2474            released: false,
2475            nonce_guard: None,
2476            connection_epoch: 0,
2477        }
2478    }
2479
2480    async fn wait_for_turn(&self, key: TxSendKey, nonce: i64) {
2481        let mut changed = self.changed.subscribe();
2482
2483        loop {
2484            if self.ready_to_send(key, nonce) {
2485                return;
2486            }
2487
2488            if changed.changed().await.is_err() {
2489                tokio::task::yield_now().await;
2490            }
2491        }
2492    }
2493
2494    fn release(&self, key: TxSendKey, nonce: i64) {
2495        let mut state = self.state.lock();
2496        let should_notify = if let Some(pending) = state.pending.get_mut(&key) {
2497            let removed = pending.remove(&nonce);
2498            if pending.is_empty() {
2499                state.pending.remove(&key);
2500            }
2501            removed
2502        } else {
2503            false
2504        };
2505        drop(state);
2506
2507        if should_notify {
2508            self.notify_waiters();
2509        }
2510    }
2511
2512    fn ready_to_send(&self, key: TxSendKey, nonce: i64) -> bool {
2513        let state = self.state.lock();
2514        state
2515            .pending
2516            .get(&key)
2517            .and_then(|pending| pending.first())
2518            .is_none_or(|first| *first >= nonce)
2519    }
2520
2521    fn notify_waiters(&self) {
2522        let version = self.version.fetch_add(1, Ordering::AcqRel) + 1;
2523        let _ = self.changed.send(version);
2524    }
2525}
2526
2527#[derive(Debug, Default)]
2528struct TxSendSequencerState {
2529    pending: BTreeMap<TxSendKey, BTreeSet<i64>>,
2530}
2531
2532#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2533struct TxSendKey {
2534    account_index: i64,
2535    api_key_index: u8,
2536}
2537
2538#[derive(Debug)]
2539struct TxSendReservation {
2540    sequencer: TxSendSequencer,
2541    key: TxSendKey,
2542    nonce: i64,
2543    released: bool,
2544    nonce_guard: Option<tokio::sync::OwnedRwLockReadGuard<()>>,
2545    connection_epoch: u64,
2546}
2547
2548impl TxSendReservation {
2549    fn with_nonce_ownership(
2550        mut self,
2551        nonce_guard: tokio::sync::OwnedRwLockReadGuard<()>,
2552        connection_epoch: u64,
2553    ) -> Self {
2554        self.nonce_guard = Some(nonce_guard);
2555        self.connection_epoch = connection_epoch;
2556        self
2557    }
2558
2559    async fn wait_for_turn(&self) {
2560        self.sequencer.wait_for_turn(self.key, self.nonce).await;
2561    }
2562
2563    fn release(&mut self) {
2564        if self.released {
2565            return;
2566        }
2567
2568        self.sequencer.release(self.key, self.nonce);
2569        self.nonce_guard.take();
2570        self.released = true;
2571    }
2572}
2573
2574impl Drop for TxSendReservation {
2575    fn drop(&mut self) {
2576        self.release();
2577    }
2578}
2579
2580#[cfg(test)]
2581async fn wait_for_tx_send_reservations(reservations: &[&TxSendReservation]) {
2582    let Some(first) = reservations.first() else {
2583        return;
2584    };
2585
2586    debug_assert!(
2587        reservations
2588            .iter()
2589            .all(|reservation| reservation.key == first.key),
2590        "batch send reservations must share one nonce stream",
2591    );
2592
2593    let nonce = reservations
2594        .iter()
2595        .map(|reservation| reservation.nonce)
2596        .min()
2597        .expect("reservations is non-empty");
2598    first.sequencer.wait_for_turn(first.key, nonce).await;
2599}
2600
2601struct PreparedCreateOrder {
2602    order: OrderAny,
2603    client_order_index: i64,
2604    tx_info: Box<serde_json::value::RawValue>,
2605    nonce: i64,
2606    api_key_index: u8,
2607    tx_hash: String,
2608    send_reservation: TxSendReservation,
2609}
2610
2611struct CreateOrderPlan {
2612    order: OrderAny,
2613    market_index: i16,
2614    base_amount: i64,
2615    price: u32,
2616    order_type: u8,
2617    time_in_force: u8,
2618    trigger_price: u32,
2619    order_expiry: i64,
2620}
2621
2622struct PreparedCancelOrder {
2623    client_order_id: ClientOrderId,
2624    strategy_id: StrategyId,
2625    instrument_id: InstrumentId,
2626    venue_order_id: Option<VenueOrderId>,
2627    tx_info: Box<serde_json::value::RawValue>,
2628    nonce: i64,
2629    api_key_index: u8,
2630    tx_hash: String,
2631    send_reservation: TxSendReservation,
2632}
2633
2634struct CancelOrderPlan {
2635    client_order_id: ClientOrderId,
2636    strategy_id: StrategyId,
2637    instrument_id: InstrumentId,
2638    venue_order_id: Option<VenueOrderId>,
2639    market_index: i16,
2640    venue_index: i64,
2641}
2642
2643#[derive(Clone)]
2644struct FanoutDispatchContext {
2645    clock: &'static AtomicTime,
2646    chain_id: u32,
2647    integrator_account_index: Option<u64>,
2648    emitter: ExecutionEventEmitter,
2649    credential: Credential,
2650    http_client: LighterHttpClient,
2651    ws_client: LighterWebSocketClient,
2652    tx_rate_limiter: Arc<LighterTxRateLimiter>,
2653    tx_send_sequencer: TxSendSequencer,
2654    nonce_submission_gate: Arc<tokio::sync::RwLock<()>>,
2655    nonce_ready_connection_epoch: Arc<AtomicU64>,
2656    dispatch: WsDispatchState,
2657    nonce_recovery_inflight: Arc<AtomicBool>,
2658    pending_tasks: TaskSpawner,
2659}
2660
2661impl FanoutDispatchContext {
2662    fn build_tx_context(&self) -> anyhow::Result<ReservedTxContext> {
2663        let connection_epoch = self.ws_client.connection_epoch();
2664        anyhow::ensure!(
2665            self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
2666            "Lighter nonce refresh is pending for connection epoch {connection_epoch}",
2667        );
2668        let nonce_guard = Arc::clone(&self.nonce_submission_gate)
2669            .try_read_owned()
2670            .context("Lighter nonce refresh is in progress")?;
2671        anyhow::ensure!(
2672            self.ws_client.connection_epoch() == connection_epoch
2673                && self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
2674            "Lighter connection or nonce state changed during transaction preparation",
2675        );
2676        let nonce = match self.dispatch.nonce_manager.next_nonce(
2677            self.credential.account_index(),
2678            self.credential.api_key_index(),
2679        ) {
2680            Ok(nonce) => nonce,
2681            Err(e @ NonceError::SkipWindowExhausted { .. }) => {
2682                self.spawn_nonce_window_recovery();
2683                anyhow::bail!("failed to allocate Lighter nonce: {e}");
2684            }
2685            Err(e) => anyhow::bail!("failed to allocate Lighter nonce: {e}"),
2686        };
2687
2688        let now_ns = self.clock.get_time_ns().as_u64() as i64;
2689        let expired_at = (now_ns / 1_000_000).saturating_add(DEFAULT_TX_EXPIRY_MS);
2690        let send_reservation = self
2691            .tx_send_sequencer
2692            .reserve(
2693                self.credential.account_index(),
2694                self.credential.api_key_index(),
2695                nonce,
2696            )
2697            .with_nonce_ownership(nonce_guard, connection_epoch);
2698
2699        Ok(ReservedTxContext {
2700            context: TxContext {
2701                account_index: self.credential.account_index(),
2702                api_key_index: self.credential.api_key_index(),
2703                nonce,
2704                expired_at,
2705            },
2706            send_reservation,
2707        })
2708    }
2709
2710    fn spawn_nonce_window_recovery(&self) {
2711        if self.nonce_recovery_inflight.swap(true, Ordering::AcqRel) {
2712            return;
2713        }
2714
2715        let inflight = Arc::clone(&self.nonce_recovery_inflight);
2716        let http_client = self.http_client.clone();
2717        let dispatch = self.dispatch.clone();
2718        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
2719        let account_index = self.credential.account_index();
2720        let api_key_index = self.credential.api_key_index();
2721
2722        let future = async move {
2723            let _refresh_guard = nonce_submission_gate.write().await;
2724            let result = http_client
2725                .get_next_nonce(account_index, api_key_index)
2726                .await;
2727            inflight.store(false, Ordering::Release);
2728
2729            match result {
2730                Ok(response) => {
2731                    let _ = dispatch.nonce_manager.sync_from_venue(
2732                        account_index,
2733                        api_key_index,
2734                        response.nonce,
2735                    );
2736                    log::debug!(
2737                        "Resynced Lighter nonce baseline after skip-window exhaustion: \
2738                         account_index={account_index}, api_key_index={api_key_index}, \
2739                         next_nonce={}",
2740                        response.nonce,
2741                    );
2742                }
2743                Err(e) => {
2744                    log::error!("Failed to resync Lighter nonce after skip-window exhaustion: {e}");
2745                }
2746            }
2747        };
2748
2749        if let Err(e) = self.pending_tasks.spawn(future) {
2750            self.nonce_recovery_inflight.store(false, Ordering::Release);
2751            log::warn!("Skipping Lighter nonce recovery after shutdown began: {e}");
2752        }
2753    }
2754
2755    fn sign_create_order(&self, plan: CreateOrderPlan) -> anyhow::Result<PreparedCreateOrder> {
2756        let cloid = plan.order.client_order_id();
2757        let client_order_index = self.dispatch.register_create_identity(&plan.order)?;
2758
2759        let ReservedTxContext {
2760            context,
2761            send_reservation,
2762        } = match self.build_tx_context() {
2763            Ok(context) => context,
2764            Err(e) => {
2765                self.dispatch.forget_cloid(client_order_index);
2766                self.dispatch.forget_order_identity(&cloid);
2767                return Err(e);
2768            }
2769        };
2770
2771        let nonce = context.nonce;
2772        let api_key_index = context.api_key_index;
2773        let mut rollback_guard = TxDispatchGuard::new(
2774            self.dispatch.clone(),
2775            &self.credential,
2776            Some(client_order_index),
2777            nonce,
2778        )
2779        .with_order_identity(cloid);
2780
2781        let tx = CreateOrderTxInfo {
2782            context,
2783            order: OrderInfo {
2784                market_index: plan.market_index,
2785                client_order_index,
2786                base_amount: plan.base_amount,
2787                price: plan.price,
2788                is_ask: matches!(plan.order.order_side(), OrderSide::Sell),
2789                order_type: plan.order_type,
2790                time_in_force: plan.time_in_force,
2791                reduce_only: plan.order.is_reduce_only(),
2792                trigger_price: plan.trigger_price,
2793                order_expiry: plan.order_expiry,
2794            },
2795            attributes: integrator_attributes(self.integrator_account_index),
2796        };
2797
2798        let signed = sign_tx(
2799            &tx,
2800            self.chain_id,
2801            &self.credential.private_key()?,
2802            fresh_k(),
2803        );
2804
2805        let tx_info =
2806            serde_json::value::RawValue::from_string(TxInfoJson::create_order(&tx, &signed))
2807                .context("failed to wrap signed Lighter tx_info JSON")?;
2808
2809        rollback_guard.disarm();
2810
2811        Ok(PreparedCreateOrder {
2812            order: plan.order,
2813            client_order_index,
2814            tx_info,
2815            nonce,
2816            api_key_index,
2817            tx_hash: signed.tx_hash_hex(),
2818            send_reservation,
2819        })
2820    }
2821
2822    async fn send_create_order(&self, prepared: PreparedCreateOrder) {
2823        let PreparedCreateOrder {
2824            order,
2825            client_order_index,
2826            tx_info,
2827            nonce,
2828            api_key_index,
2829            tx_hash,
2830            mut send_reservation,
2831        } = prepared;
2832        let connection_epoch = send_reservation.connection_epoch;
2833        let client_order_id = order.client_order_id();
2834
2835        self.emitter.emit_order_submitted(&order);
2836        log::debug!("Lighter submit_order: queueing CreateOrder tx for {client_order_id}");
2837        send_reservation.wait_for_turn().await;
2838        await_tx_quota(&self.tx_rate_limiter).await;
2839        self.dispatch.enqueue_pending_sendtx(PendingSendTx {
2840            connection_epoch,
2841            kind: PendingSendTxKind::Create {
2842                order: Box::new(order.clone()),
2843                client_order_index,
2844            },
2845            submitted_at: self.clock.get_time_ns(),
2846            nonce,
2847            api_key_index,
2848            tx_hash,
2849        });
2850
2851        if let Err(e) = self
2852            .ws_client
2853            .send_tx_on_connection(LighterTxType::CreateOrder as u8, tx_info, connection_epoch)
2854            .await
2855        {
2856            let failure = classify_lighter_ws_command_failure("submit_order", &e);
2857            let reason = command_failure_reason(&failure);
2858            if matches!(&failure, CommandFailure::Ambiguous(_)) {
2859                log::warn!(
2860                    "Lighter submit_order dispatch outcome unknown for {client_order_id}: {reason}; \
2861                     retaining pending state for venue reconciliation; diagnostic={e:?}",
2862                );
2863            } else {
2864                log::error!("{reason} for {client_order_id}; diagnostic={e:?}");
2865                self.dispatch
2866                    .remove_pending_sendtx_by_nonce(connection_epoch, nonce);
2867                rollback_tx_dispatch_create(
2868                    &self.dispatch,
2869                    &self.credential,
2870                    Some(client_order_index),
2871                    &client_order_id,
2872                    nonce,
2873                );
2874                self.emitter
2875                    .emit_order_rejected(&order, reason, self.clock.get_time_ns(), false);
2876            }
2877        }
2878        send_reservation.release();
2879    }
2880
2881    fn sign_cancel_order(&self, plan: &CancelOrderPlan) -> anyhow::Result<PreparedCancelOrder> {
2882        let ReservedTxContext {
2883            context,
2884            send_reservation,
2885        } = self.build_tx_context()?;
2886
2887        let nonce = context.nonce;
2888        let api_key_index = context.api_key_index;
2889        let mut rollback_guard =
2890            TxDispatchGuard::new(self.dispatch.clone(), &self.credential, None, nonce);
2891
2892        let tx = CancelOrderTxInfo {
2893            context,
2894            market_index: plan.market_index,
2895            index: plan.venue_index,
2896            skip_nonce: 0,
2897        };
2898
2899        let signed = sign_tx(
2900            &tx,
2901            self.chain_id,
2902            &self.credential.private_key()?,
2903            fresh_k(),
2904        );
2905
2906        let tx_info =
2907            serde_json::value::RawValue::from_string(TxInfoJson::cancel_order(&tx, &signed))
2908                .context("failed to wrap signed Lighter cancel tx_info JSON")?;
2909
2910        rollback_guard.disarm();
2911
2912        Ok(PreparedCancelOrder {
2913            client_order_id: plan.client_order_id,
2914            strategy_id: plan.strategy_id,
2915            instrument_id: plan.instrument_id,
2916            venue_order_id: plan.venue_order_id,
2917            tx_info,
2918            nonce,
2919            api_key_index,
2920            tx_hash: signed.tx_hash_hex(),
2921            send_reservation,
2922        })
2923    }
2924
2925    async fn send_cancel_order(&self, prepared: PreparedCancelOrder, emit_cancel_rejected: bool) {
2926        let PreparedCancelOrder {
2927            client_order_id,
2928            strategy_id,
2929            instrument_id,
2930            venue_order_id,
2931            tx_info,
2932            nonce,
2933            api_key_index,
2934            tx_hash,
2935            mut send_reservation,
2936        } = prepared;
2937        let connection_epoch = send_reservation.connection_epoch;
2938
2939        send_reservation.wait_for_turn().await;
2940        await_tx_quota(&self.tx_rate_limiter).await;
2941        self.dispatch.enqueue_pending_sendtx(PendingSendTx {
2942            connection_epoch,
2943            kind: if emit_cancel_rejected {
2944                PendingSendTxKind::Cancel {
2945                    strategy_id,
2946                    instrument_id,
2947                    client_order_id,
2948                    venue_order_id,
2949                }
2950            } else {
2951                PendingSendTxKind::Other
2952            },
2953            submitted_at: self.clock.get_time_ns(),
2954            nonce,
2955            api_key_index,
2956            tx_hash,
2957        });
2958
2959        if let Err(e) = self
2960            .ws_client
2961            .send_tx_on_connection(LighterTxType::CancelOrder as u8, tx_info, connection_epoch)
2962            .await
2963        {
2964            let failure = classify_lighter_ws_command_failure("cancel_order", &e);
2965            let reason = command_failure_reason(&failure);
2966            if matches!(&failure, CommandFailure::Ambiguous(_)) {
2967                log::warn!(
2968                    "Lighter cancel_order dispatch outcome unknown for {client_order_id}: {reason}; \
2969                     retaining pending state for venue reconciliation; diagnostic={e:?}",
2970                );
2971            } else {
2972                log::error!("{reason} for {client_order_id}; diagnostic={e:?}");
2973                self.dispatch
2974                    .remove_pending_sendtx_by_nonce(connection_epoch, nonce);
2975                self.dispatch
2976                    .clear_pending_order_action_if(&client_order_id, PendingOrderAction::Cancel);
2977                rollback_tx_dispatch(&self.dispatch, &self.credential, None, nonce);
2978
2979                if emit_cancel_rejected {
2980                    self.emitter.emit_order_cancel_rejected_event(
2981                        strategy_id,
2982                        instrument_id,
2983                        client_order_id,
2984                        venue_order_id,
2985                        reason,
2986                        self.clock.get_time_ns(),
2987                    );
2988                } else {
2989                    log::warn!(
2990                        "{reason} for {client_order_id}; suppressing OrderCancelRejected because order is not PendingCancel",
2991                    );
2992                }
2993            }
2994        }
2995        send_reservation.release();
2996    }
2997}
2998
2999struct PreparedModifyOrder {
3000    client_order_id: ClientOrderId,
3001    strategy_id: StrategyId,
3002    instrument_id: InstrumentId,
3003    venue_order_id: Option<VenueOrderId>,
3004    tx_info: Box<serde_json::value::RawValue>,
3005    nonce: i64,
3006    api_key_index: u8,
3007    tx_hash: String,
3008    send_reservation: TxSendReservation,
3009}
3010
3011struct PreparedIntegratorApproval {
3012    tx_info: String,
3013    nonce: i64,
3014    api_key_index: u8,
3015    approval_expiry: i64,
3016    send_reservation: TxSendReservation,
3017}
3018
3019// Cross-check between a detected account tier and the configured REST quota:
3020// AboveTier when the override exceeds the tier limit, RaiseHint when the tier
3021// allows more than standard but no override is set.
3022#[derive(Debug, PartialEq, Eq)]
3023enum TierCrossCheck {
3024    AboveTier { documented: u32 },
3025    RaiseHint { documented: u32 },
3026}
3027
3028// Computes the active REST quota to report and any cross-check advisory from the
3029// detected tier and the raw override. A zero override resolves to the standard
3030// default (matching resolve_quota), so the reported quota always matches the
3031// limiter. Pure so the reporting decision is unit-testable without log capture.
3032fn tier_quota_report(
3033    tier: LighterAccountTier,
3034    rest_quota_per_min: Option<u32>,
3035    standard_rest: u32,
3036) -> (u32, Option<TierCrossCheck>) {
3037    let configured = rest_quota_per_min.filter(|&n| n > 0);
3038    let active_rest = configured.unwrap_or(standard_rest);
3039    let cross_check = match (tier.documented_rest_quota_per_min(), configured) {
3040        (Some(documented), Some(configured)) if configured > documented => {
3041            Some(TierCrossCheck::AboveTier { documented })
3042        }
3043        (Some(documented), None) if documented > standard_rest => {
3044            Some(TierCrossCheck::RaiseHint { documented })
3045        }
3046        _ => None,
3047    };
3048    (active_rest, cross_check)
3049}
3050
3051struct TxDispatchGuard {
3052    dispatch: WsDispatchState,
3053    account_index: i64,
3054    api_key_index: u8,
3055    client_order_index: Option<i64>,
3056    client_order_id: Option<ClientOrderId>,
3057    nonce: i64,
3058    armed: bool,
3059}
3060
3061impl TxDispatchGuard {
3062    fn new(
3063        dispatch: WsDispatchState,
3064        credential: &Credential,
3065        client_order_index: Option<i64>,
3066        nonce: i64,
3067    ) -> Self {
3068        Self {
3069            dispatch,
3070            account_index: credential.account_index(),
3071            api_key_index: credential.api_key_index(),
3072            client_order_index,
3073            client_order_id: None,
3074            nonce,
3075            armed: true,
3076        }
3077    }
3078
3079    fn with_order_identity(mut self, client_order_id: ClientOrderId) -> Self {
3080        self.client_order_id = Some(client_order_id);
3081        self
3082    }
3083
3084    fn disarm(&mut self) {
3085        self.armed = false;
3086    }
3087}
3088
3089impl Drop for TxDispatchGuard {
3090    fn drop(&mut self) {
3091        if self.armed {
3092            rollback_tx_dispatch_indices(
3093                &self.dispatch,
3094                self.account_index,
3095                self.api_key_index,
3096                self.client_order_index,
3097                self.client_order_id.as_ref(),
3098                self.nonce,
3099            );
3100        }
3101    }
3102}
3103
3104// SendTxAck: remove by echoed tx_hash when present. A hashless ack is safe to
3105// attribute only when one transaction is pending. An echoed hash is
3106// authoritative: on a miss the entry was already consumed or never enqueued,
3107// and a fallback would attribute the ack to the wrong tx.
3108fn handle_send_tx_ack_for_connection(
3109    dispatch: &WsDispatchState,
3110    account_index: Option<i64>,
3111    connection_epoch: u64,
3112    code: i64,
3113    tx_hash: Option<&str>,
3114) -> Option<PendingSendTx> {
3115    let popped = match tx_hash {
3116        Some(hash) => {
3117            let matched = dispatch.remove_pending_sendtx_by_hash(connection_epoch, hash);
3118            if matched.is_none() {
3119                log::warn!("Lighter sendTx ack unmatched: tx_hash={hash} code={code}");
3120            }
3121            matched
3122        }
3123        None => dispatch.pop_pending_sendtx_if_only(connection_epoch),
3124    };
3125
3126    if let (Some(pending), Some(account_index)) = (&popped, account_index) {
3127        let _ =
3128            dispatch
3129                .nonce_manager
3130                .ack_success(account_index, pending.api_key_index, pending.nonce);
3131    }
3132
3133    log::debug!(
3134        "Lighter sendTx ack: code={code} tx_hash={tx_hash:?} popped_nonce={:?}",
3135        popped.as_ref().map(|p| p.nonce),
3136    );
3137
3138    popped
3139}
3140
3141fn spawn_acked_order_probe(pending: &PendingSendTx, context: AckedOrderProbeContext) {
3142    let Some(probe) = AckedOrderProbe::from_pending(pending) else {
3143        return;
3144    };
3145
3146    let pending_tasks = context.pending_tasks.clone();
3147
3148    let future = async move {
3149        tokio::select! {
3150            () = context.cancellation_token.cancelled() => return,
3151            () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
3152        }
3153
3154        if let Err(e) = probe_acked_order(probe, &context).await {
3155            log::warn!("Lighter acknowledged order probe failed: {e:?}");
3156        }
3157    };
3158
3159    if let Err(e) = pending_tasks.spawn(future) {
3160        log::debug!("Skipping Lighter acknowledged-order probe during shutdown: {e}");
3161    }
3162}
3163
3164#[derive(Clone)]
3165struct AckedOrderProbeContext {
3166    http_client: LighterHttpClient,
3167    registry: Arc<MarketRegistry>,
3168    credential: Credential,
3169    dispatch: WsDispatchState,
3170    account_id: AccountId,
3171    clock: &'static AtomicTime,
3172    emitter: ExecutionEventEmitter,
3173    connection_epoch: Arc<AtomicU64>,
3174    cancellation_token: CancellationToken,
3175    pending_tasks: TaskSpawner,
3176}
3177
3178async fn probe_acked_order(
3179    probe: AckedOrderProbe,
3180    context: &AckedOrderProbeContext,
3181) -> anyhow::Result<()> {
3182    if matches!(&probe, AckedOrderProbe::Create { .. }) {
3183        return probe_acked_create(probe, context).await;
3184    }
3185
3186    let report = lookup_order_status_report(
3187        &context.http_client,
3188        &context.registry,
3189        &context.credential,
3190        context.account_id,
3191        Some(probe.instrument_id()),
3192        Some(&probe.client_order_id()),
3193        probe.venue_order_id().as_ref(),
3194        &context.dispatch,
3195        context.clock,
3196    )
3197    .await?;
3198
3199    if let Some(report) = &report {
3200        context.dispatch.seed_accepted_from_report(report);
3201    }
3202
3203    warn_if_acked_order_missing(&probe, report.is_some());
3204    Ok(())
3205}
3206
3207async fn probe_acked_create(
3208    probe: AckedOrderProbe,
3209    context: &AckedOrderProbeContext,
3210) -> anyhow::Result<()> {
3211    let AckedOrderProbe::Create {
3212        order,
3213        client_order_index,
3214        connection_epoch,
3215        nonce,
3216        api_key_index,
3217        tx_hash,
3218    } = probe
3219    else {
3220        unreachable!("create probe called with non-create transaction")
3221    };
3222    let client_order_id = order.client_order_id();
3223    let mut transaction_executed = false;
3224
3225    for attempt in 1..=ACKED_CREATE_PROBE_ATTEMPTS {
3226        if context.connection_epoch.load(Ordering::Acquire) != connection_epoch {
3227            log::warn!(
3228                "Lighter acknowledged create outcome unresolved after reconnect for {client_order_id}; retaining identity for reconciliation",
3229            );
3230            return Ok(());
3231        }
3232
3233        if !context.dispatch.create_submission_is_pending(
3234            &client_order_id,
3235            client_order_index,
3236            nonce,
3237        ) {
3238            return Ok(());
3239        }
3240
3241        let report = match lookup_create_order_status_report(
3242            &context.http_client,
3243            &context.registry,
3244            &context.credential,
3245            context.account_id,
3246            order.instrument_id(),
3247            client_order_id,
3248            client_order_index,
3249            nonce,
3250            &context.dispatch,
3251            context.clock,
3252        )
3253        .await
3254        {
3255            Ok(report) => report,
3256            Err(e) => {
3257                log::warn!(
3258                    "Lighter acknowledged create order lookup failed for {client_order_id} on attempt {attempt}: {e:?}",
3259                );
3260                None
3261            }
3262        };
3263
3264        if let Some(report) = report {
3265            if context.dispatch.observe_create_submission(
3266                &client_order_id,
3267                client_order_index,
3268                nonce,
3269                report.venue_order_id,
3270            ) {
3271                context.dispatch.seed_accepted_from_report(&report);
3272                context.emitter.send_order_status_report(report);
3273            }
3274            return Ok(());
3275        }
3276
3277        match context.http_client.get_tx(tx_hash.clone()).await {
3278            Ok(tx) => {
3279                validate_acked_create_tx(
3280                    &tx,
3281                    context.credential.account_index(),
3282                    api_key_index,
3283                    client_order_index,
3284                    nonce,
3285                    &tx_hash,
3286                )?;
3287                let event = parse_acked_create_event(&tx.event_info).unwrap_or_else(|e| {
3288                    log::warn!(
3289                        "Lighter create transaction carried invalid event_info for {client_order_id}: {e}",
3290                    );
3291                    AckedCreateEvent::default()
3292                });
3293
3294                if tx.status == LighterTxStatus::Failed || !event.app_error.is_empty() {
3295                    let detail = if event.app_error.is_empty() {
3296                        "transaction failed without an application error".to_string()
3297                    } else {
3298                        event.app_error
3299                    };
3300                    let reason = format!(
3301                        "Lighter sequencer rejected acknowledged create transaction {tx_hash}: {detail}",
3302                    );
3303                    reject_create_order(
3304                        &context.dispatch,
3305                        &context.emitter,
3306                        &order,
3307                        client_order_index,
3308                        nonce,
3309                        &reason,
3310                        context.clock.get_time_ns(),
3311                        lighter_reason_indicates_post_only_rejection(&detail),
3312                        Some((&context.connection_epoch, connection_epoch)),
3313                    );
3314                    return Ok(());
3315                }
3316                transaction_executed |= tx.status == LighterTxStatus::Executed;
3317            }
3318            Err(LighterHttpError::Venue { code: 21500, .. }) => {}
3319            Err(e) => {
3320                log::warn!(
3321                    "Lighter acknowledged create transaction lookup failed for {client_order_id} on attempt {attempt}: {e}",
3322                );
3323            }
3324        }
3325
3326        if attempt < ACKED_CREATE_PROBE_ATTEMPTS {
3327            tokio::select! {
3328                () = context.cancellation_token.cancelled() => return Ok(()),
3329                () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
3330            }
3331        }
3332    }
3333
3334    if transaction_executed {
3335        let _ =
3336            context
3337                .dispatch
3338                .confirm_create_submission(&client_order_id, client_order_index, nonce);
3339        log::warn!(
3340            "Lighter acknowledged create transaction executed without a queryable order for {client_order_id}; retaining identity for reconciliation",
3341        );
3342    } else {
3343        log::warn!(
3344            "Lighter acknowledged create outcome unresolved after {ACKED_CREATE_PROBE_ATTEMPTS} transaction lookups for {client_order_id}; retaining identity for reconciliation",
3345        );
3346    }
3347    Ok(())
3348}
3349
3350#[derive(Deserialize)]
3351struct AckedCreateTxInfo {
3352    #[serde(rename = "ClientOrderIndex")]
3353    client_order_index: i64,
3354}
3355
3356#[derive(Default, Deserialize)]
3357struct AckedCreateEvent {
3358    #[serde(default, rename = "ae")]
3359    app_error: String,
3360}
3361
3362fn validate_acked_create_tx(
3363    tx: &crate::http::models::LighterTx,
3364    account_index: i64,
3365    api_key_index: u8,
3366    client_order_index: i64,
3367    nonce: i64,
3368    tx_hash: &str,
3369) -> anyhow::Result<()> {
3370    anyhow::ensure!(
3371        tx_hash_matches(&tx.hash, tx_hash)
3372            && tx.tx_type == LighterTxType::CreateOrder as u8
3373            && tx.account_index == account_index
3374            && tx.api_key_index == api_key_index
3375            && tx.nonce == nonce,
3376        "Lighter transaction lookup did not match acknowledged create identity",
3377    );
3378    let info: AckedCreateTxInfo = serde_json::from_str(&tx.info)
3379        .context("failed to parse Lighter create transaction info")?;
3380    anyhow::ensure!(
3381        info.client_order_index == client_order_index,
3382        "Lighter transaction lookup returned client_order_index {} for acknowledged create {client_order_index}",
3383        info.client_order_index,
3384    );
3385    Ok(())
3386}
3387
3388fn tx_hash_matches(left: &str, right: &str) -> bool {
3389    let left = left
3390        .strip_prefix("0x")
3391        .or_else(|| left.strip_prefix("0X"))
3392        .unwrap_or(left);
3393    let right = right
3394        .strip_prefix("0x")
3395        .or_else(|| right.strip_prefix("0X"))
3396        .unwrap_or(right);
3397    left.eq_ignore_ascii_case(right)
3398}
3399
3400fn parse_acked_create_event(event_info: &str) -> anyhow::Result<AckedCreateEvent> {
3401    if event_info.trim().is_empty() {
3402        return Ok(AckedCreateEvent::default());
3403    }
3404    serde_json::from_str(event_info).context("failed to parse Lighter create transaction event")
3405}
3406
3407fn warn_if_acked_order_missing(probe: &AckedOrderProbe, order_found: bool) {
3408    if order_found {
3409        return;
3410    }
3411
3412    match probe {
3413        AckedOrderProbe::Create { .. } => {}
3414        AckedOrderProbe::Cancel {
3415            client_order_id, ..
3416        } => {
3417            log::warn!(
3418                "Lighter cancel_order outcome unresolved: order not found after venue ack for {client_order_id}",
3419            );
3420        }
3421        AckedOrderProbe::Modify {
3422            client_order_id, ..
3423        } => {
3424            log::warn!(
3425                "Lighter modify_order outcome unresolved: order not found after venue ack for {client_order_id}",
3426            );
3427        }
3428    }
3429}
3430
3431#[derive(Debug, Clone)]
3432enum AckedOrderProbe {
3433    Create {
3434        order: Box<OrderAny>,
3435        client_order_index: i64,
3436        connection_epoch: u64,
3437        nonce: i64,
3438        api_key_index: u8,
3439        tx_hash: String,
3440    },
3441    Cancel {
3442        instrument_id: InstrumentId,
3443        client_order_id: ClientOrderId,
3444        venue_order_id: Option<VenueOrderId>,
3445    },
3446    Modify {
3447        instrument_id: InstrumentId,
3448        client_order_id: ClientOrderId,
3449        venue_order_id: Option<VenueOrderId>,
3450    },
3451}
3452
3453impl AckedOrderProbe {
3454    fn from_pending(pending: &PendingSendTx) -> Option<Self> {
3455        match &pending.kind {
3456            PendingSendTxKind::Create {
3457                order,
3458                client_order_index,
3459            } => Some(Self::Create {
3460                order: order.clone(),
3461                client_order_index: *client_order_index,
3462                connection_epoch: pending.connection_epoch,
3463                nonce: pending.nonce,
3464                api_key_index: pending.api_key_index,
3465                tx_hash: pending.tx_hash.clone(),
3466            }),
3467            PendingSendTxKind::Cancel {
3468                instrument_id,
3469                client_order_id,
3470                venue_order_id,
3471                ..
3472            } => Some(Self::Cancel {
3473                instrument_id: *instrument_id,
3474                client_order_id: *client_order_id,
3475                venue_order_id: *venue_order_id,
3476            }),
3477            PendingSendTxKind::Modify {
3478                instrument_id,
3479                client_order_id,
3480                venue_order_id,
3481                ..
3482            } => Some(Self::Modify {
3483                instrument_id: *instrument_id,
3484                client_order_id: *client_order_id,
3485                venue_order_id: *venue_order_id,
3486            }),
3487            PendingSendTxKind::Other => None,
3488        }
3489    }
3490
3491    fn instrument_id(&self) -> InstrumentId {
3492        match self {
3493            Self::Create { order, .. } => order.instrument_id(),
3494            Self::Cancel { instrument_id, .. } | Self::Modify { instrument_id, .. } => {
3495                *instrument_id
3496            }
3497        }
3498    }
3499
3500    fn client_order_id(&self) -> ClientOrderId {
3501        match self {
3502            Self::Create { order, .. } => order.client_order_id(),
3503            Self::Cancel {
3504                client_order_id, ..
3505            }
3506            | Self::Modify {
3507                client_order_id, ..
3508            } => *client_order_id,
3509        }
3510    }
3511
3512    fn venue_order_id(&self) -> Option<VenueOrderId> {
3513        match self {
3514            Self::Create { .. } => None,
3515            Self::Cancel { venue_order_id, .. } | Self::Modify { venue_order_id, .. } => {
3516                *venue_order_id
3517            }
3518        }
3519    }
3520}
3521
3522// SendTxRejected: attribute by echoed tx_hash when present (authoritative,
3523// no fallback on a miss); otherwise pop only when one transaction is pending
3524// for Ack, or head-within-window for BareError. Create emits OrderRejected,
3525// cancel/modify emit their typed rejections, and Other recovers via
3526// reconciliation. All attributed rejections roll the nonce back when still
3527// the latest issuance.
3528// Returns true on an invalid-nonce code: the sequential stream is wedged and
3529// needs a hard refresh.
3530#[expect(
3531    clippy::too_many_arguments,
3532    reason = "shared terminal create transition keeps cleanup and event attribution together"
3533)]
3534fn reject_create_order(
3535    dispatch: &WsDispatchState,
3536    emitter: &ExecutionEventEmitter,
3537    order: &OrderAny,
3538    client_order_index: i64,
3539    nonce: i64,
3540    reason: &str,
3541    now: UnixNanos,
3542    due_post_only: bool,
3543    connection_epoch: Option<(&AtomicU64, u64)>,
3544) -> bool {
3545    let cloid = order.client_order_id();
3546    if !dispatch.reject_create_submission(&cloid, client_order_index, nonce, connection_epoch) {
3547        log::warn!(
3548            "Ignored stale Lighter create rejection for cloid={cloid} client_order_index={client_order_index} nonce={nonce}",
3549        );
3550        return false;
3551    }
3552    emitter.emit_order_rejected(order, reason, now, due_post_only);
3553    true
3554}
3555
3556#[expect(
3557    clippy::too_many_arguments,
3558    reason = "consumer-loop sink that flattens one SendTxRejected message without a wrapper struct"
3559)]
3560fn handle_send_tx_rejection_for_connection(
3561    dispatch: &WsDispatchState,
3562    emitter: &ExecutionEventEmitter,
3563    account_index: Option<i64>,
3564    connection_epoch: u64,
3565    now: UnixNanos,
3566    source: SendTxRejectionSource,
3567    code: Option<i64>,
3568    message: &str,
3569    tx_hash: Option<&str>,
3570) -> bool {
3571    let needs_nonce_resync = code == Some(LIGHTER_ERROR_CODE_INVALID_NONCE);
3572    let failure = venue_rejection_failure(code, message);
3573    let reason = command_failure_reason(&failure);
3574
3575    let pending = match tx_hash {
3576        Some(hash) => dispatch.remove_pending_sendtx_by_hash(connection_epoch, hash),
3577        None => match source {
3578            SendTxRejectionSource::Ack => dispatch.pop_pending_sendtx_if_only(connection_epoch),
3579            SendTxRejectionSource::BareError => dispatch.pop_pending_sendtx_if_only_within(
3580                connection_epoch,
3581                now,
3582                SENDTX_BARE_ERROR_WINDOW_MS,
3583            ),
3584        },
3585    };
3586    let Some(pending) = pending else {
3587        log::warn!(
3588            "Lighter sendTx rejection unattributed (source={source:?} code={code:?}): {message:?}",
3589        );
3590        return needs_nonce_resync;
3591    };
3592
3593    match &pending.kind {
3594        PendingSendTxKind::Create {
3595            order,
3596            client_order_index,
3597        } => {
3598            let cloid = order.client_order_id();
3599            log::error!(
3600                "{reason} attributed to cloid={cloid} nonce={} api_key_index={}; diagnostic_code={code:?} diagnostic_message={message:?}",
3601                pending.nonce,
3602                pending.api_key_index,
3603            );
3604
3605            if let Some(account_index) = account_index {
3606                let _ = dispatch.nonce_manager.ack_failure_if_latest(
3607                    account_index,
3608                    pending.api_key_index,
3609                    pending.nonce,
3610                );
3611            }
3612            reject_create_order(
3613                dispatch,
3614                emitter,
3615                order,
3616                *client_order_index,
3617                pending.nonce,
3618                reason,
3619                now,
3620                lighter_reason_indicates_post_only_rejection(message),
3621                None,
3622            );
3623        }
3624        PendingSendTxKind::Cancel {
3625            strategy_id,
3626            instrument_id,
3627            client_order_id,
3628            venue_order_id,
3629        } => {
3630            log::error!(
3631                "{reason} attributed to cancel cloid={client_order_id} nonce={} api_key_index={}; diagnostic_code={code:?} diagnostic_message={message:?}",
3632                pending.nonce,
3633                pending.api_key_index,
3634            );
3635
3636            if let Some(account_index) = account_index {
3637                let _ = dispatch.nonce_manager.ack_failure_if_latest(
3638                    account_index,
3639                    pending.api_key_index,
3640                    pending.nonce,
3641                );
3642            }
3643            dispatch.clear_pending_order_action_if(client_order_id, PendingOrderAction::Cancel);
3644            emitter.emit_order_cancel_rejected_event(
3645                *strategy_id,
3646                *instrument_id,
3647                *client_order_id,
3648                *venue_order_id,
3649                reason,
3650                now,
3651            );
3652        }
3653        PendingSendTxKind::Modify {
3654            strategy_id,
3655            instrument_id,
3656            client_order_id,
3657            venue_order_id,
3658        } => {
3659            log::error!(
3660                "{reason} attributed to modify cloid={client_order_id} nonce={} api_key_index={}; diagnostic_code={code:?} diagnostic_message={message:?}",
3661                pending.nonce,
3662                pending.api_key_index,
3663            );
3664
3665            if let Some(account_index) = account_index {
3666                let _ = dispatch.nonce_manager.ack_failure_if_latest(
3667                    account_index,
3668                    pending.api_key_index,
3669                    pending.nonce,
3670                );
3671            }
3672            dispatch.clear_pending_order_action_if(client_order_id, PendingOrderAction::Modify);
3673            emitter.emit_order_modify_rejected_event(
3674                *strategy_id,
3675                *instrument_id,
3676                *client_order_id,
3677                *venue_order_id,
3678                reason,
3679                now,
3680            );
3681        }
3682        PendingSendTxKind::Other => {
3683            if let Some(account_index) = account_index {
3684                let _ = dispatch.nonce_manager.ack_failure_if_latest(
3685                    account_index,
3686                    pending.api_key_index,
3687                    pending.nonce,
3688                );
3689            }
3690            log::warn!(
3691                "{reason} on non-create sendTx (nonce={} api_key_index={}); diagnostic_code={code:?} diagnostic_message={message:?}",
3692                pending.nonce,
3693                pending.api_key_index,
3694            );
3695        }
3696    }
3697
3698    needs_nonce_resync
3699}
3700
3701fn lighter_reason_indicates_post_only_rejection(reason: &str) -> bool {
3702    let normalized: String = reason
3703        .chars()
3704        .filter_map(|ch| {
3705            if ch == '-' || ch == '_' || ch.is_whitespace() {
3706                None
3707            } else {
3708                Some(ch.to_ascii_lowercase())
3709            }
3710        })
3711        .collect();
3712
3713    normalized.contains("postonly") || normalized.contains("postwouldexecute")
3714}
3715
3716fn lighter_http_error_is_definite_api_rejection(error: &LighterHttpError) -> bool {
3717    match error {
3718        LighterHttpError::RateLimit(_) | LighterHttpError::Venue { .. } => true,
3719        LighterHttpError::Http { status, .. } => *status < 500,
3720        LighterHttpError::Network(_)
3721        | LighterHttpError::HistoryIncomplete { .. }
3722        | LighterHttpError::Parse(_) => false,
3723    }
3724}
3725
3726fn rollback_tx_dispatch(
3727    dispatch: &WsDispatchState,
3728    credential: &Credential,
3729    client_order_index: Option<i64>,
3730    nonce: i64,
3731) {
3732    rollback_tx_dispatch_indices(
3733        dispatch,
3734        credential.account_index(),
3735        credential.api_key_index(),
3736        client_order_index,
3737        None,
3738        nonce,
3739    );
3740}
3741
3742fn rollback_tx_dispatch_create(
3743    dispatch: &WsDispatchState,
3744    credential: &Credential,
3745    client_order_index: Option<i64>,
3746    client_order_id: &ClientOrderId,
3747    nonce: i64,
3748) {
3749    rollback_tx_dispatch_indices(
3750        dispatch,
3751        credential.account_index(),
3752        credential.api_key_index(),
3753        client_order_index,
3754        Some(client_order_id),
3755        nonce,
3756    );
3757}
3758
3759// Roll back only while still the latest issuance: decrementing past a newer
3760// signed tx would duplicate its nonce on the wire. Skipped rollbacks heal
3761// via the baseline advance or venue resync.
3762fn rollback_tx_dispatch_indices(
3763    dispatch: &WsDispatchState,
3764    account_index: i64,
3765    api_key_index: u8,
3766    client_order_index: Option<i64>,
3767    client_order_id: Option<&ClientOrderId>,
3768    nonce: i64,
3769) {
3770    let _ = dispatch
3771        .nonce_manager
3772        .ack_failure_if_latest(account_index, api_key_index, nonce);
3773
3774    if let Some(client_order_index) = client_order_index {
3775        dispatch.forget_cloid(client_order_index);
3776    }
3777
3778    if let Some(cloid) = client_order_id {
3779        dispatch.forget_order_identity(cloid);
3780    }
3781}
3782
3783fn integrator_attributes(integrator_account_index: Option<u64>) -> L2TxAttributes {
3784    integrator_account_index.map_or_else(L2TxAttributes::default, |integrator_account_index| {
3785        L2TxAttributes {
3786            integrator_account_index,
3787            ..Default::default()
3788        }
3789    })
3790}
3791
3792fn command_failure_reason(failure: &CommandFailure) -> &str {
3793    match failure {
3794        CommandFailure::NotSent(reason)
3795        | CommandFailure::Ambiguous(reason)
3796        | CommandFailure::VenueRejected(reason) => reason,
3797    }
3798}
3799
3800fn warn_pending_sendtx_unknown(pending_sendtx: &[PendingSendTx], context: &str) {
3801    if pending_sendtx.is_empty() {
3802        return;
3803    }
3804
3805    log::warn!(
3806        "Discarded {} pending sendTx entries during {context}; order state recovers via \
3807         reconciliation",
3808        pending_sendtx.len(),
3809    );
3810
3811    for pending in pending_sendtx {
3812        if let PendingSendTxKind::Create { order, .. } = &pending.kind {
3813            log::warn!(
3814                "Lighter sendTx outcome unknown during {context} for {}",
3815                order.client_order_id(),
3816            );
3817        }
3818    }
3819}
3820
3821fn classify_lighter_ws_command_failure(action: &str, error: &LighterWsError) -> CommandFailure {
3822    let fallback = format!("Lighter {action} dispatch failed");
3823    let reason = sanitize_strategy_reason(&format!("{fallback}: {error}"))
3824        .unwrap_or_else(|| fallback.clone());
3825
3826    match error {
3827        LighterWsError::SendTxOutcomeUnknown(_)
3828        | LighterWsError::Network(_)
3829        | LighterWsError::Parse(_)
3830        | LighterWsError::Transport(SendError::WriteTimeout | SendError::BrokenPipe(_)) => {
3831            CommandFailure::ambiguous(reason)
3832        }
3833        LighterWsError::Transport(
3834            SendError::InvalidInput(_)
3835            | SendError::Closed
3836            | SendError::Timeout
3837            | SendError::ConnectionChanged,
3838        )
3839        | LighterWsError::Authentication(_)
3840        | LighterWsError::Client(_) => CommandFailure::not_sent(reason),
3841    }
3842}
3843
3844fn venue_rejection_failure(code: Option<i64>, message: &str) -> CommandFailure {
3845    let clean_message = sanitize_strategy_reason(message);
3846    let reason = match (code, clean_message) {
3847        (Some(code), Some(message)) => format!("LIGHTER_{code}: {message}"),
3848        (Some(code), None) => format!("LIGHTER_{code}"),
3849        (None, Some(message)) => message,
3850        (None, None) => "Lighter venue rejected sendTx".to_string(),
3851    };
3852    let reason = sanitize_strategy_reason(&reason)
3853        .unwrap_or_else(|| "Lighter venue rejected sendTx".to_string());
3854    CommandFailure::venue_rejected(reason)
3855}
3856
3857fn sanitize_strategy_reason(input: &str) -> Option<String> {
3858    let mut output = String::new();
3859    let mut inside_markup = false;
3860    let mut pending_space = false;
3861    let mut char_count = 0;
3862    let mut chars = input.chars().peekable();
3863
3864    while let Some(ch) = chars.next() {
3865        if inside_markup {
3866            if ch == '>' {
3867                inside_markup = false;
3868            }
3869            continue;
3870        }
3871
3872        match ch {
3873            '<' if chars.peek().is_some_and(|next| {
3874                next.is_ascii_alphabetic() || matches!(next, '/' | '!' | '?')
3875            }) =>
3876            {
3877                inside_markup = true;
3878                pending_space = !output.is_empty();
3879            }
3880            _ if ch.is_control() || is_unicode_format_control(ch) || ch.is_whitespace() => {
3881                pending_space = !output.is_empty();
3882            }
3883            _ => {
3884                if pending_space && char_count < STRATEGY_REASON_MAX_CHARS {
3885                    output.push(' ');
3886                    char_count += 1;
3887                }
3888                pending_space = false;
3889
3890                if char_count == STRATEGY_REASON_MAX_CHARS {
3891                    break;
3892                }
3893                output.push(ch);
3894                char_count += 1;
3895            }
3896        }
3897    }
3898
3899    let output = output.trim().to_string();
3900    (!output.is_empty()).then_some(output)
3901}
3902
3903fn is_unicode_format_control(ch: char) -> bool {
3904    matches!(
3905        ch,
3906        '\u{00ad}'
3907            | '\u{061c}'
3908            | '\u{06dd}'
3909            | '\u{070f}'
3910            | '\u{08e2}'
3911            | '\u{180e}'
3912            | '\u{feff}'
3913            | '\u{fff9}'..='\u{fffb}'
3914            | '\u{0600}'..='\u{0605}'
3915            | '\u{0890}'..='\u{0891}'
3916            | '\u{200b}'..='\u{200f}'
3917            | '\u{202a}'..='\u{202e}'
3918            | '\u{2060}'..='\u{206f}'
3919            | '\u{110bd}'
3920            | '\u{110cd}'
3921            | '\u{13430}'..='\u{1343f}'
3922            | '\u{1bca0}'..='\u{1bca3}'
3923            | '\u{1d173}'..='\u{1d17a}'
3924            | '\u{e0001}'
3925            | '\u{e0020}'..='\u{e007f}'
3926    )
3927}
3928
3929/// Format a `start_secs-end_secs` window for Lighter's `between_timestamps`
3930/// query parameter. Returns `None` when neither bound is set; an unset end
3931/// defaults to the current time so the venue scopes pagination to the
3932/// half-open window.
3933fn format_between_timestamps(
3934    start: Option<UnixNanos>,
3935    end: Option<UnixNanos>,
3936    ts_now: UnixNanos,
3937) -> Option<String> {
3938    let (start, end) = match (start, end) {
3939        (None, None) => return None,
3940        (Some(s), Some(e)) => (s, e),
3941        (Some(s), None) => (s, ts_now),
3942        (None, Some(e)) => (UnixNanos::from(0), e),
3943    };
3944    let start_secs = start.as_u64() / 1_000_000_000;
3945    let end_secs = end.as_u64() / 1_000_000_000;
3946    Some(format!("{start_secs}-{end_secs}"))
3947}
3948
3949#[async_trait(?Send)]
3950impl ExecutionClient for LighterExecutionClient {
3951    fn is_connected(&self) -> bool {
3952        self.core.is_connected()
3953    }
3954
3955    fn client_id(&self) -> ClientId {
3956        self.core.client_id
3957    }
3958
3959    fn account_id(&self) -> AccountId {
3960        self.core.account_id
3961    }
3962
3963    fn venue(&self) -> Venue {
3964        self.core.venue
3965    }
3966
3967    fn oms_type(&self) -> OmsType {
3968        self.core.oms_type
3969    }
3970
3971    fn get_account(&self) -> Option<AccountAny> {
3972        self.core.cache().account_owned(&self.core.account_id)
3973    }
3974
3975    fn generate_account_state(
3976        &self,
3977        balances: Vec<AccountBalance>,
3978        margins: Vec<MarginBalance>,
3979        reported: bool,
3980        ts_event: UnixNanos,
3981        info: Option<Params>,
3982    ) -> anyhow::Result<()> {
3983        self.emitter
3984            .emit_account_state(balances, margins, reported, ts_event, info);
3985        Ok(())
3986    }
3987
3988    fn start(&mut self) -> anyhow::Result<()> {
3989        if self.core.is_started() {
3990            return Ok(());
3991        }
3992
3993        let sender = get_exec_event_sender();
3994        self.emitter.set_sender(sender);
3995        self.core.set_started();
3996
3997        log::info!(
3998            "Started Lighter execution client: client_id={}, account_id={}, environment={:?}, has_credentials={}",
3999            self.core.client_id,
4000            self.core.account_id,
4001            self.config.environment,
4002            self.has_credentials(),
4003        );
4004
4005        Ok(())
4006    }
4007
4008    fn stop(&mut self) -> anyhow::Result<()> {
4009        if self.core.is_stopped() && self.core.is_disconnected() && self.session_tasks_finished() {
4010            return Ok(());
4011        }
4012
4013        log::info!("Stopping Lighter execution client {}", self.core.client_id);
4014
4015        self.begin_session_shutdown();
4016
4017        self.core.set_stopped();
4018
4019        log::info!("Lighter execution client stopped");
4020        Ok(())
4021    }
4022
4023    fn reset(&mut self) -> anyhow::Result<()> {
4024        log::debug!("Resetting Lighter execution client {}", self.core.client_id);
4025        self.begin_session_shutdown();
4026        Ok(())
4027    }
4028
4029    fn dispose(&mut self) -> anyhow::Result<()> {
4030        log::debug!("Disposing Lighter execution client {}", self.core.client_id);
4031        self.stop()
4032    }
4033
4034    async fn connect(&mut self) -> anyhow::Result<()> {
4035        if self.core.is_connected() && self.pending_tasks.is_open() {
4036            return Ok(());
4037        }
4038
4039        // Without credentials the engine would accept the connection and
4040        // then deny every order per-submission. Fail before any WS/REST
4041        // work so reconciliation and strategies never start.
4042        if !self.has_credentials() {
4043            anyhow::bail!(
4044                "Lighter execution client requires credentials; \
4045                 set private_key, account_index, and api_key_index in the config \
4046                 or use the deployment-specific credential environment variables"
4047            );
4048        }
4049
4050        log::info!(
4051            "Connecting Lighter execution client {}",
4052            self.core.client_id
4053        );
4054
4055        // Synchronous stop/reset can only initiate teardown. Complete it before
4056        // publishing a replacement socket or sharing its connection epoch.
4057        if !self.session_tasks_finished() || !self.pending_tasks.is_open() {
4058            self.begin_session_shutdown();
4059            self.finish_session_shutdown().await?;
4060        }
4061
4062        if !self.pending_tasks.is_open() {
4063            self.pending_tasks
4064                .start_generation()
4065                .map_err(|e| anyhow::anyhow!("Failed to start Lighter task generation: {e}"))?;
4066            self.cancellation_token = self.pending_tasks.cancellation_token();
4067        }
4068
4069        let ws_client = self.ws_client.clone();
4070        let setup_guard = TaskGroupGuard::new(&[&self.pending_tasks], move || {
4071            ws_client.begin_shutdown();
4072        });
4073        self.auth_refresh_notify = Arc::new(tokio::sync::Notify::new());
4074
4075        // Reset the readiness gate and clear derived position/account caches
4076        // so a prior session's state cannot leak past the strict-await gate.
4077        // The Reconnected path (WS-layer transparent reconnect) is unaffected:
4078        // it does not re-enter `connect()`. Its next `account_all_positions`
4079        // frame replaces the position cache through the consumption loop.
4080        self.dispatch.account_streams_ready.reset();
4081        self.dispatch.clear_position_cache();
4082        self.dispatch.clear_account_state_cache();
4083
4084        self.ensure_instruments_initialized_async().await?;
4085        self.refresh_nonce().await?;
4086
4087        // Auto-approval is an HTTP transaction prepared before the replacement WebSocket exists
4088        self.nonce_ready_connection_epoch
4089            .store(self.ws_client.connection_epoch(), Ordering::Release);
4090        let account_detail = self.fetch_account_detail().await;
4091        self.account_tier = None;
4092
4093        if let Some(detail) = &account_detail {
4094            let tier = self.detect_account_tier(detail);
4095            self.account_tier = Some(tier);
4096
4097            if !matches!(tier, LighterAccountTier::Plus | LighterAccountTier::Premium) {
4098                log_debug!(
4099                    "Lighter {tier} account will omit integrator approval and order attribution",
4100                    color = LogColor::Blue
4101                );
4102            }
4103
4104            match tokio::time::timeout(
4105                REFERRAL_ATTRIBUTION_TIMEOUT,
4106                self.apply_referral_attribution(detail),
4107            )
4108            .await
4109            {
4110                Ok(Ok(())) => {}
4111                Ok(Err(e)) => {
4112                    log::warn!(
4113                        "Robinhood Chain referral attribution failed; continuing startup: {e:?}"
4114                    );
4115                }
4116                Err(_) => {
4117                    log::warn!(
4118                        "Robinhood Chain referral attribution timed out after {}s; continuing \
4119                         startup",
4120                        REFERRAL_ATTRIBUTION_TIMEOUT.as_secs()
4121                    );
4122                }
4123            }
4124        }
4125
4126        if let Err(e) = self.submit_integrator_auto_approval().await {
4127            // Bail on venue 21149 ("integrator is not approved") so the
4128            // operator catches it at startup rather than at first order.
4129            // Other failures are tolerated: approval is account-scoped and
4130            // may already be in place, or a reconnect can retry.
4131            let is_unapproved = e.chain().any(|cause| {
4132                matches!(
4133                    cause.downcast_ref::<LighterHttpError>(),
4134                    Some(LighterHttpError::Venue { code: 21149, .. }),
4135                )
4136            });
4137
4138            if is_unapproved {
4139                self.nonce_ready_connection_epoch
4140                    .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
4141                return Err(e.context(
4142                    "Lighter account is not integrator-approved (venue 21149); \
4143                     orders cannot be placed",
4144                ));
4145            }
4146            log::error!("Lighter integrator approval failed; continuing startup: {e:?}");
4147        }
4148
4149        self.nonce_ready_connection_epoch
4150            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
4151
4152        if let Err(e) = self.sync_nonce_from_venue().await {
4153            log::debug!(
4154                "Failed to sync Lighter nonce after integrator approval; continuing startup: {e:?}"
4155            );
4156        }
4157
4158        if let Err(e) = self.spawn_ws_consumer().await {
4159            self.begin_session_shutdown();
4160            return match self.finish_session_shutdown().await {
4161                Ok(()) => Err(e),
4162                Err(shutdown_error) => Err(anyhow::anyhow!(
4163                    "{e}; failed to roll back partial Lighter connection: {shutdown_error}"
4164                )),
4165            };
4166        }
4167        self.nonce_ready_connection_epoch
4168            .store(self.ws_client.connection_epoch(), Ordering::Release);
4169
4170        if let Err(e) = self.await_account_streams_ready(30.0).await {
4171            log::warn!("Connect failed after WS started, tearing down: {e}");
4172            self.begin_session_shutdown();
4173
4174            if let Err(shutdown_error) = self.finish_session_shutdown().await {
4175                log::warn!("Failed to finish partial Lighter connection: {shutdown_error}");
4176            }
4177
4178            return Err(e);
4179        }
4180
4181        setup_guard.disarm();
4182        self.core.set_connected();
4183
4184        log::info!("Connected: client_id={}", self.core.client_id);
4185        Ok(())
4186    }
4187
4188    async fn disconnect(&mut self) -> anyhow::Result<()> {
4189        if self.core.is_disconnected() && self.session_tasks_finished() {
4190            return Ok(());
4191        }
4192
4193        log::info!(
4194            "Disconnecting Lighter execution client {}",
4195            self.core.client_id
4196        );
4197
4198        self.begin_session_shutdown();
4199        let tasks_result = self.finish_session_shutdown().await;
4200
4201        self.core.set_disconnected();
4202
4203        log::info!("Disconnected: client_id={}", self.core.client_id);
4204        tasks_result
4205    }
4206
4207    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
4208        let credential = self.credential.as_ref().ok_or_else(|| {
4209            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
4210        })?;
4211
4212        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
4213
4214        if order.is_closed() {
4215            log::warn!("Cannot submit closed order {}", order.client_order_id());
4216            return Ok(());
4217        }
4218
4219        let cached_instrument = self
4220            .core
4221            .cache()
4222            .instrument(&order.instrument_id())
4223            .cloned();
4224
4225        if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
4226            self.emitter.emit_order_denied(&order, &reason);
4227            return Ok(());
4228        }
4229
4230        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
4231        let plan = match self.prepare_create_order_plan(&order, slippage_bps) {
4232            Ok(plan) => plan,
4233            Err(e) => {
4234                let reason = OrderDeniedReason::ValidationFailed {
4235                    detail: format!("Lighter submit_order failed: {e}"),
4236                };
4237                self.emitter.emit_order_denied(&order, &reason.to_string());
4238                return Ok(());
4239            }
4240        };
4241
4242        if let Err(e) = self.dispatch_create_order_plan(plan, credential) {
4243            let reason = OrderDeniedReason::SubmitFailed {
4244                detail: format!("Lighter submit_order failed: {e}"),
4245            };
4246            self.emitter.emit_order_denied(&order, &reason.to_string());
4247        }
4248
4249        Ok(())
4250    }
4251
4252    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
4253        let credential = self.credential.as_ref().ok_or_else(|| {
4254            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
4255        })?;
4256
4257        if cmd.order_list.client_order_ids.is_empty() {
4258            log::debug!("submit_order_list called with empty order list");
4259            return Ok(());
4260        }
4261
4262        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
4263
4264        if orders.len() > LIGHTER_MAX_BATCH_TX {
4265            let reason = OrderDeniedReason::UnsupportedOrderList {
4266                detail: format!(
4267                    "Lighter order-list fanout supports at most {LIGHTER_MAX_BATCH_TX} txs, was {}",
4268                    orders.len(),
4269                ),
4270            }
4271            .to_string();
4272
4273            for order in &orders {
4274                self.emitter.emit_order_denied(order, &reason);
4275            }
4276            return Ok(());
4277        }
4278
4279        if orders.iter().any(is_grouped_order) {
4280            let reason = OrderDeniedReason::UnsupportedOrderList {
4281                detail: format!(
4282                    "Lighter submit_order_list supports only independent orders; \
4283                     grouped contingency lists remain out of scope (order_list_id={})",
4284                    cmd.order_list.id,
4285                ),
4286            }
4287            .to_string();
4288
4289            for order in &orders {
4290                self.emitter.emit_order_denied(order, &reason);
4291            }
4292            return Ok(());
4293        }
4294
4295        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
4296        let mut plans = Vec::with_capacity(orders.len());
4297
4298        for order in orders {
4299            if order.is_closed() {
4300                log::warn!("Cannot submit closed order {}", order.client_order_id());
4301                continue;
4302            }
4303
4304            let cached_instrument = self
4305                .core
4306                .cache()
4307                .instrument(&order.instrument_id())
4308                .cloned();
4309
4310            if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
4311                self.emitter.emit_order_denied(&order, &reason);
4312                continue;
4313            }
4314
4315            match self.prepare_create_order_plan(&order, slippage_bps) {
4316                Ok(plan) => plans.push(plan),
4317                Err(e) => {
4318                    let reason = OrderDeniedReason::ValidationFailed {
4319                        detail: format!("Lighter submit_order_list failed: {e}"),
4320                    }
4321                    .to_string();
4322
4323                    self.emitter.emit_order_denied(&order, &reason);
4324                }
4325            }
4326        }
4327
4328        if plans.is_empty() {
4329            log::warn!(
4330                "Lighter submit_order_list: no supported orders to dispatch for {}",
4331                cmd.order_list.id,
4332            );
4333            return Ok(());
4334        }
4335
4336        let context = self.fanout_dispatch_context(credential)?;
4337        self.spawn_task("submit_order_list", async move {
4338            for plan in plans {
4339                let order = plan.order.clone();
4340                match context.sign_create_order(plan) {
4341                    Ok(prepared) => context.send_create_order(prepared).await,
4342                    Err(e) => {
4343                        let reason = OrderDeniedReason::SubmitFailed {
4344                            detail: format!("Lighter submit_order_list failed: {e}"),
4345                        };
4346                        context
4347                            .emitter
4348                            .emit_order_denied(&order, &reason.to_string());
4349                    }
4350                }
4351            }
4352            Ok(())
4353        });
4354
4355        Ok(())
4356    }
4357
4358    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
4359        let credential = self.credential.as_ref().ok_or_else(|| {
4360            anyhow::anyhow!("Lighter execution client cannot modify without credentials")
4361        })?;
4362        self.dispatch_signed_modify_order(&cmd, credential);
4363        Ok(())
4364    }
4365
4366    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
4367        let credential = self.credential.as_ref().ok_or_else(|| {
4368            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
4369        })?;
4370        self.dispatch_signed_cancel_order(&cmd, credential);
4371        Ok(())
4372    }
4373
4374    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
4375        // Iterate over open orders for the instrument and cancel each. The
4376        // venue offers a `CancelAllOrders` tx but it spans the whole account
4377        // rather than a single market; doing per-order cancels keeps scope
4378        // tight and avoids cancelling positions in unrelated markets.
4379        let cache = self.core.cache();
4380        let open_orders: Vec<ClientOrderId> = cache
4381            .orders_open(None, Some(&cmd.instrument_id), None, None, None)
4382            .into_iter()
4383            .map(|o| o.client_order_id())
4384            .collect();
4385
4386        for client_order_id in open_orders {
4387            let order_cmd = cancel_order_from_cancel_all(&cmd, client_order_id);
4388
4389            if let Err(e) = self.cancel_order(order_cmd) {
4390                log::warn!("cancel_all_orders: cancel for {client_order_id} failed: {e}");
4391            }
4392        }
4393        Ok(())
4394    }
4395
4396    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
4397        let credential = self.credential.as_ref().ok_or_else(|| {
4398            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
4399        })?;
4400
4401        if cmd.cancels.is_empty() {
4402            log::debug!("batch_cancel_orders called with empty cancel list");
4403            return Ok(());
4404        }
4405
4406        for cancel in &cmd.cancels {
4407            self.dispatch
4408                .set_pending_order_action(cancel.client_order_id, PendingOrderAction::Cancel);
4409        }
4410        let emit_cancel_rejected: Vec<bool> = cmd
4411            .cancels
4412            .iter()
4413            .map(|cancel| self.can_emit_order_cancel_rejected(&cancel.client_order_id))
4414            .collect();
4415
4416        for (cancel, emit) in cmd.cancels.iter().zip(&emit_cancel_rejected) {
4417            if !emit {
4418                self.dispatch.clear_pending_order_action_if(
4419                    &cancel.client_order_id,
4420                    PendingOrderAction::Cancel,
4421                );
4422            }
4423        }
4424
4425        if cmd.cancels.len() > LIGHTER_MAX_BATCH_TX {
4426            let reason = format!(
4427                "Lighter batch-cancel fanout supports at most {LIGHTER_MAX_BATCH_TX} txs, was {}",
4428                cmd.cancels.len(),
4429            );
4430
4431            for cancel in &cmd.cancels {
4432                self.dispatch.clear_pending_order_action_if(
4433                    &cancel.client_order_id,
4434                    PendingOrderAction::Cancel,
4435                );
4436                self.emitter.emit_order_cancel_rejected_event(
4437                    cancel.strategy_id,
4438                    cancel.instrument_id,
4439                    cancel.client_order_id,
4440                    cancel.venue_order_id,
4441                    &reason,
4442                    self.clock.get_time_ns(),
4443                );
4444            }
4445            return Ok(());
4446        }
4447
4448        let mut plans = Vec::with_capacity(cmd.cancels.len());
4449        for (cancel, emit_cancel_rejected) in cmd.cancels.iter().zip(emit_cancel_rejected) {
4450            match self.prepare_cancel_order_plan(cancel) {
4451                Ok(plan) => plans.push((plan, emit_cancel_rejected)),
4452                Err(e) => {
4453                    self.dispatch.clear_pending_order_action_if(
4454                        &cancel.client_order_id,
4455                        PendingOrderAction::Cancel,
4456                    );
4457                    let reason = format!("Lighter cancel_order failed: {e}");
4458
4459                    if emit_cancel_rejected {
4460                        self.emitter.emit_order_cancel_rejected_event(
4461                            cancel.strategy_id,
4462                            cancel.instrument_id,
4463                            cancel.client_order_id,
4464                            cancel.venue_order_id,
4465                            &reason,
4466                            self.clock.get_time_ns(),
4467                        );
4468                    } else {
4469                        log::warn!(
4470                            "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
4471                            cancel.client_order_id,
4472                        );
4473                    }
4474                }
4475            }
4476        }
4477
4478        if plans.is_empty() {
4479            return Ok(());
4480        }
4481
4482        let context = self.fanout_dispatch_context(credential)?;
4483        self.spawn_task("batch_cancel_orders", async move {
4484            for (plan, emit_cancel_rejected) in plans {
4485                let failure = (
4486                    plan.strategy_id,
4487                    plan.instrument_id,
4488                    plan.client_order_id,
4489                    plan.venue_order_id,
4490                );
4491
4492                match context.sign_cancel_order(&plan) {
4493                    Ok(prepared) => {
4494                        context
4495                            .send_cancel_order(prepared, emit_cancel_rejected)
4496                            .await;
4497                    }
4498                    Err(e) if emit_cancel_rejected => {
4499                        context.dispatch.clear_pending_order_action_if(
4500                            &failure.2,
4501                            PendingOrderAction::Cancel,
4502                        );
4503                        context.emitter.emit_order_cancel_rejected_event(
4504                            failure.0,
4505                            failure.1,
4506                            failure.2,
4507                            failure.3,
4508                            &format!("Lighter cancel_order failed: {e}"),
4509                            context.clock.get_time_ns(),
4510                        );
4511                    }
4512                    Err(e) => log::warn!(
4513                        "Lighter cancel_order failed: {e} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
4514                        failure.2,
4515                    ),
4516                }
4517            }
4518            Ok(())
4519        });
4520
4521        Ok(())
4522    }
4523
4524    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
4525        // Lighter has no public REST endpoint that returns a snapshot of
4526        // account balances and margins; the only authoritative source is the
4527        // `account_all_assets` WebSocket stream. Replay the most recent
4528        // cached state so the engine sees something synchronously. The
4529        // cache is populated by the consumption loop on every venue push.
4530        let cached = self.dispatch.snapshot_account_state();
4531        match cached {
4532            Some(state) => {
4533                log::debug!("Lighter query_account replaying cached AccountState");
4534                self.emitter.send_account_state(state);
4535            }
4536            None => {
4537                log::warn!(
4538                    "Lighter query_account: no AccountState cached yet \
4539                     (account_all_assets stream has not pushed since connect)",
4540                );
4541            }
4542        }
4543        Ok(())
4544    }
4545
4546    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
4547        let credential = self
4548            .credential
4549            .as_ref()
4550            .ok_or_else(|| anyhow::anyhow!("Lighter query_order requires credentials"))?
4551            .clone();
4552        let registry = Arc::clone(&self.registry);
4553        let http_client = self.http_client.clone();
4554        let emitter = self.emitter.clone();
4555        let core_account_id = self.core.account_id;
4556        let dispatch = self.dispatch.clone();
4557        let clock = self.clock;
4558
4559        self.spawn_task("query_order", async move {
4560            let report = lookup_order_status_report(
4561                &http_client,
4562                &registry,
4563                &credential,
4564                core_account_id,
4565                Some(cmd.instrument_id),
4566                Some(&cmd.client_order_id),
4567                cmd.venue_order_id.as_ref(),
4568                &dispatch,
4569                clock,
4570            )
4571            .await?;
4572
4573            match report {
4574                Some(report) => {
4575                    log::debug!(
4576                        "Lighter query_order returning report for {}",
4577                        cmd.client_order_id
4578                    );
4579                    dispatch.seed_accepted_from_report(&report);
4580                    emitter.send_order_status_report(report);
4581                }
4582                None => {
4583                    log::warn!(
4584                        "Lighter query_order: no order found for {}",
4585                        cmd.client_order_id,
4586                    );
4587                }
4588            }
4589            Ok(())
4590        });
4591        Ok(())
4592    }
4593
4594    async fn generate_order_status_report(
4595        &self,
4596        cmd: &GenerateOrderStatusReport,
4597    ) -> anyhow::Result<Option<OrderStatusReport>> {
4598        let Some(credential) = &self.credential else {
4599            log::warn!("Lighter generate_order_status_report: no credentials");
4600            return Ok(None);
4601        };
4602
4603        if cmd.client_order_id.is_none() && cmd.venue_order_id.is_none() {
4604            log::warn!(
4605                "Lighter generate_order_status_report: must supply client_order_id or venue_order_id",
4606            );
4607            return Ok(None);
4608        }
4609        let report = lookup_order_status_report(
4610            &self.http_client,
4611            &self.registry,
4612            credential,
4613            self.core.account_id,
4614            cmd.instrument_id,
4615            cmd.client_order_id.as_ref(),
4616            cmd.venue_order_id.as_ref(),
4617            &self.dispatch,
4618            self.clock,
4619        )
4620        .await?;
4621
4622        if let Some(report) = &report {
4623            self.dispatch.seed_accepted_from_report(report);
4624        }
4625
4626        Ok(report)
4627    }
4628
4629    async fn generate_order_status_reports(
4630        &self,
4631        cmd: &GenerateOrderStatusReports,
4632    ) -> anyhow::Result<Vec<OrderStatusReport>> {
4633        let Some(credential) = &self.credential else {
4634            log::warn!("Lighter generate_order_status_reports: no credentials");
4635            Self::log_report_receipt(0, "OrderStatusReport", cmd.log_receipt_level);
4636            return Ok(Vec::new());
4637        };
4638
4639        let auth = build_auth_token_for(credential)
4640            .context("failed to mint Lighter auth token for report fetch")?;
4641        let ts_init = self.clock.get_time_ns();
4642
4643        // Lighter exposes accountActiveOrders only per-market. Mass-status
4644        // requests with no scope iterate over account-active markets rather
4645        // than fanning out to every registered market, since the venue's REST
4646        // rate limit (60 req/min) would make a 180-market fan-out take
4647        // minutes. Account streams seed this set from live order, trade, and
4648        // position frames; if startup reconciliation reaches this path before
4649        // any market is known, one unscoped inactive-order page walk seeds it
4650        // from historical account activity.
4651        if cmd.instrument_id.is_none() && self.dispatch.active_markets_snapshot().is_empty() {
4652            seed_active_markets_from_inactive_orders(
4653                &self.http_client,
4654                &self.dispatch,
4655                credential,
4656                &auth,
4657                format_between_timestamps(cmd.start, cmd.end, ts_init),
4658            )
4659            .await?;
4660        }
4661
4662        let market_indices = match cmd.instrument_id {
4663            Some(id) => match self.registry.market_index(&id) {
4664                Some(idx) => vec![idx],
4665                None => {
4666                    anyhow::bail!("no Lighter market_index for order report instrument {id}",);
4667                }
4668            },
4669            None => self.dispatch.active_markets_snapshot(),
4670        };
4671
4672        if market_indices.is_empty() {
4673            log::debug!(
4674                "Lighter generate_order_status_reports: no active markets yet; returning empty",
4675            );
4676            Self::log_report_receipt(0, "OrderStatusReport", cmd.log_receipt_level);
4677            return Ok(Vec::new());
4678        }
4679
4680        let mut reports: Vec<OrderStatusReport> = Vec::new();
4681        let mut active_errors = Vec::new();
4682
4683        // Active orders are by definition still open. Returning them
4684        // unconditionally even when `cmd.start` is set: an open order's
4685        // last activity can predate the lookback window without changing
4686        // the fact that the order is currently live and reconciliation
4687        // needs to know about it.
4688        for market_index in market_indices {
4689            let active = match self
4690                .http_client
4691                .get_account_active_orders(&LighterAccountActiveOrdersQuery {
4692                    authorization: None,
4693                    auth: Some(auth.clone()),
4694                    account_index: credential.account_index(),
4695                    market_id: market_index,
4696                })
4697                .await
4698            {
4699                Ok(response) => response,
4700                Err(e) => {
4701                    let detail = format!(
4702                        "failed to fetch Lighter active orders for market_index={market_index}: {}",
4703                        scrub_auth(&format!("{e:#}")),
4704                    );
4705                    log::warn!("{detail}",);
4706                    active_errors.push(detail);
4707                    continue;
4708                }
4709            };
4710
4711            for order in &active.orders {
4712                self.dispatch.note_active_market(order.market_index);
4713
4714                let Some(report) = parse_http_order_to_report(
4715                    order,
4716                    &self.registry,
4717                    self.core.account_id,
4718                    ts_init,
4719                ) else {
4720                    let detail = format!(
4721                        "failed to parse Lighter active order {} for market_index={market_index}",
4722                        order.order_id,
4723                    );
4724                    log::warn!("{detail}");
4725                    active_errors.push(detail);
4726                    continue;
4727                };
4728                restore_reconciled_order(
4729                    &self.core,
4730                    &self.dispatch,
4731                    order,
4732                    report.order_status.is_closed(),
4733                );
4734                let report = self.dispatch.translate_order_cloid(report);
4735                let report = self.dispatch.preserve_pending_order_status(report);
4736                self.dispatch.seed_accepted_from_report(&report);
4737                reports.push(report);
4738            }
4739        }
4740
4741        if !active_errors.is_empty() {
4742            return Err(incomplete_order_reports(reports, active_errors.join("; ")));
4743        }
4744
4745        // Inactive orders (filled / canceled) are required when the engine
4746        // asks for non-`open_only` reports during a wider reconciliation.
4747        // Pagination is followed because a single market can hold more than
4748        // 200 historical inactive orders for a long-running account. The
4749        // venue-side `between_timestamps` window is set when `cmd.start`
4750        // / `cmd.end` are present so the venue, not the client, scopes the
4751        // pagination: important under the 60 req/min REST quota.
4752        if !cmd.open_only {
4753            let inactive_markets: Vec<i16> = match cmd.instrument_id {
4754                Some(id) => self
4755                    .registry
4756                    .market_index(&id)
4757                    .map(|m| vec![m])
4758                    .unwrap_or_default(),
4759                None => self.dispatch.active_markets_snapshot(),
4760            };
4761
4762            let between_timestamps = format_between_timestamps(cmd.start, cmd.end, ts_init);
4763
4764            for market_id in inactive_markets {
4765                let mut cursor: Option<String> = None;
4766                let mut seen_cursors = AHashSet::new();
4767                let mut pages = 0_usize;
4768
4769                loop {
4770                    pages += 1;
4771                    if pages > MAX_RECONCILIATION_PAGES {
4772                        return Err(incomplete_order_reports(
4773                            reports,
4774                            format!(
4775                                "Lighter inactive-order reconciliation exceeded {MAX_RECONCILIATION_PAGES} pages for market_index={market_id}",
4776                            ),
4777                        ));
4778                    }
4779
4780                    match self
4781                        .http_client
4782                        .get_account_inactive_orders(&LighterAccountInactiveOrdersQuery {
4783                            authorization: None,
4784                            auth: Some(auth.clone()),
4785                            account_index: credential.account_index(),
4786                            market_id: Some(market_id),
4787                            ask_filter: None,
4788                            between_timestamps: between_timestamps.clone(),
4789                            cursor: cursor.clone(),
4790                            limit: LIGHTER_REST_PAGE_SIZE,
4791                        })
4792                        .await
4793                    {
4794                        Ok(inactive) => {
4795                            for order in &inactive.orders {
4796                                let Some(report) = parse_http_order_to_report(
4797                                    order,
4798                                    &self.registry,
4799                                    self.core.account_id,
4800                                    ts_init,
4801                                ) else {
4802                                    return Err(incomplete_order_reports(
4803                                        reports,
4804                                        format!(
4805                                            "failed to parse Lighter inactive order {} for market_index={market_id}",
4806                                            order.order_id,
4807                                        ),
4808                                    ));
4809                                };
4810
4811                                if cmd.start.is_some_and(|start| report.ts_last < start)
4812                                    || cmd.end.is_some_and(|end| report.ts_last > end)
4813                                {
4814                                    continue;
4815                                }
4816
4817                                self.dispatch.note_active_market(order.market_index);
4818                                restore_reconciled_order(
4819                                    &self.core,
4820                                    &self.dispatch,
4821                                    order,
4822                                    report.order_status.is_closed(),
4823                                );
4824                                let report = self.dispatch.translate_order_cloid(report);
4825                                let report = self.dispatch.preserve_pending_order_status(report);
4826                                self.dispatch.seed_accepted_from_report(&report);
4827                                reports.push(report);
4828                            }
4829
4830                            match inactive.next_cursor {
4831                                Some(next) if !next.is_empty() => {
4832                                    if !seen_cursors.insert(next.clone()) {
4833                                        return Err(incomplete_order_reports(
4834                                            reports,
4835                                            format!(
4836                                                "Lighter inactive-order reconciliation repeated cursor `{next}` for market_index={market_id}",
4837                                            ),
4838                                        ));
4839                                    }
4840                                    cursor = Some(next);
4841                                }
4842                                _ => break,
4843                            }
4844                        }
4845                        Err(e) => {
4846                            return Err(incomplete_order_reports(
4847                                reports,
4848                                format!(
4849                                    "failed to fetch Lighter inactive orders for market_index={market_id}: {}",
4850                                    scrub_auth(&format!("{e:#}")),
4851                                ),
4852                            ));
4853                        }
4854                    }
4855                }
4856            }
4857        }
4858
4859        Self::log_report_receipt(reports.len(), "OrderStatusReport", cmd.log_receipt_level);
4860        Ok(reports)
4861    }
4862
4863    async fn generate_fill_reports(
4864        &self,
4865        cmd: GenerateFillReports,
4866    ) -> anyhow::Result<Vec<FillReport>> {
4867        let reports = self.paginate_fill_reports(&cmd).await?.reports;
4868        Self::log_report_receipt(reports.len(), "FillReport", cmd.log_receipt_level);
4869        Ok(reports)
4870    }
4871
4872    async fn generate_position_status_reports(
4873        &self,
4874        cmd: &GeneratePositionStatusReports,
4875    ) -> anyhow::Result<Vec<PositionStatusReport>> {
4876        let (reports, complete, _) = self.cached_position_reports(cmd)?;
4877        anyhow::ensure!(
4878            complete,
4879            "Lighter position snapshot does not cover the requested instrument scope",
4880        );
4881        Self::log_report_receipt(reports.len(), "PositionStatusReport", cmd.log_receipt_level);
4882        Ok(reports)
4883    }
4884
4885    async fn generate_mass_status(
4886        &self,
4887        lookback_mins: Option<u64>,
4888    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
4889        let ts_init = self.clock.get_time_ns();
4890
4891        // Scope inactive orders at the venue and stop descending trade
4892        // pagination once it crosses this local lookback boundary.
4893        let lookback_start: Option<UnixNanos> = lookback_mins.map(|mins| {
4894            let cutoff_ns = ts_init
4895                .as_u64()
4896                .saturating_sub(mins.saturating_mul(60).saturating_mul(1_000_000_000));
4897            UnixNanos::from(cutoff_ns)
4898        });
4899
4900        // open_only = false so the inactive-orders fan-out runs and surfaces
4901        // canceled / rejected / expired / filled orders that the engine
4902        // needs for reconciliation. The active markets set bounds the fan-out
4903        // to markets with known account activity.
4904        let mut order_cmd = GenerateOrderStatusReports::new(
4905            UUID4::new(),
4906            ts_init,
4907            false,
4908            None,
4909            lookback_start,
4910            None,
4911            None,
4912            None,
4913        );
4914        order_cmd.log_receipt_level = LogLevel::Debug;
4915        let fill_cmd = GenerateFillReports::new(
4916            UUID4::new(),
4917            ts_init,
4918            None,
4919            None,
4920            lookback_start,
4921            None,
4922            None,
4923            None,
4924        );
4925        let position_cmd =
4926            GeneratePositionStatusReports::new(UUID4::new(), ts_init, None, None, None, None, None);
4927
4928        // Preserve successful reports if a later market or history page fails.
4929        // Retry the active leg only when the failed request produced nothing.
4930        let order_result = self.generate_order_status_reports(&order_cmd).await;
4931        let (mut order_reports, mut reports_complete) = match order_result {
4932            Ok(reports) => (reports, true),
4933            Err(e) => {
4934                log::warn!(
4935                    "Lighter order report generation failed: {}",
4936                    scrub_auth(&format!("{e:#}")),
4937                );
4938                let partial = partial_order_reports(&e);
4939                let reports = if partial.is_empty() {
4940                    let mut active_cmd = GenerateOrderStatusReports::new(
4941                        UUID4::new(),
4942                        ts_init,
4943                        true,
4944                        None,
4945                        None,
4946                        None,
4947                        None,
4948                        None,
4949                    );
4950                    active_cmd.log_receipt_level = LogLevel::Debug;
4951                    let active_result = self.generate_order_status_reports(&active_cmd).await;
4952                    match active_result {
4953                        Ok(reports) => reports,
4954                        Err(e) => {
4955                            log::warn!(
4956                                "Lighter active order report generation incomplete: {}",
4957                                scrub_auth(&format!("{e:#}")),
4958                            );
4959                            partial_order_reports(&e)
4960                        }
4961                    }
4962                } else {
4963                    partial
4964                };
4965                (reports, false)
4966            }
4967        };
4968        let (mut fill_reports, fill_reports_complete) =
4969            fill_reports_for_mass_status(self.paginate_fill_reports(&fill_cmd).await)
4970                .context("Lighter fill reconciliation failed")?;
4971        reports_complete &= fill_reports_complete;
4972        Self::log_report_receipt(fill_reports.len(), "FillReport", fill_cmd.log_receipt_level);
4973
4974        let mut reported_orders: AHashSet<VenueOrderId> = order_reports
4975            .iter()
4976            .map(|report| report.venue_order_id)
4977            .collect();
4978        let mut fill_markets: Vec<i16> = fill_reports
4979            .iter()
4980            .filter(|report| !reported_orders.contains(&report.venue_order_id))
4981            .filter_map(|report| self.registry.market_index(&report.instrument_id))
4982            .collect::<AHashSet<_>>()
4983            .into_iter()
4984            .collect();
4985        fill_markets.sort_unstable();
4986
4987        for market_index in fill_markets {
4988            let Some(instrument_id) = self.registry.instrument_id(market_index) else {
4989                reports_complete = false;
4990                continue;
4991            };
4992            let mut order_cmd = GenerateOrderStatusReports::new(
4993                UUID4::new(),
4994                ts_init,
4995                false,
4996                Some(instrument_id),
4997                lookback_start,
4998                None,
4999                None,
5000                None,
5001            );
5002            order_cmd.log_receipt_level = LogLevel::Debug;
5003            let order_result = self.generate_order_status_reports(&order_cmd).await;
5004            reports_complete &= order_result.is_ok();
5005            let reports = match order_result {
5006                Ok(reports) => reports,
5007                Err(e) => {
5008                    log::warn!(
5009                        "Lighter order report generation failed: {}",
5010                        scrub_auth(&format!("{e:#}")),
5011                    );
5012                    partial_order_reports(&e)
5013                }
5014            };
5015            reported_orders.extend(reports.iter().map(|report| report.venue_order_id));
5016            order_reports.extend(reports);
5017        }
5018
5019        if fill_reports
5020            .iter()
5021            .any(|report| !reported_orders.contains(&report.venue_order_id))
5022        {
5023            reports_complete = false;
5024        }
5025
5026        for fill_report in &mut fill_reports {
5027            if let Some(client_order_id) = order_reports
5028                .iter()
5029                .find(|order_report| order_report.venue_order_id == fill_report.venue_order_id)
5030                .and_then(|order_report| order_report.client_order_id)
5031            {
5032                fill_report.client_order_id = Some(client_order_id);
5033            }
5034        }
5035
5036        let position_result = self.cached_position_reports(&position_cmd);
5037        let (mut position_reports, position_coverage) = match position_result {
5038            Ok((reports, complete, coverage)) => {
5039                reports_complete &= complete;
5040                (reports, coverage)
5041            }
5042            Err(e) => {
5043                reports_complete = false;
5044                log::warn!("Lighter position report generation failed: {e:#}");
5045                (Vec::new(), None)
5046            }
5047        };
5048        Self::log_report_receipt(
5049            position_reports.len(),
5050            "PositionStatusReport",
5051            position_cmd.log_receipt_level,
5052        );
5053
5054        if lookback_start.is_some() {
5055            let touched_instruments: AHashSet<InstrumentId> = order_reports
5056                .iter()
5057                .map(|report| report.instrument_id)
5058                .chain(fill_reports.iter().map(|report| report.instrument_id))
5059                .collect();
5060            let position_instruments: AHashSet<InstrumentId> = position_reports
5061                .iter()
5062                .map(|report| report.instrument_id)
5063                .collect();
5064            let cache = self.core.cache();
5065            let mut touched_instruments: Vec<InstrumentId> =
5066                touched_instruments.into_iter().collect();
5067            touched_instruments.sort_unstable();
5068
5069            for instrument_id in touched_instruments {
5070                let Some(instrument) = cache.instrument(&instrument_id) else {
5071                    reports_complete = false;
5072                    continue;
5073                };
5074
5075                if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
5076                    continue;
5077                }
5078                let Some(market_id) = self.registry.market_index(&instrument_id) else {
5079                    reports_complete = false;
5080                    continue;
5081                };
5082
5083                if !position_coverage
5084                    .as_ref()
5085                    .is_some_and(|skipped| !skipped.contains(&market_id))
5086                {
5087                    reports_complete = false;
5088                    continue;
5089                }
5090
5091                if position_instruments.contains(&instrument_id) {
5092                    continue;
5093                }
5094
5095                position_reports.push(PositionStatusReport::new(
5096                    self.core.account_id,
5097                    instrument_id,
5098                    PositionSide::Flat,
5099                    Quantity::zero(instrument.size_precision()),
5100                    ts_init,
5101                    ts_init,
5102                    Some(UUID4::new()),
5103                    None,
5104                    None,
5105                ));
5106            }
5107        }
5108
5109        let mut mass_status = ExecutionMassStatus::new(
5110            self.core.client_id,
5111            self.core.account_id,
5112            self.core.venue,
5113            ts_init,
5114            None,
5115        );
5116        mass_status.set_report_window(lookback_start, reports_complete);
5117        mass_status.add_order_reports(order_reports);
5118        mass_status.add_fill_reports(fill_reports);
5119        mass_status.add_position_reports(position_reports);
5120
5121        log::debug!(
5122            "Generated Lighter mass status: {} orders, {} fills, {} positions",
5123            mass_status.order_reports().len(),
5124            mass_status.fill_reports().len(),
5125            mass_status.position_reports().len(),
5126        );
5127
5128        Ok(Some(mass_status))
5129    }
5130}
5131
5132// `covers_window` is only meaningful for an account-wide sweep. Retention is per
5133// account, so a market-scoped sweep can serve nothing while older trades for that
5134// market have already been evicted by newer trades in other markets.
5135struct FillSweep {
5136    reports: Vec<FillReport>,
5137    covers_window: bool,
5138}
5139
5140#[derive(Debug, thiserror::Error)]
5141#[error("incomplete Lighter order reports: {detail}")]
5142struct IncompleteOrderReports {
5143    reports: Vec<OrderStatusReport>,
5144    detail: String,
5145}
5146
5147fn incomplete_order_reports(
5148    reports: Vec<OrderStatusReport>,
5149    detail: impl Into<String>,
5150) -> anyhow::Error {
5151    anyhow::Error::new(IncompleteOrderReports {
5152        reports,
5153        detail: detail.into(),
5154    })
5155}
5156
5157fn partial_order_reports(error: &anyhow::Error) -> Vec<OrderStatusReport> {
5158    error
5159        .downcast_ref::<IncompleteOrderReports>()
5160        .map(|incomplete| incomplete.reports.clone())
5161        .unwrap_or_default()
5162}
5163
5164fn is_commission_error(error: &anyhow::Error) -> bool {
5165    error
5166        .chain()
5167        .any(|cause| cause.downcast_ref::<LighterCommissionError>().is_some())
5168}
5169
5170fn fill_reports_for_mass_status(
5171    result: anyhow::Result<FillSweep>,
5172) -> anyhow::Result<(Vec<FillReport>, bool)> {
5173    match result {
5174        Ok(sweep) => Ok((sweep.reports, sweep.covers_window)),
5175        Err(e) if is_commission_error(&e) => Err(e),
5176        Err(e) => {
5177            log::warn!(
5178                "Lighter fill report generation failed: {}",
5179                scrub_auth(&format!("{e:#}")),
5180            );
5181            Ok((Vec::new(), false))
5182        }
5183    }
5184}
5185
5186impl LighterExecutionClient {
5187    fn cached_position_reports(
5188        &self,
5189        cmd: &GeneratePositionStatusReports,
5190    ) -> anyhow::Result<(Vec<PositionStatusReport>, bool, Option<AHashSet<i16>>)> {
5191        // Lighter has no REST position source. The latest complete WebSocket
5192        // snapshot is authoritative, while a skipped row keeps the retained
5193        // cache available only as explicitly incomplete mass-status data.
5194        let (mut reports, coverage) = self.dispatch.snapshot_positions_with_coverage();
5195        let complete = match cmd.instrument_id {
5196            Some(instrument_id) => {
5197                let market_id = self.registry.market_index(&instrument_id).ok_or_else(|| {
5198                    anyhow::anyhow!(
5199                        "no Lighter market_index for position report instrument {instrument_id}",
5200                    )
5201                })?;
5202                reports.retain(|report| report.instrument_id == instrument_id);
5203                coverage
5204                    .as_ref()
5205                    .is_some_and(|skipped| !skipped.contains(&market_id))
5206            }
5207            None => coverage.as_ref().is_some_and(|skipped| skipped.is_empty()),
5208        };
5209        Ok((reports, complete, coverage))
5210    }
5211
5212    async fn paginate_fill_reports(&self, cmd: &GenerateFillReports) -> anyhow::Result<FillSweep> {
5213        let Some(credential) = &self.credential else {
5214            log::warn!("Lighter generate_fill_reports: no credentials");
5215            return Ok(FillSweep {
5216                reports: Vec::new(),
5217                covers_window: true,
5218            });
5219        };
5220
5221        let market_id = match cmd.instrument_id {
5222            Some(instrument_id) => {
5223                Some(self.registry.market_index(&instrument_id).ok_or_else(|| {
5224                    anyhow::anyhow!("no Lighter market_index for fill instrument {instrument_id}",)
5225                })?)
5226            }
5227            None => None,
5228        };
5229
5230        let auth = build_auth_token_for(credential)
5231            .context("failed to mint Lighter auth token for fill fetch")?;
5232
5233        let ts_init = self.clock.get_time_ns();
5234        let mut reports = Vec::new();
5235        let mut cursor: Option<String> = None;
5236        let mut seen_cursors = AHashSet::new();
5237        let mut seen_in_call = AHashSet::new();
5238        let mut pages = 0_usize;
5239        let mut oldest_served: Option<UnixNanos> = None;
5240        let mut covers_window = true;
5241
5242        loop {
5243            pages += 1;
5244            anyhow::ensure!(
5245                pages <= MAX_RECONCILIATION_PAGES,
5246                "Lighter fill reconciliation exceeded {MAX_RECONCILIATION_PAGES} pages",
5247            );
5248            let query = LighterTradesQuery {
5249                authorization: None,
5250                auth: Some(auth.clone()),
5251                market_id,
5252                account_index: Some(credential.account_index()),
5253                order_index: None,
5254                sort_by: LighterTradeSortBy::Timestamp,
5255                sort_dir: Some(LighterSortDirection::Desc),
5256                cursor: cursor.clone(),
5257                // The venue's `from` parameter is not a timestamp lower bound
5258                // and can omit the newest trades when given an epoch value.
5259                from_timestamp: None,
5260                ask_filter: None,
5261                role: None,
5262                trade_type: None,
5263                limit: LIGHTER_REST_PAGE_SIZE,
5264                aggregate: None,
5265            };
5266
5267            let response = match self.http_client.get_trades(&query).await {
5268                Ok(response) => response,
5269                Err(e) => {
5270                    // `{e:#}` preserves the venue's status/body across the
5271                    // outer context wrap; `scrub_auth` masks any `auth=`
5272                    // query value the HTTP layer's error included.
5273                    log::warn!(
5274                        "Lighter get_trades failed (market_id={:?}, account_index={}, cursor={:?}): {}",
5275                        query.market_id,
5276                        credential.account_index(),
5277                        cursor,
5278                        scrub_auth(&format!("{e:#}")),
5279                    );
5280                    return Err(anyhow::Error::new(e).context("failed to fetch Lighter fills"));
5281                }
5282            };
5283
5284            for trade in &response.trades {
5285                let Some(instrument_id) = self.registry.instrument_id(trade.market_id) else {
5286                    anyhow::bail!(
5287                        "no Lighter instrument registered for fill market_index={}",
5288                        trade.market_id,
5289                    );
5290                };
5291                let Some(instrument) = self.core.cache().instrument(&instrument_id).cloned() else {
5292                    anyhow::bail!("Lighter fill instrument {instrument_id} missing from cache");
5293                };
5294
5295                match parse_ws_fill_report(
5296                    trade,
5297                    credential.account_index(),
5298                    &instrument,
5299                    self.core.account_id,
5300                    ts_init,
5301                ) {
5302                    Ok(Some(report)) => {
5303                        if cmd.start.is_some_and(|start| report.ts_event < start)
5304                            || cmd.end.is_some_and(|end| report.ts_event > end)
5305                        {
5306                            continue;
5307                        }
5308
5309                        // Mass-status reconciliation must surface the original
5310                        // Nautilus cloid, not the venue's numeric echo.
5311                        let report = self.dispatch.translate_fill_cloid(report);
5312
5313                        if !seen_in_call.insert(report.trade_id) {
5314                            log::debug!(
5315                                "Lighter duplicate trade {} ignored within HTTP fill pagination",
5316                                report.trade_id,
5317                            );
5318                            continue;
5319                        }
5320
5321                        self.dispatch.note_active_market(trade.market_id);
5322                        reports.push(report);
5323                    }
5324                    Ok(None) => {}
5325                    Err(e) => return Err(e).context("failed to parse Lighter fill report"),
5326                }
5327            }
5328
5329            let page_oldest = response
5330                .trades
5331                .iter()
5332                .filter_map(|trade| u64::try_from(trade.timestamp).ok())
5333                .map(|timestamp_ms| UnixNanos::from(timestamp_ms.saturating_mul(1_000_000)))
5334                .min();
5335
5336            if let Some(page_oldest) = page_oldest {
5337                oldest_served = Some(oldest_served.map_or(page_oldest, |ts| ts.min(page_oldest)));
5338            }
5339
5340            let reached_start_boundary = cmd
5341                .start
5342                .is_some_and(|start| page_oldest.is_some_and(|oldest| oldest < start));
5343
5344            if reached_start_boundary {
5345                break;
5346            }
5347
5348            match response.next_cursor {
5349                Some(next) if !next.is_empty() => {
5350                    anyhow::ensure!(
5351                        seen_cursors.insert(next.clone()),
5352                        "Lighter fill reconciliation repeated cursor `{next}`",
5353                    );
5354                    cursor = Some(next);
5355                }
5356                _ => {
5357                    // The venue retains a bounded number of recent trades per account,
5358                    // so an exhausted cursor ends the retained history rather than the
5359                    // account's. Only a trade older than `start` proves the requested
5360                    // window was served in full; an account-wide sweep that served
5361                    // nothing has no history to retain.
5362                    if let (Some(start), Some(oldest)) = (cmd.start, oldest_served) {
5363                        covers_window = false;
5364
5365                        log::warn!(
5366                            "Lighter fill reports do not cover {} to {}: trade pagination exhausted before the requested start; the venue `export` endpoint serves full history",
5367                            unix_nanos_to_iso8601(start),
5368                            unix_nanos_to_iso8601(oldest),
5369                        );
5370                    }
5371
5372                    break;
5373                }
5374            }
5375        }
5376
5377        reports.retain(|report| {
5378            if matches!(
5379                self.dispatch.mark_trade_reconciled(report.trade_id),
5380                Some(TradeDedupSource::Live),
5381            ) {
5382                log::debug!(
5383                    "Lighter trade {} ignored in HTTP fill reports after live delivery",
5384                    report.trade_id,
5385                );
5386                false
5387            } else {
5388                true
5389            }
5390        });
5391
5392        Ok(FillSweep {
5393            reports,
5394            covers_window,
5395        })
5396    }
5397}
5398
5399fn restore_reconciled_order(
5400    core: &ExecutionClientCore,
5401    dispatch: &WsDispatchState,
5402    raw: &crate::http::models::LighterOrder,
5403    terminal: bool,
5404) {
5405    let venue_order_id = VenueOrderId::new(raw.order_id.as_str());
5406    let cached_order = {
5407        let cache = core.cache();
5408        let Some(cloid) = cache.client_order_id(&venue_order_id).copied() else {
5409            return;
5410        };
5411        let Some(order) = cache.order_owned(&cloid) else {
5412            log::warn!(
5413                "Ignoring reconciled Lighter order missing from cache: cloid={cloid}, venue_order_id={venue_order_id}",
5414            );
5415            return;
5416        };
5417        order
5418    };
5419
5420    if cached_order.instrument_id().venue != core.venue
5421        || cached_order.account_id() != Some(core.account_id)
5422    {
5423        log::warn!(
5424            "Ignoring reconciled Lighter order outside this client: cloid={}, venue_order_id={venue_order_id}",
5425            cached_order.client_order_id(),
5426        );
5427        return;
5428    }
5429
5430    if let Err(e) = dispatch.restore_reconciled_order(
5431        &cached_order,
5432        raw.client_order_index,
5433        venue_order_id,
5434        terminal,
5435    ) {
5436        log::warn!(
5437            "Ignoring conflicting Lighter reconciliation identity: cloid={}, venue_order_id={venue_order_id}, client_order_index={}, error={e}",
5438            cached_order.client_order_id(),
5439            raw.client_order_index,
5440        );
5441    }
5442}
5443
5444fn local_submit_denial_reason(
5445    order: &OrderAny,
5446    instrument: Option<&InstrumentAny>,
5447) -> Option<String> {
5448    if instrument.is_none() {
5449        return Some(
5450            OrderDeniedReason::InstrumentNotFound {
5451                instrument_id: order.instrument_id(),
5452            }
5453            .to_string(),
5454        );
5455    }
5456
5457    if !is_lighter_supported_order_type(order.order_type()) {
5458        return Some(unsupported_lighter_order_type_reason(order.order_type()));
5459    }
5460
5461    if is_lighter_limit_style_order(order.order_type()) && order.price().is_none() {
5462        return Some(
5463            OrderDeniedReason::ValidationFailed {
5464                detail: "Lighter limit-style orders require a limit price".to_string(),
5465            }
5466            .to_string(),
5467        );
5468    }
5469
5470    if order.is_quote_quantity() {
5471        return Some(
5472            OrderDeniedReason::ValidationFailed {
5473                detail:
5474                    "Lighter orders do not support quote_quantity; submit base quantity instead"
5475                        .to_string(),
5476            }
5477            .to_string(),
5478        );
5479    }
5480
5481    if order.display_qty().is_some() {
5482        return Some(
5483            OrderDeniedReason::ValidationFailed {
5484                detail: "Lighter orders do not support display_qty iceberg instructions"
5485                    .to_string(),
5486            }
5487            .to_string(),
5488        );
5489    }
5490
5491    if is_lighter_spot_order(order, instrument) && is_lighter_conditional_order(order.order_type())
5492    {
5493        let denied = OrderDeniedReason::UnsupportedOrderType {
5494            order_type: order.order_type(),
5495        };
5496        return Some(format!(
5497            "{denied}; Lighter spot markets do not support conditional orders",
5498        ));
5499    }
5500
5501    nautilus_to_lighter_tif(
5502        order.order_type(),
5503        order.time_in_force(),
5504        order.is_post_only(),
5505    )
5506    .err()
5507    .map(|e| {
5508        let denied = OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force());
5509        format!("{denied}; {e}")
5510    })
5511}
5512
5513fn unsupported_lighter_order_type_reason(order_type: OrderType) -> String {
5514    let denied = OrderDeniedReason::UnsupportedOrderType { order_type };
5515    format!(
5516        "{denied}; Lighter supports MARKET, LIMIT, STOP_MARKET, STOP_LIMIT, MARKET_IF_TOUCHED, and LIMIT_IF_TOUCHED",
5517    )
5518}
5519
5520fn is_grouped_order(order: &OrderAny) -> bool {
5521    order.contingency_type().is_some()
5522}
5523
5524fn is_lighter_spot_order(order: &OrderAny, instrument: Option<&InstrumentAny>) -> bool {
5525    instrument.is_some_and(|instrument| matches!(instrument, InstrumentAny::CurrencyPair(_)))
5526        || product_type_from_instrument_id(&order.instrument_id()) == Some(LighterProductType::Spot)
5527}
5528
5529fn is_lighter_supported_order_type(order_type: OrderType) -> bool {
5530    matches!(
5531        order_type,
5532        OrderType::Market
5533            | OrderType::Limit
5534            | OrderType::StopMarket
5535            | OrderType::StopLimit
5536            | OrderType::MarketIfTouched
5537            | OrderType::LimitIfTouched
5538    )
5539}
5540
5541fn is_lighter_limit_style_order(order_type: OrderType) -> bool {
5542    matches!(
5543        order_type,
5544        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
5545    )
5546}
5547
5548fn is_lighter_conditional_order(order_type: OrderType) -> bool {
5549    matches!(
5550        order_type,
5551        OrderType::StopMarket
5552            | OrderType::StopLimit
5553            | OrderType::MarketIfTouched
5554            | OrderType::LimitIfTouched
5555    )
5556}
5557
5558async fn seed_active_markets_from_inactive_orders(
5559    http_client: &LighterHttpClient,
5560    dispatch: &WsDispatchState,
5561    credential: &Credential,
5562    auth: &str,
5563    between_timestamps: Option<String>,
5564) -> anyhow::Result<()> {
5565    let mut cursor: Option<String> = None;
5566    let mut seen_cursors = AHashSet::new();
5567    let mut orders_seen = 0_usize;
5568    let mut pages = 0_usize;
5569
5570    loop {
5571        pages += 1;
5572        anyhow::ensure!(
5573            pages <= MAX_RECONCILIATION_PAGES,
5574            "Lighter active-market seed exceeded {MAX_RECONCILIATION_PAGES} pages",
5575        );
5576        let response = http_client
5577            .get_account_inactive_orders(&LighterAccountInactiveOrdersQuery {
5578                authorization: None,
5579                auth: Some(auth.to_string()),
5580                account_index: credential.account_index(),
5581                market_id: None,
5582                ask_filter: None,
5583                between_timestamps: between_timestamps.clone(),
5584                cursor: cursor.clone(),
5585                limit: LIGHTER_REST_PAGE_SIZE,
5586            })
5587            .await
5588            .context("failed to seed Lighter active markets from inactive orders")?;
5589
5590        for order in &response.orders {
5591            dispatch.note_active_market(order.market_index);
5592            orders_seen += 1;
5593        }
5594
5595        match response.next_cursor {
5596            Some(next) if !next.is_empty() => {
5597                anyhow::ensure!(
5598                    seen_cursors.insert(next.clone()),
5599                    "Lighter active-market seed repeated cursor `{next}`",
5600                );
5601                cursor = Some(next);
5602            }
5603            _ => break,
5604        }
5605    }
5606
5607    if orders_seen > 0 {
5608        log::debug!("Seeded Lighter active markets from {orders_seen} inactive order report(s)");
5609    }
5610
5611    Ok(())
5612}
5613
5614fn cancel_order_from_cancel_all(
5615    cmd: &CancelAllOrders,
5616    client_order_id: ClientOrderId,
5617) -> CancelOrder {
5618    CancelOrder {
5619        trader_id: cmd.trader_id,
5620        client_id: cmd.client_id,
5621        strategy_id: cmd.strategy_id,
5622        instrument_id: cmd.instrument_id,
5623        client_order_id,
5624        venue_order_id: None,
5625        command_id: cmd.command_id,
5626        ts_init: cmd.ts_init,
5627        params: cmd.params.clone(),
5628        correlation_id: cmd.correlation_id,
5629        causation_id: cmd.causation_id,
5630    }
5631}
5632
5633fn validate_order_amount(
5634    instrument: &InstrumentAny,
5635    quantity: Quantity,
5636    price_ticks: u32,
5637    price_precision: u8,
5638) -> anyhow::Result<()> {
5639    if let Some(min_quantity) = instrument.min_quantity() {
5640        anyhow::ensure!(
5641            quantity >= min_quantity,
5642            "quantity `{quantity}` below Lighter min_base_amount `{min_quantity}` for {}",
5643            instrument.id(),
5644        );
5645    }
5646
5647    if let Some(min_notional) = instrument.min_notional() {
5648        let price = decimal_from_ticks(price_ticks, price_precision);
5649        let notional = quantity.as_decimal() * price;
5650        anyhow::ensure!(
5651            notional >= min_notional.as_decimal(),
5652            "order notional `{notional}` below Lighter min_quote_amount `{}` for {}",
5653            min_notional.as_decimal(),
5654            instrument.id(),
5655        );
5656    }
5657
5658    Ok(())
5659}
5660
5661fn decimal_from_ticks(ticks: u32, decimals: u8) -> Decimal {
5662    Decimal::from(ticks) / Decimal::from(10_i64.pow(u32::from(decimals)))
5663}
5664
5665/// Route a venue `account_orders` payload through the tracked-event path
5666/// when the cloid is known, otherwise fall back to the existing
5667/// [`OrderStatusReport`] flow used for externally-managed orders.
5668fn dispatch_lighter_order(
5669    order: &crate::http::models::LighterOrder,
5670    dispatch: &WsDispatchState,
5671    emitter: &ExecutionEventEmitter,
5672    registry: &Arc<MarketRegistry>,
5673    account_id: AccountId,
5674    trader_id: TraderId,
5675    ts_init: UnixNanos,
5676) {
5677    let instrument_id = match registry.instrument_id(order.market_index) {
5678        Some(id) => id,
5679        None => {
5680            log::debug!(
5681                "Lighter order frame dropped: no instrument for market_index={}",
5682                order.market_index,
5683            );
5684            return;
5685        }
5686    };
5687
5688    if let Some(idx) = registry.market_index(&instrument_id) {
5689        dispatch.note_active_market(idx);
5690    }
5691
5692    let instrument = match LIGHTER_INSTRUMENT_CACHE.get(&instrument_id) {
5693        Some(inst) => inst.value().clone(),
5694        None => {
5695            log::debug!("Lighter order frame dropped: instrument {instrument_id} not in cache",);
5696            return;
5697        }
5698    };
5699
5700    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
5701    let resolved_cloid =
5702        dispatch.resolve_live_order_cloid(order.client_order_id.as_str(), venue_order_id);
5703
5704    let identity = resolved_cloid.and_then(|cid| {
5705        dispatch
5706            .order_identities
5707            .get(&cid)
5708            .map(|entry| (cid, entry.value().clone()))
5709    });
5710
5711    if let Some((cloid, identity)) = identity {
5712        dispatch.venue_id_map.insert(cloid, venue_order_id);
5713
5714        // Pre-compute the parser's Open-frame context: accepted gate,
5715        // triggered gate, and shape diff against the stored snapshot.
5716        // The dispatcher owns the dispatch-state mutation and the parser
5717        // stays pure.
5718        let is_live_status = matches!(
5719            order.status,
5720            crate::common::enums::LighterOrderStatus::Pending
5721                | crate::common::enums::LighterOrderStatus::Open,
5722        );
5723        let pending_action = dispatch.pending_order_action(&cloid);
5724        if is_live_status && pending_action == Some(PendingOrderAction::Cancel) {
5725            log::debug!(
5726                "Deferring Lighter {:?} frame while cancel is pending for {cloid}",
5727                order.status,
5728            );
5729            return;
5730        }
5731
5732        let current_shape = match lighter_order_shape(order, &instrument, identity.order_type) {
5733            Ok(shape) => shape,
5734            Err(e) => {
5735                log::error!(
5736                    "Failed to compute Lighter order shape: error={e}, voi={venue_order_id}, cloid={cloid}",
5737                );
5738                return;
5739            }
5740        };
5741        let prior_shape = dispatch.snapshot_for(&cloid);
5742        let shape_changed = prior_shape
5743            .as_ref()
5744            .is_some_and(|prev| prev != &current_shape);
5745        let fresh_trigger = order.status == crate::common::enums::LighterOrderStatus::Open
5746            && order.trigger_status == crate::common::enums::LighterTriggerStatus::Ready
5747            && !dispatch.triggered_was_emitted(&cloid);
5748
5749        if pending_action == Some(PendingOrderAction::Modify) && fresh_trigger && !shape_changed {
5750            log::debug!(
5751                "Deferring Lighter trigger while modify confirmation is pending for {cloid}",
5752            );
5753            return;
5754        }
5755
5756        let open_ctx = OpenFrameContext {
5757            accepted_already_emitted: dispatch.accepted_was_emitted(&cloid),
5758            triggered_already_emitted: dispatch.triggered_was_emitted(&cloid),
5759            shape_changed,
5760        };
5761
5762        match parse_lighter_order_event(
5763            order,
5764            &instrument,
5765            &identity,
5766            cloid,
5767            account_id,
5768            trader_id,
5769            open_ctx,
5770            ts_init,
5771        ) {
5772            Ok(event_opt) => {
5773                // Refresh the stored snapshot for any tracked live frame
5774                // so a synthesised `OrderAccepted` (fill-before-open or
5775                // fresh-trigger path) leaves a baseline behind for the
5776                // next diff. Without this seed `shape_changed` would
5777                // stay permanently false and a real later modify would
5778                // be missed. Filled / Canceled / Expired / Rejected
5779                // frames skip the refresh; identity cleanup in
5780                // `dispatch_tracked_order_event` removes the snapshot on
5781                // terminal events.
5782                if is_live_status {
5783                    dispatch.store_snapshot(cloid, current_shape);
5784                }
5785
5786                if let Some(event) = event_opt {
5787                    dispatch_tracked_order_event(
5788                        event,
5789                        cloid,
5790                        venue_order_id,
5791                        &identity,
5792                        account_id,
5793                        trader_id,
5794                        emitter,
5795                        dispatch,
5796                        ts_init,
5797                    );
5798                } else if matches!(
5799                    order.status,
5800                    crate::common::enums::LighterOrderStatus::Filled,
5801                ) {
5802                    dispatch.venue_id_map.remove(&cloid);
5803                    dispatch.retire_order_identity(&cloid);
5804                }
5805            }
5806            Err(e) => {
5807                log::error!(
5808                    "Failed to parse Lighter order event: error={e}, voi={venue_order_id}, cloid={cloid}",
5809                );
5810            }
5811        }
5812    } else {
5813        match parse_ws_order_status_report(order, &instrument, account_id, ts_init) {
5814            Ok(mut report) => {
5815                report = dispatch.translate_order_cloid(report);
5816                report = dispatch.preserve_pending_order_status(report);
5817
5818                if let Some(cloid) = &report.client_order_id {
5819                    dispatch.venue_id_map.insert(*cloid, report.venue_order_id);
5820                }
5821
5822                if report.order_status.is_closed() {
5823                    evict_terminal_mappings(&report, &dispatch.venue_id_map);
5824                }
5825
5826                log::debug!(
5827                    "Lighter OrderStatusReport: voi={} status={:?} cloid={:?}",
5828                    report.venue_order_id,
5829                    report.order_status,
5830                    report.client_order_id,
5831                );
5832                emitter.send_order_status_report(report);
5833            }
5834            Err(e) => {
5835                log::error!(
5836                    "Failed to parse Lighter order status report: error={e}, order_id={}",
5837                    order.order_id,
5838                );
5839            }
5840        }
5841    }
5842}
5843
5844/// Route a venue `account_trades` payload through the tracked-event path
5845/// when the cloid is known, otherwise fall back to the existing
5846/// [`FillReport`] flow. Drops duplicate fill frames keyed by `trade_id`.
5847#[expect(
5848    clippy::too_many_arguments,
5849    reason = "consumption-loop dispatch threads identity and emitter context"
5850)]
5851fn dispatch_lighter_trade(
5852    trade: &crate::http::models::LighterTrade,
5853    dispatch: &WsDispatchState,
5854    emitter: &ExecutionEventEmitter,
5855    registry: &Arc<MarketRegistry>,
5856    account_id: AccountId,
5857    trader_id: TraderId,
5858    account_index: Option<i64>,
5859    ts_init: UnixNanos,
5860) {
5861    let Some(account_index) = account_index else {
5862        log::debug!("Lighter trade frame dropped: no credential / account_index available",);
5863        return;
5864    };
5865
5866    let instrument_id = match registry.instrument_id(trade.market_id) {
5867        Some(id) => id,
5868        None => {
5869            log::debug!(
5870                "Lighter trade frame dropped: no instrument for market_id={}",
5871                trade.market_id,
5872            );
5873            return;
5874        }
5875    };
5876
5877    if let Some(idx) = registry.market_index(&instrument_id) {
5878        dispatch.note_active_market(idx);
5879    }
5880
5881    let instrument = match LIGHTER_INSTRUMENT_CACHE.get(&instrument_id) {
5882        Some(inst) => inst.value().clone(),
5883        None => {
5884            log::debug!("Lighter trade frame dropped: instrument {instrument_id} not in cache",);
5885            return;
5886        }
5887    };
5888
5889    let user_is_bidder = trade.bid_account_id == account_index;
5890    let user_is_asker = trade.ask_account_id == account_index;
5891    if !user_is_bidder && !user_is_asker {
5892        // Defensive: the handler already filters foreign trades, so this
5893        // branch is rare in practice. Drop silently.
5894        return;
5895    }
5896
5897    // Dedupe before dispatch so a duplicate frame on reconnect does not
5898    // double-book on either the tracked or untracked path.
5899    let trade_id = match parse_lighter_trade_id(trade) {
5900        Ok(id) => id,
5901        Err(e) => {
5902            log::error!("Lighter trade has invalid trade_id: {e}");
5903            return;
5904        }
5905    };
5906
5907    if !dispatch.mark_trade_seen(trade_id) {
5908        log::debug!("Lighter duplicate trade {trade_id} ignored (already routed)",);
5909        return;
5910    }
5911
5912    let raw_client_id = if user_is_bidder {
5913        trade
5914            .bid_client_id_str
5915            .as_deref()
5916            .map_or_else(|| trade.bid_client_id.to_string(), str::to_string)
5917    } else {
5918        trade
5919            .ask_client_id_str
5920            .as_deref()
5921            .map_or_else(|| trade.ask_client_id.to_string(), str::to_string)
5922    };
5923    let venue_order_id = if user_is_bidder {
5924        trade.bid_id_str.as_deref().map_or_else(
5925            || VenueOrderId::new(trade.bid_id.to_string()),
5926            VenueOrderId::new,
5927        )
5928    } else {
5929        trade.ask_id_str.as_deref().map_or_else(
5930            || VenueOrderId::new(trade.ask_id.to_string()),
5931            VenueOrderId::new,
5932        )
5933    };
5934    let resolved_cloid = dispatch.resolve_live_trade_cloid(raw_client_id.as_str(), venue_order_id);
5935
5936    let identity = resolved_cloid.and_then(|cid| {
5937        dispatch
5938            .order_identity(&cid)
5939            .map(|identity| (cid, identity))
5940    });
5941
5942    if let Some((cloid, identity)) = identity {
5943        // Synthesise an `OrderAccepted` first if one has not been
5944        // emitted yet: fills can race ahead of the matching `Open`
5945        // order frame.
5946        ensure_accepted_emitted(
5947            cloid,
5948            venue_order_id,
5949            &identity,
5950            account_id,
5951            trader_id,
5952            emitter,
5953            dispatch,
5954            ts_init,
5955        );
5956
5957        match parse_lighter_order_filled(
5958            trade,
5959            &instrument,
5960            &identity,
5961            cloid,
5962            account_id,
5963            trader_id,
5964            account_index,
5965            ts_init,
5966        ) {
5967            Ok(Some(filled)) => {
5968                log::debug!(
5969                    "Lighter OrderFilled: voi={} qty={} px={} liq={:?} cloid={cloid}",
5970                    filled.venue_order_id,
5971                    filled.last_qty,
5972                    filled.last_px,
5973                    filled.liquidity_side,
5974                );
5975                emitter.send_order_event(OrderEventAny::Filled(filled));
5976            }
5977            Ok(None) => {}
5978            Err(e) => {
5979                // Fill never reached the engine; release the dedup marker so a replay can retry
5980                dispatch.unmark_trade_seen(&trade_id);
5981                log::error!("Failed to parse Lighter typed fill: error={e}, trade_id={trade_id}",);
5982            }
5983        }
5984    } else {
5985        match parse_ws_fill_report(trade, account_index, &instrument, account_id, ts_init) {
5986            Ok(Some(mut report)) => {
5987                report = dispatch.translate_fill_cloid(report);
5988                log::debug!(
5989                    "Lighter FillReport: voi={} qty={} px={} liq={:?} cloid={:?}",
5990                    report.venue_order_id,
5991                    report.last_qty,
5992                    report.last_px,
5993                    report.liquidity_side,
5994                    report.client_order_id,
5995                );
5996                emitter.send_fill_report(report);
5997            }
5998            Ok(None) => {}
5999            Err(e) => {
6000                // Fill never reached the engine; release the dedup marker so a replay can retry
6001                dispatch.unmark_trade_seen(&trade_id);
6002                log::error!("Failed to parse Lighter fill report: error={e}, trade_id={trade_id}",);
6003            }
6004        }
6005    }
6006}
6007
6008/// Send a [`ParsedOrderEvent`] to the engine and update dispatch state for
6009/// the originating cloid. Cleans up [`WsDispatchState::order_identities`]
6010/// on terminal events so subsequent stale frames take the untracked path.
6011#[expect(
6012    clippy::too_many_arguments,
6013    reason = "shared cleanup point across the typed-event variants"
6014)]
6015#[expect(
6016    clippy::needless_pass_by_value,
6017    reason = "event is destructured into typed OrderEventAny variants that consume the payload"
6018)]
6019fn dispatch_tracked_order_event(
6020    event: ParsedOrderEvent,
6021    cloid: ClientOrderId,
6022    venue_order_id: VenueOrderId,
6023    identity: &OrderIdentity,
6024    account_id: AccountId,
6025    trader_id: TraderId,
6026    emitter: &ExecutionEventEmitter,
6027    dispatch: &WsDispatchState,
6028    ts_init: UnixNanos,
6029) {
6030    let is_terminal;
6031
6032    match event {
6033        ParsedOrderEvent::Accepted(e) => {
6034            if !dispatch.claim_accepted_emission(&cloid) {
6035                log::debug!("Skipping duplicate OrderAccepted for {cloid}");
6036                return;
6037            }
6038            is_terminal = false;
6039            emitter.send_order_event(OrderEventAny::Accepted(e));
6040        }
6041        ParsedOrderEvent::Triggered(e) => {
6042            if !dispatch.mark_triggered_emitted(cloid) {
6043                log::debug!("Skipping duplicate OrderTriggered for {cloid}");
6044                return;
6045            }
6046            ensure_accepted_emitted(
6047                cloid,
6048                venue_order_id,
6049                identity,
6050                account_id,
6051                trader_id,
6052                emitter,
6053                dispatch,
6054                ts_init,
6055            );
6056            is_terminal = false;
6057            emitter.send_order_event(OrderEventAny::Triggered(e));
6058        }
6059        ParsedOrderEvent::Updated(e) => {
6060            // Modify-as-restate: the venue echoes the post-modify order as
6061            // `Open`; `accepted_was_emitted` already gated parsing to
6062            // produce `Updated` instead of duplicate `Accepted`. No need
6063            // to re-synthesise the accept here.
6064            dispatch.clear_pending_order_action_if(&cloid, PendingOrderAction::Modify);
6065            is_terminal = false;
6066            emitter.send_order_event(OrderEventAny::Updated(e));
6067        }
6068        ParsedOrderEvent::UpdatedThenTriggered { updated, triggered } => {
6069            if !dispatch.mark_triggered_emitted(cloid) {
6070                log::debug!("Skipping duplicate OrderTriggered for {cloid}");
6071                return;
6072            }
6073            ensure_accepted_emitted(
6074                cloid,
6075                venue_order_id,
6076                identity,
6077                account_id,
6078                trader_id,
6079                emitter,
6080                dispatch,
6081                ts_init,
6082            );
6083            dispatch.clear_pending_order_action_if(&cloid, PendingOrderAction::Modify);
6084            is_terminal = false;
6085            emitter.send_order_event(OrderEventAny::Updated(updated));
6086            emitter.send_order_event(OrderEventAny::Triggered(triggered));
6087        }
6088        ParsedOrderEvent::Canceled(e) => {
6089            ensure_accepted_emitted(
6090                cloid,
6091                venue_order_id,
6092                identity,
6093                account_id,
6094                trader_id,
6095                emitter,
6096                dispatch,
6097                ts_init,
6098            );
6099            is_terminal = true;
6100            emitter.send_order_event(OrderEventAny::Canceled(e));
6101        }
6102        ParsedOrderEvent::Expired(e) => {
6103            ensure_accepted_emitted(
6104                cloid,
6105                venue_order_id,
6106                identity,
6107                account_id,
6108                trader_id,
6109                emitter,
6110                dispatch,
6111                ts_init,
6112            );
6113            is_terminal = true;
6114            emitter.send_order_event(OrderEventAny::Expired(e));
6115        }
6116        ParsedOrderEvent::Rejected(e) => {
6117            is_terminal = true;
6118            emitter.send_order_event(OrderEventAny::Rejected(e));
6119        }
6120    }
6121
6122    if is_terminal {
6123        dispatch.venue_id_map.remove(&cloid);
6124        dispatch.retire_order_identity(&cloid);
6125    }
6126}
6127
6128/// Synthesise an `OrderAccepted` event if one has not yet been emitted for
6129/// `cloid`. Mirrors the BitMEX dispatch helper of the same name.
6130#[expect(
6131    clippy::too_many_arguments,
6132    reason = "synthesised events need the full identity context to populate the event"
6133)]
6134fn ensure_accepted_emitted(
6135    cloid: ClientOrderId,
6136    venue_order_id: VenueOrderId,
6137    identity: &OrderIdentity,
6138    account_id: AccountId,
6139    trader_id: TraderId,
6140    emitter: &ExecutionEventEmitter,
6141    dispatch: &WsDispatchState,
6142    ts_init: UnixNanos,
6143) {
6144    if !dispatch.claim_accepted_emission(&cloid) {
6145        return;
6146    }
6147    let accepted = OrderAccepted::new(
6148        trader_id,
6149        identity.strategy_id,
6150        identity.instrument_id,
6151        cloid,
6152        venue_order_id,
6153        account_id,
6154        UUID4::new(),
6155        ts_init,
6156        ts_init,
6157        false,
6158    );
6159    emitter.send_order_event(OrderEventAny::Accepted(accepted));
6160}
6161
6162#[cfg(test)]
6163mod tests {
6164    use std::{
6165        cell::RefCell,
6166        rc::Rc,
6167        sync::{Arc, atomic::AtomicUsize},
6168    };
6169
6170    use axum::{
6171        Router,
6172        routing::{get, post},
6173    };
6174    use nautilus_common::{
6175        cache::Cache,
6176        clock::TestClock,
6177        factories::OrderFactory,
6178        messages::{ExecutionEvent, ExecutionReport as EngineExecutionReport},
6179        testing::wait_until_async,
6180    };
6181    use nautilus_model::{
6182        data::QuoteTick,
6183        enums::{ContingencyType, LiquiditySide, OrderSide, OrderStatus, TimeInForce},
6184        events::{OrderCanceled, OrderEventAny, OrderPendingCancel, OrderTriggered},
6185        identifiers::{
6186            InstrumentId, OrderListId, StrategyId, Symbol, TradeId, TraderId, VenueOrderId,
6187        },
6188        instruments::CryptoPerpetual,
6189        orders::{LimitOrder, OrderList},
6190        types::{Currency, Money, Price},
6191    };
6192    use rstest::rstest;
6193
6194    use super::*;
6195    use crate::{
6196        common::{
6197            consts::LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
6198            enums::{LighterDeployment, LighterEnvironment, LighterProductType},
6199        },
6200        http::models::{LighterNextNonce, LighterTx},
6201        signing::tx::TX_HASH_BYTES,
6202    };
6203
6204    const TEST_PRIVATE_KEY: &str =
6205        "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
6206    const TEST_ACCOUNT_INDEX: u64 = 12345;
6207    const TEST_ACCOUNT_INDEX_I64: i64 = 12345;
6208    const TEST_API_KEY_INDEX: u8 = 5;
6209    const TEST_NEXT_NONCE: i64 = 42;
6210    const TEST_MARKET_INDEX: i16 = 0;
6211    const TEST_ORDER_NONCE: i64 = 281_474_720_725_346;
6212    const TEST_SUBMISSION_NONCE: i64 = 2_042;
6213
6214    fn handle_send_tx_ack(
6215        dispatch: &WsDispatchState,
6216        account_index: Option<i64>,
6217        code: i64,
6218        tx_hash: Option<&str>,
6219    ) -> Option<PendingSendTx> {
6220        handle_send_tx_ack_for_connection(dispatch, account_index, 0, code, tx_hash)
6221    }
6222
6223    #[expect(
6224        clippy::too_many_arguments,
6225        reason = "test wrapper preserves the established rejection call shape"
6226    )]
6227    fn handle_send_tx_rejection(
6228        dispatch: &WsDispatchState,
6229        emitter: &ExecutionEventEmitter,
6230        account_index: Option<i64>,
6231        now: UnixNanos,
6232        source: SendTxRejectionSource,
6233        code: Option<i64>,
6234        message: &str,
6235        tx_hash: Option<&str>,
6236    ) -> bool {
6237        handle_send_tx_rejection_for_connection(
6238            dispatch,
6239            emitter,
6240            account_index,
6241            0,
6242            now,
6243            source,
6244            code,
6245            message,
6246            tx_hash,
6247        )
6248    }
6249
6250    fn trader_id() -> TraderId {
6251        TraderId::from("TRADER-001")
6252    }
6253
6254    fn client_id() -> ClientId {
6255        ClientId::from("LIGHTER")
6256    }
6257
6258    fn account_id() -> AccountId {
6259        AccountId::from("LIGHTER-001")
6260    }
6261
6262    fn strategy_id() -> StrategyId {
6263        StrategyId::from("S-001")
6264    }
6265
6266    fn test_credential() -> Credential {
6267        Credential::new(TEST_API_KEY_INDEX, TEST_PRIVATE_KEY, TEST_ACCOUNT_INDEX).unwrap()
6268    }
6269
6270    fn test_config() -> LighterExecutionClientConfig {
6271        LighterExecutionClientConfig {
6272            account_id: account_id(),
6273            account_index: Some(TEST_ACCOUNT_INDEX),
6274            api_key_index: Some(TEST_API_KEY_INDEX),
6275            private_key: Some(TEST_PRIVATE_KEY.to_string()),
6276            base_url_http: Some("http://127.0.0.1:1".to_string()),
6277            base_url_ws: Some("ws://127.0.0.1:1/stream".to_string()),
6278            proxy_url: None,
6279            environment: LighterEnvironment::Testnet,
6280            deployment: LighterDeployment::Lighter,
6281            venue: None,
6282            http_timeout_secs: 1,
6283            ws_timeout_secs: 1,
6284            market_order_slippage_bps: 50,
6285            rest_quota_per_min: None,
6286            sendtx_quota_per_min: None,
6287            transport_backend: Default::default(),
6288        }
6289    }
6290
6291    #[rstest]
6292    fn format_between_timestamps_uses_lighter_seconds_range() {
6293        let start = UnixNanos::from(1_700_000_000_123_456_789);
6294        let end = UnixNanos::from(1_700_003_600_987_654_321);
6295        let now = UnixNanos::from(1_700_007_200_000_000_000);
6296
6297        assert_eq!(
6298            format_between_timestamps(Some(start), Some(end), now),
6299            Some("1700000000-1700003600".to_string()),
6300        );
6301        assert_eq!(
6302            format_between_timestamps(Some(start), None, now),
6303            Some("1700000000-1700007200".to_string()),
6304        );
6305        assert_eq!(
6306            format_between_timestamps(None, Some(end), now),
6307            Some("0-1700003600".to_string()),
6308        );
6309        assert_eq!(format_between_timestamps(None, None, now), None);
6310    }
6311
6312    #[rstest]
6313    #[case::write_timeout(LighterWsError::Transport(SendError::WriteTimeout), "Ambiguous")]
6314    #[case::broken_pipe(
6315        LighterWsError::Transport(SendError::BrokenPipe("writer closed".to_string())),
6316        "Ambiguous",
6317    )]
6318    #[case::handler_result_lost(
6319        LighterWsError::SendTxOutcomeUnknown("result sender dropped".to_string()),
6320        "Ambiguous",
6321    )]
6322    #[case::network(LighterWsError::Network("disconnected".to_string()), "Ambiguous")]
6323    #[case::parse(LighterWsError::Parse("invalid ack".to_string()), "Ambiguous")]
6324    #[case::invalid_input(
6325        LighterWsError::Transport(SendError::InvalidInput("invalid payload".to_string())),
6326        "NotSent",
6327    )]
6328    #[case::closed(LighterWsError::Transport(SendError::Closed), "NotSent")]
6329    #[case::connection_changed(LighterWsError::Transport(SendError::ConnectionChanged), "NotSent")]
6330    #[case::wait_timeout(LighterWsError::Transport(SendError::Timeout), "NotSent")]
6331    #[case::authentication(
6332        LighterWsError::Authentication("invalid token".to_string()),
6333        "NotSent",
6334    )]
6335    #[case::client_unavailable(
6336        LighterWsError::Client("handler unavailable".to_string()),
6337        "NotSent",
6338    )]
6339    fn ws_command_failure_classifies_delivery_evidence(
6340        #[case] error: LighterWsError,
6341        #[case] expected: &str,
6342    ) {
6343        let failure = classify_lighter_ws_command_failure("submit_order", &error);
6344        let actual = match &failure {
6345            CommandFailure::NotSent(_) => "NotSent",
6346            CommandFailure::Ambiguous(_) => "Ambiguous",
6347            CommandFailure::VenueRejected(_) => "VenueRejected",
6348        };
6349
6350        assert_eq!(actual, expected);
6351        assert!(command_failure_reason(&failure).contains("submit_order"));
6352    }
6353
6354    #[rstest]
6355    fn venue_rejection_reason_is_clean_and_bounded() {
6356        let oversized = "x".repeat(STRATEGY_REASON_MAX_CHARS + 100);
6357        let message = format!("<html>\n rejected\0 because   {oversized}</html>");
6358
6359        let failure = venue_rejection_failure(Some(20_001), &message);
6360        let reason = command_failure_reason(&failure);
6361
6362        assert!(matches!(&failure, CommandFailure::VenueRejected(_)));
6363        assert!(!reason.contains('<'));
6364        assert!(!reason.contains('\0'));
6365        assert!(!reason.contains("  "));
6366        assert_eq!(reason.chars().count(), STRATEGY_REASON_MAX_CHARS);
6367        assert!(reason.starts_with("LIGHTER_20001: rejected because "));
6368    }
6369
6370    #[rstest]
6371    fn venue_rejection_reason_uses_stable_fallback_for_empty_markup() {
6372        let failure = venue_rejection_failure(Some(20_001), "<html></html>\n");
6373
6374        assert_eq!(
6375            failure,
6376            CommandFailure::VenueRejected("LIGHTER_20001".to_string()),
6377        );
6378    }
6379
6380    #[rstest]
6381    fn venue_rejection_reason_preserves_comparison_and_removes_format_controls() {
6382        let failure = venue_rejection_failure(
6383            Some(20_001),
6384            "price must be < 100\u{202e}\u{2066}\u{200b}<strong>now</strong>",
6385        );
6386
6387        assert_eq!(
6388            failure,
6389            CommandFailure::VenueRejected("LIGHTER_20001: price must be < 100 now".to_string(),),
6390        );
6391    }
6392
6393    #[rstest]
6394    fn unsupported_order_type_reason_keeps_lighter_capabilities() {
6395        assert_eq!(
6396            unsupported_lighter_order_type_reason(OrderType::MarketToLimit),
6397            "UNSUPPORTED_ORDER_TYPE: MARKET_TO_LIMIT; Lighter supports MARKET, LIMIT, STOP_MARKET, STOP_LIMIT, MARKET_IF_TOUCHED, and LIMIT_IF_TOUCHED",
6398        );
6399    }
6400
6401    #[rstest]
6402    fn mass_status_propagates_commission_error_and_downgrades_other_fill_errors() {
6403        let e = anyhow::Error::new(LighterCommissionError::new("invalid precision"))
6404            .context("failed to parse Lighter fill report");
6405
6406        let commission_error = fill_reports_for_mass_status(Err(e))
6407            .expect_err("commission construction must fail mass status");
6408        let ordinary_error = fill_reports_for_mass_status(Err(anyhow::anyhow!("transport failed")))
6409            .expect("ordinary source errors make mass status incomplete");
6410        let complete = fill_reports_for_mass_status(Ok(FillSweep {
6411            reports: Vec::new(),
6412            covers_window: true,
6413        }))
6414        .expect("complete fill sweep");
6415
6416        assert!(is_commission_error(&commission_error));
6417        assert!(ordinary_error.0.is_empty());
6418        assert!(!ordinary_error.1);
6419        assert!(complete.0.is_empty());
6420        assert!(complete.1);
6421    }
6422
6423    fn create_execution_client() -> (
6424        LighterExecutionClient,
6425        Rc<RefCell<Cache>>,
6426        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6427    ) {
6428        create_execution_client_with_config(test_config())
6429    }
6430
6431    fn create_execution_client_with_config(
6432        config: LighterExecutionClientConfig,
6433    ) -> (
6434        LighterExecutionClient,
6435        Rc<RefCell<Cache>>,
6436        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6437    ) {
6438        let cache = Rc::new(RefCell::new(Cache::default()));
6439        let venue = config.resolved_venue();
6440        let core = ExecutionClientCore::new(
6441            trader_id(),
6442            client_id(),
6443            venue,
6444            OmsType::Netting,
6445            account_id(),
6446            AccountType::Margin,
6447            None,
6448            cache.clone(),
6449        );
6450
6451        let mut client = LighterExecutionClient::new(core, config).unwrap();
6452        client.dispatch.nonce_manager.refresh(
6453            TEST_ACCOUNT_INDEX_I64,
6454            TEST_API_KEY_INDEX,
6455            TEST_NEXT_NONCE,
6456        );
6457
6458        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
6459        client.emitter.set_sender(sender);
6460
6461        (client, cache, receiver)
6462    }
6463
6464    fn register_test_instrument(
6465        client: &LighterExecutionClient,
6466        cache: &Rc<RefCell<Cache>>,
6467    ) -> InstrumentId {
6468        let instrument_id =
6469            client
6470                .registry
6471                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
6472        let instrument = InstrumentAny::CryptoPerpetual(
6473            CryptoPerpetual::builder()
6474                .instrument_id(instrument_id)
6475                .raw_symbol(Symbol::new("ETH-PERP"))
6476                .base_currency(Currency::from("ETH"))
6477                .quote_currency(Currency::from("USDC"))
6478                .settlement_currency(Currency::from("USDC"))
6479                .is_inverse(false)
6480                .price_precision(2)
6481                .size_precision(4)
6482                .price_increment(Price::from("0.01"))
6483                .size_increment(Quantity::from("0.0001"))
6484                .min_notional(Money::from("10.000000 USDC"))
6485                .ts_event(UnixNanos::default())
6486                .ts_init(UnixNanos::default())
6487                .build()
6488                .unwrap(),
6489        );
6490
6491        cache.borrow_mut().add_instrument(instrument).unwrap();
6492
6493        instrument_id
6494    }
6495
6496    fn test_order_factory() -> OrderFactory {
6497        let clock = Rc::new(RefCell::new(TestClock::new()));
6498        OrderFactory::new(
6499            trader_id(),
6500            strategy_id(),
6501            Some(0),
6502            Some(0),
6503            clock,
6504            false,
6505            false,
6506        )
6507    }
6508
6509    fn test_limit_order(
6510        factory: &mut OrderFactory,
6511        instrument_id: InstrumentId,
6512        client_order_id: &str,
6513    ) -> OrderAny {
6514        test_limit_order_with(
6515            factory,
6516            instrument_id,
6517            client_order_id,
6518            OrderSide::Buy,
6519            TimeInForce::Gtc,
6520            None,
6521            false,
6522        )
6523    }
6524
6525    fn test_limit_order_with(
6526        factory: &mut OrderFactory,
6527        instrument_id: InstrumentId,
6528        client_order_id: &str,
6529        side: OrderSide,
6530        tif: TimeInForce,
6531        expire_time: Option<UnixNanos>,
6532        reduce_only: bool,
6533    ) -> OrderAny {
6534        factory.limit(
6535            instrument_id,
6536            side,
6537            Quantity::from("0.1000"),
6538            Price::from("2361.31"),
6539            Some(tif),
6540            expire_time,
6541            Some(false),
6542            Some(reduce_only),
6543            None,
6544            None,
6545            None,
6546            None,
6547            None,
6548            None,
6549            None,
6550            Some(ClientOrderId::from(client_order_id)),
6551        )
6552    }
6553
6554    fn cache_order(cache: &Rc<RefCell<Cache>>, order: OrderAny) {
6555        cache
6556            .borrow_mut()
6557            .add_order(order, None, Some(client_id()), false)
6558            .unwrap();
6559    }
6560
6561    fn cache_accepted_order(
6562        cache: &Rc<RefCell<Cache>>,
6563        order: OrderAny,
6564        venue_order_id: VenueOrderId,
6565        client_id: Option<ClientId>,
6566    ) -> (InstrumentId, ClientOrderId) {
6567        let instrument_id = order.instrument_id();
6568        let client_order_id = order.client_order_id();
6569        cache
6570            .borrow_mut()
6571            .add_order(order, None, client_id, false)
6572            .unwrap();
6573
6574        let accepted = OrderEventAny::Accepted(OrderAccepted::new(
6575            trader_id(),
6576            strategy_id(),
6577            instrument_id,
6578            client_order_id,
6579            venue_order_id,
6580            account_id(),
6581            UUID4::new(),
6582            UnixNanos::default(),
6583            UnixNanos::default(),
6584            false,
6585        ));
6586        cache.borrow_mut().update_order(&accepted).unwrap();
6587
6588        (instrument_id, client_order_id)
6589    }
6590
6591    fn cache_pending_cancel_order(
6592        cache: &Rc<RefCell<Cache>>,
6593        order: OrderAny,
6594        venue_order_id: VenueOrderId,
6595    ) {
6596        let (instrument_id, client_order_id) =
6597            cache_accepted_order(cache, order, venue_order_id, Some(client_id()));
6598
6599        let pending_cancel = OrderEventAny::PendingCancel(OrderPendingCancel::new(
6600            trader_id(),
6601            strategy_id(),
6602            instrument_id,
6603            client_order_id,
6604            Some(account_id()),
6605            UUID4::new(),
6606            UnixNanos::default(),
6607            UnixNanos::default(),
6608            false,
6609            Some(venue_order_id),
6610        ));
6611        cache.borrow_mut().update_order(&pending_cancel).unwrap();
6612    }
6613
6614    fn submit_order_list_command(orders: &[OrderAny], order_list_id: &str) -> SubmitOrderList {
6615        let order_list = OrderList::new(
6616            OrderListId::from(order_list_id),
6617            orders[0].instrument_id(),
6618            strategy_id(),
6619            orders.iter().map(|order| order.client_order_id()).collect(),
6620            UnixNanos::default(),
6621        );
6622        let order_inits = orders
6623            .iter()
6624            .map(|order| order.init_event().clone())
6625            .collect();
6626
6627        SubmitOrderList::new(
6628            trader_id(),
6629            Some(client_id()),
6630            strategy_id(),
6631            order_list,
6632            order_inits,
6633            None,
6634            None,
6635            None,
6636            UUID4::new(),
6637            UnixNanos::default(),
6638            None,
6639        )
6640    }
6641
6642    fn test_contingent_limit_order(
6643        instrument_id: InstrumentId,
6644        client_order_id: &str,
6645        order_list_id: &str,
6646        linked_order_id: &str,
6647    ) -> OrderAny {
6648        OrderAny::Limit(LimitOrder::new(
6649            trader_id(),
6650            strategy_id(),
6651            instrument_id,
6652            ClientOrderId::from(client_order_id),
6653            OrderSide::Buy,
6654            Quantity::from("0.1000"),
6655            Price::from("2361.31"),
6656            TimeInForce::Gtc,
6657            None,
6658            false,
6659            false,
6660            false,
6661            None,
6662            None,
6663            None,
6664            Some(ContingencyType::Oco),
6665            Some(OrderListId::from(order_list_id)),
6666            Some(vec![ClientOrderId::from(linked_order_id)]),
6667            None,
6668            None,
6669            None,
6670            None,
6671            None,
6672            UUID4::new(),
6673            UnixNanos::default(),
6674        ))
6675    }
6676
6677    async fn recv_order_event(
6678        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6679    ) -> OrderEventAny {
6680        let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
6681            .await
6682            .expect("timed out waiting for execution event")
6683            .expect("execution event channel closed");
6684
6685        match event {
6686            ExecutionEvent::Order(event) => event,
6687            event => panic!("expected order event, was {event:?}"),
6688        }
6689    }
6690
6691    async fn assert_modify_rejected_reason(
6692        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6693        reason_part: &str,
6694    ) {
6695        match recv_order_event(rx).await {
6696            OrderEventAny::ModifyRejected(event) => {
6697                assert!(
6698                    event.reason.as_str().contains(reason_part),
6699                    "expected modify rejection containing `{reason_part}`, was `{}`",
6700                    event.reason,
6701                );
6702            }
6703            event => panic!("expected modify rejected event, was {event:?}"),
6704        }
6705    }
6706
6707    fn assert_nonce_reusable(dispatch: &WsDispatchState) {
6708        assert_eq!(
6709            dispatch
6710                .nonce_manager
6711                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
6712            Some(TEST_NEXT_NONCE - 1),
6713        );
6714        assert_eq!(
6715            dispatch
6716                .nonce_manager
6717                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
6718                .unwrap(),
6719            TEST_NEXT_NONCE,
6720        );
6721    }
6722
6723    #[tokio::test]
6724    async fn auth_token_rotation_retries_failed_mint() {
6725        let credential = test_credential();
6726        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
6727        let cancellation_token = CancellationToken::new();
6728        let mint_attempts = Arc::new(AtomicUsize::new(0));
6729        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
6730
6731        let outcome = tokio::time::timeout(
6732            Duration::from_secs(3),
6733            refresh_auth_token_until_rotated(
6734                &credential,
6735                &channels,
6736                &cancellation_token,
6737                AuthTokenRefreshBackoff {
6738                    initial_delay: Duration::from_millis(10),
6739                    max_delay: Duration::from_millis(20),
6740                    window: Duration::from_secs(1),
6741                },
6742                {
6743                    let mint_attempts = Arc::clone(&mint_attempts);
6744                    move |_| {
6745                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
6746                        if attempt == 0 {
6747                            Err(anyhow::anyhow!("mint unavailable"))
6748                        } else {
6749                            Ok(format!("token-{attempt}"))
6750                        }
6751                    }
6752                },
6753                {
6754                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
6755                    move |_channel, _token| {
6756                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
6757                        async move {
6758                            subscribe_attempts.fetch_add(1, Ordering::AcqRel);
6759                            Ok::<(), crate::websocket::error::LighterWsError>(())
6760                        }
6761                    }
6762                },
6763            ),
6764        )
6765        .await
6766        .expect("rotation retry must complete within the test window");
6767
6768        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
6769        assert_eq!(
6770            mint_attempts.load(Ordering::Acquire),
6771            2,
6772            "failed mint must retry before the next refresh interval",
6773        );
6774        assert_eq!(
6775            subscribe_attempts.load(Ordering::Acquire),
6776            channels.len(),
6777            "subscriptions must wait until token mint succeeds",
6778        );
6779    }
6780
6781    #[tokio::test]
6782    async fn auth_token_rotation_retries_failed_resubscribe() {
6783        let credential = test_credential();
6784        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
6785        let cancellation_token = CancellationToken::new();
6786        let mint_attempts = Arc::new(AtomicUsize::new(0));
6787        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
6788
6789        let outcome = tokio::time::timeout(
6790            Duration::from_secs(3),
6791            refresh_auth_token_until_rotated(
6792                &credential,
6793                &channels,
6794                &cancellation_token,
6795                AuthTokenRefreshBackoff {
6796                    initial_delay: Duration::from_millis(10),
6797                    max_delay: Duration::from_millis(20),
6798                    window: Duration::from_secs(1),
6799                },
6800                {
6801                    let mint_attempts = Arc::clone(&mint_attempts);
6802                    move |_| {
6803                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
6804                        Ok(format!("token-{attempt}"))
6805                    }
6806                },
6807                {
6808                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
6809                    move |_channel, _token| {
6810                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
6811                        async move {
6812                            let attempt = subscribe_attempts.fetch_add(1, Ordering::AcqRel);
6813                            if attempt == 0 {
6814                                Err(crate::websocket::error::LighterWsError::Client(
6815                                    "handler unavailable".to_string(),
6816                                ))
6817                            } else {
6818                                Ok(())
6819                            }
6820                        }
6821                    }
6822                },
6823            ),
6824        )
6825        .await
6826        .expect("rotation retry must complete within the test window");
6827
6828        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
6829        assert_eq!(
6830            mint_attempts.load(Ordering::Acquire),
6831            2,
6832            "failed resubscribe must trigger a fresh auth-token mint",
6833        );
6834        assert_eq!(
6835            subscribe_attempts.load(Ordering::Acquire),
6836            channels.len() * 2,
6837            "failed resubscribe must retry the private account-channel set",
6838        );
6839    }
6840
6841    #[tokio::test]
6842    async fn auth_token_rotation_exhausts_retry_window() {
6843        let credential = test_credential();
6844        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
6845        let cancellation_token = CancellationToken::new();
6846        let mint_attempts = Arc::new(AtomicUsize::new(0));
6847
6848        let refresh = refresh_auth_token_until_rotated(
6849            &credential,
6850            &channels,
6851            &cancellation_token,
6852            AuthTokenRefreshBackoff {
6853                initial_delay: Duration::from_millis(100),
6854                max_delay: Duration::from_millis(100),
6855                window: Duration::from_secs(1),
6856            },
6857            {
6858                let mint_attempts = Arc::clone(&mint_attempts);
6859                move |_| {
6860                    mint_attempts.fetch_add(1, Ordering::AcqRel);
6861                    Err(anyhow::anyhow!("mint unavailable"))
6862                }
6863            },
6864            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
6865        );
6866        let observe_retry = wait_until_async(
6867            || async { mint_attempts.load(Ordering::Acquire) > 1 },
6868            Duration::from_secs(2),
6869        );
6870
6871        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(3), async {
6872            tokio::join!(refresh, observe_retry)
6873        })
6874        .await
6875        .expect("rotation retry exhaustion must complete within the test window");
6876
6877        assert_eq!(outcome, AuthTokenRefreshOutcome::Exhausted);
6878        assert!(
6879            mint_attempts.load(Ordering::Acquire) > 1,
6880            "persistent mint failure must retry until the window is exhausted",
6881        );
6882    }
6883
6884    #[tokio::test]
6885    async fn auth_token_rotation_cancels_during_retry_backoff() {
6886        let credential = test_credential();
6887        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
6888        let cancellation_token = CancellationToken::new();
6889        let mint_attempts = Arc::new(AtomicUsize::new(0));
6890
6891        let cancel = cancellation_token.clone();
6892        let cancel_after_first_attempt = wait_until_async(
6893            || async { mint_attempts.load(Ordering::Acquire) > 0 },
6894            Duration::from_secs(2),
6895        );
6896
6897        let refresh = refresh_auth_token_until_rotated(
6898            &credential,
6899            &channels,
6900            &cancellation_token,
6901            AuthTokenRefreshBackoff {
6902                initial_delay: Duration::from_secs(2),
6903                max_delay: Duration::from_secs(2),
6904                window: Duration::from_secs(5),
6905            },
6906            {
6907                let mint_attempts = Arc::clone(&mint_attempts);
6908                move |_| {
6909                    mint_attempts.fetch_add(1, Ordering::AcqRel);
6910                    Err(anyhow::anyhow!("mint unavailable"))
6911                }
6912            },
6913            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
6914        );
6915
6916        let cancel_task = async move {
6917            cancel_after_first_attempt.await;
6918            cancel.cancel();
6919        };
6920
6921        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(6), async {
6922            tokio::join!(refresh, cancel_task)
6923        })
6924        .await
6925        .expect("rotation cancellation must complete within the test window");
6926
6927        assert_eq!(outcome, AuthTokenRefreshOutcome::Cancelled);
6928        assert_eq!(
6929            mint_attempts.load(Ordering::Acquire),
6930            1,
6931            "cancellation during backoff must stop before the next retry",
6932        );
6933    }
6934
6935    #[rstest]
6936    #[case::rotated(AuthTokenRefreshOutcome::Rotated, Some(AUTH_TOKEN_REFRESH_INTERVAL))]
6937    #[case::cancelled(AuthTokenRefreshOutcome::Cancelled, None)]
6938    #[case::exhausted(
6939        AuthTokenRefreshOutcome::Exhausted,
6940        Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY)
6941    )]
6942    fn auth_token_refresh_next_delay_matches_outcome(
6943        #[case] outcome: AuthTokenRefreshOutcome,
6944        #[case] expected: Option<Duration>,
6945    ) {
6946        assert_eq!(auth_token_refresh_next_delay(outcome), expected);
6947    }
6948
6949    #[rstest]
6950    fn auth_token_rotation_channels_match_private_account_streams() {
6951        assert_eq!(
6952            auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64),
6953            [
6954                LighterWsChannel::AccountAllOrders(TEST_ACCOUNT_INDEX_I64),
6955                LighterWsChannel::AccountAllTrades(TEST_ACCOUNT_INDEX_I64),
6956                LighterWsChannel::AccountAllPositions(TEST_ACCOUNT_INDEX_I64),
6957                LighterWsChannel::AccountAllAssets(TEST_ACCOUNT_INDEX_I64),
6958                LighterWsChannel::UserStats(TEST_ACCOUNT_INDEX_I64),
6959            ],
6960        );
6961    }
6962
6963    #[rstest]
6964    #[case::below_max(
6965        Duration::from_millis(10),
6966        Duration::from_millis(100),
6967        Duration::from_millis(20)
6968    )]
6969    #[case::at_max(
6970        Duration::from_millis(100),
6971        Duration::from_millis(100),
6972        Duration::from_millis(100)
6973    )]
6974    #[case::overflow_clamps(Duration::MAX, Duration::from_secs(300), Duration::from_secs(300))]
6975    fn next_auth_token_refresh_retry_delay_doubles_and_caps(
6976        #[case] current: Duration,
6977        #[case] max: Duration,
6978        #[case] expected: Duration,
6979    ) {
6980        assert_eq!(next_auth_token_refresh_retry_delay(current, max), expected);
6981    }
6982
6983    #[rstest]
6984    #[tokio::test]
6985    async fn auth_token_refresh_is_retained_until_session_shutdown() {
6986        let (mut client, _cache, _rx) = create_execution_client();
6987
6988        client
6989            .spawn_auth_token_refresh(test_credential())
6990            .expect("spawn auth token refresh");
6991        client
6992            .nonce_recovery_inflight
6993            .store(true, Ordering::Release);
6994
6995        assert!(
6996            client
6997                .auth_refresh_handle
6998                .as_ref()
6999                .is_some_and(|handle| !handle.is_finished()),
7000        );
7001
7002        client.begin_session_shutdown();
7003        client
7004            .finish_session_shutdown()
7005            .await
7006            .expect("session shutdown");
7007
7008        assert!(client.auth_refresh_handle.is_none());
7009        assert!(!client.nonce_recovery_inflight.load(Ordering::Acquire));
7010    }
7011
7012    #[rstest]
7013    #[tokio::test]
7014    async fn session_shutdown_rejects_tasks_registered_during_abort() {
7015        struct SpawnOnDrop {
7016            pending_tasks: TaskSpawner,
7017            rejected: Arc<AtomicBool>,
7018            polled: Arc<AtomicBool>,
7019        }
7020
7021        impl Drop for SpawnOnDrop {
7022            fn drop(&mut self) {
7023                let polled = Arc::clone(&self.polled);
7024                let result = self.pending_tasks.spawn(async move {
7025                    polled.store(true, Ordering::Release);
7026                });
7027                self.rejected.store(result.is_err(), Ordering::Release);
7028            }
7029        }
7030
7031        let (mut client, _cache, _rx) = create_execution_client();
7032        let rejected = Arc::new(AtomicBool::new(false));
7033        let polled = Arc::new(AtomicBool::new(false));
7034        let pending_spawner = client.pending_tasks.spawner().expect("task spawner");
7035        let guard = SpawnOnDrop {
7036            pending_tasks: pending_spawner.clone(),
7037            rejected: Arc::clone(&rejected),
7038            polled: Arc::clone(&polled),
7039        };
7040
7041        pending_spawner
7042            .spawn(async move {
7043                let _guard = guard;
7044                std::future::pending::<()>().await;
7045            })
7046            .expect("parent task spawn");
7047
7048        client
7049            .finish_session_shutdown()
7050            .await
7051            .expect("session shutdown");
7052
7053        assert!(client.pending_tasks.is_empty());
7054        assert!(rejected.load(Ordering::Acquire));
7055        assert!(!polled.load(Ordering::Acquire));
7056    }
7057
7058    #[rstest]
7059    #[tokio::test]
7060    async fn session_shutdown_discards_pending_sendtx_before_epoch_reuse() {
7061        struct EnqueueOnDrop {
7062            dispatch: WsDispatchState,
7063            pending: Option<PendingSendTx>,
7064        }
7065
7066        impl Drop for EnqueueOnDrop {
7067            fn drop(&mut self) {
7068                self.dispatch
7069                    .enqueue_pending_sendtx(self.pending.take().expect("pending sendTx"));
7070            }
7071        }
7072
7073        let (mut client, _cache, _rx) = create_execution_client();
7074        let old_epoch = client.ws_client.connection_epoch();
7075        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
7076            connection_epoch: old_epoch,
7077            kind: PendingSendTxKind::Other,
7078            submitted_at: UnixNanos::default(),
7079            nonce: TEST_NEXT_NONCE,
7080            api_key_index: TEST_API_KEY_INDEX,
7081            tx_hash: "shutdown-pending".to_string(),
7082        });
7083        let guard = EnqueueOnDrop {
7084            dispatch: client.dispatch.clone(),
7085            pending: Some(PendingSendTx {
7086                connection_epoch: old_epoch,
7087                kind: PendingSendTxKind::Other,
7088                submitted_at: UnixNanos::default(),
7089                nonce: TEST_NEXT_NONCE + 1,
7090                api_key_index: TEST_API_KEY_INDEX,
7091                tx_hash: "shutdown-late".to_string(),
7092            }),
7093        };
7094
7095        client
7096            .pending_tasks
7097            .spawn(async move {
7098                let _guard = guard;
7099                std::future::pending::<()>().await;
7100            })
7101            .expect("pending sendTx producer spawn");
7102        client
7103            .ws_stream_handle
7104            .insert(get_runtime().spawn(std::future::pending()));
7105
7106        client.begin_session_shutdown();
7107        client
7108            .finish_session_shutdown()
7109            .await
7110            .expect("session shutdown");
7111
7112        assert_eq!(client.ws_client.connection_epoch(), old_epoch);
7113        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7114    }
7115
7116    #[rstest]
7117    fn replacing_ws_client_preserves_cached_instruments() {
7118        let (mut client, cache, _rx) = create_execution_client();
7119        let instrument_id = register_test_instrument(&client, &cache);
7120        let instrument = cache
7121            .borrow()
7122            .instrument(&instrument_id)
7123            .expect("instrument")
7124            .clone();
7125        client
7126            .ws_client
7127            .cache_instrument(TEST_MARKET_INDEX, instrument.clone());
7128
7129        let _old_ws_client = client.take_ws_client();
7130
7131        let cached_instruments = client.ws_client.instruments_cache();
7132        assert_eq!(
7133            cached_instruments
7134                .get(&TEST_MARKET_INDEX)
7135                .expect("cached instrument")
7136                .value(),
7137            &instrument,
7138        );
7139    }
7140
7141    #[tokio::test]
7142    async fn tx_send_sequencer_blocks_higher_nonce_until_lower_batch_releases() {
7143        let sequencer = TxSendSequencer::new();
7144        let mut lower_a =
7145            sequencer.reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
7146        let mut lower_b = sequencer.reserve(
7147            TEST_ACCOUNT_INDEX_I64,
7148            TEST_API_KEY_INDEX,
7149            TEST_NEXT_NONCE + 1,
7150        );
7151        let mut higher = sequencer.reserve(
7152            TEST_ACCOUNT_INDEX_I64,
7153            TEST_API_KEY_INDEX,
7154            TEST_NEXT_NONCE + 2,
7155        );
7156        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
7157
7158        let higher_task = tokio::spawn(async move {
7159            higher.wait_for_turn().await;
7160            sent_tx.send(higher.nonce).unwrap();
7161            higher.release();
7162        });
7163
7164        assert!(
7165            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
7166                .await
7167                .is_err(),
7168            "higher nonce must wait while the lower batch is pending",
7169        );
7170
7171        {
7172            let lower_reservations = [&lower_a, &lower_b];
7173            tokio::time::timeout(
7174                Duration::from_millis(50),
7175                wait_for_tx_send_reservations(&lower_reservations),
7176            )
7177            .await
7178            .expect("lower batch must already have the send turn");
7179        }
7180
7181        lower_a.release();
7182        lower_b.release();
7183
7184        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
7185            .await
7186            .expect("higher nonce must proceed after lower batch releases")
7187            .expect("send channel must stay open");
7188        higher_task.await.unwrap();
7189
7190        assert_eq!(
7191            sent,
7192            TEST_NEXT_NONCE + 2,
7193            "higher nonce must send after the lower batch releases",
7194        );
7195    }
7196
7197    #[tokio::test]
7198    async fn tx_send_reservation_drop_releases_lower_nonce() {
7199        let sequencer = TxSendSequencer::new();
7200        let lower = sequencer.reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
7201        let mut higher = sequencer.reserve(
7202            TEST_ACCOUNT_INDEX_I64,
7203            TEST_API_KEY_INDEX,
7204            TEST_NEXT_NONCE + 1,
7205        );
7206        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
7207
7208        let higher_task = tokio::spawn(async move {
7209            higher.wait_for_turn().await;
7210            sent_tx.send(higher.nonce).unwrap();
7211            higher.release();
7212        });
7213
7214        assert!(
7215            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
7216                .await
7217                .is_err(),
7218            "higher nonce must wait while the lower reservation is pending",
7219        );
7220
7221        drop(lower);
7222
7223        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
7224            .await
7225            .expect("higher nonce must proceed after lower reservation drops")
7226            .expect("send channel must stay open");
7227        higher_task.await.unwrap();
7228
7229        assert_eq!(
7230            sent,
7231            TEST_NEXT_NONCE + 1,
7232            "higher nonce must send after the lower reservation drops",
7233        );
7234    }
7235
7236    #[tokio::test]
7237    async fn tx_send_sequencer_keeps_nonce_streams_independent() {
7238        let sequencer = TxSendSequencer::new();
7239        let _other_account = sequencer.reserve(
7240            TEST_ACCOUNT_INDEX_I64 + 1,
7241            TEST_API_KEY_INDEX,
7242            TEST_NEXT_NONCE,
7243        );
7244        let _other_api_key = sequencer.reserve(
7245            TEST_ACCOUNT_INDEX_I64,
7246            TEST_API_KEY_INDEX + 1,
7247            TEST_NEXT_NONCE,
7248        );
7249        let mut current = sequencer.reserve(
7250            TEST_ACCOUNT_INDEX_I64,
7251            TEST_API_KEY_INDEX,
7252            TEST_NEXT_NONCE + 10,
7253        );
7254
7255        tokio::time::timeout(Duration::from_millis(50), current.wait_for_turn())
7256            .await
7257            .expect("lower nonces for other keys must not block this key");
7258        current.release();
7259    }
7260
7261    #[rstest]
7262    fn tx_dispatch_guard_rolls_back_nonce_and_cloid_when_armed() {
7263        let dispatch = WsDispatchState::new();
7264        let credential = test_credential();
7265        dispatch
7266            .nonce_manager
7267            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
7268
7269        let cloid = ClientOrderId::from("O-GUARD-ARMED");
7270        let client_order_index = dispatch.derive_client_order_index(&cloid);
7271        dispatch.register_cloid(client_order_index, cloid).unwrap();
7272        let nonce = dispatch
7273            .nonce_manager
7274            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
7275            .unwrap();
7276
7277        {
7278            let _guard = TxDispatchGuard::new(
7279                dispatch.clone(),
7280                &credential,
7281                Some(client_order_index),
7282                nonce,
7283            );
7284        }
7285
7286        assert_nonce_reusable(&dispatch);
7287        assert!(dispatch.cloid_map.get(&client_order_index).is_none());
7288    }
7289
7290    #[rstest]
7291    fn tx_dispatch_guard_rolls_back_nonce_without_cloid_when_armed() {
7292        let dispatch = WsDispatchState::new();
7293        let credential = test_credential();
7294        dispatch
7295            .nonce_manager
7296            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
7297        let nonce = dispatch
7298            .nonce_manager
7299            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
7300            .unwrap();
7301
7302        {
7303            let _guard = TxDispatchGuard::new(dispatch.clone(), &credential, None, nonce);
7304        }
7305
7306        assert_nonce_reusable(&dispatch);
7307        assert!(dispatch.cloid_map.is_empty());
7308    }
7309
7310    #[rstest]
7311    fn tx_dispatch_guard_preserves_nonce_and_cloid_when_disarmed() {
7312        let dispatch = WsDispatchState::new();
7313        let credential = test_credential();
7314        dispatch
7315            .nonce_manager
7316            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
7317
7318        let cloid = ClientOrderId::from("O-GUARD-DISARMED");
7319        let client_order_index = dispatch.derive_client_order_index(&cloid);
7320        dispatch.register_cloid(client_order_index, cloid).unwrap();
7321        let nonce = dispatch
7322            .nonce_manager
7323            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
7324            .unwrap();
7325
7326        {
7327            let mut guard = TxDispatchGuard::new(
7328                dispatch.clone(),
7329                &credential,
7330                Some(client_order_index),
7331                nonce,
7332            );
7333            guard.disarm();
7334        }
7335
7336        assert_eq!(
7337            dispatch
7338                .nonce_manager
7339                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
7340            Some(TEST_NEXT_NONCE),
7341        );
7342        assert_eq!(
7343            dispatch
7344                .cloid_map
7345                .get(&client_order_index)
7346                .map(|entry| *entry.value()),
7347            Some(cloid),
7348        );
7349    }
7350
7351    #[tokio::test]
7352    async fn submit_order_send_failure_emits_submitted_then_rejected_and_rolls_back() {
7353        let (client, cache, mut rx) = create_execution_client();
7354        let instrument_id = register_test_instrument(&client, &cache);
7355        let mut factory = test_order_factory();
7356        let order = test_limit_order(&mut factory, instrument_id, "O-SUBMIT-FAIL");
7357        let client_order_index = client
7358            .dispatch
7359            .derive_client_order_index(&order.client_order_id());
7360        cache_order(&cache, order.clone());
7361
7362        let command = SubmitOrder::from_order(
7363            &order,
7364            trader_id(),
7365            Some(client_id()),
7366            None,
7367            UUID4::new(),
7368            UnixNanos::default(),
7369        );
7370        client.submit_order(command).unwrap();
7371
7372        let submitted = recv_order_event(&mut rx).await;
7373        let rejected = recv_order_event(&mut rx).await;
7374
7375        match submitted {
7376            OrderEventAny::Submitted(event) => {
7377                assert_eq!(event.client_order_id, order.client_order_id());
7378                assert_eq!(event.instrument_id, instrument_id);
7379            }
7380            event => panic!("expected submitted event, was {event:?}"),
7381        }
7382
7383        match rejected {
7384            OrderEventAny::Rejected(event) => {
7385                assert_eq!(event.client_order_id, order.client_order_id());
7386                assert_eq!(event.instrument_id, instrument_id);
7387                assert!(
7388                    event
7389                        .reason
7390                        .as_str()
7391                        .contains("Lighter submit_order dispatch failed"),
7392                );
7393                assert!(event.reason.as_str().contains("handler unavailable"));
7394            }
7395            event => panic!("expected rejected event, was {event:?}"),
7396        }
7397
7398        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
7399        assert_nonce_reusable(&client.dispatch);
7400        assert_eq!(
7401            client.dispatch.pending_sendtx_len(),
7402            0,
7403            "local-send-failure must remove the pending entry by nonce",
7404        );
7405    }
7406
7407    #[tokio::test]
7408    async fn submit_order_unknown_send_outcome_retains_pending_state() {
7409        let (client, cache, mut rx) = create_execution_client();
7410        let instrument_id = register_test_instrument(&client, &cache);
7411        let mut factory = test_order_factory();
7412        let order = test_limit_order(&mut factory, instrument_id, "O-SUBMIT-UNKNOWN");
7413        let client_order_index = client
7414            .dispatch
7415            .derive_client_order_index(&order.client_order_id());
7416        cache_order(&cache, order.clone());
7417        client.ws_client.drop_next_send_tx_result_for_test().await;
7418
7419        client
7420            .submit_order(SubmitOrder::from_order(
7421                &order,
7422                trader_id(),
7423                Some(client_id()),
7424                None,
7425                UUID4::new(),
7426                UnixNanos::default(),
7427            ))
7428            .unwrap();
7429
7430        assert!(matches!(
7431            recv_order_event(&mut rx).await,
7432            OrderEventAny::Submitted(_)
7433        ));
7434        assert!(
7435            tokio::time::timeout(Duration::from_millis(50), rx.recv())
7436                .await
7437                .is_err(),
7438            "unknown outcome must not emit OrderRejected",
7439        );
7440        assert!(client.dispatch.cloid_map.contains_key(&client_order_index));
7441        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
7442        assert_eq!(
7443            client
7444                .dispatch
7445                .nonce_manager
7446                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
7447            Some(TEST_NEXT_NONCE),
7448        );
7449
7450        let stale = client.dispatch.drain_pending_sendtx(0);
7451        assert_eq!(stale.len(), 1);
7452        assert!(matches!(stale[0].kind, PendingSendTxKind::Create { .. }));
7453        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7454        assert!(client.dispatch.cloid_map.contains_key(&client_order_index));
7455        assert!(
7456            client
7457                .dispatch
7458                .order_identity(&order.client_order_id())
7459                .is_some(),
7460            "reconnect draining must retain identity for reconciliation",
7461        );
7462        assert_eq!(
7463            client
7464                .dispatch
7465                .nonce_manager
7466                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
7467            Some(TEST_NEXT_NONCE),
7468        );
7469    }
7470
7471    #[tokio::test]
7472    async fn submit_order_cloid_probe_exhaustion_denies_without_overwrite() {
7473        let (client, cache, mut rx) = create_execution_client();
7474        let instrument_id = register_test_instrument(&client, &cache);
7475        let mut factory = test_order_factory();
7476        let order = test_limit_order(&mut factory, instrument_id, "O-PROBE-EXHAUSTED");
7477        let cloid = order.client_order_id();
7478        let mut index = client.dispatch.derive_client_order_index(&cloid);
7479        let mut existing = Vec::new();
7480
7481        for attempt in 0..=crate::websocket::dispatch::CLOID_INDEX_PROBE_LIMIT {
7482            let existing_cloid = ClientOrderId::from(format!("EXISTING-{attempt}"));
7483            client.dispatch.cloid_map.insert(index, existing_cloid);
7484            existing.push((index, existing_cloid));
7485            index = if index == i64::from(i32::MAX) {
7486                0
7487            } else {
7488                index + 1
7489            };
7490        }
7491        cache_order(&cache, order.clone());
7492
7493        client
7494            .submit_order(SubmitOrder::from_order(
7495                &order,
7496                trader_id(),
7497                Some(client_id()),
7498                None,
7499                UUID4::new(),
7500                UnixNanos::default(),
7501            ))
7502            .unwrap();
7503
7504        let event = recv_order_event(&mut rx).await;
7505        assert!(matches!(event, OrderEventAny::Denied(_)));
7506        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7507        assert!(client.dispatch.order_identity(&cloid).is_none());
7508
7509        for (existing_index, existing_cloid) in existing {
7510            assert_eq!(
7511                client
7512                    .dispatch
7513                    .cloid_map
7514                    .get(&existing_index)
7515                    .map(|entry| *entry.value()),
7516                Some(existing_cloid),
7517            );
7518        }
7519    }
7520
7521    #[tokio::test]
7522    async fn submit_sell_order_send_failure_dispatches_and_rolls_back() {
7523        // Mirror of the buy-side test for OrderSide::Sell; covers the
7524        // `is_ask=true` branch of the CreateOrderTxInfo payload.
7525        let (client, cache, mut rx) = create_execution_client();
7526        let instrument_id = register_test_instrument(&client, &cache);
7527        let mut factory = test_order_factory();
7528        let order = test_limit_order_with(
7529            &mut factory,
7530            instrument_id,
7531            "O-SUBMIT-FAIL-SELL",
7532            OrderSide::Sell,
7533            TimeInForce::Gtc,
7534            None,
7535            false,
7536        );
7537        let client_order_index = client
7538            .dispatch
7539            .derive_client_order_index(&order.client_order_id());
7540        cache_order(&cache, order.clone());
7541
7542        let command = SubmitOrder::from_order(
7543            &order,
7544            trader_id(),
7545            Some(client_id()),
7546            None,
7547            UUID4::new(),
7548            UnixNanos::default(),
7549        );
7550        client.submit_order(command).unwrap();
7551
7552        let _submitted = recv_order_event(&mut rx).await;
7553        let rejected = recv_order_event(&mut rx).await;
7554
7555        match rejected {
7556            OrderEventAny::Rejected(event) => {
7557                assert_eq!(event.client_order_id, order.client_order_id());
7558                assert!(
7559                    event
7560                        .reason
7561                        .as_str()
7562                        .contains("Lighter submit_order dispatch failed"),
7563                );
7564            }
7565            event => panic!("expected rejected event, was {event:?}"),
7566        }
7567
7568        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
7569        assert_nonce_reusable(&client.dispatch);
7570    }
7571
7572    #[tokio::test]
7573    async fn submit_gtd_order_with_explicit_expiry_dispatches_and_rolls_back() {
7574        // Covers the GTD branch in `order_expiry_for`: an explicit
7575        // expire_time must propagate as venue millis through the dispatch
7576        // path. Asserts the order reaches the dispatch step (rejected here
7577        // by handler unavailability, not by the adapter validating GTD).
7578        let (client, cache, mut rx) = create_execution_client();
7579        let instrument_id = register_test_instrument(&client, &cache);
7580        let mut factory = test_order_factory();
7581        let expiry = UnixNanos::from(
7582            client
7583                .clock
7584                .get_time_ns()
7585                .as_u64()
7586                .saturating_add(10 * 60 * 1_000_000_000),
7587        );
7588        let order = test_limit_order_with(
7589            &mut factory,
7590            instrument_id,
7591            "O-SUBMIT-FAIL-GTD",
7592            OrderSide::Buy,
7593            TimeInForce::Gtd,
7594            Some(expiry),
7595            false,
7596        );
7597        let client_order_index = client
7598            .dispatch
7599            .derive_client_order_index(&order.client_order_id());
7600        cache_order(&cache, order.clone());
7601
7602        let command = SubmitOrder::from_order(
7603            &order,
7604            trader_id(),
7605            Some(client_id()),
7606            None,
7607            UUID4::new(),
7608            UnixNanos::default(),
7609        );
7610        client.submit_order(command).unwrap();
7611
7612        let _submitted = recv_order_event(&mut rx).await;
7613        let rejected = recv_order_event(&mut rx).await;
7614
7615        match rejected {
7616            OrderEventAny::Rejected(event) => {
7617                assert!(
7618                    event
7619                        .reason
7620                        .as_str()
7621                        .contains("Lighter submit_order dispatch failed"),
7622                );
7623            }
7624            event => panic!("expected rejected event, was {event:?}"),
7625        }
7626
7627        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
7628        assert_nonce_reusable(&client.dispatch);
7629    }
7630
7631    #[tokio::test]
7632    async fn submit_gtd_order_with_short_expiry_is_denied_before_nonce_allocation() {
7633        let (client, cache, mut rx) = create_execution_client();
7634        let instrument_id = register_test_instrument(&client, &cache);
7635        let mut factory = test_order_factory();
7636        let expiry = UnixNanos::from(
7637            client
7638                .clock
7639                .get_time_ns()
7640                .as_u64()
7641                .saturating_add(60 * 1_000_000_000),
7642        );
7643        let order = test_limit_order_with(
7644            &mut factory,
7645            instrument_id,
7646            "O-SHORT-GTD",
7647            OrderSide::Buy,
7648            TimeInForce::Gtd,
7649            Some(expiry),
7650            false,
7651        );
7652        cache_order(&cache, order.clone());
7653
7654        let command = SubmitOrder::from_order(
7655            &order,
7656            trader_id(),
7657            Some(client_id()),
7658            None,
7659            UUID4::new(),
7660            UnixNanos::default(),
7661        );
7662        client.submit_order(command).unwrap();
7663
7664        let denied = recv_order_event(&mut rx).await;
7665        match denied {
7666            OrderEventAny::Denied(event) => {
7667                assert!(event.reason.as_str().contains("at least 5 minutes"));
7668            }
7669            event => panic!("expected denied event, was {event:?}"),
7670        }
7671        assert_nonce_reusable(&client.dispatch);
7672    }
7673
7674    #[tokio::test]
7675    async fn submit_reduce_only_order_dispatches_and_rolls_back() {
7676        // Reduce-only is a venue constraint; this pins the adapter pass-through
7677        let (client, cache, mut rx) = create_execution_client();
7678        let instrument_id = register_test_instrument(&client, &cache);
7679        let mut factory = test_order_factory();
7680        let order = test_limit_order_with(
7681            &mut factory,
7682            instrument_id,
7683            "O-SUBMIT-FAIL-REDUCE",
7684            OrderSide::Sell,
7685            TimeInForce::Gtc,
7686            None,
7687            true,
7688        );
7689        assert!(order.is_reduce_only());
7690        let client_order_index = client
7691            .dispatch
7692            .derive_client_order_index(&order.client_order_id());
7693        cache_order(&cache, order.clone());
7694
7695        let command = SubmitOrder::from_order(
7696            &order,
7697            trader_id(),
7698            Some(client_id()),
7699            None,
7700            UUID4::new(),
7701            UnixNanos::default(),
7702        );
7703        client.submit_order(command).unwrap();
7704
7705        let _submitted = recv_order_event(&mut rx).await;
7706        let rejected = recv_order_event(&mut rx).await;
7707
7708        match rejected {
7709            OrderEventAny::Rejected(event) => {
7710                assert!(
7711                    event
7712                        .reason
7713                        .as_str()
7714                        .contains("Lighter submit_order dispatch failed"),
7715                );
7716            }
7717            event => panic!("expected rejected event, was {event:?}"),
7718        }
7719
7720        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
7721        assert_nonce_reusable(&client.dispatch);
7722    }
7723
7724    #[tokio::test]
7725    async fn submit_order_list_send_failure_emits_submitted_then_rejected_and_rolls_back() {
7726        let (client, cache, mut rx) = create_execution_client();
7727        let instrument_id = register_test_instrument(&client, &cache);
7728        let mut factory = test_order_factory();
7729        let order_a = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-A");
7730        let order_b = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-B");
7731        let index_a = client
7732            .dispatch
7733            .derive_client_order_index(&order_a.client_order_id());
7734        let index_b = client
7735            .dispatch
7736            .derive_client_order_index(&order_b.client_order_id());
7737        cache_order(&cache, order_a.clone());
7738        cache_order(&cache, order_b.clone());
7739
7740        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-FAIL");
7741        client.submit_order_list(command).unwrap();
7742
7743        for expected in [order_a.client_order_id(), order_b.client_order_id()] {
7744            match recv_order_event(&mut rx).await {
7745                OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, expected),
7746                other => panic!("expected Submitted, was {other:?}"),
7747            }
7748
7749            match recv_order_event(&mut rx).await {
7750                OrderEventAny::Rejected(e) => {
7751                    assert_eq!(e.client_order_id, expected);
7752                    assert!(
7753                        e.reason
7754                            .as_str()
7755                            .contains("Lighter submit_order dispatch failed"),
7756                    );
7757                }
7758                other => panic!("expected Rejected, was {other:?}"),
7759            }
7760        }
7761        assert!(client.dispatch.cloid_map.get(&index_a).is_none());
7762        assert!(client.dispatch.cloid_map.get(&index_b).is_none());
7763        assert_nonce_reusable(&client.dispatch);
7764        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7765    }
7766
7767    #[tokio::test]
7768    async fn submit_order_list_over_max_batch_size_denies_all_without_dispatch() {
7769        let (client, cache, mut rx) = create_execution_client();
7770        let instrument_id = register_test_instrument(&client, &cache);
7771        let mut factory = test_order_factory();
7772        let mut orders = Vec::new();
7773
7774        for i in 0..=LIGHTER_MAX_BATCH_TX {
7775            let order = test_limit_order(&mut factory, instrument_id, &format!("O-LIST-MAX-{i}"));
7776            cache_order(&cache, order.clone());
7777            orders.push(order);
7778        }
7779
7780        let command = submit_order_list_command(&orders, "OL-MAX");
7781        client.submit_order_list(command).unwrap();
7782
7783        for order in &orders {
7784            match recv_order_event(&mut rx).await {
7785                OrderEventAny::Denied(e) => {
7786                    assert_eq!(e.client_order_id, order.client_order_id());
7787                    assert!(
7788                        e.reason
7789                            .as_str()
7790                            .contains("order-list fanout supports at most 15 txs"),
7791                    );
7792                }
7793                other => panic!("expected Denied, was {other:?}"),
7794            }
7795        }
7796
7797        assert!(
7798            tokio::time::timeout(Duration::from_millis(50), rx.recv())
7799                .await
7800                .is_err(),
7801            "max-size denial must not emit extra events",
7802        );
7803        assert_nonce_reusable(&client.dispatch);
7804        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7805    }
7806
7807    #[tokio::test]
7808    async fn submit_order_list_denies_unsupported_order_and_dispatches_supported() {
7809        let (client, cache, mut rx) = create_execution_client();
7810        let instrument_id = register_test_instrument(&client, &cache);
7811        let mut factory = test_order_factory();
7812        let valid = test_limit_order(&mut factory, instrument_id, "O-LIST-VALID");
7813        let unsupported = factory.limit(
7814            instrument_id,
7815            OrderSide::Buy,
7816            Quantity::from("0.1000"),
7817            Price::from("2361.31"),
7818            Some(TimeInForce::Gtc),
7819            None,
7820            Some(false),
7821            Some(false),
7822            None,
7823            Some(Quantity::from("0.0500")),
7824            None,
7825            None,
7826            None,
7827            None,
7828            None,
7829            Some(ClientOrderId::from("O-LIST-ICEBERG")),
7830        );
7831        let unsupported_index = client
7832            .dispatch
7833            .derive_client_order_index(&unsupported.client_order_id());
7834        cache_order(&cache, valid.clone());
7835        cache_order(&cache, unsupported.clone());
7836
7837        let command =
7838            submit_order_list_command(&[unsupported.clone(), valid.clone()], "OL-PARTIAL");
7839        client.submit_order_list(command).unwrap();
7840
7841        match recv_order_event(&mut rx).await {
7842            OrderEventAny::Denied(e) => {
7843                assert_eq!(e.client_order_id, unsupported.client_order_id());
7844                assert!(e.reason.as_str().contains("display_qty"));
7845            }
7846            other => panic!("expected Denied, was {other:?}"),
7847        }
7848
7849        match recv_order_event(&mut rx).await {
7850            OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, valid.client_order_id()),
7851            other => panic!("expected Submitted, was {other:?}"),
7852        }
7853
7854        match recv_order_event(&mut rx).await {
7855            OrderEventAny::Rejected(e) => {
7856                assert_eq!(e.client_order_id, valid.client_order_id());
7857                assert!(
7858                    e.reason
7859                        .as_str()
7860                        .contains("Lighter submit_order dispatch failed"),
7861                );
7862            }
7863            other => panic!("expected Rejected, was {other:?}"),
7864        }
7865
7866        assert!(client.dispatch.cloid_map.get(&unsupported_index).is_none());
7867        assert_nonce_reusable(&client.dispatch);
7868    }
7869
7870    #[tokio::test]
7871    async fn submit_order_list_grouped_contingency_denies_all_without_dispatch() {
7872        let (client, cache, mut rx) = create_execution_client();
7873        let instrument_id = register_test_instrument(&client, &cache);
7874        let order_a_id = "O-LIST-OCO-A";
7875        let order_b_id = "O-LIST-OCO-B";
7876        let order_a = test_contingent_limit_order(instrument_id, order_a_id, "OL-OCO", order_b_id);
7877        let order_b = test_contingent_limit_order(instrument_id, order_b_id, "OL-OCO", order_a_id);
7878        cache_order(&cache, order_a.clone());
7879        cache_order(&cache, order_b.clone());
7880
7881        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-OCO");
7882        client.submit_order_list(command).unwrap();
7883
7884        for order in [&order_a, &order_b] {
7885            match recv_order_event(&mut rx).await {
7886                OrderEventAny::Denied(e) => {
7887                    assert_eq!(e.client_order_id, order.client_order_id());
7888                    assert!(e.reason.as_str().contains("supports only independent"));
7889                }
7890                other => panic!("expected Denied, was {other:?}"),
7891            }
7892        }
7893
7894        assert!(
7895            tokio::time::timeout(Duration::from_millis(50), rx.recv())
7896                .await
7897                .is_err(),
7898            "grouped list denial must not emit extra events",
7899        );
7900        assert_nonce_reusable(&client.dispatch);
7901        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
7902    }
7903
7904    #[tokio::test]
7905    async fn cancel_order_send_failure_emits_cancel_rejected_and_rolls_back() {
7906        let (client, cache, mut rx) = create_execution_client();
7907        let instrument_id = register_test_instrument(&client, &cache);
7908        let mut factory = test_order_factory();
7909        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-FAIL");
7910        let client_order_id = order.client_order_id();
7911        let venue_order_id = VenueOrderId::from("123");
7912        cache_pending_cancel_order(&cache, order, venue_order_id);
7913
7914        let command = CancelOrder::new(
7915            trader_id(),
7916            Some(client_id()),
7917            strategy_id(),
7918            instrument_id,
7919            client_order_id,
7920            Some(venue_order_id),
7921            UUID4::new(),
7922            UnixNanos::default(),
7923            None,
7924            None,
7925        );
7926        client.cancel_order(command).unwrap();
7927
7928        let rejected = recv_order_event(&mut rx).await;
7929
7930        match rejected {
7931            OrderEventAny::CancelRejected(event) => {
7932                assert_eq!(event.client_order_id, client_order_id);
7933                assert_eq!(event.instrument_id, instrument_id);
7934                assert_eq!(event.venue_order_id, Some(venue_order_id));
7935                assert!(
7936                    event
7937                        .reason
7938                        .as_str()
7939                        .contains("Lighter cancel_order dispatch failed"),
7940                );
7941                assert!(event.reason.as_str().contains("handler unavailable"));
7942            }
7943            event => panic!("expected cancel rejected event, was {event:?}"),
7944        }
7945
7946        assert_nonce_reusable(&client.dispatch);
7947        assert_eq!(
7948            client.dispatch.pending_sendtx_len(),
7949            0,
7950            "local-send-failure must remove the pending cancel entry",
7951        );
7952        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
7953    }
7954
7955    #[tokio::test]
7956    async fn cancel_order_unknown_send_outcome_retains_pending_state() {
7957        let (client, cache, mut rx) = create_execution_client();
7958        let instrument_id = register_test_instrument(&client, &cache);
7959        let mut factory = test_order_factory();
7960        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-UNKNOWN");
7961        let client_order_id = order.client_order_id();
7962        let venue_order_id = VenueOrderId::from("123");
7963        cache_pending_cancel_order(&cache, order, venue_order_id);
7964        client.ws_client.drop_next_send_tx_result_for_test().await;
7965
7966        client
7967            .cancel_order(CancelOrder::new(
7968                trader_id(),
7969                Some(client_id()),
7970                strategy_id(),
7971                instrument_id,
7972                client_order_id,
7973                Some(venue_order_id),
7974                UUID4::new(),
7975                UnixNanos::default(),
7976                None,
7977                None,
7978            ))
7979            .unwrap();
7980
7981        assert!(
7982            tokio::time::timeout(Duration::from_millis(50), rx.recv())
7983                .await
7984                .is_err(),
7985            "unknown outcome must not emit OrderCancelRejected",
7986        );
7987        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
7988        assert_eq!(
7989            client.dispatch.pending_order_action(&client_order_id),
7990            Some(PendingOrderAction::Cancel),
7991        );
7992        assert_eq!(
7993            client
7994                .dispatch
7995                .nonce_manager
7996                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
7997            Some(TEST_NEXT_NONCE),
7998        );
7999    }
8000
8001    #[tokio::test]
8002    async fn cancel_order_prepare_failure_emits_cancel_rejected_without_dispatch() {
8003        let (client, cache, mut rx) = create_execution_client();
8004        let instrument_id = register_test_instrument(&client, &cache);
8005        let mut factory = test_order_factory();
8006        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NO-VOI");
8007        let client_order_id = order.client_order_id();
8008        cache_pending_cancel_order(&cache, order, VenueOrderId::from("123"));
8009
8010        let command = CancelOrder::new(
8011            trader_id(),
8012            Some(client_id()),
8013            strategy_id(),
8014            instrument_id,
8015            client_order_id,
8016            None,
8017            UUID4::new(),
8018            UnixNanos::default(),
8019            None,
8020            None,
8021        );
8022        client.cancel_order(command).unwrap();
8023
8024        let rejected = recv_order_event(&mut rx).await;
8025        match rejected {
8026            OrderEventAny::CancelRejected(event) => {
8027                assert_eq!(event.client_order_id, client_order_id);
8028                assert_eq!(event.instrument_id, instrument_id);
8029                assert_eq!(event.venue_order_id, None);
8030                assert!(
8031                    event
8032                        .reason
8033                        .as_str()
8034                        .contains("Lighter cancel_order failed")
8035                );
8036                assert!(
8037                    event
8038                        .reason
8039                        .as_str()
8040                        .contains("venue order_id not yet known")
8041                );
8042            }
8043            event => panic!("expected cancel rejected event, was {event:?}"),
8044        }
8045
8046        assert_nonce_reusable(&client.dispatch);
8047        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8048        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
8049        assert!(
8050            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8051                .await
8052                .is_err(),
8053            "prepare failure must emit exactly one cancel rejection",
8054        );
8055    }
8056
8057    #[tokio::test]
8058    async fn cancel_order_prepare_failure_emits_cancel_rejected_when_order_uncached() {
8059        let (client, cache, mut rx) = create_execution_client();
8060        let instrument_id = register_test_instrument(&client, &cache);
8061        let client_order_id = ClientOrderId::from("O-CANCEL-NO-CACHE");
8062        let venue_order_id = VenueOrderId::from("123");
8063
8064        let command = CancelOrder::new(
8065            trader_id(),
8066            Some(client_id()),
8067            strategy_id(),
8068            instrument_id,
8069            client_order_id,
8070            Some(venue_order_id),
8071            UUID4::new(),
8072            UnixNanos::default(),
8073            None,
8074            None,
8075        );
8076        client.cancel_order(command).unwrap();
8077
8078        let rejected = recv_order_event(&mut rx).await;
8079        match rejected {
8080            OrderEventAny::CancelRejected(event) => {
8081                assert_eq!(event.client_order_id, client_order_id);
8082                assert_eq!(event.instrument_id, instrument_id);
8083                assert_eq!(event.venue_order_id, Some(venue_order_id));
8084                assert!(
8085                    event
8086                        .reason
8087                        .as_str()
8088                        .contains("Lighter cancel_order failed")
8089                );
8090                assert!(event.reason.as_str().contains("order not found in cache"));
8091            }
8092            event => panic!("expected cancel rejected event, was {event:?}"),
8093        }
8094
8095        assert_nonce_reusable(&client.dispatch);
8096        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8097        assert!(
8098            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8099                .await
8100                .is_err(),
8101            "prepare failure must emit exactly one cancel rejection",
8102        );
8103    }
8104
8105    #[tokio::test]
8106    async fn cancel_all_orders_prepare_failure_suppresses_cancel_rejected_for_open_order() {
8107        let (client, cache, mut rx) = create_execution_client();
8108        let instrument_id = register_test_instrument(&client, &cache);
8109        let mut factory = test_order_factory();
8110        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-ALL-NO-VOI");
8111        cache_accepted_order(&cache, order, VenueOrderId::from("123"), Some(client_id()));
8112
8113        let command = CancelAllOrders::new(
8114            trader_id(),
8115            Some(client_id()),
8116            strategy_id(),
8117            instrument_id,
8118            Some(OrderSide::Buy),
8119            UUID4::new(),
8120            UnixNanos::default(),
8121            None,
8122            None,
8123        );
8124        client.cancel_all_orders(command).unwrap();
8125
8126        assert_nonce_reusable(&client.dispatch);
8127        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8128        assert!(
8129            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8130                .await
8131                .is_err(),
8132            "cancel-all prepare failure must not emit an invalid cancel rejection",
8133        );
8134    }
8135
8136    #[tokio::test]
8137    async fn cancel_order_nonce_prepare_failure_emits_cancel_rejected() {
8138        let mut config = test_config();
8139        config.base_url_http = Some(spawn_next_nonce_server(100).await);
8140        let (client, cache, mut rx) = create_execution_client_with_config(config);
8141        let instrument_id = register_test_instrument(&client, &cache);
8142        let mut factory = test_order_factory();
8143        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NONCE-FAIL");
8144        let client_order_id = order.client_order_id();
8145        let venue_order_id = VenueOrderId::from("123");
8146        cache_pending_cancel_order(&cache, order, venue_order_id);
8147
8148        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8149        for _ in 0..window {
8150            client
8151                .dispatch
8152                .nonce_manager
8153                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8154                .unwrap();
8155        }
8156
8157        let command = CancelOrder::new(
8158            trader_id(),
8159            Some(client_id()),
8160            strategy_id(),
8161            instrument_id,
8162            client_order_id,
8163            Some(venue_order_id),
8164            UUID4::new(),
8165            UnixNanos::default(),
8166            None,
8167            None,
8168        );
8169        client.cancel_order(command).unwrap();
8170
8171        let rejected = recv_order_event(&mut rx).await;
8172        match rejected {
8173            OrderEventAny::CancelRejected(event) => {
8174                assert_eq!(event.client_order_id, client_order_id);
8175                assert_eq!(event.venue_order_id, Some(venue_order_id));
8176                assert!(
8177                    event
8178                        .reason
8179                        .as_str()
8180                        .contains("failed to allocate Lighter nonce"),
8181                );
8182                assert!(event.reason.as_str().contains("skip-window exhausted"));
8183            }
8184            event => panic!("expected cancel rejected event, was {event:?}"),
8185        }
8186
8187        wait_for_spawned_tasks(&client).await;
8188        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8189        assert_eq!(
8190            client
8191                .dispatch
8192                .nonce_manager
8193                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8194                .unwrap(),
8195            100,
8196        );
8197    }
8198
8199    #[tokio::test]
8200    async fn batch_cancel_orders_send_failure_emits_rejected_per_cancel_and_rolls_back() {
8201        let (client, cache, mut rx) = create_execution_client();
8202        let instrument_id = register_test_instrument(&client, &cache);
8203        let mut factory = test_order_factory();
8204        let cancels = ["O-BATCH-CANCEL-A", "O-BATCH-CANCEL-B"]
8205            .iter()
8206            .enumerate()
8207            .map(|(i, id)| {
8208                let order = test_limit_order(&mut factory, instrument_id, id);
8209                let client_order_id = order.client_order_id();
8210                let venue_order_id = VenueOrderId::from(format!("{}", 123 + i).as_str());
8211                cache_pending_cancel_order(&cache, order, venue_order_id);
8212
8213                CancelOrder::new(
8214                    trader_id(),
8215                    Some(client_id()),
8216                    strategy_id(),
8217                    instrument_id,
8218                    client_order_id,
8219                    Some(venue_order_id),
8220                    UUID4::new(),
8221                    UnixNanos::default(),
8222                    None,
8223                    None,
8224                )
8225            })
8226            .collect::<Vec<_>>();
8227
8228        let command = BatchCancelOrders::new(
8229            trader_id(),
8230            Some(client_id()),
8231            strategy_id(),
8232            instrument_id,
8233            cancels.clone(),
8234            UUID4::new(),
8235            UnixNanos::default(),
8236            None,
8237            None,
8238        );
8239        client.batch_cancel_orders(command).unwrap();
8240
8241        let first = recv_order_event(&mut rx).await;
8242        let second = recv_order_event(&mut rx).await;
8243        let rejected_ids = [first, second].map(|event| match event {
8244            OrderEventAny::CancelRejected(e) => {
8245                assert!(
8246                    e.reason
8247                        .as_str()
8248                        .contains("Lighter cancel_order dispatch failed"),
8249                );
8250                e.client_order_id
8251            }
8252            other => panic!("expected CancelRejected, was {other:?}"),
8253        });
8254
8255        for cancel in cancels {
8256            assert!(rejected_ids.contains(&cancel.client_order_id));
8257            assert_eq!(
8258                client
8259                    .dispatch
8260                    .pending_order_action(&cancel.client_order_id),
8261                None,
8262            );
8263        }
8264        assert_nonce_reusable(&client.dispatch);
8265        assert_eq!(
8266            client.dispatch.pending_sendtx_len(),
8267            0,
8268            "local-send-failure must remove batch cancel pending entries",
8269        );
8270    }
8271
8272    #[tokio::test]
8273    async fn batch_cancel_orders_over_max_batch_size_rejects_each_cancel_without_dispatch() {
8274        let (client, cache, mut rx) = create_execution_client();
8275        let instrument_id = register_test_instrument(&client, &cache);
8276        let cancels = (0..=LIGHTER_MAX_BATCH_TX)
8277            .map(|i| {
8278                CancelOrder::new(
8279                    trader_id(),
8280                    Some(client_id()),
8281                    strategy_id(),
8282                    instrument_id,
8283                    ClientOrderId::from(format!("O-BATCH-CANCEL-MAX-{i}").as_str()),
8284                    Some(VenueOrderId::from(format!("{}", 1_000 + i).as_str())),
8285                    UUID4::new(),
8286                    UnixNanos::default(),
8287                    None,
8288                    None,
8289                )
8290            })
8291            .collect::<Vec<_>>();
8292
8293        let command = BatchCancelOrders::new(
8294            trader_id(),
8295            Some(client_id()),
8296            strategy_id(),
8297            instrument_id,
8298            cancels.clone(),
8299            UUID4::new(),
8300            UnixNanos::default(),
8301            None,
8302            None,
8303        );
8304        client.batch_cancel_orders(command).unwrap();
8305
8306        for cancel in &cancels {
8307            match recv_order_event(&mut rx).await {
8308                OrderEventAny::CancelRejected(e) => {
8309                    assert_eq!(e.client_order_id, cancel.client_order_id);
8310                    assert!(
8311                        e.reason
8312                            .as_str()
8313                            .contains("batch-cancel fanout supports at most 15 txs"),
8314                    );
8315                }
8316                other => panic!("expected CancelRejected, was {other:?}"),
8317            }
8318            assert_eq!(
8319                client
8320                    .dispatch
8321                    .pending_order_action(&cancel.client_order_id),
8322                None,
8323            );
8324        }
8325        assert_nonce_reusable(&client.dispatch);
8326        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8327    }
8328
8329    #[rstest]
8330    fn cancel_order_from_cancel_all_preserves_tracing_ids() {
8331        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
8332        let client_order_id = ClientOrderId::from("O-CANCEL-ALL-CHILD");
8333        let command_id = UUID4::new();
8334        let correlation_id = UUID4::new();
8335        let causation_id = UUID4::new();
8336        let ts_init = UnixNanos::default();
8337        let mut cmd = CancelAllOrders::new(
8338            trader_id(),
8339            Some(client_id()),
8340            strategy_id(),
8341            instrument_id,
8342            Some(OrderSide::Buy),
8343            command_id,
8344            ts_init,
8345            None,
8346            Some(correlation_id),
8347        );
8348        cmd.causation_id = Some(causation_id);
8349
8350        let order_cmd = cancel_order_from_cancel_all(&cmd, client_order_id);
8351
8352        assert_eq!(order_cmd.trader_id, trader_id());
8353        assert_eq!(order_cmd.client_id, Some(client_id()));
8354        assert_eq!(order_cmd.strategy_id, strategy_id());
8355        assert_eq!(order_cmd.instrument_id, instrument_id);
8356        assert_eq!(order_cmd.client_order_id, client_order_id);
8357        assert_eq!(order_cmd.venue_order_id, None);
8358        assert_eq!(order_cmd.command_id, command_id);
8359        assert_eq!(order_cmd.ts_init, ts_init);
8360        assert_eq!(order_cmd.params, None);
8361        assert_eq!(order_cmd.correlation_id, Some(correlation_id));
8362        assert_eq!(order_cmd.causation_id, Some(causation_id));
8363    }
8364
8365    #[tokio::test]
8366    async fn modify_order_send_failure_emits_modify_rejected_and_rolls_back() {
8367        let (client, cache, mut rx) = create_execution_client();
8368        let instrument_id = register_test_instrument(&client, &cache);
8369        let mut factory = test_order_factory();
8370        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-FAIL");
8371        let client_order_id = order.client_order_id();
8372        let venue_order_id = VenueOrderId::from("123");
8373        cache_order(&cache, order);
8374
8375        let command = ModifyOrder::new(
8376            trader_id(),
8377            Some(client_id()),
8378            strategy_id(),
8379            instrument_id,
8380            client_order_id,
8381            Some(venue_order_id),
8382            Some(Quantity::from("0.2000")),
8383            Some(Price::from("2362.00")),
8384            None,
8385            UUID4::new(),
8386            UnixNanos::default(),
8387            None,
8388            None,
8389        );
8390        client.modify_order(command).unwrap();
8391
8392        let rejected = recv_order_event(&mut rx).await;
8393
8394        match rejected {
8395            OrderEventAny::ModifyRejected(event) => {
8396                assert_eq!(event.client_order_id, client_order_id);
8397                assert_eq!(event.instrument_id, instrument_id);
8398                assert_eq!(event.venue_order_id, Some(venue_order_id));
8399                assert!(
8400                    event
8401                        .reason
8402                        .as_str()
8403                        .contains("Lighter modify_order dispatch failed"),
8404                );
8405                assert!(event.reason.as_str().contains("handler unavailable"));
8406            }
8407            event => panic!("expected modify rejected event, was {event:?}"),
8408        }
8409
8410        assert_nonce_reusable(&client.dispatch);
8411        assert_eq!(
8412            client.dispatch.pending_sendtx_len(),
8413            0,
8414            "local-send-failure must remove the pending modify entry",
8415        );
8416        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
8417    }
8418
8419    #[tokio::test]
8420    async fn modify_order_unknown_send_outcome_retains_pending_state() {
8421        let (client, cache, mut rx) = create_execution_client();
8422        let instrument_id = register_test_instrument(&client, &cache);
8423        let mut factory = test_order_factory();
8424        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-UNKNOWN");
8425        let client_order_id = order.client_order_id();
8426        let venue_order_id = VenueOrderId::from("123");
8427        cache_order(&cache, order);
8428        client.ws_client.drop_next_send_tx_result_for_test().await;
8429
8430        client
8431            .modify_order(ModifyOrder::new(
8432                trader_id(),
8433                Some(client_id()),
8434                strategy_id(),
8435                instrument_id,
8436                client_order_id,
8437                Some(venue_order_id),
8438                Some(Quantity::from("0.2000")),
8439                Some(Price::from("2362.00")),
8440                None,
8441                UUID4::new(),
8442                UnixNanos::default(),
8443                None,
8444                None,
8445            ))
8446            .unwrap();
8447
8448        assert!(
8449            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8450                .await
8451                .is_err(),
8452            "unknown outcome must not emit OrderModifyRejected",
8453        );
8454        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
8455        assert_eq!(
8456            client.dispatch.pending_order_action(&client_order_id),
8457            Some(PendingOrderAction::Modify),
8458        );
8459        assert_eq!(
8460            client
8461                .dispatch
8462                .nonce_manager
8463                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
8464            Some(TEST_NEXT_NONCE),
8465        );
8466    }
8467
8468    #[tokio::test]
8469    async fn modify_order_prepare_failure_emits_modify_rejected_without_dispatch() {
8470        let (client, cache, mut rx) = create_execution_client();
8471        let instrument_id = register_test_instrument(&client, &cache);
8472        let client_order_id = ClientOrderId::from("O-MODIFY-NO-CACHE");
8473        let venue_order_id = VenueOrderId::from("123");
8474
8475        let command = ModifyOrder::new(
8476            trader_id(),
8477            Some(client_id()),
8478            strategy_id(),
8479            instrument_id,
8480            client_order_id,
8481            Some(venue_order_id),
8482            Some(Quantity::from("0.2000")),
8483            Some(Price::from("2362.00")),
8484            None,
8485            UUID4::new(),
8486            UnixNanos::default(),
8487            None,
8488            None,
8489        );
8490        client.modify_order(command).unwrap();
8491
8492        let rejected = recv_order_event(&mut rx).await;
8493        match rejected {
8494            OrderEventAny::ModifyRejected(event) => {
8495                assert_eq!(event.client_order_id, client_order_id);
8496                assert_eq!(event.instrument_id, instrument_id);
8497                assert_eq!(event.venue_order_id, Some(venue_order_id));
8498                assert!(
8499                    event
8500                        .reason
8501                        .as_str()
8502                        .contains("Lighter modify_order failed")
8503                );
8504                assert!(event.reason.as_str().contains("order not found in cache"));
8505            }
8506            event => panic!("expected modify rejected event, was {event:?}"),
8507        }
8508
8509        assert_nonce_reusable(&client.dispatch);
8510        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8511        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
8512        assert!(
8513            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8514                .await
8515                .is_err(),
8516            "prepare failure must emit exactly one modify rejection",
8517        );
8518    }
8519
8520    #[tokio::test]
8521    async fn modify_order_prepare_failure_emits_modify_rejected_when_instrument_uncached() {
8522        let (client, cache, mut rx) = create_execution_client();
8523        let instrument_id =
8524            client
8525                .registry
8526                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
8527        let mut factory = test_order_factory();
8528        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NO-INSTRUMENT");
8529        let client_order_id = order.client_order_id();
8530        let venue_order_id = VenueOrderId::from("123");
8531        cache_order(&cache, order);
8532
8533        let command = ModifyOrder::new(
8534            trader_id(),
8535            Some(client_id()),
8536            strategy_id(),
8537            instrument_id,
8538            client_order_id,
8539            Some(venue_order_id),
8540            Some(Quantity::from("0.2000")),
8541            Some(Price::from("2362.00")),
8542            None,
8543            UUID4::new(),
8544            UnixNanos::default(),
8545            None,
8546            None,
8547        );
8548        client.modify_order(command).unwrap();
8549
8550        let rejected = recv_order_event(&mut rx).await;
8551        match rejected {
8552            OrderEventAny::ModifyRejected(event) => {
8553                assert_eq!(event.client_order_id, client_order_id);
8554                assert_eq!(event.instrument_id, instrument_id);
8555                assert_eq!(event.venue_order_id, Some(venue_order_id));
8556                assert!(
8557                    event
8558                        .reason
8559                        .as_str()
8560                        .contains("Lighter modify_order failed")
8561                );
8562                assert!(
8563                    event
8564                        .reason
8565                        .as_str()
8566                        .contains("instrument not found in cache")
8567                );
8568            }
8569            event => panic!("expected modify rejected event, was {event:?}"),
8570        }
8571
8572        assert_nonce_reusable(&client.dispatch);
8573        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8574        assert!(
8575            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8576                .await
8577                .is_err(),
8578            "prepare failure must emit exactly one modify rejection",
8579        );
8580    }
8581
8582    #[tokio::test]
8583    async fn modify_stop_market_derives_price_from_trigger_without_explicit_price() {
8584        // A trigger-only STOP_MARKET carries no limit price; modifying its trigger
8585        // must derive the wire cap from the trigger rather than trip the price
8586        // guard. Prepare succeeds, so the only failure here is the test harness's
8587        // missing WS send handler.
8588        let (client, cache, mut rx) = create_execution_client();
8589        let instrument_id = register_test_instrument(&client, &cache);
8590        let mut factory = test_order_factory();
8591        let order = factory.stop_market(
8592            instrument_id,
8593            OrderSide::Sell,
8594            Quantity::from("0.1000"),
8595            Price::from("2300.00"), // trigger
8596            None,
8597            Some(TimeInForce::Gtc),
8598            None,
8599            Some(false),
8600            Some(false),
8601            None,
8602            None,
8603            None,
8604            None,
8605            None,
8606            None,
8607            Some(ClientOrderId::from("O-MODIFY-STOP-MARKET")),
8608        );
8609        let client_order_id = order.client_order_id();
8610        let venue_order_id = VenueOrderId::from("123");
8611        cache_order(&cache, order);
8612
8613        let command = ModifyOrder::new(
8614            trader_id(),
8615            Some(client_id()),
8616            strategy_id(),
8617            instrument_id,
8618            client_order_id,
8619            Some(venue_order_id),
8620            None,
8621            None,
8622            Some(Price::from("2310.00")),
8623            UUID4::new(),
8624            UnixNanos::default(),
8625            None,
8626            None,
8627        );
8628        client.modify_order(command).unwrap();
8629
8630        let rejected = recv_order_event(&mut rx).await;
8631        match rejected {
8632            OrderEventAny::ModifyRejected(event) => {
8633                assert!(
8634                    !event.reason.as_str().contains("requires a price"),
8635                    "trigger-only stop modify must not trip the price guard, was: {}",
8636                    event.reason,
8637                );
8638                assert!(
8639                    event.reason.as_str().contains("dispatch failed"),
8640                    "expected send-stage failure after a successful prepare, was: {}",
8641                    event.reason,
8642                );
8643            }
8644            event => panic!("expected modify rejected event, was {event:?}"),
8645        }
8646        assert_nonce_reusable(&client.dispatch);
8647    }
8648
8649    #[tokio::test]
8650    async fn modify_order_rejects_sub_tick_quantity_before_nonce_allocation() {
8651        let (client, cache, mut rx) = create_execution_client();
8652        let instrument_id = register_test_instrument(&client, &cache);
8653        let mut factory = test_order_factory();
8654        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-SUB-TICK");
8655        let client_order_id = order.client_order_id();
8656        cache_order(&cache, order);
8657
8658        client
8659            .modify_order(ModifyOrder::new(
8660                trader_id(),
8661                Some(client_id()),
8662                strategy_id(),
8663                instrument_id,
8664                client_order_id,
8665                Some(VenueOrderId::from("123")),
8666                Some(Quantity::from("0.00001")),
8667                Some(Price::from("2362.00")),
8668                None,
8669                UUID4::new(),
8670                UnixNanos::default(),
8671                None,
8672                None,
8673            ))
8674            .unwrap();
8675
8676        assert_modify_rejected_reason(&mut rx, "rounds to 0 ticks").await;
8677        assert_nonce_reusable(&client.dispatch);
8678    }
8679
8680    #[tokio::test]
8681    async fn modify_order_rejects_below_min_notional_before_nonce_allocation() {
8682        let (client, cache, mut rx) = create_execution_client();
8683        let instrument_id = register_test_instrument(&client, &cache);
8684        let mut factory = test_order_factory();
8685        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-MIN-NOTIONAL");
8686        let client_order_id = order.client_order_id();
8687        cache_order(&cache, order);
8688
8689        client
8690            .modify_order(ModifyOrder::new(
8691                trader_id(),
8692                Some(client_id()),
8693                strategy_id(),
8694                instrument_id,
8695                client_order_id,
8696                Some(VenueOrderId::from("123")),
8697                Some(Quantity::from("0.0001")),
8698                Some(Price::from("2362.00")),
8699                None,
8700                UUID4::new(),
8701                UnixNanos::default(),
8702                None,
8703                None,
8704            ))
8705            .unwrap();
8706
8707        assert_modify_rejected_reason(&mut rx, "below Lighter min_quote_amount").await;
8708        assert_nonce_reusable(&client.dispatch);
8709    }
8710
8711    #[tokio::test]
8712    async fn modify_conditional_order_rejects_sub_tick_trigger_before_nonce_allocation() {
8713        let (client, cache, mut rx) = create_execution_client();
8714        let instrument_id = register_test_instrument(&client, &cache);
8715        let mut factory = test_order_factory();
8716        let order = factory.stop_market(
8717            instrument_id,
8718            OrderSide::Sell,
8719            Quantity::from("0.1000"),
8720            Price::from("2300.00"),
8721            None,
8722            Some(TimeInForce::Gtc),
8723            None,
8724            Some(false),
8725            Some(false),
8726            None,
8727            None,
8728            None,
8729            None,
8730            None,
8731            None,
8732            Some(ClientOrderId::from("O-MODIFY-SUB-TICK-TRIGGER")),
8733        );
8734        let client_order_id = order.client_order_id();
8735        cache_order(&cache, order);
8736
8737        client
8738            .modify_order(ModifyOrder::new(
8739                trader_id(),
8740                Some(client_id()),
8741                strategy_id(),
8742                instrument_id,
8743                client_order_id,
8744                Some(VenueOrderId::from("123")),
8745                None,
8746                None,
8747                Some(Price::from("0.001")),
8748                UUID4::new(),
8749                UnixNanos::default(),
8750                None,
8751                None,
8752            ))
8753            .unwrap();
8754
8755        assert_modify_rejected_reason(&mut rx, "rounds to 0 ticks").await;
8756        assert_nonce_reusable(&client.dispatch);
8757    }
8758
8759    #[tokio::test]
8760    async fn modify_order_nonce_prepare_failure_emits_modify_rejected() {
8761        let mut config = test_config();
8762        config.base_url_http = Some(spawn_next_nonce_server(101).await);
8763        let (client, cache, mut rx) = create_execution_client_with_config(config);
8764        let instrument_id = register_test_instrument(&client, &cache);
8765        let mut factory = test_order_factory();
8766        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NONCE-FAIL");
8767        let client_order_id = order.client_order_id();
8768        let venue_order_id = VenueOrderId::from("123");
8769        cache_order(&cache, order);
8770
8771        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8772        for _ in 0..window {
8773            client
8774                .dispatch
8775                .nonce_manager
8776                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8777                .unwrap();
8778        }
8779
8780        let command = ModifyOrder::new(
8781            trader_id(),
8782            Some(client_id()),
8783            strategy_id(),
8784            instrument_id,
8785            client_order_id,
8786            Some(venue_order_id),
8787            Some(Quantity::from("0.2000")),
8788            Some(Price::from("2362.00")),
8789            None,
8790            UUID4::new(),
8791            UnixNanos::default(),
8792            None,
8793            None,
8794        );
8795        client.modify_order(command).unwrap();
8796
8797        let rejected = recv_order_event(&mut rx).await;
8798        match rejected {
8799            OrderEventAny::ModifyRejected(event) => {
8800                assert_eq!(event.client_order_id, client_order_id);
8801                assert_eq!(event.venue_order_id, Some(venue_order_id));
8802                assert!(
8803                    event
8804                        .reason
8805                        .as_str()
8806                        .contains("failed to allocate Lighter nonce"),
8807                );
8808                assert!(event.reason.as_str().contains("skip-window exhausted"));
8809            }
8810            event => panic!("expected modify rejected event, was {event:?}"),
8811        }
8812
8813        wait_for_spawned_tasks(&client).await;
8814        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8815        assert_eq!(
8816            client
8817                .dispatch
8818                .nonce_manager
8819                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8820                .unwrap(),
8821            101,
8822        );
8823    }
8824
8825    #[tokio::test]
8826    async fn update_leverage_requires_credentials() {
8827        let (mut client, _cache, _rx) = create_execution_client();
8828        client.credential = None;
8829        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
8830
8831        let err = client
8832            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
8833            .unwrap_err();
8834
8835        assert!(
8836            err.to_string()
8837                .contains("cannot update leverage without credentials"),
8838        );
8839    }
8840
8841    #[tokio::test]
8842    async fn update_leverage_requires_registered_instrument() {
8843        let (client, _cache, _rx) = create_execution_client();
8844        let unknown = InstrumentId::from("DOGE-PERP.LIGHTER");
8845
8846        let err = client
8847            .update_leverage(unknown, 500, LighterPositionMarginMode::Isolated)
8848            .unwrap_err();
8849
8850        assert!(
8851            err.to_string()
8852                .contains("no Lighter market_index registered")
8853        );
8854        // Pin that nonce was not burned on the rejected path: instrument
8855        // lookup must happen before `build_tx_context` allocates a nonce.
8856        assert_nonce_reusable(&client.dispatch);
8857    }
8858
8859    #[tokio::test]
8860    async fn update_leverage_dispatches_and_rolls_back_on_send_failure() {
8861        let (client, cache, _rx) = create_execution_client();
8862        let instrument_id = register_test_instrument(&client, &cache);
8863
8864        client
8865            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
8866            .unwrap();
8867
8868        wait_for_spawned_tasks(&client).await;
8869        assert_nonce_reusable(&client.dispatch);
8870    }
8871
8872    #[tokio::test]
8873    async fn update_leverage_unknown_send_outcome_retains_pending_nonce() {
8874        let (client, cache, _rx) = create_execution_client();
8875        let instrument_id = register_test_instrument(&client, &cache);
8876        client.ws_client.drop_next_send_tx_result_for_test().await;
8877
8878        client
8879            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
8880            .unwrap();
8881
8882        wait_for_spawned_tasks(&client).await;
8883        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
8884        assert_eq!(
8885            client
8886                .dispatch
8887                .nonce_manager
8888                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
8889            Some(TEST_NEXT_NONCE),
8890        );
8891    }
8892
8893    #[tokio::test]
8894    async fn update_leverage_rejects_zero_margin_fraction() {
8895        let (client, cache, _rx) = create_execution_client();
8896        let instrument_id = register_test_instrument(&client, &cache);
8897
8898        let err = client
8899            .update_leverage(instrument_id, 0, LighterPositionMarginMode::Cross)
8900            .unwrap_err();
8901        assert!(err.to_string().contains("must be in 1..=10_000"));
8902    }
8903
8904    #[tokio::test]
8905    async fn update_leverage_rejects_above_margin_fraction_tick() {
8906        let (client, cache, _rx) = create_execution_client();
8907        let instrument_id = register_test_instrument(&client, &cache);
8908
8909        let err = client
8910            .update_leverage(instrument_id, 10_001, LighterPositionMarginMode::Cross)
8911            .unwrap_err();
8912        assert!(err.to_string().contains("must be in 1..=10_000"));
8913    }
8914
8915    #[tokio::test]
8916    async fn update_leverage_accepts_minimum_margin_fraction() {
8917        // Pin the inclusive lower bound of the venue's `MarginFractionTick`
8918        // range. An exclusive `(1.., ...)` range check would fail this case.
8919        let (client, cache, _rx) = create_execution_client();
8920        let instrument_id = register_test_instrument(&client, &cache);
8921
8922        client
8923            .update_leverage(instrument_id, 1, LighterPositionMarginMode::Cross)
8924            .unwrap();
8925
8926        wait_for_spawned_tasks(&client).await;
8927        assert_nonce_reusable(&client.dispatch);
8928    }
8929
8930    #[tokio::test]
8931    async fn update_leverage_accepts_maximum_margin_fraction() {
8932        // Pin the inclusive upper bound of the venue's `MarginFractionTick`
8933        // range. An exclusive `(..10_000)` range check would fail this case.
8934        let (client, cache, _rx) = create_execution_client();
8935        let instrument_id = register_test_instrument(&client, &cache);
8936
8937        client
8938            .update_leverage(instrument_id, 10_000, LighterPositionMarginMode::Isolated)
8939            .unwrap();
8940
8941        wait_for_spawned_tasks(&client).await;
8942        assert_nonce_reusable(&client.dispatch);
8943    }
8944
8945    async fn wait_for_spawned_tasks(client: &LighterExecutionClient) {
8946        wait_until_async(
8947            || async { client.pending_tasks_all_finished() },
8948            Duration::from_secs(2),
8949        )
8950        .await;
8951    }
8952
8953    fn mark_all_streams_ready(client: &LighterExecutionClient) {
8954        let ready = &client.dispatch.account_streams_ready;
8955        ready.mark_orders();
8956        ready.mark_trades();
8957        ready.mark_positions();
8958        ready.mark_assets();
8959        ready.mark_user_stats();
8960    }
8961
8962    #[tokio::test]
8963    async fn await_account_streams_ready_times_out_when_no_frame_arrives() {
8964        // Drives the timeout branch `connect()` uses to tear the WS down
8965        // when at least one account stream has not delivered a first frame.
8966        let (client, _cache, _rx) = create_execution_client();
8967
8968        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
8969
8970        assert!(
8971            err.to_string().contains("Timeout")
8972                && err.to_string().contains("Lighter account streams"),
8973            "unexpected error message, was {err}",
8974        );
8975    }
8976
8977    #[tokio::test]
8978    async fn await_account_streams_ready_returns_when_all_streams_marked() {
8979        let (client, _cache, _rx) = create_execution_client();
8980        mark_all_streams_ready(&client);
8981
8982        client.await_account_streams_ready(0.05).await.unwrap();
8983    }
8984
8985    #[tokio::test]
8986    async fn await_account_streams_ready_returns_when_streams_arrive_mid_wait() {
8987        // Pins that the Notify-based wait wakes promptly when frames land
8988        // after the wait has started.
8989        let (client, _cache, _rx) = create_execution_client();
8990        let ready = Arc::clone(&client.dispatch.account_streams_ready);
8991
8992        let wait = client.await_account_streams_ready(1.0);
8993        let seed = async move {
8994            tokio::time::sleep(Duration::from_millis(20)).await;
8995            ready.mark_orders();
8996            ready.mark_trades();
8997            ready.mark_positions();
8998            ready.mark_assets();
8999            ready.mark_user_stats();
9000        };
9001
9002        let (result, ()) = tokio::join!(wait, seed);
9003        result.unwrap();
9004    }
9005
9006    #[tokio::test]
9007    async fn await_account_streams_ready_times_out_with_partial_marks() {
9008        // Three out of four streams marked must still time out: strict
9009        // await means every account stream has to deliver before connect
9010        // unblocks.
9011        let (client, _cache, _rx) = create_execution_client();
9012        let ready = &client.dispatch.account_streams_ready;
9013        ready.mark_orders();
9014        ready.mark_trades();
9015        ready.mark_positions();
9016
9017        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
9018        assert!(
9019            err.to_string().contains("assets"),
9020            "pending list should call out the missing stream, was {err}",
9021        );
9022    }
9023
9024    #[tokio::test]
9025    async fn await_account_streams_ready_after_reset_requires_new_marks() {
9026        // Pins the connect-retry contract: marks from a prior session
9027        // must not satisfy a fresh await once `reset()` has cleared the
9028        // gate. A regression that drops the reset() call from connect()
9029        // would let a retried session return immediately with stale flags.
9030        let (client, _cache, _rx) = create_execution_client();
9031        mark_all_streams_ready(&client);
9032        client.await_account_streams_ready(0.05).await.unwrap();
9033
9034        client.dispatch.account_streams_ready.reset();
9035
9036        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
9037        let msg = err.to_string();
9038        assert!(msg.contains("orders"), "pending list missing orders: {msg}");
9039        assert!(msg.contains("trades"), "pending list missing trades: {msg}");
9040        assert!(
9041            msg.contains("positions"),
9042            "pending list missing positions: {msg}",
9043        );
9044        assert!(msg.contains("assets"), "pending list missing assets: {msg}");
9045        assert!(
9046            msg.contains("user_stats"),
9047            "pending list missing user_stats: {msg}",
9048        );
9049    }
9050
9051    fn test_market_order(
9052        factory: &mut OrderFactory,
9053        instrument_id: InstrumentId,
9054        client_order_id: &str,
9055        side: OrderSide,
9056    ) -> OrderAny {
9057        factory.market(
9058            instrument_id,
9059            side,
9060            Quantity::from("0.1000"),
9061            Some(TimeInForce::Ioc),
9062            Some(false),
9063            Some(false),
9064            None,
9065            None,
9066            None,
9067            Some(ClientOrderId::from(client_order_id)),
9068        )
9069    }
9070
9071    fn add_test_quote(
9072        cache: &Rc<RefCell<Cache>>,
9073        instrument_id: InstrumentId,
9074        bid: &str,
9075        ask: &str,
9076    ) {
9077        let quote = QuoteTick::new(
9078            instrument_id,
9079            Price::from(bid),
9080            Price::from(ask),
9081            Quantity::from("1.0000"),
9082            Quantity::from("1.0000"),
9083            UnixNanos::default(),
9084            UnixNanos::default(),
9085        );
9086        cache.borrow_mut().add_quote(quote).unwrap();
9087    }
9088
9089    #[tokio::test]
9090    async fn submit_market_order_without_cached_quote_emits_denied() {
9091        let (client, cache, mut rx) = create_execution_client();
9092        let instrument_id = register_test_instrument(&client, &cache);
9093        let mut factory = test_order_factory();
9094        let order = test_market_order(
9095            &mut factory,
9096            instrument_id,
9097            "O-MARKET-NO-QUOTE",
9098            OrderSide::Buy,
9099        );
9100        cache_order(&cache, order.clone());
9101
9102        let command = SubmitOrder::from_order(
9103            &order,
9104            trader_id(),
9105            Some(client_id()),
9106            None,
9107            UUID4::new(),
9108            UnixNanos::default(),
9109        );
9110        // submit_order returns Err but also emits OrderDenied; consume both.
9111        let _ = client.submit_order(command);
9112
9113        let event = recv_order_event(&mut rx).await;
9114        match event {
9115            OrderEventAny::Denied(event) => {
9116                assert!(
9117                    event.reason.as_str().contains("no cached quote"),
9118                    "expected no-cached-quote in reason, was {:?}",
9119                    event.reason,
9120                );
9121            }
9122            event => panic!("expected denied event, was {event:?}"),
9123        }
9124        assert_nonce_reusable(&client.dispatch);
9125    }
9126
9127    #[tokio::test]
9128    async fn submit_market_buy_with_quote_uses_ask_widened_by_slippage() {
9129        let (client, cache, mut rx) = create_execution_client();
9130        let instrument_id = register_test_instrument(&client, &cache);
9131        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
9132
9133        let mut factory = test_order_factory();
9134        let order = test_market_order(
9135            &mut factory,
9136            instrument_id,
9137            "O-MARKET-QUOTED-BUY",
9138            OrderSide::Buy,
9139        );
9140        cache_order(&cache, order.clone());
9141
9142        let command = SubmitOrder::from_order(
9143            &order,
9144            trader_id(),
9145            Some(client_id()),
9146            None,
9147            UUID4::new(),
9148            UnixNanos::default(),
9149        );
9150        let _ = client.submit_order(command);
9151
9152        let submitted = recv_order_event(&mut rx).await;
9153        assert!(
9154            matches!(submitted, OrderEventAny::Submitted(_)),
9155            "expected submitted, was {submitted:?}",
9156        );
9157        let rejected = recv_order_event(&mut rx).await;
9158        match rejected {
9159            OrderEventAny::Rejected(event) => {
9160                assert!(
9161                    event
9162                        .reason
9163                        .as_str()
9164                        .contains("Lighter submit_order dispatch failed"),
9165                );
9166            }
9167            event => panic!("expected rejected event, was {event:?}"),
9168        }
9169        assert_nonce_reusable(&client.dispatch);
9170    }
9171
9172    #[tokio::test]
9173    async fn submit_order_with_sub_tick_quantity_emits_denied() {
9174        let (client, cache, mut rx) = create_execution_client();
9175        let instrument_id = register_test_instrument(&client, &cache);
9176        let mut factory = test_order_factory();
9177        // ETH-PERP size_precision=4; quantity 0.00001 truncates to 0 ticks.
9178        let order = factory.limit(
9179            instrument_id,
9180            OrderSide::Buy,
9181            Quantity::from("0.00001"),
9182            Price::from("2361.31"),
9183            Some(TimeInForce::Gtc),
9184            None,
9185            Some(false),
9186            Some(false),
9187            None,
9188            None,
9189            None,
9190            None,
9191            None,
9192            None,
9193            None,
9194            Some(ClientOrderId::from("O-SUB-TICK-QTY")),
9195        );
9196        cache_order(&cache, order.clone());
9197
9198        let command = SubmitOrder::from_order(
9199            &order,
9200            trader_id(),
9201            Some(client_id()),
9202            None,
9203            UUID4::new(),
9204            UnixNanos::default(),
9205        );
9206        let _ = client.submit_order(command);
9207
9208        let event = recv_order_event(&mut rx).await;
9209        match event {
9210            OrderEventAny::Denied(event) => {
9211                assert!(
9212                    event.reason.as_str().contains("rounds to 0 ticks"),
9213                    "expected rounds-to-0 in reason, was {:?}",
9214                    event.reason,
9215                );
9216            }
9217            event => panic!("expected denied event, was {event:?}"),
9218        }
9219        assert_nonce_reusable(&client.dispatch);
9220    }
9221
9222    #[tokio::test]
9223    async fn submit_order_below_min_notional_emits_denied() {
9224        let (client, cache, mut rx) = create_execution_client();
9225        let instrument_id = register_test_instrument(&client, &cache);
9226        let mut factory = test_order_factory();
9227        let order = factory.limit(
9228            instrument_id,
9229            OrderSide::Buy,
9230            Quantity::from("0.0010"),
9231            Price::from("2361.31"),
9232            Some(TimeInForce::Gtc),
9233            None,
9234            Some(false),
9235            Some(false),
9236            None,
9237            None,
9238            None,
9239            None,
9240            None,
9241            None,
9242            None,
9243            Some(ClientOrderId::from("O-BELOW-MIN-NOTIONAL")),
9244        );
9245        cache_order(&cache, order.clone());
9246
9247        let command = SubmitOrder::from_order(
9248            &order,
9249            trader_id(),
9250            Some(client_id()),
9251            None,
9252            UUID4::new(),
9253            UnixNanos::default(),
9254        );
9255        let _ = client.submit_order(command);
9256
9257        let event = recv_order_event(&mut rx).await;
9258        match event {
9259            OrderEventAny::Denied(event) => {
9260                assert!(
9261                    event.reason.as_str().contains("min_quote_amount"),
9262                    "expected min_quote_amount in reason, was {:?}",
9263                    event.reason,
9264                );
9265            }
9266            event => panic!("expected denied event, was {event:?}"),
9267        }
9268        assert_nonce_reusable(&client.dispatch);
9269    }
9270
9271    #[tokio::test]
9272    async fn submit_stop_market_with_sub_tick_trigger_emits_denied() {
9273        let (client, cache, mut rx) = create_execution_client();
9274        let instrument_id = register_test_instrument(&client, &cache);
9275        let mut factory = test_order_factory();
9276        // ETH-PERP price_precision=2; trigger 0.001 truncates to 0 ticks.
9277        let order = factory.stop_market(
9278            instrument_id,
9279            OrderSide::Buy,
9280            Quantity::from("0.1000"),
9281            Price::from("0.001"),
9282            None,
9283            Some(TimeInForce::Gtc),
9284            None,
9285            Some(false),
9286            Some(false),
9287            None,
9288            None,
9289            None,
9290            None,
9291            None,
9292            None,
9293            Some(ClientOrderId::from("O-STOP-SUB-TICK")),
9294        );
9295        cache_order(&cache, order.clone());
9296
9297        let command = SubmitOrder::from_order(
9298            &order,
9299            trader_id(),
9300            Some(client_id()),
9301            None,
9302            UUID4::new(),
9303            UnixNanos::default(),
9304        );
9305        let _ = client.submit_order(command);
9306
9307        let event = recv_order_event(&mut rx).await;
9308        match event {
9309            OrderEventAny::Denied(event) => {
9310                assert!(
9311                    event.reason.as_str().contains("rounds to 0 ticks"),
9312                    "expected rounds-to-0 in reason, was {:?}",
9313                    event.reason,
9314                );
9315            }
9316            event => panic!("expected denied event, was {event:?}"),
9317        }
9318        assert_nonce_reusable(&client.dispatch);
9319    }
9320
9321    #[tokio::test]
9322    async fn submit_stop_market_dispatches_using_trigger_widened_by_slippage() {
9323        let (client, cache, mut rx) = create_execution_client();
9324        let instrument_id = register_test_instrument(&client, &cache);
9325        let mut factory = test_order_factory();
9326        let order = factory.stop_market(
9327            instrument_id,
9328            OrderSide::Sell,
9329            Quantity::from("0.1000"),
9330            Price::from("2300.00"), // trigger
9331            None,
9332            Some(TimeInForce::Gtc),
9333            None,
9334            Some(false),
9335            Some(false),
9336            None,
9337            None,
9338            None,
9339            None,
9340            None,
9341            None,
9342            Some(ClientOrderId::from("O-STOP-MARKET")),
9343        );
9344        cache_order(&cache, order.clone());
9345
9346        let command = SubmitOrder::from_order(
9347            &order,
9348            trader_id(),
9349            Some(client_id()),
9350            None,
9351            UUID4::new(),
9352            UnixNanos::default(),
9353        );
9354        let _ = client.submit_order(command);
9355
9356        let submitted = recv_order_event(&mut rx).await;
9357        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
9358        let rejected = recv_order_event(&mut rx).await;
9359        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
9360        assert_nonce_reusable(&client.dispatch);
9361    }
9362
9363    #[tokio::test]
9364    async fn submit_market_order_respects_per_order_slippage_override() {
9365        // 0-bps override on a valid ask exercises the params path without
9366        // adding any widening.
9367        let (client, cache, mut rx) = create_execution_client();
9368        let instrument_id = register_test_instrument(&client, &cache);
9369        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
9370
9371        let mut factory = test_order_factory();
9372        let order = test_market_order(
9373            &mut factory,
9374            instrument_id,
9375            "O-MARKET-ZERO-SLIP",
9376            OrderSide::Buy,
9377        );
9378        cache_order(&cache, order.clone());
9379
9380        let params: Params =
9381            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 0})).unwrap();
9382        let mut command = SubmitOrder::from_order(
9383            &order,
9384            trader_id(),
9385            Some(client_id()),
9386            None,
9387            UUID4::new(),
9388            UnixNanos::default(),
9389        );
9390        command.params = Some(params);
9391        let _ = client.submit_order(command);
9392
9393        let submitted = recv_order_event(&mut rx).await;
9394        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
9395        let rejected = recv_order_event(&mut rx).await;
9396        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
9397        assert_nonce_reusable(&client.dispatch);
9398    }
9399
9400    #[tokio::test]
9401    async fn resolve_slippage_bps_prefers_params_over_config_default() {
9402        let (client, _cache, _rx) = create_execution_client();
9403        assert_eq!(client.resolve_slippage_bps(None), 50);
9404
9405        let override_params: Params =
9406            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 100})).unwrap();
9407        assert_eq!(client.resolve_slippage_bps(Some(&override_params)), 100);
9408
9409        let unrelated_params: Params =
9410            serde_json::from_value(serde_json::json!({"other_key": 999})).unwrap();
9411        assert_eq!(client.resolve_slippage_bps(Some(&unrelated_params)), 50);
9412    }
9413
9414    #[tokio::test]
9415    async fn submit_market_sell_with_quote_uses_bid_widened_by_slippage() {
9416        let (client, cache, mut rx) = create_execution_client();
9417        let instrument_id = register_test_instrument(&client, &cache);
9418        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
9419
9420        let mut factory = test_order_factory();
9421        let order = test_market_order(
9422            &mut factory,
9423            instrument_id,
9424            "O-MARKET-QUOTED-SELL",
9425            OrderSide::Sell,
9426        );
9427        cache_order(&cache, order.clone());
9428
9429        let command = SubmitOrder::from_order(
9430            &order,
9431            trader_id(),
9432            Some(client_id()),
9433            None,
9434            UUID4::new(),
9435            UnixNanos::default(),
9436        );
9437        let _ = client.submit_order(command);
9438
9439        let submitted = recv_order_event(&mut rx).await;
9440        assert!(
9441            matches!(submitted, OrderEventAny::Submitted(_)),
9442            "expected submitted, was {submitted:?}",
9443        );
9444        let rejected = recv_order_event(&mut rx).await;
9445        match rejected {
9446            OrderEventAny::Rejected(event) => {
9447                assert!(
9448                    event
9449                        .reason
9450                        .as_str()
9451                        .contains("Lighter submit_order dispatch failed"),
9452                );
9453            }
9454            event => panic!("expected rejected event, was {event:?}"),
9455        }
9456        assert_nonce_reusable(&client.dispatch);
9457    }
9458
9459    #[rstest]
9460    fn integrator_attributes_tag_mainnet_orders() {
9461        assert_eq!(
9462            integrator_attributes(Some(LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX)),
9463            L2TxAttributes {
9464                integrator_account_index: LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
9465                ..Default::default()
9466            },
9467        );
9468    }
9469
9470    #[rstest]
9471    fn integrator_attributes_leave_testnet_orders_unattributed() {
9472        assert_eq!(integrator_attributes(None), L2TxAttributes::default());
9473    }
9474
9475    #[rstest]
9476    #[case::unavailable(None, None)]
9477    #[case::standard(Some(LighterAccountTier::Standard), None)]
9478    #[case::premium(
9479        Some(LighterAccountTier::Premium),
9480        Some(LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX)
9481    )]
9482    #[case::plus(
9483        Some(LighterAccountTier::Plus),
9484        Some(LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX)
9485    )]
9486    #[case::builder(Some(LighterAccountTier::Builder), None)]
9487    #[case::unknown(Some(LighterAccountTier::Unknown(7)), None)]
9488    fn integrator_account_index_follows_account_tier(
9489        #[case] account_tier: Option<LighterAccountTier>,
9490        #[case] expected: Option<u64>,
9491    ) {
9492        let mut config = test_config();
9493        config.environment = LighterEnvironment::Mainnet;
9494        let (mut client, _cache, _rx) = create_execution_client_with_config(config);
9495        client.account_tier = account_tier;
9496
9497        assert_eq!(client.integrator_account_index(), expected);
9498    }
9499
9500    #[rstest]
9501    fn robinhood_orders_keep_default_l2_attributes() {
9502        let mut config = test_config();
9503        config.deployment = LighterDeployment::Robinhood;
9504        config.environment = LighterEnvironment::Mainnet;
9505
9506        assert_eq!(
9507            integrator_attributes(deployment::integrator_account_index(
9508                config.deployment,
9509                config.environment,
9510            )),
9511            L2TxAttributes::default(),
9512        );
9513    }
9514
9515    use std::str::FromStr;
9516
9517    use nautilus_live::ExecutionEventEmitter;
9518    use rust_decimal::Decimal;
9519
9520    use crate::{
9521        common::enums::{
9522            LighterOrderKind, LighterOrderSide, LighterOrderStatus, LighterOrderTimeInForce,
9523            LighterTradeType, LighterTriggerStatus,
9524        },
9525        http::models::{LighterOrder, LighterTrade},
9526    };
9527
9528    fn dispatcher_emitter() -> (
9529        ExecutionEventEmitter,
9530        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9531    ) {
9532        let mut emitter = ExecutionEventEmitter::new(
9533            get_atomic_clock_realtime(),
9534            trader_id(),
9535            account_id(),
9536            AccountType::Margin,
9537            None,
9538        );
9539        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
9540        emitter.set_sender(sender);
9541        (emitter, receiver)
9542    }
9543
9544    /// Test rig that owns a `WsDispatchState`, a process-global instrument
9545    /// cache entry, a `MarketRegistry`, and an emitter wired to a receiver.
9546    /// Used by every dispatcher test to keep the call-site short.
9547    struct DispatcherRig {
9548        dispatch: WsDispatchState,
9549        registry: Arc<MarketRegistry>,
9550        emitter: ExecutionEventEmitter,
9551        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9552        instrument_id: InstrumentId,
9553        cloid: ClientOrderId,
9554    }
9555
9556    fn dispatcher_rig(cloid_suffix: &str) -> DispatcherRig {
9557        let registry = Arc::new(MarketRegistry::new());
9558        // All dispatcher tests share the same instrument (ETH-PERP) so
9559        // `LIGHTER_INSTRUMENT_CACHE` only ever holds one entry; per-test
9560        // isolation comes from the per-rig `WsDispatchState` and the
9561        // unique cloid built from `cloid_suffix`.
9562        let instrument_id = registry.insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
9563        let instrument = InstrumentAny::CryptoPerpetual(
9564            CryptoPerpetual::builder()
9565                .instrument_id(instrument_id)
9566                .raw_symbol(Symbol::new("ETH-PERP"))
9567                .base_currency(Currency::from("ETH"))
9568                .quote_currency(Currency::from("USDC"))
9569                .settlement_currency(Currency::from("USDC"))
9570                .is_inverse(false)
9571                .price_precision(2)
9572                .size_precision(4)
9573                .price_increment(Price::from("0.01"))
9574                .size_increment(Quantity::from("0.0001"))
9575                .min_notional(Money::from("10.000000 USDC"))
9576                .ts_event(UnixNanos::default())
9577                .ts_init(UnixNanos::default())
9578                .build()
9579                .unwrap(),
9580        );
9581        LIGHTER_INSTRUMENT_CACHE.insert(instrument_id, instrument);
9582        let (emitter, rx) = dispatcher_emitter();
9583        DispatcherRig {
9584            dispatch: WsDispatchState::new(),
9585            registry,
9586            emitter,
9587            rx,
9588            instrument_id,
9589            cloid: ClientOrderId::new(format!("CLOID-{cloid_suffix}")),
9590        }
9591    }
9592
9593    fn register_identity(rig: &DispatcherRig) {
9594        register_identity_for(rig, OrderType::Limit);
9595    }
9596
9597    fn register_identity_for(rig: &DispatcherRig, order_type: OrderType) {
9598        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
9599        let client_order_index = rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
9600        rig.dispatch.register_order_identity(
9601            rig.cloid,
9602            OrderIdentity::new(
9603                rig.instrument_id,
9604                strategy_id(),
9605                OrderSide::Buy,
9606                order_type,
9607                client_order_index,
9608            ),
9609        );
9610        rig.dispatch
9611            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
9612    }
9613
9614    fn register_unowned_identity(rig: &DispatcherRig) {
9615        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
9616        let client_order_index = rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
9617        rig.dispatch.register_order_identity(
9618            rig.cloid,
9619            OrderIdentity::new(
9620                rig.instrument_id,
9621                strategy_id(),
9622                OrderSide::Buy,
9623                OrderType::Limit,
9624                client_order_index,
9625            ),
9626        );
9627    }
9628
9629    fn dispatcher_test_order(rig: &DispatcherRig, status: LighterOrderStatus) -> LighterOrder {
9630        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
9631        rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
9632
9633        LighterOrder {
9634            order_index: 281_476_929_510_110,
9635            client_order_index: derived,
9636            order_id: "281476929510110".to_string(),
9637            client_order_id: derived.to_string(),
9638            market_index: TEST_MARKET_INDEX,
9639            owner_account_index: TEST_ACCOUNT_INDEX_I64,
9640            initial_base_amount: Decimal::from_str("0.0050").unwrap(),
9641            price: Decimal::from_str("2352.74").unwrap(),
9642            nonce: TEST_ORDER_NONCE,
9643            remaining_base_amount: Decimal::from_str("0.0050").unwrap(),
9644            is_ask: false,
9645            base_size: 50,
9646            base_price: 235_274,
9647            filled_base_amount: Decimal::ZERO,
9648            filled_quote_amount: Decimal::ZERO,
9649            side: Some(LighterOrderSide::Buy),
9650            order_type: LighterOrderKind::Limit,
9651            time_in_force: LighterOrderTimeInForce::GoodTillTime,
9652            reduce_only: false,
9653            trigger_price: Decimal::ZERO,
9654            order_expiry: 1_780_360_584_479,
9655            status,
9656            trigger_status: LighterTriggerStatus::Na,
9657            trigger_time: 0,
9658            parent_order_index: 0,
9659            parent_order_id: "0".to_string(),
9660            to_trigger_order_id_0: "0".to_string(),
9661            to_trigger_order_id_1: "0".to_string(),
9662            to_cancel_order_id_0: "0".to_string(),
9663            integrator_fee_collector_index: "0".to_string(),
9664            integrator_taker_fee: Decimal::ZERO,
9665            integrator_maker_fee: Decimal::ZERO,
9666            block_height: 227_535_532,
9667            timestamp: 1_777_941_383_576,
9668            created_at: 1_777_941_383_576,
9669            updated_at: 1_777_941_383_900,
9670            transaction_time: 1_777_941_383_576_735,
9671        }
9672    }
9673
9674    fn dispatcher_test_trade(rig: &DispatcherRig, user_is_bidder: bool) -> LighterTrade {
9675        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
9676        rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
9677        LighterTrade {
9678            trade_id: 19_209_006_902,
9679            trade_id_str: Some("19209006902".to_string()),
9680            tx_hash: "000000128b1ee814".to_string(),
9681            trade_type: LighterTradeType::Trade,
9682            market_id: TEST_MARKET_INDEX,
9683            size: Decimal::from_str("0.1336").unwrap(),
9684            price: Decimal::from_str("2352.73").unwrap(),
9685            usd_amount: Decimal::from_str("314.324728").unwrap(),
9686            ask_id: 281_476_929_510_102,
9687            ask_id_str: Some("281476929510102".to_string()),
9688            bid_id: 562_947_905_631_053,
9689            bid_id_str: Some("562947905631053".to_string()),
9690            ask_client_id: if user_is_bidder { 0 } else { derived },
9691            ask_client_id_str: Some(if user_is_bidder {
9692                "0".to_string()
9693            } else {
9694                derived.to_string()
9695            }),
9696            bid_client_id: if user_is_bidder { derived } else { 0 },
9697            bid_client_id_str: Some(if user_is_bidder {
9698                derived.to_string()
9699            } else {
9700                "0".to_string()
9701            }),
9702            ask_account_id: if user_is_bidder {
9703                91_249
9704            } else {
9705                TEST_ACCOUNT_INDEX_I64
9706            },
9707            bid_account_id: if user_is_bidder {
9708                TEST_ACCOUNT_INDEX_I64
9709            } else {
9710                91_249
9711            },
9712            is_maker_ask: false,
9713            block_height: 227_535_535,
9714            timestamp: 1_777_941_384_181,
9715            taker_fee: Some(196),
9716            taker_position_size_before: None,
9717            taker_entry_quote_before: None,
9718            taker_initial_margin_fraction_before: None,
9719            taker_position_sign_changed: None,
9720            maker_fee: Some(28),
9721            maker_position_size_before: None,
9722            maker_entry_quote_before: None,
9723            maker_initial_margin_fraction_before: None,
9724            maker_position_sign_changed: None,
9725            transaction_time: 1_777_941_384_181_586,
9726            ask_account_pnl: None,
9727            bid_account_pnl: None,
9728        }
9729    }
9730
9731    /// Drain all pending events from the rig's receiver. Useful when a
9732    /// test wants to assert what landed without timing-sensitive
9733    /// `recv_order_event` waits.
9734    fn drain_events(
9735        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9736    ) -> Vec<ExecutionEvent> {
9737        let mut events = Vec::new();
9738        while let Ok(event) = rx.try_recv() {
9739            events.push(event);
9740        }
9741        events
9742    }
9743
9744    #[rstest]
9745    fn dispatch_lighter_order_tracked_emits_accepted_then_silent_repeat() {
9746        let mut rig = dispatcher_rig("1");
9747        register_identity(&rig);
9748        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
9749
9750        dispatch_lighter_order(
9751            &order,
9752            &rig.dispatch,
9753            &rig.emitter,
9754            &rig.registry,
9755            account_id(),
9756            trader_id(),
9757            UnixNanos::from(1),
9758        );
9759        dispatch_lighter_order(
9760            &order,
9761            &rig.dispatch,
9762            &rig.emitter,
9763            &rig.registry,
9764            account_id(),
9765            trader_id(),
9766            UnixNanos::from(2),
9767        );
9768
9769        let events = drain_events(&mut rig.rx);
9770        assert_eq!(
9771            events.len(),
9772            1,
9773            "exactly one event expected, was {events:?}",
9774        );
9775
9776        match &events[0] {
9777            ExecutionEvent::Order(OrderEventAny::Accepted(e)) => {
9778                assert_eq!(e.client_order_id, rig.cloid);
9779                assert_eq!(e.venue_order_id.to_string(), "281476929510110");
9780            }
9781            other => panic!("expected Accepted, was {other:?}"),
9782        }
9783        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
9784        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_some());
9785    }
9786
9787    #[rstest]
9788    fn dispatch_lighter_order_tracked_emits_updated_on_shape_change() {
9789        let mut rig = dispatcher_rig("2");
9790        register_identity(&rig);
9791        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
9792
9793        dispatch_lighter_order(
9794            &order,
9795            &rig.dispatch,
9796            &rig.emitter,
9797            &rig.registry,
9798            account_id(),
9799            trader_id(),
9800            UnixNanos::from(1),
9801        );
9802        assert_eq!(drain_events(&mut rig.rx).len(), 1);
9803
9804        let mut modified = order;
9805        modified.price = Decimal::from_str("2400.00").unwrap();
9806
9807        dispatch_lighter_order(
9808            &modified,
9809            &rig.dispatch,
9810            &rig.emitter,
9811            &rig.registry,
9812            account_id(),
9813            trader_id(),
9814            UnixNanos::from(2),
9815        );
9816
9817        let events = drain_events(&mut rig.rx);
9818        assert_eq!(
9819            events.len(),
9820            1,
9821            "expected one Updated event, was {events:?}",
9822        );
9823
9824        match &events[0] {
9825            ExecutionEvent::Order(OrderEventAny::Updated(e)) => {
9826                assert_eq!(e.client_order_id, rig.cloid);
9827                assert_eq!(e.price, Some(Price::from("2400.00")));
9828            }
9829            other => panic!("expected Updated, was {other:?}"),
9830        }
9831        let snapshot = rig.dispatch.snapshot_for(&rig.cloid).expect("snapshot");
9832        assert_eq!(snapshot.price, Some(Price::from("2400.00")));
9833    }
9834
9835    #[rstest]
9836    #[case(LighterOrderStatus::Pending)]
9837    #[case(LighterOrderStatus::Open)]
9838    fn dispatch_lighter_order_defers_live_frame_while_cancel_pending(
9839        #[case] status: LighterOrderStatus,
9840    ) {
9841        let mut rig = dispatcher_rig(match status {
9842            LighterOrderStatus::Pending => "24-PENDING",
9843            LighterOrderStatus::Open => "24-OPEN",
9844            other => panic!("unexpected case {other:?}"),
9845        });
9846        register_identity(&rig);
9847        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
9848
9849        dispatch_lighter_order(
9850            &order,
9851            &rig.dispatch,
9852            &rig.emitter,
9853            &rig.registry,
9854            account_id(),
9855            trader_id(),
9856            UnixNanos::from(1),
9857        );
9858        assert_eq!(drain_events(&mut rig.rx).len(), 1);
9859
9860        rig.dispatch
9861            .set_pending_order_action(rig.cloid, PendingOrderAction::Cancel);
9862        let mut late = order;
9863        late.status = status;
9864        late.initial_base_amount = Decimal::from_str("0.0075").unwrap();
9865        late.price = Decimal::from_str("2400.00").unwrap();
9866
9867        dispatch_lighter_order(
9868            &late,
9869            &rig.dispatch,
9870            &rig.emitter,
9871            &rig.registry,
9872            account_id(),
9873            trader_id(),
9874            UnixNanos::from(2),
9875        );
9876
9877        let events = drain_events(&mut rig.rx);
9878        assert_eq!(events.len(), 0, "late frame must be silent: {events:?}");
9879        assert_eq!(
9880            rig.dispatch.pending_order_action(&rig.cloid),
9881            Some(PendingOrderAction::Cancel),
9882        );
9883        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
9884        assert!(!rig.dispatch.triggered_was_emitted(&rig.cloid));
9885        assert_eq!(
9886            rig.dispatch.lookup_venue_order_id(&rig.cloid),
9887            Some(VenueOrderId::new("281476929510110")),
9888        );
9889        assert!(rig.dispatch.order_identities.contains_key(&rig.cloid));
9890        assert_eq!(
9891            rig.dispatch.snapshot_for(&rig.cloid),
9892            Some(crate::websocket::dispatch::OrderShapeSnapshot {
9893                quantity: Quantity::from("0.0050"),
9894                price: Some(Price::from("2352.74")),
9895                trigger_price: None,
9896            }),
9897        );
9898    }
9899
9900    #[rstest]
9901    fn dispatch_lighter_order_emits_updated_then_triggered_for_one_frame() {
9902        let mut rig = dispatcher_rig("25");
9903        register_identity_for(&rig, OrderType::StopLimit);
9904        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
9905        order.order_type = LighterOrderKind::StopLossLimit;
9906        order.trigger_price = Decimal::from_str("2300.00").unwrap();
9907
9908        dispatch_lighter_order(
9909            &order,
9910            &rig.dispatch,
9911            &rig.emitter,
9912            &rig.registry,
9913            account_id(),
9914            trader_id(),
9915            UnixNanos::from(1),
9916        );
9917        let accepted = drain_events(&mut rig.rx);
9918        assert_eq!(accepted.len(), 1, "expected one acceptance: {accepted:?}");
9919        assert!(matches!(
9920            accepted[0],
9921            ExecutionEvent::Order(OrderEventAny::Accepted(_)),
9922        ));
9923
9924        rig.dispatch
9925            .set_pending_order_action(rig.cloid, PendingOrderAction::Modify);
9926        let mut triggered = order;
9927        triggered.status = LighterOrderStatus::Open;
9928        triggered.trigger_status = LighterTriggerStatus::Ready;
9929        triggered.initial_base_amount = Decimal::from_str("0.0075").unwrap();
9930        triggered.price = Decimal::from_str("2400.00").unwrap();
9931        triggered.trigger_price = Decimal::from_str("2390.00").unwrap();
9932
9933        dispatch_lighter_order(
9934            &triggered,
9935            &rig.dispatch,
9936            &rig.emitter,
9937            &rig.registry,
9938            account_id(),
9939            trader_id(),
9940            UnixNanos::from(2),
9941        );
9942
9943        let events = drain_events(&mut rig.rx);
9944        assert_eq!(
9945            events.len(),
9946            2,
9947            "expected Updated then Triggered: {events:?}",
9948        );
9949
9950        match &events[0] {
9951            ExecutionEvent::Order(OrderEventAny::Updated(event)) => {
9952                assert_eq!(event.trader_id, trader_id());
9953                assert_eq!(event.strategy_id, strategy_id());
9954                assert_eq!(event.instrument_id, rig.instrument_id);
9955                assert_eq!(event.client_order_id, rig.cloid);
9956                assert_eq!(
9957                    event.venue_order_id,
9958                    Some(VenueOrderId::new("281476929510110")),
9959                );
9960                assert_eq!(event.account_id, Some(account_id()));
9961                assert_eq!(event.quantity, Quantity::from("0.0075"));
9962                assert_eq!(event.price, Some(Price::from("2400.00")));
9963                assert_eq!(event.trigger_price, Some(Price::from("2390.00")));
9964            }
9965            other => panic!("first event should be Updated, was {other:?}"),
9966        }
9967
9968        match &events[1] {
9969            ExecutionEvent::Order(OrderEventAny::Triggered(event)) => {
9970                assert_eq!(event.trader_id, trader_id());
9971                assert_eq!(event.strategy_id, strategy_id());
9972                assert_eq!(event.instrument_id, rig.instrument_id);
9973                assert_eq!(event.client_order_id, rig.cloid);
9974                assert_eq!(
9975                    event.venue_order_id,
9976                    Some(VenueOrderId::new("281476929510110")),
9977                );
9978                assert_eq!(event.account_id, Some(account_id()));
9979            }
9980            other => panic!("second event should be Triggered, was {other:?}"),
9981        }
9982        assert_eq!(rig.dispatch.pending_order_action(&rig.cloid), None);
9983        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
9984        assert!(rig.dispatch.triggered_was_emitted(&rig.cloid));
9985        assert_eq!(
9986            rig.dispatch.snapshot_for(&rig.cloid),
9987            Some(crate::websocket::dispatch::OrderShapeSnapshot {
9988                quantity: Quantity::from("0.0075"),
9989                price: Some(Price::from("2400.00")),
9990                trigger_price: Some(Price::from("2390.00")),
9991            }),
9992        );
9993
9994        dispatch_lighter_order(
9995            &triggered,
9996            &rig.dispatch,
9997            &rig.emitter,
9998            &rig.registry,
9999            account_id(),
10000            trader_id(),
10001            UnixNanos::from(3),
10002        );
10003        assert!(
10004            drain_events(&mut rig.rx).is_empty(),
10005            "replayed frame must be silent",
10006        );
10007    }
10008
10009    #[rstest]
10010    fn dispatch_lighter_order_defers_trigger_until_modify_resolves() {
10011        let mut rig = dispatcher_rig("26");
10012        register_identity_for(&rig, OrderType::StopLimit);
10013        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
10014        order.order_type = LighterOrderKind::StopLossLimit;
10015        order.trigger_price = Decimal::from_str("2300.00").unwrap();
10016
10017        dispatch_lighter_order(
10018            &order,
10019            &rig.dispatch,
10020            &rig.emitter,
10021            &rig.registry,
10022            account_id(),
10023            trader_id(),
10024            UnixNanos::from(1),
10025        );
10026        assert_eq!(drain_events(&mut rig.rx).len(), 1);
10027
10028        rig.dispatch
10029            .set_pending_order_action(rig.cloid, PendingOrderAction::Modify);
10030        order.status = LighterOrderStatus::Open;
10031        order.trigger_status = LighterTriggerStatus::Ready;
10032        dispatch_lighter_order(
10033            &order,
10034            &rig.dispatch,
10035            &rig.emitter,
10036            &rig.registry,
10037            account_id(),
10038            trader_id(),
10039            UnixNanos::from(2),
10040        );
10041
10042        assert!(drain_events(&mut rig.rx).is_empty());
10043        assert_eq!(
10044            rig.dispatch.pending_order_action(&rig.cloid),
10045            Some(PendingOrderAction::Modify),
10046        );
10047        assert!(!rig.dispatch.triggered_was_emitted(&rig.cloid));
10048
10049        assert!(
10050            rig.dispatch
10051                .clear_pending_order_action_if(&rig.cloid, PendingOrderAction::Modify,)
10052        );
10053        dispatch_lighter_order(
10054            &order,
10055            &rig.dispatch,
10056            &rig.emitter,
10057            &rig.registry,
10058            account_id(),
10059            trader_id(),
10060            UnixNanos::from(3),
10061        );
10062
10063        let events = drain_events(&mut rig.rx);
10064        assert_eq!(events.len(), 1, "expected deferred trigger: {events:?}");
10065        match &events[0] {
10066            ExecutionEvent::Order(OrderEventAny::Triggered(event)) => {
10067                assert_eq!(event.client_order_id, rig.cloid);
10068                assert_eq!(
10069                    event.venue_order_id,
10070                    Some(VenueOrderId::new("281476929510110")),
10071                );
10072                assert_eq!(event.account_id, Some(account_id()));
10073            }
10074            other => panic!("expected Triggered, was {other:?}"),
10075        }
10076        assert!(rig.dispatch.triggered_was_emitted(&rig.cloid));
10077    }
10078
10079    #[rstest]
10080    fn dispatch_lighter_order_pending_emits_accepted_then_updated() {
10081        let mut rig = dispatcher_rig("23");
10082        register_identity(&rig);
10083        let order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
10084
10085        dispatch_lighter_order(
10086            &order,
10087            &rig.dispatch,
10088            &rig.emitter,
10089            &rig.registry,
10090            account_id(),
10091            trader_id(),
10092            UnixNanos::from(1),
10093        );
10094        let accepted = drain_events(&mut rig.rx);
10095
10096        let mut modified = order;
10097        modified.price = Decimal::from_str("2400.00").unwrap();
10098        dispatch_lighter_order(
10099            &modified,
10100            &rig.dispatch,
10101            &rig.emitter,
10102            &rig.registry,
10103            account_id(),
10104            trader_id(),
10105            UnixNanos::from(2),
10106        );
10107        let updated = drain_events(&mut rig.rx);
10108
10109        assert_eq!(
10110            accepted.len(),
10111            1,
10112            "expected one Accepted event: {accepted:?}"
10113        );
10114        assert!(matches!(
10115            accepted[0],
10116            ExecutionEvent::Order(OrderEventAny::Accepted(_))
10117        ));
10118        assert_eq!(updated.len(), 1, "expected one Updated event: {updated:?}");
10119        assert!(matches!(
10120            updated[0],
10121            ExecutionEvent::Order(OrderEventAny::Updated(_))
10122        ));
10123        assert_eq!(
10124            rig.dispatch
10125                .snapshot_for(&rig.cloid)
10126                .expect("snapshot")
10127                .price,
10128            Some(Price::from("2400.00")),
10129        );
10130    }
10131
10132    #[rstest]
10133    fn dispatch_lighter_order_untracked_emits_report() {
10134        let mut rig = dispatcher_rig("3");
10135        // No identity registered: this is an external order.
10136        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
10137        order.client_order_id = "external-1".to_string();
10138        order.client_order_index = 0;
10139
10140        dispatch_lighter_order(
10141            &order,
10142            &rig.dispatch,
10143            &rig.emitter,
10144            &rig.registry,
10145            account_id(),
10146            trader_id(),
10147            UnixNanos::from(1),
10148        );
10149
10150        let events = drain_events(&mut rig.rx);
10151        assert_eq!(events.len(), 1);
10152        match &events[0] {
10153            ExecutionEvent::Report(report) => match report {
10154                EngineExecutionReport::Order(r) => {
10155                    assert_eq!(r.venue_order_id.to_string(), "281476929510110");
10156                }
10157                other => panic!("expected order report, was {other:?}"),
10158            },
10159            other => panic!("expected report, was {other:?}"),
10160        }
10161        assert!(
10162            !rig.dispatch
10163                .accepted_was_emitted(&ClientOrderId::new("external-1"))
10164        );
10165    }
10166
10167    #[rstest]
10168    fn same_index_external_order_waits_for_create_submission_ownership() {
10169        let mut rig = dispatcher_rig("EPOCH-ORDER");
10170        register_unowned_identity(&rig);
10171        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
10172        let venue_order_id = VenueOrderId::new(order.order_id.as_str());
10173
10174        dispatch_lighter_order(
10175            &order,
10176            &rig.dispatch,
10177            &rig.emitter,
10178            &rig.registry,
10179            account_id(),
10180            trader_id(),
10181            UnixNanos::from(1),
10182        );
10183
10184        let external = drain_events(&mut rig.rx);
10185        assert_eq!(external.len(), 1);
10186        match &external[0] {
10187            ExecutionEvent::Report(EngineExecutionReport::Order(report)) => {
10188                assert_eq!(report.venue_order_id, venue_order_id);
10189                assert_eq!(
10190                    report.client_order_id,
10191                    Some(ClientOrderId::new(venue_order_id.as_str())),
10192                );
10193            }
10194            other => panic!("expected one external order report, was {other:?}"),
10195        }
10196        assert!(!rig.dispatch.accepted_was_emitted(&rig.cloid));
10197        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
10198
10199        rig.dispatch
10200            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
10201        dispatch_lighter_order(
10202            &order,
10203            &rig.dispatch,
10204            &rig.emitter,
10205            &rig.registry,
10206            account_id(),
10207            trader_id(),
10208            UnixNanos::from(2),
10209        );
10210
10211        let replayed_external = drain_events(&mut rig.rx);
10212        assert_eq!(replayed_external.len(), 1);
10213        match &replayed_external[0] {
10214            ExecutionEvent::Report(EngineExecutionReport::Order(report)) => {
10215                assert_eq!(report.venue_order_id, venue_order_id);
10216                assert_eq!(
10217                    report.client_order_id,
10218                    Some(ClientOrderId::new(venue_order_id.as_str())),
10219                );
10220            }
10221            other => panic!("expected replayed external order report, was {other:?}"),
10222        }
10223        assert!(!rig.dispatch.accepted_was_emitted(&rig.cloid));
10224        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
10225
10226        let mut local_order = order.clone();
10227        local_order.order_id = "281476929510111".to_string();
10228        let local_venue_order_id = VenueOrderId::new(local_order.order_id.as_str());
10229        dispatch_lighter_order(
10230            &local_order,
10231            &rig.dispatch,
10232            &rig.emitter,
10233            &rig.registry,
10234            account_id(),
10235            trader_id(),
10236            UnixNanos::from(3),
10237        );
10238
10239        let local = drain_events(&mut rig.rx);
10240        assert_eq!(local.len(), 1);
10241        match &local[0] {
10242            ExecutionEvent::Order(OrderEventAny::Accepted(event)) => {
10243                assert_eq!(event.client_order_id, rig.cloid);
10244                assert_eq!(event.venue_order_id, local_venue_order_id);
10245                assert_eq!(event.account_id, account_id());
10246            }
10247            other => panic!("expected one local OrderAccepted, was {other:?}"),
10248        }
10249        assert_eq!(
10250            rig.dispatch
10251                .venue_id_map
10252                .get(&rig.cloid)
10253                .map(|entry| *entry),
10254            Some(local_venue_order_id),
10255        );
10256        assert_eq!(
10257            rig.dispatch
10258                .resolve_live_order_cloid(order.client_order_id.as_str(), local_venue_order_id,),
10259            Some(rig.cloid),
10260            "a bound identity remains stable across replay epochs",
10261        );
10262    }
10263
10264    #[rstest]
10265    fn dispatch_lighter_trade_tracked_synthesizes_accepted_before_filled() {
10266        // Fill-before-open: the trade arrives before the matching Open
10267        // frame. The dispatcher must synthesise `OrderAccepted` first so
10268        // the engine sees the lifecycle in order.
10269        let mut rig = dispatcher_rig("4");
10270        register_identity(&rig);
10271
10272        let trade = dispatcher_test_trade(&rig, true);
10273
10274        dispatch_lighter_trade(
10275            &trade,
10276            &rig.dispatch,
10277            &rig.emitter,
10278            &rig.registry,
10279            account_id(),
10280            trader_id(),
10281            Some(TEST_ACCOUNT_INDEX_I64),
10282            UnixNanos::from(1),
10283        );
10284
10285        let events = drain_events(&mut rig.rx);
10286        assert_eq!(
10287            events.len(),
10288            2,
10289            "expected Accepted then Filled, was {events:?}",
10290        );
10291
10292        match &events[0] {
10293            ExecutionEvent::Order(OrderEventAny::Accepted(_)) => {}
10294            other => panic!("first event should be Accepted, was {other:?}"),
10295        }
10296
10297        match &events[1] {
10298            ExecutionEvent::Order(OrderEventAny::Filled(e)) => {
10299                assert_eq!(e.client_order_id, rig.cloid);
10300                assert_eq!(e.last_qty, Quantity::from("0.1336"));
10301                assert_eq!(e.last_px, Price::from("2352.73"));
10302            }
10303            other => panic!("second event should be Filled, was {other:?}"),
10304        }
10305        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
10306    }
10307
10308    #[rstest]
10309    fn dispatch_lighter_trade_dedupes_repeated_trade_ids() {
10310        let mut rig = dispatcher_rig("5");
10311        register_identity(&rig);
10312        let trade = dispatcher_test_trade(&rig, true);
10313
10314        for _ in 0..3 {
10315            dispatch_lighter_trade(
10316                &trade,
10317                &rig.dispatch,
10318                &rig.emitter,
10319                &rig.registry,
10320                account_id(),
10321                trader_id(),
10322                Some(TEST_ACCOUNT_INDEX_I64),
10323                UnixNanos::from(1),
10324            );
10325        }
10326
10327        let events = drain_events(&mut rig.rx);
10328        // First call: Accepted + Filled. Subsequent calls deduped by trade_id.
10329        assert_eq!(
10330            events.len(),
10331            2,
10332            "expected dedup after first dispatch, was {events:?}"
10333        );
10334    }
10335
10336    #[rstest]
10337    fn dispatch_lighter_trade_parse_failure_rolls_back_dedup() {
10338        // A parse failure must not consume the dedup slot, else the fill is lost on replay
10339        let mut rig = dispatcher_rig("21");
10340        register_identity(&rig);
10341        let mut trade = dispatcher_test_trade(&rig, true);
10342        trade.timestamp = -1;
10343        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
10344
10345        dispatch_lighter_trade(
10346            &trade,
10347            &rig.dispatch,
10348            &rig.emitter,
10349            &rig.registry,
10350            account_id(),
10351            trader_id(),
10352            Some(TEST_ACCOUNT_INDEX_I64),
10353            UnixNanos::from(1),
10354        );
10355
10356        let events = drain_events(&mut rig.rx);
10357        assert!(
10358            !events
10359                .iter()
10360                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
10361            "malformed trade must not emit a fill, was {events:?}",
10362        );
10363        assert!(
10364            rig.dispatch.mark_trade_seen(trade_id),
10365            "parse-failed trade must be re-markable (dedup rolled back)",
10366        );
10367    }
10368
10369    #[rstest]
10370    fn dispatch_lighter_trade_untracked_parse_failure_rolls_back_dedup() {
10371        // Untracked path (no registered identity -> FillReport): dedup rollback must still hold
10372        let mut rig = dispatcher_rig("22");
10373        let mut trade = dispatcher_test_trade(&rig, true);
10374        trade.timestamp = -1;
10375        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
10376
10377        dispatch_lighter_trade(
10378            &trade,
10379            &rig.dispatch,
10380            &rig.emitter,
10381            &rig.registry,
10382            account_id(),
10383            trader_id(),
10384            Some(TEST_ACCOUNT_INDEX_I64),
10385            UnixNanos::from(1),
10386        );
10387
10388        let events = drain_events(&mut rig.rx);
10389        assert!(
10390            events.is_empty(),
10391            "untracked malformed trade must emit nothing, was {events:?}",
10392        );
10393        assert!(
10394            rig.dispatch.mark_trade_seen(trade_id),
10395            "untracked parse-failed trade must be re-markable (dedup rolled back)",
10396        );
10397    }
10398
10399    #[rstest]
10400    fn dispatch_tracked_order_event_terminal_cancel_retires_identity_and_removes_snapshot() {
10401        let mut rig = dispatcher_rig("6");
10402        register_identity(&rig);
10403        rig.dispatch.mark_accepted_emitted(rig.cloid);
10404        rig.dispatch.store_snapshot(
10405            rig.cloid,
10406            crate::websocket::dispatch::OrderShapeSnapshot {
10407                quantity: Quantity::from("0.0050"),
10408                price: Some(Price::from("2352.74")),
10409                trigger_price: None,
10410            },
10411        );
10412        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
10413        order.filled_base_amount = Decimal::ZERO;
10414        let mut trade = dispatcher_test_trade(&rig, true);
10415        trade.bid_id = order.order_index;
10416        trade.bid_id_str = Some(order.order_id.clone());
10417        dispatch_lighter_order(
10418            &order,
10419            &rig.dispatch,
10420            &rig.emitter,
10421            &rig.registry,
10422            account_id(),
10423            trader_id(),
10424            UnixNanos::from(1),
10425        );
10426
10427        let events = drain_events(&mut rig.rx);
10428        let canceled = events
10429            .iter()
10430            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Canceled(_))))
10431            .expect("expected a Canceled event");
10432        if let ExecutionEvent::Order(OrderEventAny::Canceled(e)) = canceled {
10433            assert_eq!(e.client_order_id, rig.cloid);
10434        }
10435        assert!(!rig.dispatch.order_identities.contains_key(&rig.cloid));
10436        assert!(rig.dispatch.order_identity(&rig.cloid).is_some());
10437        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_none());
10438        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
10439
10440        dispatch_lighter_trade(
10441            &trade,
10442            &rig.dispatch,
10443            &rig.emitter,
10444            &rig.registry,
10445            account_id(),
10446            trader_id(),
10447            Some(TEST_ACCOUNT_INDEX_I64),
10448            UnixNanos::from(2),
10449        );
10450        let trailing = drain_events(&mut rig.rx);
10451        assert_eq!(
10452            trailing.len(),
10453            1,
10454            "expected one trailing fill: {trailing:?}"
10455        );
10456
10457        match &trailing[0] {
10458            ExecutionEvent::Order(OrderEventAny::Filled(event)) => {
10459                assert_eq!(event.client_order_id, rig.cloid);
10460            }
10461            other => panic!("expected typed trailing OrderFilled, was {other:?}"),
10462        }
10463    }
10464
10465    #[rstest]
10466    fn dispatch_tracked_cancel_after_report_seed_skips_synthesized_accept() {
10467        let mut rig = dispatcher_rig("10");
10468        register_identity(&rig);
10469
10470        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
10471        let instrument = LIGHTER_INSTRUMENT_CACHE
10472            .get(&rig.instrument_id)
10473            .expect("instrument cached");
10474        let report = parse_ws_order_status_report(
10475            &report_order,
10476            instrument.value(),
10477            account_id(),
10478            UnixNanos::from(1),
10479        )
10480        .map(|report| report.with_client_order_id(rig.cloid))
10481        .expect("report parses");
10482
10483        assert_eq!(report.order_status, OrderStatus::Accepted);
10484        rig.dispatch.seed_accepted_from_report(&report);
10485
10486        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
10487        cancel_order.filled_base_amount = Decimal::ZERO;
10488        dispatch_lighter_order(
10489            &cancel_order,
10490            &rig.dispatch,
10491            &rig.emitter,
10492            &rig.registry,
10493            account_id(),
10494            trader_id(),
10495            UnixNanos::from(2),
10496        );
10497
10498        let events = drain_events(&mut rig.rx);
10499        assert_eq!(
10500            events.len(),
10501            1,
10502            "report-seeded cancel should emit only Canceled, was {events:?}",
10503        );
10504        assert!(
10505            !events
10506                .iter()
10507                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_)))),
10508            "typed cancel must not synthesize a second Accepted",
10509        );
10510
10511        match &events[0] {
10512            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
10513                assert_eq!(e.client_order_id, rig.cloid);
10514                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
10515            }
10516            other => panic!("expected Canceled, was {other:?}"),
10517        }
10518    }
10519
10520    #[rstest]
10521    fn dispatch_tracked_cancel_after_submitted_report_seed_skips_synthesized_accept() {
10522        let mut rig = dispatcher_rig("11");
10523        register_identity(&rig);
10524
10525        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
10526        let instrument = LIGHTER_INSTRUMENT_CACHE
10527            .get(&rig.instrument_id)
10528            .expect("instrument cached");
10529        let mut report = parse_ws_order_status_report(
10530            &report_order,
10531            instrument.value(),
10532            account_id(),
10533            UnixNanos::from(1),
10534        )
10535        .map(|report| report.with_client_order_id(rig.cloid))
10536        .expect("report parses");
10537        report.order_status = OrderStatus::Submitted;
10538
10539        rig.dispatch.seed_accepted_from_report(&report);
10540        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
10541
10542        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
10543        cancel_order.filled_base_amount = Decimal::ZERO;
10544
10545        dispatch_lighter_order(
10546            &cancel_order,
10547            &rig.dispatch,
10548            &rig.emitter,
10549            &rig.registry,
10550            account_id(),
10551            trader_id(),
10552            UnixNanos::from(2),
10553        );
10554
10555        let events = drain_events(&mut rig.rx);
10556        assert_eq!(
10557            events.len(),
10558            1,
10559            "Cancel after Submitted report should emit only Canceled, was {events:?}",
10560        );
10561
10562        match &events[0] {
10563            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
10564                assert_eq!(e.client_order_id, rig.cloid);
10565                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
10566            }
10567            other => panic!("expected Canceled, was {other:?}"),
10568        }
10569    }
10570
10571    #[rstest]
10572    fn dispatch_tracked_order_event_accept_dedup_is_idempotent() {
10573        let mut rig = dispatcher_rig("7");
10574        register_identity(&rig);
10575        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
10576
10577        // First dispatch emits Accepted. Second dispatch must be silent
10578        // (no shape change) and not re-emit Accepted.
10579        dispatch_lighter_order(
10580            &order,
10581            &rig.dispatch,
10582            &rig.emitter,
10583            &rig.registry,
10584            account_id(),
10585            trader_id(),
10586            UnixNanos::from(1),
10587        );
10588        dispatch_lighter_order(
10589            &order,
10590            &rig.dispatch,
10591            &rig.emitter,
10592            &rig.registry,
10593            account_id(),
10594            trader_id(),
10595            UnixNanos::from(2),
10596        );
10597
10598        let events = drain_events(&mut rig.rx);
10599        let accepted_count = events
10600            .iter()
10601            .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_))))
10602            .count();
10603        assert_eq!(accepted_count, 1, "Accepted must be emitted exactly once");
10604    }
10605
10606    #[rstest]
10607    fn dispatch_lighter_order_drops_when_instrument_uncached() {
10608        // Construct a rig but use a market_index the registry does not know.
10609        let registry = Arc::new(MarketRegistry::new());
10610        let (emitter, mut rx) = dispatcher_emitter();
10611        let dispatch = WsDispatchState::new();
10612        let cloid = ClientOrderId::new("CLOID-MISSING");
10613        dispatch.register_order_identity(
10614            cloid,
10615            OrderIdentity::new(
10616                InstrumentId::from("MISSING-PERP.LIGHTER"),
10617                strategy_id(),
10618                OrderSide::Buy,
10619                OrderType::Limit,
10620                1,
10621            ),
10622        );
10623        let mut order = LighterOrder {
10624            order_index: 1,
10625            client_order_index: 1,
10626            order_id: "1".to_string(),
10627            client_order_id: "1".to_string(),
10628            market_index: 999, // not in registry
10629            owner_account_index: TEST_ACCOUNT_INDEX_I64,
10630            initial_base_amount: Decimal::ZERO,
10631            price: Decimal::ZERO,
10632            nonce: 0,
10633            remaining_base_amount: Decimal::ZERO,
10634            is_ask: false,
10635            base_size: 0,
10636            base_price: 0,
10637            filled_base_amount: Decimal::ZERO,
10638            filled_quote_amount: Decimal::ZERO,
10639            side: Some(LighterOrderSide::Buy),
10640            order_type: LighterOrderKind::Limit,
10641            time_in_force: LighterOrderTimeInForce::GoodTillTime,
10642            reduce_only: false,
10643            trigger_price: Decimal::ZERO,
10644            order_expiry: 0,
10645            status: LighterOrderStatus::Open,
10646            trigger_status: LighterTriggerStatus::Na,
10647            trigger_time: 0,
10648            parent_order_index: 0,
10649            parent_order_id: "0".to_string(),
10650            to_trigger_order_id_0: "0".to_string(),
10651            to_trigger_order_id_1: "0".to_string(),
10652            to_cancel_order_id_0: "0".to_string(),
10653            integrator_fee_collector_index: "0".to_string(),
10654            integrator_taker_fee: Decimal::ZERO,
10655            integrator_maker_fee: Decimal::ZERO,
10656            block_height: 0,
10657            timestamp: 0,
10658            created_at: 0,
10659            updated_at: 0,
10660            transaction_time: 0,
10661        };
10662        order.client_order_id = "1".to_string();
10663
10664        dispatch_lighter_order(
10665            &order,
10666            &dispatch,
10667            &emitter,
10668            &registry,
10669            account_id(),
10670            trader_id(),
10671            UnixNanos::from(1),
10672        );
10673
10674        let events = drain_events(&mut rx);
10675        assert!(
10676            events.is_empty(),
10677            "no event for uncached instrument, was {events:?}"
10678        );
10679        assert!(dispatch.order_identities.contains_key(&cloid));
10680    }
10681
10682    #[rstest]
10683    fn dispatch_lighter_trade_filters_non_account_trades_defensively() {
10684        let mut rig = dispatcher_rig("8");
10685        // Trade involves accounts 91249 and 91250, but the supplied
10686        // account_index is TEST_ACCOUNT_INDEX_I64 (12345). The handler is
10687        // the first defensive filter; this verifies the dispatcher path
10688        // also drops foreign trades cleanly.
10689        let mut trade = dispatcher_test_trade(&rig, true);
10690        trade.bid_account_id = 91_249;
10691        trade.ask_account_id = 91_250;
10692
10693        dispatch_lighter_trade(
10694            &trade,
10695            &rig.dispatch,
10696            &rig.emitter,
10697            &rig.registry,
10698            account_id(),
10699            trader_id(),
10700            Some(TEST_ACCOUNT_INDEX_I64),
10701            UnixNanos::from(1),
10702        );
10703
10704        let events = drain_events(&mut rig.rx);
10705        assert!(
10706            events.is_empty(),
10707            "foreign trade must produce no event, was {events:?}"
10708        );
10709        assert!(
10710            !rig.dispatch
10711                .seen_trade_ids
10712                .contains(&TradeId::new("19209006902"),)
10713        );
10714    }
10715
10716    #[rstest]
10717    fn register_cloid_in_submit_path_uses_probed_index_on_collision() {
10718        // Forcing a collision at the derived index for a fresh cloid must
10719        // result in `register_cloid` returning a different (probed) index;
10720        // the submit path uses this returned value as the venue-side
10721        // client_order_index, so it must be the probed one.
10722        let dispatch = WsDispatchState::new();
10723        let cloid = ClientOrderId::new("PROBE-CLOID");
10724        let derived = dispatch.derive_client_order_index(&cloid);
10725
10726        let intruder = ClientOrderId::new("INTRUDER");
10727        dispatch.cloid_map.insert(derived, intruder);
10728
10729        let chosen = dispatch.register_cloid(derived, cloid).unwrap();
10730
10731        assert_ne!(chosen, derived);
10732        assert_eq!(
10733            dispatch.cloid_map.get(&derived).map(|e| *e.value()),
10734            Some(intruder),
10735        );
10736        assert_eq!(
10737            dispatch.cloid_map.get(&chosen).map(|e| *e.value()),
10738            Some(cloid),
10739        );
10740    }
10741
10742    #[rstest]
10743    fn reconciliation_restores_active_collision_probed_identity() {
10744        let (client, cache, _rx) = create_execution_client();
10745        let instrument_id = register_test_instrument(&client, &cache);
10746        let mut factory = test_order_factory();
10747        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-ACTIVE");
10748        let cloid = order.client_order_id();
10749        let venue_order_id = VenueOrderId::from("281476929510110");
10750        cache_accepted_order(&cache, order, venue_order_id, None);
10751        let (base_index, client_order_index) = forced_probed_index(cloid);
10752        let raw =
10753            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
10754
10755        restore_reconciled_order(&client.core, &client.dispatch, &raw, false);
10756        let report =
10757            parse_http_order_to_report(&raw, &client.registry, account_id(), UnixNanos::from(1))
10758                .unwrap();
10759        let translated = client.dispatch.translate_order_cloid(report);
10760        let replacement = ClientOrderId::from("O-RECON-ACTIVE-REPLACEMENT");
10761        let replacement_index = client
10762            .dispatch
10763            .register_cloid(client_order_index, replacement)
10764            .unwrap();
10765        let wrong_venue_order_id = VenueOrderId::from("281476929510111");
10766        let wrong_raw = reconciliation_raw_order(
10767            client_order_index,
10768            wrong_venue_order_id,
10769            LighterOrderStatus::Open,
10770        );
10771        let wrong_report = parse_http_order_to_report(
10772            &wrong_raw,
10773            &client.registry,
10774            account_id(),
10775            UnixNanos::from(2),
10776        )
10777        .unwrap();
10778        let wrong_translated = client.dispatch.translate_order_cloid(wrong_report);
10779
10780        assert_ne!(client_order_index, base_index);
10781        assert_eq!(
10782            client.dispatch.client_order_index(&cloid),
10783            Some(client_order_index),
10784        );
10785        assert_eq!(
10786            client
10787                .dispatch
10788                .resolve_client_order_index(client_order_index),
10789            Some(cloid),
10790        );
10791        assert_eq!(
10792            client.dispatch.lookup_venue_order_id(&cloid),
10793            Some(venue_order_id),
10794        );
10795        assert!(client.dispatch.order_identities.contains_key(&cloid));
10796        assert!(client.dispatch.accepted_was_emitted(&cloid));
10797        assert_eq!(translated.client_order_id, Some(cloid));
10798        assert_eq!(translated.venue_order_id, venue_order_id);
10799        assert_ne!(replacement_index, client_order_index);
10800        assert_eq!(
10801            client
10802                .dispatch
10803                .resolve_client_order_index(replacement_index),
10804            Some(replacement),
10805        );
10806        assert_eq!(
10807            wrong_translated.client_order_id,
10808            Some(ClientOrderId::new(wrong_venue_order_id.as_str())),
10809        );
10810        assert_eq!(wrong_translated.venue_order_id, wrong_venue_order_id);
10811    }
10812
10813    #[rstest]
10814    fn reconciliation_retires_stale_active_cache_from_terminal_report() {
10815        let (client, cache, _rx) = create_execution_client();
10816        let instrument_id = register_test_instrument(&client, &cache);
10817        let mut factory = test_order_factory();
10818        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-FILLED");
10819        let cloid = order.client_order_id();
10820        let venue_order_id = VenueOrderId::from("281476929510119");
10821        cache_accepted_order(&cache, order, venue_order_id, None);
10822        let (_, client_order_index) = forced_probed_index(cloid);
10823        let raw = reconciliation_raw_order(
10824            client_order_index,
10825            venue_order_id,
10826            LighterOrderStatus::Filled,
10827        );
10828
10829        restore_reconciled_order(&client.core, &client.dispatch, &raw, true);
10830        let fill = client
10831            .dispatch
10832            .translate_fill_cloid(reconciliation_fill_report(
10833                instrument_id,
10834                client_order_index,
10835                venue_order_id,
10836                "19209006919",
10837            ));
10838
10839        assert!(!client.dispatch.cloid_map.contains_key(&client_order_index));
10840        assert!(!client.dispatch.order_identities.contains_key(&cloid));
10841        assert_eq!(
10842            client.dispatch.client_order_index(&cloid),
10843            Some(client_order_index),
10844        );
10845        assert_eq!(fill.client_order_id, Some(cloid));
10846        assert_eq!(fill.venue_order_id, venue_order_id);
10847    }
10848
10849    #[rstest]
10850    fn reconciliation_restores_reused_retired_index_for_fill_translation() {
10851        let (client, cache, _rx) = create_execution_client();
10852        let instrument_id = register_test_instrument(&client, &cache);
10853        let mut factory = test_order_factory();
10854        let first_order = test_limit_order(&mut factory, instrument_id, "O-RECON-RETIRED-A");
10855        let second_order = test_limit_order(&mut factory, instrument_id, "O-RECON-RETIRED-B");
10856        let first_cloid = first_order.client_order_id();
10857        let second_cloid = second_order.client_order_id();
10858        let first_venue_order_id = VenueOrderId::from("281476929510120");
10859        let second_venue_order_id = VenueOrderId::from("281476929510121");
10860        cache_canceled_order(&cache, first_order, first_venue_order_id);
10861        cache_canceled_order(&cache, second_order, second_venue_order_id);
10862        let (_, client_order_index) = forced_probed_index(first_cloid);
10863        let first_raw = reconciliation_raw_order(
10864            client_order_index,
10865            first_venue_order_id,
10866            LighterOrderStatus::Canceled,
10867        );
10868        let second_raw = reconciliation_raw_order(
10869            client_order_index,
10870            second_venue_order_id,
10871            LighterOrderStatus::Canceled,
10872        );
10873
10874        restore_reconciled_order(&client.core, &client.dispatch, &first_raw, true);
10875        restore_reconciled_order(&client.core, &client.dispatch, &second_raw, true);
10876        let first_fill = client
10877            .dispatch
10878            .translate_fill_cloid(reconciliation_fill_report(
10879                instrument_id,
10880                client_order_index,
10881                first_venue_order_id,
10882                "19209006920",
10883            ));
10884        let second_fill = client
10885            .dispatch
10886            .translate_fill_cloid(reconciliation_fill_report(
10887                instrument_id,
10888                client_order_index,
10889                second_venue_order_id,
10890                "19209006921",
10891            ));
10892        let replacement = ClientOrderId::from("O-RECON-RETIRED-REPLACEMENT");
10893        let replacement_index = client
10894            .dispatch
10895            .register_cloid(client_order_index, replacement)
10896            .unwrap();
10897
10898        assert!(!client.dispatch.cloid_map.contains_key(&client_order_index));
10899        assert!(!client.dispatch.order_identities.contains_key(&first_cloid));
10900        assert!(!client.dispatch.order_identities.contains_key(&second_cloid));
10901        assert_eq!(
10902            client.dispatch.client_order_index(&first_cloid),
10903            Some(client_order_index),
10904        );
10905        assert_eq!(
10906            client.dispatch.client_order_index(&second_cloid),
10907            Some(client_order_index),
10908        );
10909        assert_eq!(
10910            client
10911                .dispatch
10912                .resolve_client_order_index(client_order_index),
10913            None,
10914        );
10915        assert_eq!(first_fill.client_order_id, Some(first_cloid));
10916        assert_eq!(first_fill.venue_order_id, first_venue_order_id);
10917        assert_eq!(second_fill.client_order_id, Some(second_cloid));
10918        assert_eq!(second_fill.venue_order_id, second_venue_order_id);
10919        assert_ne!(replacement_index, client_order_index);
10920        assert_eq!(
10921            client
10922                .dispatch
10923                .resolve_client_order_index(replacement_index),
10924            Some(replacement),
10925        );
10926    }
10927
10928    #[rstest]
10929    fn reconciliation_restores_triggered_marker() {
10930        let (client, cache, _rx) = create_execution_client();
10931        let instrument_id = register_test_instrument(&client, &cache);
10932        let mut factory = test_order_factory();
10933        let order = factory.stop_limit(
10934            instrument_id,
10935            OrderSide::Sell,
10936            Quantity::from("0.1000"),
10937            Price::from("2290.00"),
10938            Price::from("2300.00"),
10939            None,
10940            Some(TimeInForce::Gtc),
10941            None,
10942            Some(false),
10943            Some(false),
10944            Some(false),
10945            None,
10946            None,
10947            None,
10948            None,
10949            None,
10950            None,
10951            Some(ClientOrderId::from("O-RECON-TRIGGERED")),
10952        );
10953        let cloid = order.client_order_id();
10954        let venue_order_id = VenueOrderId::from("281476929510124");
10955        cache_accepted_order(&cache, order, venue_order_id, None);
10956        let triggered = OrderEventAny::Triggered(OrderTriggered::new(
10957            trader_id(),
10958            strategy_id(),
10959            instrument_id,
10960            cloid,
10961            UUID4::new(),
10962            UnixNanos::from(1),
10963            UnixNanos::from(2),
10964            false,
10965            Some(venue_order_id),
10966            Some(account_id()),
10967        ));
10968        cache.borrow_mut().update_order(&triggered).unwrap();
10969        let (_, client_order_index) = forced_probed_index(cloid);
10970        let raw =
10971            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
10972
10973        restore_reconciled_order(&client.core, &client.dispatch, &raw, false);
10974
10975        assert!(client.dispatch.order_identities.contains_key(&cloid));
10976        assert!(client.dispatch.accepted_was_emitted(&cloid));
10977        assert!(client.dispatch.triggered_was_emitted(&cloid));
10978    }
10979
10980    #[rstest]
10981    fn reconciliation_routes_reused_index_by_exact_venue_order_id() {
10982        let (client, cache, _rx) = create_execution_client();
10983        let instrument_id = register_test_instrument(&client, &cache);
10984        let mut factory = test_order_factory();
10985        let active_order = test_limit_order(&mut factory, instrument_id, "O-RECON-LIVE-ACTIVE");
10986        let retired_order = test_limit_order(&mut factory, instrument_id, "O-RECON-LIVE-RETIRED");
10987        let active_cloid = active_order.client_order_id();
10988        let retired_cloid = retired_order.client_order_id();
10989        let active_venue_order_id = VenueOrderId::from("281476929510125");
10990        let retired_venue_order_id = VenueOrderId::from("281476929510126");
10991        cache_accepted_order(
10992            &cache,
10993            active_order,
10994            active_venue_order_id,
10995            Some(client_id()),
10996        );
10997        cache_canceled_order(&cache, retired_order, retired_venue_order_id);
10998        let (_, client_order_index) = forced_probed_index(active_cloid);
10999        let active_raw = reconciliation_raw_order(
11000            client_order_index,
11001            active_venue_order_id,
11002            LighterOrderStatus::Open,
11003        );
11004        let retired_raw = reconciliation_raw_order(
11005            client_order_index,
11006            retired_venue_order_id,
11007            LighterOrderStatus::Canceled,
11008        );
11009        let raw_client_id = client_order_index.to_string();
11010
11011        restore_reconciled_order(&client.core, &client.dispatch, &active_raw, false);
11012        restore_reconciled_order(&client.core, &client.dispatch, &retired_raw, true);
11013
11014        assert_eq!(
11015            client
11016                .dispatch
11017                .resolve_live_order_cloid(&raw_client_id, active_venue_order_id,),
11018            Some(active_cloid),
11019        );
11020        assert_eq!(
11021            client
11022                .dispatch
11023                .resolve_live_order_cloid(&raw_client_id, retired_venue_order_id,),
11024            Some(retired_cloid),
11025        );
11026        assert_eq!(
11027            client
11028                .dispatch
11029                .resolve_live_trade_cloid(&raw_client_id, active_venue_order_id),
11030            Some(active_cloid),
11031        );
11032        assert_eq!(
11033            client
11034                .dispatch
11035                .resolve_live_trade_cloid(&raw_client_id, retired_venue_order_id),
11036            Some(retired_cloid),
11037        );
11038    }
11039
11040    #[rstest]
11041    fn reconciliation_does_not_restore_from_numeric_client_index_alone() {
11042        let (client, cache, _rx) = create_execution_client();
11043        let instrument_id = register_test_instrument(&client, &cache);
11044        let mut factory = test_order_factory();
11045        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-NUMERIC-ONLY");
11046        let cloid = order.client_order_id();
11047        let client_order_index = client.dispatch.derive_client_order_index(&cloid);
11048        let venue_order_id = VenueOrderId::from("281476929510130");
11049        cache_order(&cache, order);
11050        let raw =
11051            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
11052
11053        restore_reconciled_order(&client.core, &client.dispatch, &raw, false);
11054        let report =
11055            parse_http_order_to_report(&raw, &client.registry, account_id(), UnixNanos::from(1))
11056                .unwrap();
11057        let translated = client.dispatch.translate_order_cloid(report);
11058
11059        assert_eq!(client.dispatch.client_order_index(&cloid), None);
11060        assert_eq!(
11061            client
11062                .dispatch
11063                .resolve_client_order_index(client_order_index),
11064            None,
11065        );
11066        assert!(!client.dispatch.order_identities.contains_key(&cloid));
11067        assert_eq!(
11068            translated.client_order_id,
11069            Some(ClientOrderId::new(venue_order_id.as_str())),
11070        );
11071        assert_eq!(translated.venue_order_id, venue_order_id);
11072    }
11073
11074    fn forced_probed_index(cloid: ClientOrderId) -> (i64, i64) {
11075        let source = WsDispatchState::new();
11076        let base_index = source.derive_client_order_index(&cloid);
11077        source
11078            .cloid_map
11079            .insert(base_index, ClientOrderId::from("O-FORCED-COLLISION"));
11080        let client_order_index = source.register_cloid(base_index, cloid).unwrap();
11081        (base_index, client_order_index)
11082    }
11083
11084    fn reconciliation_raw_order(
11085        client_order_index: i64,
11086        venue_order_id: VenueOrderId,
11087        status: LighterOrderStatus,
11088    ) -> LighterOrder {
11089        let rig = dispatcher_rig("RECONCILIATION-RAW");
11090        let mut raw = dispatcher_test_order(&rig, status);
11091        raw.order_index = venue_order_id.as_str().parse().unwrap();
11092        raw.order_id = venue_order_id.to_string();
11093        raw.client_order_index = client_order_index;
11094        raw.client_order_id = client_order_index.to_string();
11095        raw
11096    }
11097
11098    fn cache_canceled_order(
11099        cache: &Rc<RefCell<Cache>>,
11100        order: OrderAny,
11101        venue_order_id: VenueOrderId,
11102    ) {
11103        let (instrument_id, client_order_id) =
11104            cache_accepted_order(cache, order, venue_order_id, Some(client_id()));
11105        let canceled = OrderEventAny::Canceled(OrderCanceled::new(
11106            trader_id(),
11107            strategy_id(),
11108            instrument_id,
11109            client_order_id,
11110            UUID4::new(),
11111            UnixNanos::from(1),
11112            UnixNanos::from(2),
11113            false,
11114            Some(venue_order_id),
11115            Some(account_id()),
11116        ));
11117        cache.borrow_mut().update_order(&canceled).unwrap();
11118    }
11119
11120    fn reconciliation_fill_report(
11121        instrument_id: InstrumentId,
11122        client_order_index: i64,
11123        venue_order_id: VenueOrderId,
11124        trade_id: &str,
11125    ) -> FillReport {
11126        FillReport::new(
11127            account_id(),
11128            instrument_id,
11129            venue_order_id,
11130            TradeId::new(trade_id),
11131            OrderSide::Buy,
11132            Quantity::from("0.1336"),
11133            Price::from("2352.73"),
11134            Money::from("0.000196 USDC"),
11135            LiquiditySide::Taker,
11136            Some(ClientOrderId::new(client_order_index.to_string())),
11137            None,
11138            UnixNanos::from(1),
11139            UnixNanos::from(2),
11140            Some(UUID4::new()),
11141        )
11142    }
11143
11144    #[rstest]
11145    fn dispatch_lighter_order_seeds_snapshot_after_synthesized_accept() {
11146        // After a synthesised `OrderAccepted` (fill-before-open), the
11147        // next `Open` frame must seed the shape snapshot even when the
11148        // parser returns None. Without the seed, shape_changed stays
11149        // permanently false and a later modify is lost.
11150        let mut rig = dispatcher_rig("9");
11151        register_identity(&rig);
11152
11153        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
11154        let mut trade = dispatcher_test_trade(&rig, true);
11155        trade.bid_id = order.order_index;
11156        trade.bid_id_str = Some(order.order_id.clone());
11157        dispatch_lighter_trade(
11158            &trade,
11159            &rig.dispatch,
11160            &rig.emitter,
11161            &rig.registry,
11162            account_id(),
11163            trader_id(),
11164            Some(TEST_ACCOUNT_INDEX_I64),
11165            UnixNanos::from(1),
11166        );
11167        assert_eq!(drain_events(&mut rig.rx).len(), 2);
11168        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
11169        assert!(
11170            rig.dispatch.snapshot_for(&rig.cloid).is_none(),
11171            "synthesised Accept has no snapshot until the Open frame seeds one",
11172        );
11173
11174        // Open frame lands later (matches venue ordering). Parser
11175        // returns None (already accepted, shape unchanged) but the
11176        // dispatcher must still seed the snapshot baseline.
11177        dispatch_lighter_order(
11178            &order,
11179            &rig.dispatch,
11180            &rig.emitter,
11181            &rig.registry,
11182            account_id(),
11183            trader_id(),
11184            UnixNanos::from(2),
11185        );
11186        assert!(
11187            rig.dispatch.snapshot_for(&rig.cloid).is_some(),
11188            "Open frame after synthesised accept must seed the snapshot",
11189        );
11190
11191        // A real modify must now fire Updated.
11192        let mut modified = order;
11193        modified.price = Decimal::from_str("2400.00").unwrap();
11194        dispatch_lighter_order(
11195            &modified,
11196            &rig.dispatch,
11197            &rig.emitter,
11198            &rig.registry,
11199            account_id(),
11200            trader_id(),
11201            UnixNanos::from(3),
11202        );
11203        let events = drain_events(&mut rig.rx);
11204        let updated = events
11205            .iter()
11206            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Updated(_))));
11207        assert!(
11208            updated.is_some(),
11209            "real modify must produce Updated, events={events:?}"
11210        );
11211    }
11212
11213    fn enqueue_create(client: &LighterExecutionClient, order: &OrderAny, nonce: i64) -> i64 {
11214        let client_order_index = client
11215            .dispatch
11216            .derive_client_order_index(&order.client_order_id());
11217        client
11218            .dispatch
11219            .register_cloid(client_order_index, order.client_order_id())
11220            .unwrap();
11221        client.dispatch.register_order_identity(
11222            order.client_order_id(),
11223            OrderIdentity::new(
11224                order.instrument_id(),
11225                order.strategy_id(),
11226                order.order_side(),
11227                order.order_type(),
11228                client_order_index,
11229            ),
11230        );
11231        let now = UnixNanos::from(1_000_000_000);
11232        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11233            connection_epoch: 0,
11234            kind: PendingSendTxKind::Create {
11235                order: Box::new(order.clone()),
11236                client_order_index,
11237            },
11238            submitted_at: now,
11239            nonce,
11240            api_key_index: TEST_API_KEY_INDEX,
11241            tx_hash: format!("hash{nonce:02x}"),
11242        });
11243        client.dispatch.nonce_manager.refresh(
11244            TEST_ACCOUNT_INDEX_I64,
11245            TEST_API_KEY_INDEX,
11246            nonce + 1,
11247        );
11248        let _ = client
11249            .dispatch
11250            .nonce_manager
11251            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX);
11252        client_order_index
11253    }
11254
11255    fn enqueue_other(client: &LighterExecutionClient, nonce: i64) {
11256        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11257            connection_epoch: 0,
11258            kind: PendingSendTxKind::Other,
11259            submitted_at: UnixNanos::from(1_000_000_000),
11260            nonce,
11261            api_key_index: TEST_API_KEY_INDEX,
11262            tx_hash: format!("hash{nonce:02x}"),
11263        });
11264    }
11265
11266    fn enqueue_cancel(
11267        client: &LighterExecutionClient,
11268        instrument_id: InstrumentId,
11269        client_order_id: ClientOrderId,
11270        venue_order_id: VenueOrderId,
11271        nonce: i64,
11272    ) {
11273        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11274            connection_epoch: 0,
11275            kind: PendingSendTxKind::Cancel {
11276                strategy_id: strategy_id(),
11277                instrument_id,
11278                client_order_id,
11279                venue_order_id: Some(venue_order_id),
11280            },
11281            submitted_at: UnixNanos::from(1_000_000_000),
11282            nonce,
11283            api_key_index: TEST_API_KEY_INDEX,
11284            tx_hash: format!("hash{nonce:02x}"),
11285        });
11286    }
11287
11288    fn enqueue_modify(
11289        client: &LighterExecutionClient,
11290        instrument_id: InstrumentId,
11291        client_order_id: ClientOrderId,
11292        venue_order_id: VenueOrderId,
11293        nonce: i64,
11294    ) {
11295        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11296            connection_epoch: 0,
11297            kind: PendingSendTxKind::Modify {
11298                strategy_id: strategy_id(),
11299                instrument_id,
11300                client_order_id,
11301                venue_order_id: Some(venue_order_id),
11302            },
11303            submitted_at: UnixNanos::from(1_000_000_000),
11304            nonce,
11305            api_key_index: TEST_API_KEY_INDEX,
11306            tx_hash: format!("hash{nonce:02x}"),
11307        });
11308    }
11309
11310    #[tokio::test]
11311    async fn handle_send_tx_ack_removes_hash_matched_entry() {
11312        let (client, cache, mut rx) = create_execution_client();
11313        let instrument_id = register_test_instrument(&client, &cache);
11314        let mut factory = test_order_factory();
11315        let order_a = test_limit_order(&mut factory, instrument_id, "ACK-A");
11316        let order_b = test_limit_order(&mut factory, instrument_id, "ACK-B");
11317        enqueue_create(&client, &order_a, 10);
11318        enqueue_create(&client, &order_b, 11);
11319
11320        // Out-of-order ack: B's hash must remove B even though A is at head.
11321        let acked = handle_send_tx_ack(
11322            &client.dispatch,
11323            Some(TEST_ACCOUNT_INDEX_I64),
11324            200,
11325            Some("hash0b"),
11326        );
11327
11328        let acked = acked.expect("create ack");
11329        assert!(matches!(acked.kind, PendingSendTxKind::Create { .. }));
11330        assert!(matches!(
11331            AckedOrderProbe::from_pending(&acked),
11332            Some(AckedOrderProbe::Create {
11333                nonce: 11,
11334                connection_epoch: 0,
11335                ..
11336            }),
11337        ));
11338        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "only B pops");
11339        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
11340        match head.kind {
11341            PendingSendTxKind::Create { order, .. } => {
11342                assert_eq!(order.client_order_id(), order_a.client_order_id());
11343            }
11344            _ => panic!("expected Create kind"),
11345        }
11346        assert!(
11347            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11348                .await
11349                .is_err(),
11350            "ack must not emit an event",
11351        );
11352    }
11353
11354    #[rstest]
11355    fn acknowledged_create_tx_validation_requires_exact_identity() {
11356        let tx = LighterTx {
11357            code: 200,
11358            message: None,
11359            hash: "ABCDEF".to_string(),
11360            tx_type: LighterTxType::CreateOrder as u8,
11361            info: serde_json::json!({"ClientOrderIndex": 42}).to_string(),
11362            event_info: serde_json::json!({"ae": ""}).to_string(),
11363            status: LighterTxStatus::Failed,
11364            account_index: TEST_ACCOUNT_INDEX_I64,
11365            nonce: 10,
11366            api_key_index: TEST_API_KEY_INDEX,
11367        };
11368        assert!(
11369            validate_acked_create_tx(
11370                &tx,
11371                TEST_ACCOUNT_INDEX_I64,
11372                TEST_API_KEY_INDEX,
11373                42,
11374                10,
11375                "0xabcdef",
11376            )
11377            .is_ok(),
11378        );
11379
11380        let mismatches = [
11381            LighterTx {
11382                hash: "different".to_string(),
11383                ..tx.clone()
11384            },
11385            LighterTx {
11386                tx_type: LighterTxType::CancelOrder as u8,
11387                ..tx.clone()
11388            },
11389            LighterTx {
11390                account_index: TEST_ACCOUNT_INDEX_I64 + 1,
11391                ..tx.clone()
11392            },
11393            LighterTx {
11394                nonce: 11,
11395                ..tx.clone()
11396            },
11397            LighterTx {
11398                api_key_index: TEST_API_KEY_INDEX + 1,
11399                ..tx.clone()
11400            },
11401        ];
11402
11403        for mismatch in mismatches {
11404            let error = validate_acked_create_tx(
11405                &mismatch,
11406                TEST_ACCOUNT_INDEX_I64,
11407                TEST_API_KEY_INDEX,
11408                42,
11409                10,
11410                "abcdef",
11411            )
11412            .expect_err("mismatched transaction identity must fail");
11413            assert_eq!(
11414                error.to_string(),
11415                "Lighter transaction lookup did not match acknowledged create identity",
11416            );
11417        }
11418
11419        let wrong_index = LighterTx {
11420            info: serde_json::json!({"ClientOrderIndex": 43}).to_string(),
11421            ..tx
11422        };
11423        let error = validate_acked_create_tx(
11424            &wrong_index,
11425            TEST_ACCOUNT_INDEX_I64,
11426            TEST_API_KEY_INDEX,
11427            42,
11428            10,
11429            "abcdef",
11430        )
11431        .expect_err("mismatched client order index must fail");
11432        assert_eq!(
11433            error.to_string(),
11434            "Lighter transaction lookup returned client_order_index 43 for acknowledged create 42",
11435        );
11436    }
11437
11438    #[tokio::test]
11439    async fn send_tx_responses_only_attribute_within_their_connection_epoch() {
11440        let (client, _cache, mut rx) = create_execution_client();
11441        for (connection_epoch, nonce) in [(4, 20), (5, 21)] {
11442            client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11443                connection_epoch,
11444                kind: PendingSendTxKind::Other,
11445                submitted_at: UnixNanos::from(1_000_000_000 + nonce as u64),
11446                nonce,
11447                api_key_index: TEST_API_KEY_INDEX,
11448                tx_hash: "same-hash".to_string(),
11449            });
11450        }
11451
11452        let acked =
11453            handle_send_tx_ack_for_connection(&client.dispatch, None, 5, 200, Some("same-hash"))
11454                .expect("replacement epoch response should match");
11455        assert_eq!(acked.connection_epoch, 5);
11456        assert_eq!(acked.nonce, 21);
11457        {
11458            let queue = client.dispatch.pending_sendtx.lock();
11459            assert_eq!(queue.len(), 1);
11460            assert_eq!(queue[0].connection_epoch, 4);
11461            assert_eq!(queue[0].nonce, 20);
11462        }
11463
11464        assert!(!handle_send_tx_rejection_for_connection(
11465            &client.dispatch,
11466            &client.emitter,
11467            None,
11468            4,
11469            UnixNanos::from(1_000_000_100),
11470            SendTxRejectionSource::Ack,
11471            Some(21702),
11472            "invalid price",
11473            Some("same-hash"),
11474        ));
11475        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11476        assert!(
11477            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11478                .await
11479                .is_err(),
11480            "non-order responses must not emit order events",
11481        );
11482    }
11483
11484    #[tokio::test]
11485    async fn handle_send_tx_ack_unmatched_hash_pops_nothing() {
11486        let (client, cache, mut rx) = create_execution_client();
11487        let instrument_id = register_test_instrument(&client, &cache);
11488        let mut factory = test_order_factory();
11489        let order = test_limit_order(&mut factory, instrument_id, "ACK-UNMATCHED");
11490        enqueue_create(&client, &order, 10);
11491
11492        let acked = handle_send_tx_ack(
11493            &client.dispatch,
11494            Some(TEST_ACCOUNT_INDEX_I64),
11495            200,
11496            Some("0xabc"),
11497        );
11498
11499        assert!(acked.is_none());
11500        assert_eq!(
11501            client.dispatch.pending_sendtx_len(),
11502            1,
11503            "an echoed hash with no matching entry must not pop the head",
11504        );
11505        assert!(
11506            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11507                .await
11508                .is_err(),
11509            "unmatched ack must not emit an event",
11510        );
11511    }
11512
11513    #[tokio::test]
11514    async fn handle_send_tx_ack_hashless_with_multiple_pending_pops_nothing() {
11515        let (client, _cache, _rx) = create_execution_client();
11516        enqueue_other(&client, 10);
11517        enqueue_other(&client, 11);
11518
11519        let acked = handle_send_tx_ack(&client.dispatch, Some(TEST_ACCOUNT_INDEX_I64), 200, None);
11520
11521        assert!(acked.is_none());
11522        assert_eq!(
11523            client.dispatch.pending_sendtx_len(),
11524            2,
11525            "a hashless ack is ambiguous when multiple transactions are pending",
11526        );
11527    }
11528
11529    #[tokio::test]
11530    async fn prepared_create_tx_hash_round_trips_through_ack() {
11531        // The hash prepare threads into the queue must be the lowercase hex
11532        // form a venue ack echoes, or every live ack goes unattributed.
11533        let (client, cache, _rx) = create_execution_client();
11534        let instrument_id = register_test_instrument(&client, &cache);
11535        let mut factory = test_order_factory();
11536        let order = test_limit_order(&mut factory, instrument_id, "ACK-REAL-HASH");
11537
11538        let credential = test_credential();
11539        let plan = client
11540            .prepare_create_order_plan(&order, 0)
11541            .expect("prepare must validate");
11542        let prepared = client
11543            .fanout_dispatch_context(&credential)
11544            .expect("task admission")
11545            .sign_create_order(plan)
11546            .expect("prepare must sign");
11547
11548        assert_eq!(prepared.tx_hash.len(), TX_HASH_BYTES * 2);
11549        assert!(
11550            prepared
11551                .tx_hash
11552                .chars()
11553                .all(|c| matches!(c, '0'..='9' | 'a'..='f')),
11554            "tx_hash must be lowercase hex, was `{}`",
11555            prepared.tx_hash,
11556        );
11557
11558        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11559            connection_epoch: 0,
11560            kind: PendingSendTxKind::Create {
11561                order: Box::new(order),
11562                client_order_index: prepared.client_order_index,
11563            },
11564            submitted_at: UnixNanos::from(1_000_000_000),
11565            nonce: prepared.nonce,
11566            api_key_index: prepared.api_key_index,
11567            tx_hash: prepared.tx_hash.clone(),
11568        });
11569
11570        let acked = handle_send_tx_ack(
11571            &client.dispatch,
11572            Some(TEST_ACCOUNT_INDEX_I64),
11573            200,
11574            Some(&prepared.tx_hash),
11575        );
11576
11577        assert!(acked.is_some());
11578        assert_eq!(
11579            client.dispatch.pending_sendtx_len(),
11580            0,
11581            "venue echo of the signed hash must match the enqueued entry",
11582        );
11583    }
11584
11585    #[tokio::test]
11586    async fn handle_send_tx_ack_returns_cancel_for_noop_probe() {
11587        let (client, cache, mut rx) = create_execution_client();
11588        let instrument_id = register_test_instrument(&client, &cache);
11589        let client_order_id = ClientOrderId::from("ACK-CANCEL-NOOP");
11590        let venue_order_id = VenueOrderId::from("123");
11591        enqueue_cancel(&client, instrument_id, client_order_id, venue_order_id, 12);
11592
11593        let acked = handle_send_tx_ack(
11594            &client.dispatch,
11595            Some(TEST_ACCOUNT_INDEX_I64),
11596            200,
11597            Some("hash0c"),
11598        )
11599        .expect("acked cancel pending entry");
11600        let probe = AckedOrderProbe::from_pending(&acked).expect("cancel should schedule probe");
11601
11602        match probe {
11603            AckedOrderProbe::Cancel {
11604                instrument_id: actual_instrument_id,
11605                client_order_id: actual_client_order_id,
11606                venue_order_id: actual_venue_order_id,
11607            } => {
11608                assert_eq!(actual_instrument_id, instrument_id);
11609                assert_eq!(actual_client_order_id, client_order_id);
11610                assert_eq!(actual_venue_order_id, Some(venue_order_id));
11611            }
11612            other => panic!("expected cancel probe, was {other:?}"),
11613        }
11614        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11615        assert!(
11616            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11617                .await
11618                .is_err(),
11619            "ack itself must not emit before the no-op probe runs",
11620        );
11621    }
11622
11623    #[rstest]
11624    #[tokio::test]
11625    async fn acknowledged_order_probe_is_retained_until_session_shutdown() {
11626        let (mut client, cache, _rx) = create_execution_client();
11627        let instrument_id = register_test_instrument(&client, &cache);
11628        let pending = PendingSendTx {
11629            connection_epoch: 0,
11630            kind: PendingSendTxKind::Cancel {
11631                strategy_id: strategy_id(),
11632                instrument_id,
11633                client_order_id: ClientOrderId::from("ACK-RETAINED"),
11634                venue_order_id: Some(VenueOrderId::from("123")),
11635            },
11636            submitted_at: UnixNanos::from(1_000_000_000),
11637            nonce: 12,
11638            api_key_index: TEST_API_KEY_INDEX,
11639            tx_hash: "hash0c".to_string(),
11640        };
11641        let context = AckedOrderProbeContext {
11642            http_client: client.http_client.clone(),
11643            registry: Arc::clone(&client.registry),
11644            credential: test_credential(),
11645            dispatch: client.dispatch.clone(),
11646            account_id: client.core.account_id,
11647            clock: client.clock,
11648            emitter: client.emitter.clone(),
11649            connection_epoch: client.ws_client.connection_epoch_atomic(),
11650            cancellation_token: client.cancellation_token.clone(),
11651            pending_tasks: client.pending_tasks.spawner().expect("task spawner"),
11652        };
11653
11654        spawn_acked_order_probe(&pending, context);
11655
11656        assert_eq!(client.pending_tasks.len(), 1);
11657
11658        client.begin_session_shutdown();
11659        client
11660            .finish_session_shutdown()
11661            .await
11662            .expect("session shutdown");
11663
11664        assert!(client.pending_tasks.is_empty());
11665    }
11666
11667    #[tokio::test]
11668    async fn handle_send_tx_ack_returns_modify_for_noop_probe() {
11669        let (client, cache, mut rx) = create_execution_client();
11670        let instrument_id = register_test_instrument(&client, &cache);
11671        let client_order_id = ClientOrderId::from("ACK-MODIFY-NOOP");
11672        let venue_order_id = VenueOrderId::from("456");
11673        enqueue_modify(&client, instrument_id, client_order_id, venue_order_id, 13);
11674
11675        let acked = handle_send_tx_ack(
11676            &client.dispatch,
11677            Some(TEST_ACCOUNT_INDEX_I64),
11678            200,
11679            Some("hash0d"),
11680        )
11681        .expect("acked modify pending entry");
11682        let probe = AckedOrderProbe::from_pending(&acked).expect("modify should schedule probe");
11683
11684        match probe {
11685            AckedOrderProbe::Modify {
11686                instrument_id: actual_instrument_id,
11687                client_order_id: actual_client_order_id,
11688                venue_order_id: actual_venue_order_id,
11689            } => {
11690                assert_eq!(actual_instrument_id, instrument_id);
11691                assert_eq!(actual_client_order_id, client_order_id);
11692                assert_eq!(actual_venue_order_id, Some(venue_order_id));
11693            }
11694            other => panic!("expected modify probe, was {other:?}"),
11695        }
11696        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11697        assert!(
11698            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11699                .await
11700                .is_err(),
11701            "ack itself must not emit before the no-op probe runs",
11702        );
11703    }
11704
11705    #[tokio::test]
11706    async fn ack_noop_probe_missing_cancel_leaves_outcome_unresolved() {
11707        let (client, cache, mut rx) = create_execution_client();
11708        let instrument_id = register_test_instrument(&client, &cache);
11709        let client_order_id = ClientOrderId::from("ACK-CANCEL-MISSING");
11710        let venue_order_id = VenueOrderId::from("123");
11711        let probe = AckedOrderProbe::Cancel {
11712            instrument_id,
11713            client_order_id,
11714            venue_order_id: Some(venue_order_id),
11715        };
11716
11717        warn_if_acked_order_missing(&probe, false);
11718        assert!(
11719            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11720                .await
11721                .is_err(),
11722            "one missing lookup must not become CancelRejected",
11723        );
11724    }
11725
11726    #[tokio::test]
11727    async fn ack_noop_probe_missing_modify_leaves_outcome_unresolved() {
11728        let (client, cache, mut rx) = create_execution_client();
11729        let instrument_id = register_test_instrument(&client, &cache);
11730        let client_order_id = ClientOrderId::from("ACK-MODIFY-MISSING");
11731        let venue_order_id = VenueOrderId::from("456");
11732        let probe = AckedOrderProbe::Modify {
11733            instrument_id,
11734            client_order_id,
11735            venue_order_id: Some(venue_order_id),
11736        };
11737
11738        warn_if_acked_order_missing(&probe, false);
11739        assert!(
11740            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11741                .await
11742                .is_err(),
11743            "one missing lookup must not become ModifyRejected",
11744        );
11745    }
11746
11747    #[tokio::test]
11748    async fn ack_noop_probe_found_order_skips_rejection() {
11749        let (client, cache, mut rx) = create_execution_client();
11750        let instrument_id = register_test_instrument(&client, &cache);
11751        let probe = AckedOrderProbe::Cancel {
11752            instrument_id,
11753            client_order_id: ClientOrderId::from("ACK-CANCEL-FOUND"),
11754            venue_order_id: Some(VenueOrderId::from("123")),
11755        };
11756
11757        warn_if_acked_order_missing(&probe, true);
11758        assert!(
11759            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11760                .await
11761                .is_err(),
11762            "found acked order must not emit a no-op rejection",
11763        );
11764    }
11765
11766    #[tokio::test]
11767    async fn send_tx_acks_recover_skip_window_past_window_size() {
11768        let (client, _cache, _rx) = create_execution_client();
11769        let window = i64::from(client.dispatch.nonce_manager.skip_window());
11770
11771        for _ in 0..window {
11772            let nonce = client
11773                .dispatch
11774                .nonce_manager
11775                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11776                .unwrap();
11777            enqueue_other(&client, nonce);
11778        }
11779        assert!(
11780            client
11781                .dispatch
11782                .nonce_manager
11783                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11784                .is_err(),
11785            "window must trip after {window} unacked txs",
11786        );
11787
11788        // Each venue ack reopens one slot, carrying issuance past 2x the
11789        // window without a refresh.
11790        for i in 0..window {
11791            let acked = handle_send_tx_ack(
11792                &client.dispatch,
11793                Some(TEST_ACCOUNT_INDEX_I64),
11794                200,
11795                Some(&format!("hash{:02x}", TEST_NEXT_NONCE + i)),
11796            );
11797            assert!(matches!(
11798                acked.map(|pending| pending.kind),
11799                Some(PendingSendTxKind::Other),
11800            ));
11801            let nonce = client
11802                .dispatch
11803                .nonce_manager
11804                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11805                .unwrap();
11806            assert_eq!(nonce, TEST_NEXT_NONCE + window + i);
11807            enqueue_other(&client, nonce);
11808        }
11809    }
11810
11811    #[tokio::test]
11812    async fn handle_send_tx_rejection_rolls_back_latest_nonce() {
11813        let (client, cache, mut rx) = create_execution_client();
11814        let instrument_id = register_test_instrument(&client, &cache);
11815        let mut factory = test_order_factory();
11816        let order = test_limit_order(&mut factory, instrument_id, "REJECT-LATEST");
11817
11818        let nonce = client
11819            .dispatch
11820            .nonce_manager
11821            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11822            .unwrap();
11823        let client_order_index = client.dispatch.register_create_identity(&order).unwrap();
11824        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11825            connection_epoch: 0,
11826            kind: PendingSendTxKind::Create {
11827                order: Box::new(order.clone()),
11828                client_order_index,
11829            },
11830            submitted_at: UnixNanos::from(1_000_000_000),
11831            nonce,
11832            api_key_index: TEST_API_KEY_INDEX,
11833            tx_hash: format!("hash{nonce:02x}"),
11834        });
11835
11836        handle_send_tx_rejection(
11837            &client.dispatch,
11838            &client.emitter,
11839            Some(TEST_ACCOUNT_INDEX_I64),
11840            UnixNanos::from(1_000_000_000),
11841            SendTxRejectionSource::Ack,
11842            Some(21702),
11843            "invalid price",
11844            None,
11845        );
11846
11847        let event = recv_order_event(&mut rx).await;
11848        assert!(matches!(event, OrderEventAny::Rejected(_)));
11849        assert_nonce_reusable(&client.dispatch);
11850    }
11851
11852    #[tokio::test]
11853    async fn handle_send_tx_rejection_hashless_with_multiple_pending_is_unattributed() {
11854        let (client, cache, mut rx) = create_execution_client();
11855        let instrument_id = register_test_instrument(&client, &cache);
11856        let mut factory = test_order_factory();
11857        let order_a = test_limit_order(&mut factory, instrument_id, "REJECT-AMBIGUOUS-A");
11858        let order_b = test_limit_order(&mut factory, instrument_id, "REJECT-AMBIGUOUS-B");
11859        enqueue_create(&client, &order_a, 10);
11860        enqueue_create(&client, &order_b, 11);
11861
11862        handle_send_tx_rejection(
11863            &client.dispatch,
11864            &client.emitter,
11865            Some(TEST_ACCOUNT_INDEX_I64),
11866            UnixNanos::from(1_000_000_000),
11867            SendTxRejectionSource::Ack,
11868            Some(21702),
11869            "invalid price",
11870            None,
11871        );
11872
11873        assert_eq!(client.dispatch.pending_sendtx_len(), 2);
11874        assert!(
11875            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11876                .await
11877                .is_err(),
11878            "an ambiguous hashless rejection must not reject either order",
11879        );
11880    }
11881
11882    #[tokio::test]
11883    async fn handle_send_tx_rejection_with_newer_issuance_skips_rollback() {
11884        let (client, cache, mut rx) = create_execution_client();
11885        let instrument_id = register_test_instrument(&client, &cache);
11886        let mut factory = test_order_factory();
11887        let order = test_limit_order(&mut factory, instrument_id, "REJECT-STALE");
11888
11889        let rejected_nonce = client
11890            .dispatch
11891            .nonce_manager
11892            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11893            .unwrap();
11894        let newer_nonce = client
11895            .dispatch
11896            .nonce_manager
11897            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11898            .unwrap();
11899        let client_order_index = client.dispatch.register_create_identity(&order).unwrap();
11900        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
11901            connection_epoch: 0,
11902            kind: PendingSendTxKind::Create {
11903                order: Box::new(order.clone()),
11904                client_order_index,
11905            },
11906            submitted_at: UnixNanos::from(1_000_000_000),
11907            nonce: rejected_nonce,
11908            api_key_index: TEST_API_KEY_INDEX,
11909            tx_hash: format!("hash{rejected_nonce:02x}"),
11910        });
11911
11912        handle_send_tx_rejection(
11913            &client.dispatch,
11914            &client.emitter,
11915            Some(TEST_ACCOUNT_INDEX_I64),
11916            UnixNanos::from(1_000_000_000),
11917            SendTxRejectionSource::Ack,
11918            Some(21702),
11919            "invalid price",
11920            None,
11921        );
11922
11923        let event = recv_order_event(&mut rx).await;
11924        assert!(matches!(event, OrderEventAny::Rejected(_)));
11925        // The newer nonce is signed into an in-flight tx: the failed nonce
11926        // must not be freed for reissue.
11927        assert_eq!(
11928            client
11929                .dispatch
11930                .nonce_manager
11931                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
11932            Some(newer_nonce),
11933        );
11934        assert_eq!(
11935            client
11936                .dispatch
11937                .nonce_manager
11938                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
11939                .unwrap(),
11940            newer_nonce + 1,
11941        );
11942    }
11943
11944    async fn spawn_next_nonce_server(nonce: i64) -> String {
11945        let body = serde_json::to_string(&LighterNextNonce {
11946            code: 200,
11947            message: None,
11948            nonce,
11949        })
11950        .unwrap();
11951        let app = Router::new().route(
11952            "/api/v1/nextNonce",
11953            get(move || {
11954                let body = body.clone();
11955                async move { body }
11956            }),
11957        );
11958        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11959        let addr = listener.local_addr().unwrap();
11960        tokio::spawn(async move {
11961            axum::serve(listener, app).await.unwrap();
11962        });
11963
11964        format!("http://{addr}")
11965    }
11966
11967    async fn spawn_counting_next_nonce_server(nonce: i64) -> (String, Arc<AtomicUsize>) {
11968        let requests = Arc::new(AtomicUsize::new(0));
11969        let requests_for_route = Arc::clone(&requests);
11970        let body = serde_json::to_string(&LighterNextNonce {
11971            code: 200,
11972            message: None,
11973            nonce,
11974        })
11975        .unwrap();
11976        let app = Router::new().route(
11977            "/api/v1/nextNonce",
11978            get(move || {
11979                requests_for_route.fetch_add(1, Ordering::SeqCst);
11980                let body = body.clone();
11981                async move { body }
11982            }),
11983        );
11984        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11985        let addr = listener.local_addr().unwrap();
11986        tokio::spawn(async move {
11987            axum::serve(listener, app).await.unwrap();
11988        });
11989
11990        (format!("http://{addr}"), requests)
11991    }
11992
11993    #[tokio::test]
11994    async fn hard_nonce_refresh_waits_for_submission_and_replaces_its_baseline() {
11995        let (base_url_http, requests) = spawn_counting_next_nonce_server(100).await;
11996        let mut config = test_config();
11997        config.base_url_http = Some(base_url_http);
11998        let (client, _cache, _rx) = create_execution_client_with_config(config);
11999        let credential = test_credential();
12000        client
12001            .dispatch
12002            .nonce_manager
12003            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, 40);
12004
12005        let reserved = client.build_tx_context(&credential).unwrap();
12006        assert_eq!(reserved.context.nonce, 40);
12007        let mut refresh = Box::pin(client.refresh_nonce());
12008        assert!(
12009            tokio::time::timeout(Duration::from_millis(50), refresh.as_mut())
12010                .await
12011                .is_err(),
12012            "hard refresh must wait while a submission retains its nonce",
12013        );
12014        assert_eq!(requests.load(Ordering::SeqCst), 0);
12015        assert_eq!(
12016            client.nonce_ready_connection_epoch.load(Ordering::Acquire),
12017            NONCE_CONNECTION_EPOCH_UNAVAILABLE,
12018        );
12019        assert_eq!(
12020            client
12021                .dispatch
12022                .nonce_manager
12023                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12024            Some(40),
12025        );
12026
12027        drop(reserved);
12028        refresh.await.unwrap();
12029
12030        assert_eq!(requests.load(Ordering::SeqCst), 1);
12031        assert_eq!(
12032            client.nonce_ready_connection_epoch.load(Ordering::Acquire),
12033            NONCE_CONNECTION_EPOCH_UNAVAILABLE,
12034        );
12035        client
12036            .nonce_ready_connection_epoch
12037            .store(client.ws_client.connection_epoch(), Ordering::Release);
12038        let next = client.build_tx_context(&credential).unwrap();
12039        assert_eq!(next.context.nonce, 100);
12040        assert_eq!(
12041            client
12042                .dispatch
12043                .nonce_manager
12044                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12045            Some(100),
12046        );
12047    }
12048
12049    #[tokio::test]
12050    async fn nonce_refresh_retry_reopens_submission_with_replacement_baseline() {
12051        let (base_url_http, requests) = spawn_counting_next_nonce_server(100).await;
12052        let mut config = test_config();
12053        config.base_url_http = Some(base_url_http);
12054        let (client, _cache, _rx) = create_execution_client_with_config(config);
12055        let credential = test_credential();
12056        let connection_epoch = client.ws_client.connection_epoch();
12057        client
12058            .dispatch
12059            .nonce_manager
12060            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, 40);
12061        client
12062            .nonce_ready_connection_epoch
12063            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
12064
12065        NonceRefreshRetry {
12066            http_client: client.http_client.clone(),
12067            dispatch: client.dispatch.clone(),
12068            credential: Some(credential.clone()),
12069            submission_gate: Arc::clone(&client.nonce_submission_gate),
12070            ready_connection_epoch: Arc::clone(&client.nonce_ready_connection_epoch),
12071            ws_client: client.ws_client.clone(),
12072            cancellation_token: client.cancellation_token.clone(),
12073            pending_tasks: client.pending_tasks.spawner().expect("task spawner"),
12074        }
12075        .spawn(connection_epoch);
12076
12077        assert_eq!(client.pending_tasks.len(), 1);
12078
12079        let err = client
12080            .build_tx_context(&credential)
12081            .expect_err("submission must stay closed until retry succeeds");
12082        assert_eq!(
12083            err.to_string(),
12084            format!("Lighter nonce refresh is pending for connection epoch {connection_epoch}"),
12085        );
12086        wait_until_async(
12087            || async {
12088                client.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
12089            },
12090            Duration::from_secs(3),
12091        )
12092        .await;
12093
12094        let reserved = client.build_tx_context(&credential).unwrap();
12095        assert_eq!(requests.load(Ordering::SeqCst), 1);
12096        assert_eq!(reserved.context.nonce, 100);
12097        assert_eq!(
12098            client
12099                .dispatch
12100                .nonce_manager
12101                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12102            Some(99),
12103        );
12104        assert_eq!(
12105            client
12106                .dispatch
12107                .nonce_manager
12108                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12109            Some(100),
12110        );
12111        assert!(client.pending_tasks.all_finished());
12112    }
12113
12114    async fn spawn_integrator_approval_rejection_server() -> String {
12115        let app = Router::new()
12116            .route(
12117                "/api/v1/getMakerOnlyApiKeys",
12118                get(|| async { r#"{"code":200,"api_key_indexes":[]}"# }),
12119            )
12120            .route(
12121                "/api/v1/sendTx",
12122                post(|| async { r#"{"code":21702,"message":"invalid approval"}"# }),
12123            );
12124        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12125        let addr = listener.local_addr().unwrap();
12126        tokio::spawn(async move {
12127            axum::serve(listener, app).await.unwrap();
12128        });
12129
12130        format!("http://{addr}")
12131    }
12132
12133    #[tokio::test]
12134    async fn integrator_approval_api_rejection_releases_reservation_and_nonce() {
12135        let mut config = test_config();
12136        config.environment = LighterEnvironment::Mainnet;
12137        config.base_url_http = Some(spawn_integrator_approval_rejection_server().await);
12138        let (mut client, _cache, _rx) = create_execution_client_with_config(config);
12139        client.account_tier = Some(LighterAccountTier::Premium);
12140
12141        let err = client.submit_integrator_auto_approval().await.unwrap_err();
12142
12143        assert!(format!("{err:#}").contains("invalid approval"));
12144        assert_nonce_reusable(&client.dispatch);
12145    }
12146
12147    #[tokio::test]
12148    async fn skip_window_exhaustion_resyncs_baseline_from_venue() {
12149        let venue_next_nonce = 100;
12150        let mut config = test_config();
12151        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
12152        let (client, _cache, _rx) = create_execution_client_with_config(config);
12153        let credential = test_credential();
12154
12155        let window = i64::from(client.dispatch.nonce_manager.skip_window());
12156        for _ in 0..window {
12157            client
12158                .dispatch
12159                .nonce_manager
12160                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12161                .unwrap();
12162        }
12163
12164        let err = client
12165            .build_tx_context(&credential)
12166            .expect_err("window must trip");
12167        assert!(
12168            err.to_string().contains("skip-window exhausted"),
12169            "unexpected error, was {err}",
12170        );
12171
12172        wait_for_spawned_tasks(&client).await;
12173
12174        assert_eq!(
12175            client
12176                .dispatch
12177                .nonce_manager
12178                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12179            Some(venue_next_nonce - 1),
12180            "venue resync must advance the baseline",
12181        );
12182        assert!(
12183            !client.nonce_recovery_inflight.load(Ordering::Acquire),
12184            "recovery latch must release for the next exhaustion",
12185        );
12186        let context = client.build_tx_context(&credential).unwrap().context;
12187        assert_eq!(
12188            context.nonce, venue_next_nonce,
12189            "allocation must resume at the venue nonce",
12190        );
12191    }
12192
12193    #[tokio::test]
12194    async fn skip_window_recovery_fetch_failure_releases_latch() {
12195        // 404 fails the fetch fast; unroutable addresses retry past the task wait
12196        let app = Router::new();
12197        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12198        let addr = listener.local_addr().unwrap();
12199        tokio::spawn(async move {
12200            axum::serve(listener, app).await.unwrap();
12201        });
12202        let mut config = test_config();
12203        config.base_url_http = Some(format!("http://{addr}"));
12204        let (client, _cache, _rx) = create_execution_client_with_config(config);
12205        let credential = test_credential();
12206
12207        let window = i64::from(client.dispatch.nonce_manager.skip_window());
12208        for _ in 0..window {
12209            client
12210                .dispatch
12211                .nonce_manager
12212                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12213                .unwrap();
12214        }
12215        client
12216            .build_tx_context(&credential)
12217            .expect_err("window must trip");
12218
12219        wait_for_spawned_tasks(&client).await;
12220
12221        assert_eq!(
12222            client
12223                .dispatch
12224                .nonce_manager
12225                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12226            Some(TEST_NEXT_NONCE - 1),
12227            "failed venue fetch must leave the baseline unchanged",
12228        );
12229        assert!(
12230            !client.nonce_recovery_inflight.load(Ordering::Acquire),
12231            "failed venue fetch must release the recovery latch",
12232        );
12233    }
12234
12235    #[tokio::test]
12236    async fn skip_window_recovery_dedupes_concurrent_fetches() {
12237        let hits = Arc::new(AtomicUsize::new(0));
12238        let server_hits = Arc::clone(&hits);
12239        let body = serde_json::to_string(&LighterNextNonce {
12240            code: 200,
12241            message: None,
12242            nonce: 100,
12243        })
12244        .unwrap();
12245        let app = Router::new().route(
12246            "/api/v1/nextNonce",
12247            get(move || {
12248                server_hits.fetch_add(1, Ordering::AcqRel);
12249                let body = body.clone();
12250                async move { body }
12251            }),
12252        );
12253        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12254        let addr = listener.local_addr().unwrap();
12255        tokio::spawn(async move {
12256            axum::serve(listener, app).await.unwrap();
12257        });
12258
12259        let mut config = test_config();
12260        config.base_url_http = Some(format!("http://{addr}"));
12261        let (client, _cache, _rx) = create_execution_client_with_config(config);
12262        let credential = test_credential();
12263
12264        let window = i64::from(client.dispatch.nonce_manager.skip_window());
12265        for _ in 0..window {
12266            client
12267                .dispatch
12268                .nonce_manager
12269                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12270                .unwrap();
12271        }
12272
12273        // Back-to-back exhaustions land inside one HTTP round trip; the
12274        // latch must collapse them into a single fetch.
12275        client
12276            .build_tx_context(&credential)
12277            .expect_err("window must trip");
12278        client
12279            .build_tx_context(&credential)
12280            .expect_err("window must still be exhausted");
12281
12282        wait_for_spawned_tasks(&client).await;
12283
12284        assert_eq!(
12285            hits.load(Ordering::Acquire),
12286            1,
12287            "burst exhaustion must trigger a single venue fetch",
12288        );
12289        assert!(!client.nonce_recovery_inflight.load(Ordering::Acquire));
12290    }
12291
12292    #[tokio::test]
12293    async fn refresh_nonce_releases_recovery_latch() {
12294        let venue_next_nonce = 77;
12295        let mut config = test_config();
12296        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
12297        let (client, _cache, _rx) = create_execution_client_with_config(config);
12298
12299        // Simulate a recovery task aborted between latch set and clear
12300        client
12301            .nonce_recovery_inflight
12302            .store(true, Ordering::Release);
12303
12304        client.refresh_nonce().await.unwrap();
12305
12306        assert!(
12307            !client.nonce_recovery_inflight.load(Ordering::Acquire),
12308            "connect-time refresh must release a stuck recovery latch",
12309        );
12310        assert_eq!(
12311            client
12312                .dispatch
12313                .nonce_manager
12314                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12315                .unwrap(),
12316            venue_next_nonce,
12317            "refresh must hard-reset allocation to the venue nonce",
12318        );
12319    }
12320
12321    #[tokio::test]
12322    async fn handle_send_tx_rejection_ack_create_emits_order_rejected() {
12323        let (client, cache, mut rx) = create_execution_client();
12324        let instrument_id = register_test_instrument(&client, &cache);
12325        let mut factory = test_order_factory();
12326        let order = test_limit_order(&mut factory, instrument_id, "REJECT-CREATE");
12327        let client_order_index = enqueue_create(&client, &order, 42);
12328
12329        handle_send_tx_rejection(
12330            &client.dispatch,
12331            &client.emitter,
12332            Some(TEST_ACCOUNT_INDEX_I64),
12333            UnixNanos::from(1_000_000_000),
12334            SendTxRejectionSource::Ack,
12335            Some(21702),
12336            "invalid price",
12337            None,
12338        );
12339
12340        let event = recv_order_event(&mut rx).await;
12341        match event {
12342            OrderEventAny::Rejected(e) => {
12343                assert_eq!(e.client_order_id, order.client_order_id());
12344                assert_eq!(e.reason.as_str(), "LIGHTER_21702: invalid price");
12345                assert!(!e.due_post_only);
12346            }
12347            other => panic!("expected Rejected, was {other:?}"),
12348        }
12349        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12350        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
12351    }
12352
12353    #[tokio::test]
12354    async fn handle_send_tx_rejection_ack_create_sets_due_post_only() {
12355        let (client, cache, mut rx) = create_execution_client();
12356        let instrument_id = register_test_instrument(&client, &cache);
12357        let mut factory = test_order_factory();
12358        let order = test_limit_order(&mut factory, instrument_id, "REJECT-POST-ONLY");
12359        enqueue_create(&client, &order, 43);
12360
12361        handle_send_tx_rejection(
12362            &client.dispatch,
12363            &client.emitter,
12364            Some(TEST_ACCOUNT_INDEX_I64),
12365            UnixNanos::from(1_000_000_000),
12366            SendTxRejectionSource::Ack,
12367            Some(21700),
12368            "post-only order would execute",
12369            None,
12370        );
12371
12372        let event = recv_order_event(&mut rx).await;
12373        match event {
12374            OrderEventAny::Rejected(e) => {
12375                assert_eq!(e.client_order_id, order.client_order_id());
12376                assert!(e.due_post_only);
12377            }
12378            other => panic!("expected Rejected, was {other:?}"),
12379        }
12380        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12381    }
12382
12383    #[tokio::test]
12384    async fn handle_send_tx_rejection_cancel_emits_cancel_rejected() {
12385        let (client, cache, mut rx) = create_execution_client();
12386        let instrument_id = register_test_instrument(&client, &cache);
12387        let client_order_id = ClientOrderId::from("REJECT-CANCEL");
12388        let venue_order_id = VenueOrderId::from("123");
12389        let nonce = client
12390            .dispatch
12391            .nonce_manager
12392            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12393            .unwrap();
12394
12395        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
12396            connection_epoch: 0,
12397            kind: PendingSendTxKind::Cancel {
12398                strategy_id: strategy_id(),
12399                instrument_id,
12400                client_order_id,
12401                venue_order_id: Some(venue_order_id),
12402            },
12403            submitted_at: UnixNanos::from(1_000_000_000),
12404            nonce,
12405            api_key_index: TEST_API_KEY_INDEX,
12406            tx_hash: format!("hash{nonce:02x}"),
12407        });
12408
12409        handle_send_tx_rejection(
12410            &client.dispatch,
12411            &client.emitter,
12412            Some(TEST_ACCOUNT_INDEX_I64),
12413            UnixNanos::from(1_000_000_000),
12414            SendTxRejectionSource::Ack,
12415            Some(21727),
12416            "order is not cancelable",
12417            None,
12418        );
12419
12420        let event = recv_order_event(&mut rx).await;
12421        match event {
12422            OrderEventAny::CancelRejected(e) => {
12423                assert_eq!(e.client_order_id, client_order_id);
12424                assert_eq!(e.instrument_id, instrument_id);
12425                assert_eq!(e.venue_order_id, Some(venue_order_id));
12426                assert_eq!(e.reason.as_str(), "LIGHTER_21727: order is not cancelable",);
12427            }
12428            other => panic!("expected CancelRejected, was {other:?}"),
12429        }
12430        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12431        assert_nonce_reusable(&client.dispatch);
12432    }
12433
12434    #[tokio::test]
12435    async fn handle_send_tx_rejection_modify_emits_modify_rejected() {
12436        let (client, cache, mut rx) = create_execution_client();
12437        let instrument_id = register_test_instrument(&client, &cache);
12438        let client_order_id = ClientOrderId::from("REJECT-MODIFY");
12439        let venue_order_id = VenueOrderId::from("456");
12440        let nonce = client
12441            .dispatch
12442            .nonce_manager
12443            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12444            .unwrap();
12445
12446        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
12447            connection_epoch: 0,
12448            kind: PendingSendTxKind::Modify {
12449                strategy_id: strategy_id(),
12450                instrument_id,
12451                client_order_id,
12452                venue_order_id: Some(venue_order_id),
12453            },
12454            submitted_at: UnixNanos::from(1_000_000_000),
12455            nonce,
12456            api_key_index: TEST_API_KEY_INDEX,
12457            tx_hash: format!("hash{nonce:02x}"),
12458        });
12459
12460        handle_send_tx_rejection(
12461            &client.dispatch,
12462            &client.emitter,
12463            Some(TEST_ACCOUNT_INDEX_I64),
12464            UnixNanos::from(1_000_000_000),
12465            SendTxRejectionSource::Ack,
12466            Some(21702),
12467            "modify rejected by venue",
12468            None,
12469        );
12470
12471        let event = recv_order_event(&mut rx).await;
12472        match event {
12473            OrderEventAny::ModifyRejected(e) => {
12474                assert_eq!(e.client_order_id, client_order_id);
12475                assert_eq!(e.instrument_id, instrument_id);
12476                assert_eq!(e.venue_order_id, Some(venue_order_id));
12477                assert_eq!(e.reason.as_str(), "LIGHTER_21702: modify rejected by venue",);
12478            }
12479            other => panic!("expected ModifyRejected, was {other:?}"),
12480        }
12481        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12482        assert_nonce_reusable(&client.dispatch);
12483    }
12484
12485    #[tokio::test]
12486    async fn handle_send_tx_rejection_hash_match_attributes_past_the_head() {
12487        let (client, cache, mut rx) = create_execution_client();
12488        let instrument_id = register_test_instrument(&client, &cache);
12489        let mut factory = test_order_factory();
12490        let order_a = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-A");
12491        let order_b = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-B");
12492        enqueue_create(&client, &order_a, 10);
12493        enqueue_create(&client, &order_b, 11);
12494
12495        // A desynced or out-of-order rejection for B must not consume A.
12496        handle_send_tx_rejection(
12497            &client.dispatch,
12498            &client.emitter,
12499            Some(TEST_ACCOUNT_INDEX_I64),
12500            UnixNanos::from(1_000_000_000),
12501            SendTxRejectionSource::Ack,
12502            Some(21702),
12503            "invalid price",
12504            Some("hash0b"),
12505        );
12506
12507        let event = recv_order_event(&mut rx).await;
12508        match event {
12509            OrderEventAny::Rejected(e) => {
12510                assert_eq!(e.client_order_id, order_b.client_order_id());
12511            }
12512            other => panic!("expected Rejected, was {other:?}"),
12513        }
12514        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "A must survive");
12515        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
12516        match head.kind {
12517            PendingSendTxKind::Create { order, .. } => {
12518                assert_eq!(order.client_order_id(), order_a.client_order_id());
12519            }
12520            _ => panic!("expected Create kind"),
12521        }
12522    }
12523
12524    #[tokio::test]
12525    async fn handle_send_tx_rejection_unmatched_hash_pops_nothing() {
12526        let (client, cache, mut rx) = create_execution_client();
12527        let instrument_id = register_test_instrument(&client, &cache);
12528        let mut factory = test_order_factory();
12529        let order = test_limit_order(&mut factory, instrument_id, "REJECT-UNMATCHED");
12530        enqueue_create(&client, &order, 10);
12531
12532        handle_send_tx_rejection(
12533            &client.dispatch,
12534            &client.emitter,
12535            Some(TEST_ACCOUNT_INDEX_I64),
12536            UnixNanos::from(1_000_000_000),
12537            SendTxRejectionSource::Ack,
12538            Some(21702),
12539            "invalid price",
12540            Some("0xbeef"),
12541        );
12542
12543        assert_eq!(
12544            client.dispatch.pending_sendtx_len(),
12545            1,
12546            "an echoed hash with no matching entry must not pop the head",
12547        );
12548        assert!(
12549            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12550                .await
12551                .is_err(),
12552            "unmatched rejection must not emit an event",
12553        );
12554    }
12555
12556    #[tokio::test]
12557    async fn handle_send_tx_rejection_bare_error_within_window_attributes() {
12558        let (client, cache, mut rx) = create_execution_client();
12559        let instrument_id = register_test_instrument(&client, &cache);
12560        let mut factory = test_order_factory();
12561        let order = test_limit_order(&mut factory, instrument_id, "BARE-IN");
12562        enqueue_create(&client, &order, 50);
12563
12564        let within_window = UnixNanos::from(1_000_000_000 + 500 * 1_000_000);
12565        handle_send_tx_rejection(
12566            &client.dispatch,
12567            &client.emitter,
12568            Some(TEST_ACCOUNT_INDEX_I64),
12569            within_window,
12570            SendTxRejectionSource::BareError,
12571            Some(21149),
12572            "integrator is not approved",
12573            None,
12574        );
12575
12576        let event = recv_order_event(&mut rx).await;
12577        assert!(matches!(event, OrderEventAny::Rejected(_)));
12578        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12579    }
12580
12581    #[tokio::test]
12582    async fn handle_send_tx_rejection_bare_error_outside_window_skips() {
12583        let (client, cache, mut rx) = create_execution_client();
12584        let instrument_id = register_test_instrument(&client, &cache);
12585        let mut factory = test_order_factory();
12586        let order = test_limit_order(&mut factory, instrument_id, "BARE-OUT");
12587        enqueue_create(&client, &order, 60);
12588
12589        let outside_window = UnixNanos::from(1_000_000_000 + 2_000 * 1_000_000);
12590        handle_send_tx_rejection(
12591            &client.dispatch,
12592            &client.emitter,
12593            Some(TEST_ACCOUNT_INDEX_I64),
12594            outside_window,
12595            SendTxRejectionSource::BareError,
12596            Some(99),
12597            "late error",
12598            None,
12599        );
12600
12601        assert_eq!(
12602            client.dispatch.pending_sendtx_len(),
12603            1,
12604            "head must remain queued past the 1s attribution window",
12605        );
12606        assert!(
12607            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12608                .await
12609                .is_err(),
12610            "no event must be emitted outside the window",
12611        );
12612    }
12613
12614    #[tokio::test]
12615    async fn handle_send_tx_rejection_bare_error_with_multiple_pending_skips() {
12616        let (client, cache, mut rx) = create_execution_client();
12617        let instrument_id = register_test_instrument(&client, &cache);
12618        let mut factory = test_order_factory();
12619        let order_a = test_limit_order(&mut factory, instrument_id, "BARE-MULTI-A");
12620        let order_b = test_limit_order(&mut factory, instrument_id, "BARE-MULTI-B");
12621        enqueue_create(&client, &order_a, 70);
12622        enqueue_create(&client, &order_b, 71);
12623
12624        handle_send_tx_rejection(
12625            &client.dispatch,
12626            &client.emitter,
12627            Some(TEST_ACCOUNT_INDEX_I64),
12628            UnixNanos::from(1_000_000_000 + 500 * 1_000_000),
12629            SendTxRejectionSource::BareError,
12630            Some(21149),
12631            "integrator is not approved",
12632            None,
12633        );
12634
12635        assert_eq!(client.dispatch.pending_sendtx_len(), 2);
12636        assert!(
12637            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12638                .await
12639                .is_err(),
12640            "a bare error cannot be attributed with multiple transactions pending",
12641        );
12642    }
12643
12644    #[tokio::test]
12645    async fn handle_send_tx_rejection_other_kind_rolls_back_latest_nonce() {
12646        let (client, _cache, _rx) = create_execution_client();
12647        let nonce = client
12648            .dispatch
12649            .nonce_manager
12650            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12651            .unwrap();
12652        enqueue_other(&client, nonce);
12653
12654        let needs_resync = handle_send_tx_rejection(
12655            &client.dispatch,
12656            &client.emitter,
12657            Some(TEST_ACCOUNT_INDEX_I64),
12658            UnixNanos::from(1_000_000_000),
12659            SendTxRejectionSource::Ack,
12660            Some(23000),
12661            "Too Many Requests",
12662            None,
12663        );
12664
12665        assert!(!needs_resync, "rate-limit rejection must not force resync");
12666        assert_nonce_reusable(&client.dispatch);
12667    }
12668
12669    #[tokio::test]
12670    async fn handle_send_tx_rejection_other_kind_skips_rollback_with_newer_issuance() {
12671        let (client, _cache, _rx) = create_execution_client();
12672        let rejected_nonce = client
12673            .dispatch
12674            .nonce_manager
12675            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12676            .unwrap();
12677        let newer_nonce = client
12678            .dispatch
12679            .nonce_manager
12680            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12681            .unwrap();
12682        enqueue_other(&client, rejected_nonce);
12683
12684        handle_send_tx_rejection(
12685            &client.dispatch,
12686            &client.emitter,
12687            Some(TEST_ACCOUNT_INDEX_I64),
12688            UnixNanos::from(1_000_000_000),
12689            SendTxRejectionSource::Ack,
12690            Some(23000),
12691            "Too Many Requests",
12692            None,
12693        );
12694
12695        assert_eq!(
12696            client
12697                .dispatch
12698                .nonce_manager
12699                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12700            Some(newer_nonce),
12701            "non-latest rejection must leave last_issued alone",
12702        );
12703    }
12704
12705    #[tokio::test]
12706    async fn handle_send_tx_rejection_invalid_nonce_signals_resync() {
12707        let (client, _cache, _rx) = create_execution_client();
12708
12709        enqueue_other(&client, 70);
12710        let attributed = handle_send_tx_rejection(
12711            &client.dispatch,
12712            &client.emitter,
12713            Some(TEST_ACCOUNT_INDEX_I64),
12714            UnixNanos::from(1_000_000_000),
12715            SendTxRejectionSource::Ack,
12716            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
12717            "invalid nonce",
12718            None,
12719        );
12720        assert!(attributed, "attributed invalid nonce must signal resync");
12721
12722        let unattributed = handle_send_tx_rejection(
12723            &client.dispatch,
12724            &client.emitter,
12725            Some(TEST_ACCOUNT_INDEX_I64),
12726            UnixNanos::from(1_000_000_000),
12727            SendTxRejectionSource::Ack,
12728            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
12729            "invalid nonce",
12730            None,
12731        );
12732        assert!(
12733            unattributed,
12734            "unattributed invalid nonce must still signal resync",
12735        );
12736
12737        enqueue_other(&client, 71);
12738        let other_code = handle_send_tx_rejection(
12739            &client.dispatch,
12740            &client.emitter,
12741            Some(TEST_ACCOUNT_INDEX_I64),
12742            UnixNanos::from(1_000_000_000),
12743            SendTxRejectionSource::Ack,
12744            Some(21702),
12745            "invalid price",
12746            None,
12747        );
12748        assert!(!other_code, "other rejection codes must not force resync");
12749    }
12750
12751    #[tokio::test]
12752    async fn handle_send_tx_rejection_other_kind_logs_and_skips_emit() {
12753        let (client, _cache, mut rx) = create_execution_client();
12754        enqueue_other(&client, 70);
12755
12756        handle_send_tx_rejection(
12757            &client.dispatch,
12758            &client.emitter,
12759            Some(TEST_ACCOUNT_INDEX_I64),
12760            UnixNanos::from(1_000_000_000),
12761            SendTxRejectionSource::Ack,
12762            Some(21727),
12763            "invalid client order index",
12764            None,
12765        );
12766
12767        assert_eq!(client.dispatch.pending_sendtx_len(), 0, "Other head pops");
12768        assert!(
12769            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12770                .await
12771                .is_err(),
12772            "Other-kind rejection must not emit OrderRejected",
12773        );
12774    }
12775
12776    #[tokio::test]
12777    async fn handle_send_tx_rejection_empty_queue_logs_warn() {
12778        let (client, _cache, mut rx) = create_execution_client();
12779
12780        handle_send_tx_rejection(
12781            &client.dispatch,
12782            &client.emitter,
12783            Some(TEST_ACCOUNT_INDEX_I64),
12784            UnixNanos::from(1_000_000_000),
12785            SendTxRejectionSource::Ack,
12786            Some(1),
12787            "no pending",
12788            None,
12789        );
12790
12791        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12792        assert!(
12793            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12794                .await
12795                .is_err(),
12796        );
12797    }
12798
12799    #[rstest]
12800    #[case::standard_no_override(LighterAccountTier::Standard, None, 60, None)]
12801    #[case::standard_zero_is_default(LighterAccountTier::Standard, Some(0), 60, None)]
12802    #[case::standard_override_above_tier(
12803        LighterAccountTier::Standard,
12804        Some(24_000),
12805        24_000,
12806        Some(TierCrossCheck::AboveTier { documented: 60 })
12807    )]
12808    #[case::premium_raise_hint(
12809        LighterAccountTier::Premium,
12810        None,
12811        60,
12812        Some(TierCrossCheck::RaiseHint { documented: 24_000 })
12813    )]
12814    #[case::premium_configured_no_advisory(LighterAccountTier::Premium, Some(24_000), 24_000, None)]
12815    #[case::unknown_no_advisory(LighterAccountTier::Unknown(7), None, 60, None)]
12816    fn test_tier_quota_report(
12817        #[case] tier: LighterAccountTier,
12818        #[case] configured: Option<u32>,
12819        #[case] expected_active: u32,
12820        #[case] expected_cross_check: Option<TierCrossCheck>,
12821    ) {
12822        assert_eq!(
12823            tier_quota_report(tier, configured, 60),
12824            (expected_active, expected_cross_check),
12825        );
12826    }
12827}