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, Mutex,
33        atomic::{AtomicBool, AtomicU64, Ordering},
34    },
35    time::Duration,
36};
37
38use anyhow::Context;
39use async_trait::async_trait;
40use nautilus_common::{
41    clients::ExecutionClient,
42    enums::LogColor,
43    live::{runner::get_exec_event_sender, runtime::get_runtime},
44    log_debug,
45    messages::execution::{
46        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
47        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
48        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
49    },
50};
51use nautilus_core::{
52    MUTEX_POISONED, UUID4, UnixNanos,
53    params::Params,
54    time::{AtomicTime, get_atomic_clock_realtime},
55};
56use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
57use nautilus_model::{
58    accounts::AccountAny,
59    enums::{AccountType, ContingencyType, OmsType, OrderSide, OrderType, PositionSideSpecified},
60    events::{OrderAccepted, OrderEventAny},
61    identifiers::{
62        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TraderId, Venue, VenueOrderId,
63    },
64    instruments::{Instrument, InstrumentAny},
65    orders::{Order, OrderAny},
66    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
67    types::{AccountBalance, MarginBalance, Quantity},
68};
69use rust_decimal::Decimal;
70use tokio::task::JoinHandle;
71use tokio_util::sync::CancellationToken;
72
73use crate::{
74    common::{
75        consts::{
76            LIGHTER_ERROR_CODE_INVALID_NONCE, LIGHTER_MAX_BATCH_TX,
77            LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX, LIGHTER_VENUE,
78        },
79        credential::{Credential, scrub_auth},
80        enums::{LighterAccountTier, LighterPositionMarginMode, LighterProductType, LighterTxType},
81        rate_limit::{LighterTxRateLimiter, await_tx_quota, build_tx_rate_limiter, resolve_quota},
82        symbol::{MarketRegistry, product_type_from_instrument_id},
83        urls::lighter_chain_id,
84    },
85    config::LighterExecClientConfig,
86    http::{
87        client::{LIGHTER_REST_PAGE_SIZE, LighterHttpClient, LighterRawHttpClient},
88        error::LighterHttpError,
89        models::{LighterSendTxBatchRequest, LighterSendTxRequest},
90        query::{
91            LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
92            LighterSortDirection, LighterTradeSortBy, LighterTradesQuery,
93        },
94    },
95    signing::{
96        auth_token::{build_auth_token_for, fresh_k},
97        nonce::NonceError,
98        tx::{
99            ApproveIntegratorTxInfo, CancelOrderTxInfo, CreateOrderTxInfo, L2TxAttributes,
100            ModifyOrderTxInfo, OrderInfo, TxContext, TxInfoJson, UpdateLeverageTxInfo, sign_tx,
101        },
102    },
103    websocket::{
104        client::LighterWebSocketClient,
105        dispatch::{
106            LIGHTER_INSTRUMENT_CACHE, OrderIdentity, PendingSendTx, PendingSendTxKind,
107            WsDispatchState, cache_instruments_for_reports, derive_market_order_price_ticks,
108            evict_terminal_mappings, lookup_order_status_report, nautilus_to_lighter_order_type,
109            nautilus_to_lighter_tif, order_expiry_for, parse_http_order_to_report, price_to_ticks,
110            quantity_to_ticks, resolve_cloid, translate_fill_cloid, translate_order_cloid,
111            unwrap_reports_or_warn,
112        },
113        messages::{
114            AccountStream, ExecutionReport, LighterWsChannel, NautilusWsMessage,
115            SendTxRejectionSource,
116        },
117        parse::{
118            OpenFrameContext, ParsedOrderEvent, lighter_order_shape, parse_lighter_order_event,
119            parse_lighter_order_filled, parse_lighter_trade_id, parse_ws_fill_report,
120            parse_ws_order_status_report,
121        },
122    },
123};
124
125/// Default `expired_at` window applied to a signed tx if the order does not
126/// supply its own GTD expiry: 5 minutes from wall-clock at submission time.
127const DEFAULT_TX_EXPIRY_MS: i64 = 5 * 60 * 1_000;
128
129/// Delay before probing an acked cancel/modify to distinguish venue no-ops
130/// from account stream lag.
131const ACKED_ORDER_LOOKUP_DELAY: Duration = Duration::from_secs(2);
132
133/// Refresh the auth token this far before its issuance deadline. The
134/// [`crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS`] is 7 hours;
135/// rotating at 6 hours leaves an hour of headroom for transient refresh
136/// failures.
137const AUTH_TOKEN_REFRESH_INTERVAL: std::time::Duration =
138    std::time::Duration::from_secs(6 * 60 * 60);
139
140// Refresh interval must stay below the token TTL, or rotation hands out already-expired tokens
141const _: () = assert!(
142    AUTH_TOKEN_REFRESH_INTERVAL.as_secs()
143        < crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS as u64,
144    "AUTH_TOKEN_REFRESH_INTERVAL must stay below DEFAULT_AUTH_TOKEN_TTL_SECS",
145);
146
147// Retry budget after a scheduled rotation failure: 7 h TTL minus the 6 h
148// refresh cadence leaves one hour before the old token expires.
149const AUTH_TOKEN_REFRESH_RETRY_WINDOW: Duration = Duration::from_secs(60 * 60);
150const AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(30);
151
152// Also used as the cadence after a retry window is exhausted
153const AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY: Duration = Duration::from_secs(5 * 60);
154const AUTH_TOKEN_REFRESH_BACKOFF: AuthTokenRefreshBackoff = AuthTokenRefreshBackoff {
155    initial_delay: AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY,
156    max_delay: AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY,
157    window: AUTH_TOKEN_REFRESH_RETRY_WINDOW,
158};
159const WS_CONSUMER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
160// Bounds the informational tier-detection call so a slow or failing
161// `/account` endpoint cannot stall connect for the HTTP retry budget.
162const ACCOUNT_TIER_DETECT_TIMEOUT: Duration = Duration::from_secs(10);
163
164/// Attribution window for a bare venue error frame. The frame carries no
165/// `tx_hash` or cloid; if the oldest pending sendTx was submitted within
166/// this window we attribute and emit `OrderRejected`. Outside the window
167/// the existing submit-timeout drives expiry.
168const SENDTX_BARE_ERROR_WINDOW_MS: u64 = 1_000;
169const INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS: i64 = 5 * 365 * 24 * 60 * 60 * 1_000;
170const INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK: u32 = 0;
171
172#[derive(Debug)]
173pub struct LighterExecutionClient {
174    core: ExecutionClientCore,
175    clock: &'static AtomicTime,
176    config: LighterExecClientConfig,
177    emitter: ExecutionEventEmitter,
178    credential: Option<Credential>,
179    http_client: LighterHttpClient,
180    ws_client: LighterWebSocketClient,
181    tx_rate_limiter: Arc<LighterTxRateLimiter>,
182    tx_send_sequencer: TxSendSequencer,
183    registry: Arc<MarketRegistry>,
184    pending_tasks: Mutex<Vec<JoinHandle<()>>>,
185    ws_stream_handle: Mutex<Option<JoinHandle<()>>>,
186    cancellation_token: CancellationToken,
187    /// WebSocket dispatch state: cloid translation tables, nonce manager,
188    /// and the cached AccountState that backs `query_account`. Lives in
189    /// [`crate::websocket::dispatch`].
190    dispatch: WsDispatchState,
191    /// Latches a burst of exhausted allocations into one venue nonce fetch.
192    nonce_recovery_inflight: Arc<AtomicBool>,
193}
194
195impl LighterExecutionClient {
196    /// Creates a new [`LighterExecutionClient`] instance.
197    ///
198    /// Resolves credentials from `config` or the matching environment
199    /// variables (see [`crate::common::credential`]). Missing credentials
200    /// degrade to an unauthenticated client that can bootstrap instruments
201    /// but cannot submit transactions; the constructor returns an error if
202    /// supplied values are malformed.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the HTTP client fails to initialize or if any
207    /// supplied credential value cannot be parsed.
208    pub fn new(core: ExecutionClientCore, config: LighterExecClientConfig) -> anyhow::Result<Self> {
209        let credential = Credential::resolve(
210            config.private_key.clone(),
211            config.account_index,
212            config.api_key_index,
213            config.environment,
214        )
215        .context("failed to resolve Lighter credentials")?;
216
217        let registry = Arc::new(MarketRegistry::new());
218
219        // One transaction limiter shared across the HTTP and WebSocket sendTx
220        // paths so their combined rate honours the single per-account venue bucket.
221        let tx_rate_limiter = build_tx_rate_limiter(config.sendtx_quota_per_min);
222
223        let raw_http = LighterRawHttpClient::new_with_quotas(
224            config.environment,
225            config.base_url_http.clone(),
226            config.http_timeout_secs,
227            config.proxy_url.clone(),
228            resolve_quota(config.rest_quota_per_min),
229            Some(Arc::clone(&tx_rate_limiter)),
230        )
231        .context("failed to construct Lighter raw HTTP client")?;
232        let http_client =
233            LighterHttpClient::from_raw_with_registry(raw_http, Arc::clone(&registry));
234
235        let ws_client = LighterWebSocketClient::new(
236            config.base_url_ws.clone(),
237            config.environment,
238            Arc::clone(&registry),
239            config.transport_backend,
240            config.proxy_url.clone(),
241        );
242
243        let clock = get_atomic_clock_realtime();
244        let emitter = ExecutionEventEmitter::new(
245            clock,
246            core.trader_id,
247            core.account_id,
248            AccountType::Margin,
249            None,
250        );
251        Ok(Self {
252            core,
253            clock,
254            config,
255            emitter,
256            credential,
257            http_client,
258            ws_client,
259            tx_rate_limiter,
260            tx_send_sequencer: TxSendSequencer::new(),
261            registry,
262            pending_tasks: Mutex::new(Vec::new()),
263            ws_stream_handle: Mutex::new(None),
264            cancellation_token: CancellationToken::new(),
265            dispatch: WsDispatchState::new(),
266            nonce_recovery_inflight: Arc::new(AtomicBool::new(false)),
267        })
268    }
269
270    /// Returns a reference to the configuration.
271    #[must_use]
272    pub fn config(&self) -> &LighterExecClientConfig {
273        &self.config
274    }
275
276    /// Returns `true` when the client holds resolved Lighter credentials.
277    #[must_use]
278    pub fn has_credentials(&self) -> bool {
279        self.credential.is_some()
280    }
281
282    /// Returns `true` when every background task spawned by this client has
283    /// completed. Useful in tests to wait for fire-and-forget HTTP work.
284    ///
285    /// # Panics
286    ///
287    /// Panics if the internal mutex is poisoned, which can only occur if a
288    /// task holding the lock previously panicked.
289    #[must_use]
290    pub fn pending_tasks_all_finished(&self) -> bool {
291        let tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
292        tasks.iter().all(|h| h.is_finished())
293    }
294
295    fn spawn_task<F>(&self, description: &'static str, fut: F)
296    where
297        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
298    {
299        let handle = get_runtime().spawn(async move {
300            if let Err(e) = fut.await {
301                log::warn!("{description} failed: {e:?}");
302            }
303        });
304
305        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
306        tasks.retain(|h| !h.is_finished());
307        tasks.push(handle);
308    }
309
310    fn abort_pending_tasks(&self) {
311        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
312        for handle in tasks.drain(..) {
313            handle.abort();
314        }
315    }
316
317    async fn ensure_instruments_initialized_async(&self) -> anyhow::Result<()> {
318        if self.core.instruments_initialized() {
319            return Ok(());
320        }
321
322        let instruments = self
323            .http_client
324            .request_instruments()
325            .await
326            .context("failed to request Lighter instruments")?;
327
328        let ws_cache: Vec<(i16, InstrumentAny)> = instruments
329            .iter()
330            .filter_map(|instrument| {
331                self.registry
332                    .market_index(&instrument.id())
333                    .map(|market_index| (market_index, instrument.clone()))
334            })
335            .collect();
336        self.ws_client.cache_instruments(ws_cache);
337        cache_instruments_for_reports(&instruments);
338
339        log::debug!(
340            "Bootstrapped {} Lighter instruments ({} registry entries)",
341            instruments.len(),
342            self.registry.len(),
343        );
344
345        self.core.set_instruments_initialized();
346        Ok(())
347    }
348
349    async fn await_account_streams_ready(&self, timeout_secs: f64) -> anyhow::Result<()> {
350        let timeout = Duration::from_secs_f64(timeout_secs);
351        self.dispatch.account_streams_ready.await_all(timeout).await
352    }
353
354    async fn refresh_nonce(&self) -> anyhow::Result<()> {
355        let Some(credential) = &self.credential else {
356            return Ok(());
357        };
358
359        let response = self
360            .http_client
361            .get_next_nonce(credential.account_index(), credential.api_key_index())
362            .await
363            .context("failed to fetch Lighter nextNonce")?;
364
365        self.dispatch.nonce_manager.refresh(
366            credential.account_index(),
367            credential.api_key_index(),
368            response.nonce,
369        );
370
371        // Release the latch in case a disconnect aborted recovery mid-task
372        self.nonce_recovery_inflight.store(false, Ordering::Release);
373
374        log::debug!(
375            "Refreshed Lighter nonce baseline: account_index={}, api_key_index={}, next_nonce={}",
376            credential.account_index(),
377            credential.api_key_index(),
378            response.nonce,
379        );
380        Ok(())
381    }
382
383    // Logs the venue-reported account tier in blue. Informational only: the
384    // active quotas are resolved from config at construction, never raised here
385    // (the higher venue limits require registering the caller IP, so the tier
386    // alone does not guarantee them). The call is bounded by
387    // ACCOUNT_TIER_DETECT_TIMEOUT and failures are swallowed, so detection
388    // cannot fail connect or stall it for the HTTP retry budget.
389    async fn detect_account_tier(&self) {
390        let Some(credential) = &self.credential else {
391            return;
392        };
393        let account_index = credential.account_index();
394
395        let detail = match tokio::time::timeout(
396            ACCOUNT_TIER_DETECT_TIMEOUT,
397            self.http_client.get_account_detail(account_index),
398        )
399        .await
400        {
401            Ok(Ok(detail)) => detail,
402            Ok(Err(e)) => {
403                log::warn!(
404                    "Failed to detect Lighter account tier for account_index={account_index}; \
405                     continuing at the configured REST quota: {e}"
406                );
407                return;
408            }
409            Err(_) => {
410                log::warn!(
411                    "Lighter account tier detection timed out after {}s for \
412                     account_index={account_index}; continuing at the configured REST quota",
413                    ACCOUNT_TIER_DETECT_TIMEOUT.as_secs()
414                );
415                return;
416            }
417        };
418
419        let code = detail.account_type;
420        let tier = LighterAccountTier::from_code(code);
421        let standard_rest = LighterAccountTier::Standard
422            .documented_rest_quota_per_min()
423            .unwrap_or(60);
424        let (active_rest, cross_check) =
425            tier_quota_report(tier, self.config.rest_quota_per_min, standard_rest);
426
427        log_debug!(
428            "Lighter execution account {account_index} reported tier {tier} \
429             (account_type={code}); active REST quota {active_rest} req/min",
430            color = LogColor::Blue
431        );
432
433        match cross_check {
434            Some(TierCrossCheck::AboveTier { documented }) => {
435                log::warn!(
436                    "Configured Lighter rest_quota_per_min={active_rest} exceeds the {tier} tier \
437                     limit of {documented} req/min; the venue may reject requests unless the \
438                     caller IP is registered for the higher limit"
439                );
440            }
441            Some(TierCrossCheck::RaiseHint { documented }) => {
442                log_debug!(
443                    "Lighter {tier} tier permits up to {documented} REST req/min; set \
444                     rest_quota_per_min (and register the caller IP with Lighter) to use it",
445                    color = LogColor::Blue
446                );
447            }
448            None => {}
449        }
450    }
451
452    /// Returns `Ok(true)` if this credential's `api_key_index` is maker-only.
453    /// Maker-only keys cannot submit `ApproveIntegrator`, so the caller skips
454    /// the integrator auto-approval when `true`.
455    async fn is_maker_only_api_key(&self, credential: &Credential) -> anyhow::Result<bool> {
456        let auth_token = build_auth_token_for(credential)
457            .context("failed to mint Lighter auth token for maker-only check")?;
458        let response = self
459            .http_client
460            .get_maker_only_api_keys(credential.account_index(), auth_token)
461            .await
462            .context("failed to query getMakerOnlyApiKeys")?;
463        let api_key_index = i64::from(credential.api_key_index());
464        Ok(response.api_key_indexes.contains(&api_key_index))
465    }
466
467    async fn submit_integrator_auto_approval(&self) -> anyhow::Result<()> {
468        let Some(credential) = &self.credential else {
469            return Ok(());
470        };
471
472        let mut maker_only_check_failed = false;
473
474        match self.is_maker_only_api_key(credential).await {
475            Ok(true) => {
476                log::warn!(
477                    "Skipping Lighter integrator auto-approval: api_key_index={} is maker-only; \
478                     ensure the account has been approved by a non-maker-only key",
479                    credential.api_key_index(),
480                );
481                return Ok(());
482            }
483            Ok(false) => {}
484            Err(e) => {
485                maker_only_check_failed = true;
486                log::debug!(
487                    "Lighter maker-only api key check failed; attempting integrator approval \
488                     anyway: {e:?}"
489                );
490            }
491        }
492
493        let mut approval = self.prepare_integrator_auto_approval(credential)?;
494
495        let request = LighterSendTxRequest::new(
496            LighterTxType::ApproveIntegrator as u8,
497            approval.tx_info.clone(),
498        );
499
500        approval.send_reservation.wait_for_turn().await;
501
502        let response = self.http_client.send_tx(&request).await.with_context(|| {
503            let hint = if maker_only_check_failed {
504                " (maker-only pre-flight check failed earlier; venue may reject with 62007 \
505                 if this key is maker-only)"
506            } else {
507                ""
508            };
509            format!(
510                "failed to submit Lighter integrator approval nonce={} api_key_index={}{hint}",
511                approval.nonce, approval.api_key_index,
512            )
513        })?;
514
515        approval.send_reservation.release();
516
517        log::debug!(
518            "Submitted Lighter integrator approval: integrator={}, nonce={}, \
519             api_key_index={}, approval_expiry={}, tx_hash={}",
520            LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
521            approval.nonce,
522            approval.api_key_index,
523            approval.approval_expiry,
524            response.tx_hash,
525        );
526        Ok(())
527    }
528
529    fn prepare_integrator_auto_approval(
530        &self,
531        credential: &Credential,
532    ) -> anyhow::Result<PreparedIntegratorApproval> {
533        let ReservedTxContext {
534            context,
535            send_reservation,
536        } = self.build_tx_context(credential)?;
537
538        let now_ms = (self.clock.get_time_ns().as_u64() as i64) / 1_000_000;
539        let approval_expiry = now_ms.saturating_add(INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS);
540        let nonce = context.nonce;
541        let api_key_index = context.api_key_index;
542
543        let tx = ApproveIntegratorTxInfo {
544            context,
545            integrator_account_index: LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX as i64,
546            max_perps_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
547            max_perps_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
548            max_spot_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
549            max_spot_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
550            approval_expiry,
551            skip_nonce: 0,
552        };
553
554        let signed = sign_tx(
555            &tx,
556            lighter_chain_id(self.config.environment),
557            &credential.private_key()?,
558            fresh_k(),
559        );
560        let tx_info = TxInfoJson::approve_integrator(&tx, &signed, "");
561
562        Ok(PreparedIntegratorApproval {
563            tx_info,
564            nonce,
565            api_key_index,
566            approval_expiry,
567            send_reservation,
568        })
569    }
570
571    async fn spawn_ws_consumer(&mut self) -> anyhow::Result<()> {
572        // Local clone owns the handler `task_handle` until post-connect
573        // setup succeeds. Transferring earlier would leave failures unable
574        // to drain the task through the clone's `disconnect()`.
575        let mut ws_client = self.ws_client.clone();
576        ws_client
577            .connect()
578            .await
579            .context("failed to connect to Lighter WebSocket")?;
580
581        // Wrapped so any failure routes through the clone's `disconnect()`
582        // (which still owns the handler task); mirrors Hyperliquid's
583        // `post_ws` block.
584        let post_connect = async {
585            ws_client
586                .wait_until_active(10.0)
587                .await
588                .context("Lighter WebSocket did not reach active state")?;
589
590            if let Some(credential) = &self.credential {
591                let auth_token = build_auth_token_for(credential)
592                    .context("failed to mint Lighter auth token")?;
593                let account_index = credential.account_index();
594
595                ws_client
596                    .set_execution_context(self.core.account_id, account_index)
597                    .await
598                    .map_err(|e| anyhow::anyhow!("failed to set Lighter execution context: {e}"))?;
599
600                // Subscribe to the five account-scoped streams the consumption
601                // loop converts into typed reports. The handler merges
602                // `account_all_assets` and `user_stats` into a single
603                // AccountState (see websocket/account_state.rs).
604                let channels = [
605                    LighterWsChannel::AccountAllOrders(account_index),
606                    LighterWsChannel::AccountAllTrades(account_index),
607                    LighterWsChannel::AccountAllPositions(account_index),
608                    LighterWsChannel::AccountAllAssets(account_index),
609                    LighterWsChannel::UserStats(account_index),
610                ];
611
612                for channel in channels {
613                    ws_client
614                        .subscribe_account(channel.clone(), auth_token.clone())
615                        .await
616                        .map_err(|e| {
617                            anyhow::anyhow!(
618                                "failed to subscribe to Lighter account channel {channel:?}: {e}",
619                            )
620                        })?;
621                }
622
623                log::debug!("Subscribed to Lighter account streams: account_index={account_index}",);
624            } else {
625                log::warn!(
626                    "Lighter execution client has no credentials: account streams not subscribed; \
627                     typed execution reports will not flow"
628                );
629            }
630
631            Ok::<(), anyhow::Error>(())
632        };
633
634        if let Err(e) = post_connect.await {
635            log::warn!("Lighter post-connect setup failed, tearing down WS: {e}");
636            if let Err(disconnect_err) = ws_client.disconnect().await {
637                log::error!(
638                    "Error disconnecting Lighter WebSocket during connect teardown: {disconnect_err}"
639                );
640            }
641            return Err(e);
642        }
643
644        if let Some(handle) = ws_client.take_task_handle() {
645            self.ws_client.set_task_handle(handle);
646        }
647
648        let cancellation_token = self.cancellation_token.clone();
649        let emitter = self.emitter.clone();
650        let dispatch = self.dispatch.clone();
651        let credential_for_loop = self.credential.clone();
652        let http_client_for_loop = self.http_client.clone();
653        let registry_for_loop = Arc::clone(&self.registry);
654        let account_id_for_loop = self.core.account_id;
655        let clock_for_loop = self.clock;
656
657        let task = get_runtime().spawn(async move {
658            log::debug!("Lighter execution WebSocket consumption loop started");
659
660            loop {
661                tokio::select! {
662                    () = cancellation_token.cancelled() => {
663                        log::debug!("Lighter execution consumption loop cancelled");
664                        break;
665                    }
666                    msg_opt = ws_client.next_event() => {
667                        match msg_opt {
668                            Some(NautilusWsMessage::ExecutionReports(reports)) => {
669                                let mut order_count = 0_usize;
670                                let mut fill_count = 0_usize;
671                                let trader_id = emitter.trader_id();
672                                let account_index = credential_for_loop
673                                    .as_ref()
674                                    .map(|c| c.account_index());
675
676                                for report in reports {
677                                    match report {
678                                        ExecutionReport::Order(order) => {
679                                            order_count += 1;
680                                            dispatch_lighter_order(
681                                                &order,
682                                                &dispatch,
683                                                &emitter,
684                                                &registry_for_loop,
685                                                account_id_for_loop,
686                                                trader_id,
687                                                clock_for_loop.get_time_ns(),
688                                            );
689                                        }
690                                        ExecutionReport::Fill(trade) => {
691                                            fill_count += 1;
692                                            dispatch_lighter_trade(
693                                                &trade,
694                                                &dispatch,
695                                                &emitter,
696                                                &registry_for_loop,
697                                                account_id_for_loop,
698                                                trader_id,
699                                                account_index,
700                                                clock_for_loop.get_time_ns(),
701                                            );
702                                        }
703                                    }
704                                }
705                                log::debug!(
706                                    "Lighter execution batch: orders={order_count} fills={fill_count}",
707                                );
708                            }
709                            Some(NautilusWsMessage::PositionSnapshot {
710                                reports,
711                                skipped_market_ids,
712                            }) => {
713                                // Replace even when empty, but keep rows the
714                                // handler could not parse or map.
715                                for r in &reports {
716                                    if let Some(idx) =
717                                        registry_for_loop.market_index(&r.instrument_id)
718                                    {
719                                        dispatch.note_active_market(idx);
720                                    }
721                                }
722                                let position_count = reports.len();
723                                let retained_positions: Vec<InstrumentId> = skipped_market_ids
724                                    .iter()
725                                    .filter_map(|market_id| {
726                                        registry_for_loop.instrument_id(*market_id)
727                                    })
728                                    .collect();
729                                let removed = if retained_positions.is_empty() {
730                                    dispatch.replace_positions(&reports)
731                                } else {
732                                    dispatch.replace_positions_except(&reports, &retained_positions)
733                                };
734                                log::debug!(
735                                    "Lighter position snapshot: positions={position_count}, skipped_markets={}, removed={}",
736                                    skipped_market_ids.len(),
737                                    removed.len(),
738                                );
739
740                                for r in reports {
741                                    log::debug!(
742                                        "Lighter PositionStatusReport: instrument={} side={:?} qty={}",
743                                        r.instrument_id,
744                                        r.position_side,
745                                        r.quantity,
746                                    );
747                                    emitter.send_position_report(r);
748                                }
749
750                                // Emit a flat report for any instrument the
751                                // venue dropped from this snapshot so the
752                                // engine sees the close. Without this, an
753                                // externally-closed position lingers in the
754                                // engine cache even though the dispatch
755                                // cache cleared it.
756                                let now = clock_for_loop.get_time_ns();
757
758                                for instrument_id in removed {
759                                    let flat = PositionStatusReport::new(
760                                        account_id_for_loop,
761                                        instrument_id,
762                                        PositionSideSpecified::Flat,
763                                        Quantity::zero(0),
764                                        now,
765                                        now,
766                                        Some(UUID4::new()),
767                                        None,
768                                        None,
769                                    );
770                                    emitter.send_position_report(flat);
771                                }
772                            }
773                            Some(NautilusWsMessage::AccountState(state)) => {
774                                log::debug!(
775                                    "Lighter AccountState: balances={} margins={}",
776                                    state.balances.len(),
777                                    state.margins.len(),
778                                );
779                                // Cache so query_account can serve a recent
780                                // snapshot without a REST round-trip; Lighter
781                                // does not currently expose a REST account
782                                // endpoint that would make a fresh fetch
783                                // possible.
784                                dispatch.cache_account_state((*state).clone());
785                                emitter.send_account_state(*state);
786                            }
787                            Some(NautilusWsMessage::Reconnected) => {
788                                // Subscriptions are restored by
789                                // `LighterWebSocketClient`'s reconnect logic;
790                                // the execution context is preserved by the
791                                // handler across reconnects. Refresh the nonce
792                                // baseline since the venue's expected next
793                                // nonce may have advanced while we were
794                                // disconnected.
795                                log::debug!("Lighter WebSocket reconnected (execution stream)");
796
797                                // No cache touch here: the next venue
798                                // `account_all_positions` snapshot is
799                                // authoritative and drives the diff. A
800                                // synthetic flat from the lifecycle would
801                                // produce a false close+reopen on a healthy
802                                // flap. Trade-off: between reconnect and the
803                                // next snapshot (~<1s typically),
804                                // `generate_position_status_reports` may
805                                // return stale data.
806
807                                // Drained creates have unknown outcomes;
808                                // reconciliation resolves them, so warn
809                                // rather than emit rejections.
810                                let stale = dispatch.drain_pending_sendtx();
811                                if !stale.is_empty() {
812                                    log::warn!(
813                                        "Discarded {} pending sendTx entries on reconnect; \
814                                         order state recovers via reconciliation",
815                                        stale.len(),
816                                    );
817
818                                    for pending in &stale {
819                                        if let PendingSendTxKind::Create { order, .. } =
820                                            &pending.kind
821                                        {
822                                            log::warn!(
823                                                "Lighter sendTx outcome unknown after reconnect \
824                                                 for {}",
825                                                order.client_order_id(),
826                                            );
827                                        }
828                                    }
829                                }
830
831                                if let Some(credential) = &credential_for_loop {
832                                    match http_client_for_loop
833                                        .get_next_nonce(
834                                            credential.account_index(),
835                                            credential.api_key_index(),
836                                        )
837                                        .await
838                                    {
839                                        Ok(response) => {
840                                            dispatch.nonce_manager.refresh(
841                                                credential.account_index(),
842                                                credential.api_key_index(),
843                                                response.nonce,
844                                            );
845                                            log::debug!(
846                                                "Refreshed Lighter nonce after reconnect: \
847                                                 account_index={}, next_nonce={}",
848                                                credential.account_index(),
849                                                response.nonce,
850                                            );
851                                        }
852                                        Err(e) => {
853                                            log::error!(
854                                                "Failed to refresh Lighter nonce after reconnect: {e}",
855                                            );
856                                        }
857                                    }
858                                }
859                            }
860                            Some(NautilusWsMessage::SendTxAck { tx_hash, code }) => {
861                                let account_index = credential_for_loop
862                                    .as_ref()
863                                    .map(|c| c.account_index());
864                                let acked = handle_send_tx_ack(
865                                    &dispatch,
866                                    account_index,
867                                    code,
868                                    tx_hash.as_deref(),
869                                );
870
871                                if let (Some(pending), Some(credential)) =
872                                    (acked, credential_for_loop.clone())
873                                {
874                                    spawn_acked_order_probe(
875                                        &pending,
876                                        AckedOrderProbeContext {
877                                            http_client: http_client_for_loop.clone(),
878                                            registry: Arc::clone(&registry_for_loop),
879                                            credential,
880                                            dispatch: dispatch.clone(),
881                                            emitter: emitter.clone(),
882                                            account_id: account_id_for_loop,
883                                            clock: clock_for_loop,
884                                            cancellation_token: cancellation_token.clone(),
885                                        },
886                                    );
887                                }
888                            }
889                            Some(NautilusWsMessage::SendTxRejected {
890                                source,
891                                code,
892                                message,
893                                tx_hash,
894                            }) => {
895                                let account_index = credential_for_loop
896                                    .as_ref()
897                                    .map(|c| c.account_index());
898                                let needs_nonce_resync = handle_send_tx_rejection(
899                                    &dispatch,
900                                    &emitter,
901                                    account_index,
902                                    clock_for_loop.get_time_ns(),
903                                    source,
904                                    code,
905                                    &message,
906                                    tx_hash.as_deref(),
907                                );
908
909                                // Invalid nonce means the sequential stream is
910                                // wedged on a burned nonce; only a hard refresh
911                                // moves allocation back down.
912                                if needs_nonce_resync
913                                    && let Some(credential) = &credential_for_loop
914                                {
915                                    match http_client_for_loop
916                                        .get_next_nonce(
917                                            credential.account_index(),
918                                            credential.api_key_index(),
919                                        )
920                                        .await
921                                    {
922                                        Ok(response) => {
923                                            dispatch.nonce_manager.refresh(
924                                                credential.account_index(),
925                                                credential.api_key_index(),
926                                                response.nonce,
927                                            );
928                                            log::debug!(
929                                                "Hard-refreshed Lighter nonce after invalid-nonce \
930                                                 rejection: account_index={}, next_nonce={}",
931                                                credential.account_index(),
932                                                response.nonce,
933                                            );
934                                        }
935                                        Err(e) => {
936                                            log::error!(
937                                                "Failed to refresh Lighter nonce after \
938                                                 invalid-nonce rejection: {e}",
939                                            );
940                                        }
941                                    }
942                                }
943                            }
944                            Some(NautilusWsMessage::Raw(value)) => {
945                                log::debug!("Unhandled Lighter raw frame on execution stream: {value}");
946                            }
947                            Some(NautilusWsMessage::AccountStreamFirstFrame(stream)) => {
948                                // FIFO with preceding reports on the same
949                                // channel: any typed reports the handler
950                                // emitted for this frame have already been
951                                // applied by the cases above, so marking
952                                // here is safe to unblock `await_all`.
953                                match stream {
954                                    AccountStream::Orders => {
955                                        dispatch.account_streams_ready.mark_orders();
956                                    }
957                                    AccountStream::Trades => {
958                                        dispatch.account_streams_ready.mark_trades();
959                                    }
960                                    AccountStream::Positions => {
961                                        dispatch.account_streams_ready.mark_positions();
962                                    }
963                                    AccountStream::Assets => {
964                                        dispatch.account_streams_ready.mark_assets();
965                                    }
966                                    AccountStream::UserStats => {
967                                        dispatch.account_streams_ready.mark_user_stats();
968                                    }
969                                }
970                            }
971                            // Public market data variants reach the execution
972                            // stream only if the user shares a websocket clone
973                            // with the data client (no production caller does).
974                            Some(
975                                NautilusWsMessage::Trades(_)
976                                | NautilusWsMessage::Quote(_)
977                                | NautilusWsMessage::Deltas(_)
978                                | NautilusWsMessage::Depth10(_)
979                                | NautilusWsMessage::Bar(_)
980                                | NautilusWsMessage::MarkPrice(_)
981                                | NautilusWsMessage::IndexPrice(_)
982                                | NautilusWsMessage::FundingRate(_),
983                            ) => {}
984                            None => {
985                                log::debug!("Lighter execution next_event returned None");
986                                tokio::select! {
987                                    () = cancellation_token.cancelled() => {
988                                        log::debug!(
989                                            "Lighter execution consumption loop cancelled"
990                                        );
991                                        break;
992                                    }
993                                    () = tokio::time::sleep(Duration::from_secs(1)) => {}
994                                }
995                            }
996                        }
997                    }
998                }
999            }
1000
1001            log::debug!("Lighter execution WebSocket consumption loop finished");
1002        });
1003
1004        let mut handle = self.ws_stream_handle.lock().expect(MUTEX_POISONED);
1005        *handle = Some(task);
1006        drop(handle);
1007
1008        if let Some(credential) = &self.credential {
1009            self.spawn_auth_token_refresh(credential.clone());
1010        }
1011
1012        Ok(())
1013    }
1014
1015    fn spawn_auth_token_refresh(&self, credential: Credential) {
1016        let ws_client = self.ws_client.clone();
1017        let cancellation_token = self.cancellation_token.clone();
1018        let account_index = credential.account_index();
1019        let channels = auth_token_rotation_channels(account_index);
1020
1021        get_runtime().spawn(async move {
1022            log::debug!(
1023                "Lighter auth-token refresh task started: interval={}s, account_index={account_index}",
1024                AUTH_TOKEN_REFRESH_INTERVAL.as_secs(),
1025            );
1026
1027            let mut next_refresh_delay = AUTH_TOKEN_REFRESH_INTERVAL;
1028
1029            loop {
1030                if !sleep_or_auth_token_refresh_cancelled(
1031                    next_refresh_delay,
1032                    &cancellation_token,
1033                )
1034                .await
1035                {
1036                    log::debug!("Lighter auth-token refresh task cancelled");
1037                    break;
1038                }
1039
1040                let outcome = refresh_auth_token_until_rotated(
1041                    &credential,
1042                    &channels,
1043                    &cancellation_token,
1044                    AUTH_TOKEN_REFRESH_BACKOFF,
1045                    |credential| -> anyhow::Result<String> { build_auth_token_for(credential) },
1046                    |channel, token| {
1047                        let ws_client = ws_client.clone();
1048                        async move { ws_client.subscribe_account(channel, token).await }
1049                    },
1050                )
1051                .await;
1052
1053                if let Some(delay) = auth_token_refresh_next_delay(outcome) {
1054                    if outcome == AuthTokenRefreshOutcome::Exhausted {
1055                        log::error!(
1056                            "Lighter auth-token rotation exhausted retry window; retrying again in {}s: account_index={account_index}",
1057                            delay.as_secs(),
1058                        );
1059                    }
1060                    next_refresh_delay = delay;
1061                } else {
1062                    log::debug!("Lighter auth-token refresh task cancelled");
1063                    break;
1064                }
1065            }
1066        });
1067    }
1068
1069    // Per-order `params["market_order_slippage_bps"]` overrides the config default.
1070    fn resolve_slippage_bps(&self, params: Option<&Params>) -> u32 {
1071        params
1072            .and_then(|p| p.get_u64("market_order_slippage_bps"))
1073            .map_or(self.config.market_order_slippage_bps, |v| v as u32)
1074    }
1075
1076    fn build_tx_context(&self, credential: &Credential) -> anyhow::Result<ReservedTxContext> {
1077        let nonce = match self
1078            .dispatch
1079            .nonce_manager
1080            .next_nonce(credential.account_index(), credential.api_key_index())
1081        {
1082            Ok(nonce) => nonce,
1083            Err(e @ NonceError::SkipWindowExhausted { .. }) => {
1084                // Lost acks leave the baseline stale; resync from the venue so
1085                // later commands recover. The fetch is async; this command fails.
1086                self.spawn_nonce_window_recovery(credential);
1087                anyhow::bail!("failed to allocate Lighter nonce: {e}");
1088            }
1089            Err(e) => anyhow::bail!("failed to allocate Lighter nonce: {e}"),
1090        };
1091
1092        let now_ns = self.clock.get_time_ns().as_u64() as i64;
1093        let expired_at = (now_ns / 1_000_000).saturating_add(DEFAULT_TX_EXPIRY_MS);
1094        let send_reservation = self.tx_send_sequencer.reserve(
1095            credential.account_index(),
1096            credential.api_key_index(),
1097            nonce,
1098        );
1099
1100        let context = TxContext {
1101            account_index: credential.account_index(),
1102            api_key_index: credential.api_key_index(),
1103            nonce,
1104            expired_at,
1105        };
1106
1107        Ok(ReservedTxContext {
1108            context,
1109            send_reservation,
1110        })
1111    }
1112
1113    fn spawn_nonce_window_recovery(&self, credential: &Credential) {
1114        if self.nonce_recovery_inflight.swap(true, Ordering::AcqRel) {
1115            return;
1116        }
1117
1118        let inflight = Arc::clone(&self.nonce_recovery_inflight);
1119        let http_client = self.http_client.clone();
1120        let dispatch = self.dispatch.clone();
1121        let account_index = credential.account_index();
1122        let api_key_index = credential.api_key_index();
1123
1124        self.spawn_task("nonce_window_recovery", async move {
1125            let result = http_client
1126                .get_next_nonce(account_index, api_key_index)
1127                .await;
1128            inflight.store(false, Ordering::Release);
1129
1130            match result {
1131                Ok(response) => {
1132                    // Monotonic sync, not `refresh`: a hard reset could
1133                    // reissue nonces already signed into in-flight txs.
1134                    let _ = dispatch.nonce_manager.sync_from_venue(
1135                        account_index,
1136                        api_key_index,
1137                        response.nonce,
1138                    );
1139                    log::debug!(
1140                        "Resynced Lighter nonce baseline after skip-window exhaustion: \
1141                         account_index={account_index}, api_key_index={api_key_index}, \
1142                         next_nonce={}",
1143                        response.nonce,
1144                    );
1145                }
1146                Err(e) => {
1147                    log::error!(
1148                        "Failed to resync Lighter nonce after skip-window exhaustion: {e}",
1149                    );
1150                }
1151            }
1152            Ok(())
1153        });
1154    }
1155
1156    fn dispatch_signed_create_order(
1157        &self,
1158        order: &OrderAny,
1159        credential: &Credential,
1160        slippage_bps: u32,
1161    ) -> anyhow::Result<()> {
1162        let prepared = self.prepare_signed_create_order(order, credential, slippage_bps)?;
1163        let PreparedCreateOrder {
1164            order,
1165            client_order_index,
1166            tx_info,
1167            nonce,
1168            api_key_index,
1169            tx_hash,
1170            mut send_reservation,
1171        } = prepared;
1172        let ws_client = self.ws_client.clone();
1173        let dispatch = self.dispatch.clone();
1174        let tx_rate_limiter = self.tx_rate_limiter.clone();
1175        let credential = credential.clone();
1176        let client_order_id = order.client_order_id();
1177        let emitter = self.emitter.clone();
1178        let clock = self.clock;
1179
1180        self.emitter.emit_order_submitted(&order);
1181
1182        self.spawn_task("submit_order", async move {
1183            log::debug!("Lighter submit_order: queueing CreateOrder tx for {client_order_id}");
1184            send_reservation.wait_for_turn().await;
1185            // Pace before enqueueing so the pending FIFO order matches the send
1186            // order and `submitted_at` is fresh for ack/rejection attribution.
1187            await_tx_quota(&tx_rate_limiter).await;
1188            dispatch.enqueue_pending_sendtx(PendingSendTx {
1189                kind: PendingSendTxKind::Create {
1190                    order: Box::new(order.clone()),
1191                    client_order_index,
1192                },
1193                submitted_at: clock.get_time_ns(),
1194                nonce,
1195                api_key_index,
1196                tx_hash,
1197            });
1198
1199            if let Err(e) = ws_client
1200                .send_tx(LighterTxType::CreateOrder as u8, tx_info)
1201                .await
1202            {
1203                let reason = format!("Lighter submit_order dispatch failed: {e}");
1204                log::error!("{reason} for {client_order_id}");
1205                dispatch.remove_pending_sendtx_by_nonce(nonce);
1206                rollback_tx_dispatch_create(
1207                    &dispatch,
1208                    &credential,
1209                    Some(client_order_index),
1210                    &client_order_id,
1211                    nonce,
1212                );
1213
1214                emitter.emit_order_rejected(&order, &reason, clock.get_time_ns(), false);
1215            }
1216            send_reservation.release();
1217            Ok(())
1218        });
1219
1220        Ok(())
1221    }
1222
1223    fn prepare_signed_create_order(
1224        &self,
1225        order: &OrderAny,
1226        credential: &Credential,
1227        slippage_bps: u32,
1228    ) -> anyhow::Result<PreparedCreateOrder> {
1229        let instrument_id = order.instrument_id();
1230        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1231            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
1232        })?;
1233
1234        let instrument = self.core.cache().try_instrument(&instrument_id)?.clone();
1235
1236        let order_kind = nautilus_to_lighter_order_type(order.order_type())?;
1237
1238        let tif = nautilus_to_lighter_tif(
1239            order.order_type(),
1240            order.time_in_force(),
1241            order.is_post_only(),
1242        )?;
1243        let now_ms = (self.clock.get_time_ns().as_u64() / 1_000_000) as i64;
1244        let order_expiry = order_expiry_for(
1245            order.order_type(),
1246            &order.time_in_force(),
1247            order.expire_time(),
1248            now_ms,
1249        );
1250
1251        let base_amount = quantity_to_ticks(&order.quantity(), instrument.size_precision())?;
1252
1253        // `quantity_to_ticks` floors sub-precision quantities to 0.
1254        anyhow::ensure!(
1255            base_amount > 0,
1256            "quantity `{}` rounds to 0 ticks at size_precision {}",
1257            order.quantity(),
1258            instrument.size_precision(),
1259        );
1260        let price_precision = instrument.price_precision();
1261        let is_buy = matches!(order.order_side(), OrderSide::Buy);
1262
1263        // Lighter requires `price` on market-style orders as the worst
1264        // acceptable cap; derive it from far-side quote or trigger.
1265        let price_ticks = match order.order_type() {
1266            OrderType::Market => {
1267                let quote = self
1268                    .core
1269                    .cache()
1270                    .quote(&instrument_id)
1271                    .copied()
1272                    .ok_or_else(|| {
1273                        anyhow::anyhow!(
1274                            "no cached quote for {instrument_id}: subscribe to quotes before submitting MARKET orders",
1275                        )
1276                    })?;
1277                let base = if is_buy {
1278                    quote.ask_price.as_decimal()
1279                } else {
1280                    quote.bid_price.as_decimal()
1281                };
1282                derive_market_order_price_ticks(base, is_buy, price_precision, slippage_bps)?
1283            }
1284            OrderType::StopMarket | OrderType::MarketIfTouched => {
1285                let trigger = order.trigger_price().ok_or_else(|| {
1286                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type(),)
1287                })?;
1288                derive_market_order_price_ticks(
1289                    trigger.as_decimal(),
1290                    is_buy,
1291                    price_precision,
1292                    slippage_bps,
1293                )?
1294            }
1295            _ => order
1296                .price()
1297                .map(|p| price_to_ticks(&p, price_precision))
1298                .transpose()?
1299                .unwrap_or(0),
1300        };
1301
1302        let trigger_price_ticks = order
1303            .trigger_price()
1304            .map(|p| price_to_ticks(&p, price_precision))
1305            .transpose()?
1306            .unwrap_or(0);
1307
1308        // Conditional types: `price_to_ticks` floors sub-tick triggers to 0,
1309        // which Lighter would then reject.
1310        if matches!(
1311            order.order_type(),
1312            OrderType::StopMarket
1313                | OrderType::StopLimit
1314                | OrderType::MarketIfTouched
1315                | OrderType::LimitIfTouched
1316        ) {
1317            anyhow::ensure!(
1318                trigger_price_ticks > 0,
1319                "trigger_price `{:?}` rounds to 0 ticks at precision {price_precision}",
1320                order.trigger_price(),
1321            );
1322        }
1323        validate_order_amount(&instrument, order.quantity(), price_ticks, price_precision)?;
1324
1325        let cloid = order.client_order_id();
1326        let initial_index = self.dispatch.derive_client_order_index(&cloid);
1327        let client_order_index = self.dispatch.register_cloid(initial_index, cloid);
1328        self.dispatch.register_order_identity(
1329            cloid,
1330            crate::websocket::dispatch::OrderIdentity {
1331                instrument_id,
1332                strategy_id: order.strategy_id(),
1333                order_side: order.order_side(),
1334                order_type: order.order_type(),
1335            },
1336        );
1337
1338        let ReservedTxContext {
1339            context,
1340            send_reservation,
1341        } = match self.build_tx_context(credential) {
1342            Ok(context) => context,
1343            Err(e) => {
1344                self.dispatch.forget_cloid(client_order_index);
1345                self.dispatch.forget_order_identity(&cloid);
1346                return Err(e);
1347            }
1348        };
1349
1350        let nonce = context.nonce;
1351        let api_key_index = context.api_key_index;
1352
1353        let mut rollback_guard = TxDispatchGuard::new(
1354            self.dispatch.clone(),
1355            credential,
1356            Some(client_order_index),
1357            nonce,
1358        )
1359        .with_order_identity(cloid);
1360        let tx = CreateOrderTxInfo {
1361            context,
1362            order: OrderInfo {
1363                market_index,
1364                client_order_index,
1365                base_amount,
1366                price: price_ticks,
1367                is_ask: matches!(order.order_side(), OrderSide::Sell),
1368                order_type: order_kind as u8,
1369                time_in_force: tif as u8,
1370                reduce_only: order.is_reduce_only(),
1371                trigger_price: trigger_price_ticks,
1372                order_expiry,
1373            },
1374            attributes: integrator_attributes(),
1375        };
1376
1377        let signed = sign_tx(
1378            &tx,
1379            lighter_chain_id(self.config.environment),
1380            &credential.private_key()?,
1381            fresh_k(),
1382        );
1383
1384        let tx_info_str = TxInfoJson::create_order(&tx, &signed);
1385        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
1386            .context("failed to wrap signed Lighter tx_info JSON")?;
1387        rollback_guard.disarm();
1388
1389        Ok(PreparedCreateOrder {
1390            order: order.clone(),
1391            client_order_index,
1392            tx_info,
1393            nonce,
1394            api_key_index,
1395            tx_hash: signed.tx_hash_hex(),
1396            send_reservation,
1397        })
1398    }
1399
1400    fn dispatch_signed_cancel_order(&self, cmd: &CancelOrder, credential: &Credential) {
1401        let prepared = match self.prepare_signed_cancel_order(cmd, credential) {
1402            Ok(prepared) => prepared,
1403            Err(e) => {
1404                let reason = format!("Lighter cancel_order failed: {e}");
1405                if self.can_emit_order_cancel_rejected(&cmd.client_order_id) {
1406                    log::warn!("{reason} for {}", cmd.client_order_id);
1407                    self.emitter.emit_order_cancel_rejected_event(
1408                        cmd.strategy_id,
1409                        cmd.instrument_id,
1410                        cmd.client_order_id,
1411                        cmd.venue_order_id,
1412                        &reason,
1413                        self.clock.get_time_ns(),
1414                    );
1415                } else {
1416                    log::warn!(
1417                        "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
1418                        cmd.client_order_id,
1419                    );
1420                }
1421                return;
1422            }
1423        };
1424        let PreparedCancelOrder {
1425            client_order_id,
1426            strategy_id,
1427            instrument_id,
1428            venue_order_id,
1429            tx_info,
1430            nonce,
1431            api_key_index,
1432            tx_hash,
1433            mut send_reservation,
1434        } = prepared;
1435        let emit_cancel_rejected = self.can_emit_order_cancel_rejected(&client_order_id);
1436
1437        let ws_client = self.ws_client.clone();
1438        let dispatch = self.dispatch.clone();
1439        let credential = credential.clone();
1440        let emitter = self.emitter.clone();
1441        let clock = self.clock;
1442
1443        let tx_rate_limiter = self.tx_rate_limiter.clone();
1444
1445        self.spawn_task("cancel_order", async move {
1446            send_reservation.wait_for_turn().await;
1447            await_tx_quota(&tx_rate_limiter).await;
1448            dispatch.enqueue_pending_sendtx(PendingSendTx {
1449                kind: if emit_cancel_rejected {
1450                    PendingSendTxKind::Cancel {
1451                        strategy_id,
1452                        instrument_id,
1453                        client_order_id,
1454                        venue_order_id,
1455                    }
1456                } else {
1457                    PendingSendTxKind::Other
1458                },
1459                submitted_at: clock.get_time_ns(),
1460                nonce,
1461                api_key_index,
1462                tx_hash,
1463            });
1464
1465            if let Err(e) = ws_client
1466                .send_tx(LighterTxType::CancelOrder as u8, tx_info)
1467                .await
1468            {
1469                let reason = format!("Lighter cancel_order dispatch failed: {e}");
1470                log::error!("{reason} for {client_order_id}");
1471                dispatch.remove_pending_sendtx_by_nonce(nonce);
1472                rollback_tx_dispatch(&dispatch, &credential, None, nonce);
1473
1474                if emit_cancel_rejected {
1475                    emitter.emit_order_cancel_rejected_event(
1476                        strategy_id,
1477                        instrument_id,
1478                        client_order_id,
1479                        venue_order_id,
1480                        &reason,
1481                        clock.get_time_ns(),
1482                    );
1483                } else {
1484                    log::warn!(
1485                        "{reason} for {client_order_id}; suppressing OrderCancelRejected because order is not PendingCancel",
1486                    );
1487                }
1488            }
1489
1490            send_reservation.release();
1491            Ok(())
1492        });
1493    }
1494
1495    fn can_emit_order_cancel_rejected(&self, client_order_id: &ClientOrderId) -> bool {
1496        self.core
1497            .cache()
1498            .order(client_order_id)
1499            .is_none_or(|order| order.is_pending_cancel())
1500    }
1501
1502    fn prepare_signed_cancel_order(
1503        &self,
1504        cmd: &CancelOrder,
1505        credential: &Credential,
1506    ) -> anyhow::Result<PreparedCancelOrder> {
1507        let market_index = self
1508            .registry
1509            .market_index(&cmd.instrument_id)
1510            .ok_or_else(|| {
1511                anyhow::anyhow!(
1512                    "no Lighter market_index registered for instrument {}",
1513                    cmd.instrument_id,
1514                )
1515            })?;
1516
1517        self.core.cache().try_order(&cmd.client_order_id)?;
1518
1519        // Lighter cancel_order targets a single order by venue order_id.
1520        // The map is populated on the first OrderStatusReport for the cloid.
1521        let voi = cmd
1522            .venue_order_id
1523            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
1524            .ok_or_else(|| {
1525                anyhow::anyhow!(
1526                    "cannot cancel Lighter order {}: venue order_id not yet known \
1527                     (await OrderAccepted before issuing cancel)",
1528                    cmd.client_order_id,
1529                )
1530            })?;
1531
1532        let venue_index: i64 = voi
1533            .as_str()
1534            .parse()
1535            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
1536
1537        let ReservedTxContext {
1538            context,
1539            send_reservation,
1540        } = self.build_tx_context(credential)?;
1541
1542        let captured_nonce = context.nonce;
1543        let captured_api_key_index = context.api_key_index;
1544        let mut rollback_guard =
1545            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
1546        let tx = CancelOrderTxInfo {
1547            context,
1548            market_index,
1549            index: venue_index,
1550            skip_nonce: 0,
1551        };
1552
1553        let signed = sign_tx(
1554            &tx,
1555            lighter_chain_id(self.config.environment),
1556            &credential.private_key()?,
1557            fresh_k(),
1558        );
1559        let tx_info_str = TxInfoJson::cancel_order(&tx, &signed);
1560        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
1561            .context("failed to wrap signed Lighter cancel tx_info JSON")?;
1562        rollback_guard.disarm();
1563
1564        Ok(PreparedCancelOrder {
1565            client_order_id: cmd.client_order_id,
1566            strategy_id: cmd.strategy_id,
1567            instrument_id: cmd.instrument_id,
1568            venue_order_id: Some(voi),
1569            tx_info,
1570            nonce: captured_nonce,
1571            api_key_index: captured_api_key_index,
1572            tx_hash: signed.tx_hash_hex(),
1573            send_reservation,
1574        })
1575    }
1576
1577    fn dispatch_signed_modify_order(&self, cmd: &ModifyOrder, credential: &Credential) {
1578        let prepared = match self.prepare_signed_modify_order(cmd, credential) {
1579            Ok(prepared) => prepared,
1580            Err(e) => {
1581                let reason = format!("Lighter modify_order failed: {e}");
1582                log::warn!("{reason} for {}", cmd.client_order_id);
1583                self.emitter.emit_order_modify_rejected_event(
1584                    cmd.strategy_id,
1585                    cmd.instrument_id,
1586                    cmd.client_order_id,
1587                    cmd.venue_order_id,
1588                    &reason,
1589                    self.clock.get_time_ns(),
1590                );
1591                return;
1592            }
1593        };
1594        let PreparedModifyOrder {
1595            client_order_id,
1596            strategy_id,
1597            instrument_id,
1598            venue_order_id,
1599            tx_info,
1600            nonce,
1601            api_key_index,
1602            tx_hash,
1603            mut send_reservation,
1604        } = prepared;
1605
1606        let ws_client = self.ws_client.clone();
1607        let dispatch = self.dispatch.clone();
1608        let credential = credential.clone();
1609        let emitter = self.emitter.clone();
1610        let clock = self.clock;
1611
1612        let tx_rate_limiter = self.tx_rate_limiter.clone();
1613
1614        self.spawn_task("modify_order", async move {
1615            send_reservation.wait_for_turn().await;
1616            await_tx_quota(&tx_rate_limiter).await;
1617            dispatch.enqueue_pending_sendtx(PendingSendTx {
1618                kind: PendingSendTxKind::Modify {
1619                    strategy_id,
1620                    instrument_id,
1621                    client_order_id,
1622                    venue_order_id,
1623                },
1624                submitted_at: clock.get_time_ns(),
1625                nonce,
1626                api_key_index,
1627                tx_hash,
1628            });
1629
1630            if let Err(e) = ws_client
1631                .send_tx(LighterTxType::ModifyOrder as u8, tx_info)
1632                .await
1633            {
1634                let reason = format!("Lighter modify_order dispatch failed: {e}");
1635                log::error!("{reason} for {client_order_id}");
1636                dispatch.remove_pending_sendtx_by_nonce(nonce);
1637                rollback_tx_dispatch(&dispatch, &credential, None, nonce);
1638                emitter.emit_order_modify_rejected_event(
1639                    strategy_id,
1640                    instrument_id,
1641                    client_order_id,
1642                    venue_order_id,
1643                    &reason,
1644                    clock.get_time_ns(),
1645                );
1646            }
1647            send_reservation.release();
1648            Ok(())
1649        });
1650    }
1651
1652    fn prepare_signed_modify_order(
1653        &self,
1654        cmd: &ModifyOrder,
1655        credential: &Credential,
1656    ) -> anyhow::Result<PreparedModifyOrder> {
1657        let market_index = self
1658            .registry
1659            .market_index(&cmd.instrument_id)
1660            .ok_or_else(|| {
1661                anyhow::anyhow!(
1662                    "no Lighter market_index registered for instrument {}",
1663                    cmd.instrument_id,
1664                )
1665            })?;
1666
1667        let voi = cmd
1668            .venue_order_id
1669            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
1670            .ok_or_else(|| {
1671                anyhow::anyhow!(
1672                    "cannot modify Lighter order {}: venue order_id not yet known \
1673                     (await OrderAccepted before issuing modify)",
1674                    cmd.client_order_id,
1675                )
1676            })?;
1677
1678        let venue_index: i64 = voi
1679            .as_str()
1680            .parse()
1681            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
1682
1683        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1684        let instrument = self
1685            .core
1686            .cache()
1687            .try_instrument(&cmd.instrument_id)?
1688            .clone();
1689
1690        let new_qty = cmd.quantity.unwrap_or(order.quantity());
1691        let price_precision = instrument.price_precision();
1692        let new_trigger = cmd.trigger_price.or(order.trigger_price());
1693
1694        // Market-style stops carry no limit price; Lighter still needs a worst-
1695        // acceptable `price` cap, derived from the trigger and slippage like submit.
1696        let price_ticks = match order.order_type() {
1697            OrderType::StopMarket | OrderType::MarketIfTouched => {
1698                let trigger = new_trigger.ok_or_else(|| {
1699                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type())
1700                })?;
1701                let is_buy = matches!(order.order_side(), OrderSide::Buy);
1702                let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
1703
1704                derive_market_order_price_ticks(
1705                    trigger.as_decimal(),
1706                    is_buy,
1707                    price_precision,
1708                    slippage_bps,
1709                )?
1710            }
1711            _ => {
1712                let new_price = cmd.price.or(order.price()).ok_or_else(|| {
1713                    anyhow::anyhow!("modify_order requires a price (none on order or command)")
1714                })?;
1715
1716                price_to_ticks(&new_price, price_precision)?
1717            }
1718        };
1719
1720        let base_amount = quantity_to_ticks(&new_qty, instrument.size_precision())?;
1721        let trigger_price_ticks = match new_trigger {
1722            Some(trigger) if trigger.raw != 0 => price_to_ticks(&trigger, price_precision)?,
1723            _ => 0,
1724        };
1725
1726        let ReservedTxContext {
1727            context,
1728            send_reservation,
1729        } = self.build_tx_context(credential)?;
1730
1731        let captured_nonce = context.nonce;
1732        let captured_api_key_index = context.api_key_index;
1733
1734        let mut rollback_guard =
1735            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
1736        let tx = ModifyOrderTxInfo {
1737            context,
1738            market_index,
1739            index: venue_index,
1740            base_amount,
1741            price: price_ticks,
1742            trigger_price: trigger_price_ticks,
1743            attributes: integrator_attributes(),
1744        };
1745
1746        let signed = sign_tx(
1747            &tx,
1748            lighter_chain_id(self.config.environment),
1749            &credential.private_key()?,
1750            fresh_k(),
1751        );
1752
1753        let tx_info_str = TxInfoJson::modify_order(&tx, &signed);
1754        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
1755            .context("failed to wrap signed Lighter modify tx_info JSON")?;
1756        rollback_guard.disarm();
1757
1758        Ok(PreparedModifyOrder {
1759            client_order_id: cmd.client_order_id,
1760            strategy_id: cmd.strategy_id,
1761            instrument_id: cmd.instrument_id,
1762            venue_order_id: Some(voi),
1763            tx_info,
1764            nonce: captured_nonce,
1765            api_key_index: captured_api_key_index,
1766            tx_hash: signed.tx_hash_hex(),
1767            send_reservation,
1768        })
1769    }
1770
1771    /// Submit Lighter's native `UpdateLeverage` tx (`tx_type = 20`).
1772    ///
1773    /// Changes the initial margin fraction and the position margin mode for
1774    /// the given market. `initial_margin_fraction` is in venue ticks
1775    /// (1e-4 fraction): `500` = 5% initial margin = 20x leverage,
1776    /// `1000` = 10% = 10x, etc. Valid range is `1..=10_000`
1777    /// (the upstream `MarginFractionTick` cap).
1778    ///
1779    /// Nautilus does not expose a `set_leverage` command on the execution
1780    /// trait, so this method is callable directly from strategy or bootstrap
1781    /// code.
1782    ///
1783    /// # Errors
1784    ///
1785    /// Returns an error if credentials are missing, the instrument is not
1786    /// registered, `initial_margin_fraction` is outside `1..=10_000`, or
1787    /// the dispatch pre-flight (nonce allocation, signing) fails. Transport
1788    /// errors after dispatch are logged but not returned synchronously.
1789    pub fn update_leverage(
1790        &self,
1791        instrument_id: InstrumentId,
1792        initial_margin_fraction: u16,
1793        margin_mode: LighterPositionMarginMode,
1794    ) -> anyhow::Result<()> {
1795        let credential = self.credential.as_ref().ok_or_else(|| {
1796            anyhow::anyhow!("Lighter execution client cannot update leverage without credentials")
1797        })?;
1798
1799        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1800            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
1801        })?;
1802
1803        anyhow::ensure!(
1804            (1..=10_000).contains(&initial_margin_fraction),
1805            "initial_margin_fraction must be in 1..=10_000, was {initial_margin_fraction}",
1806        );
1807
1808        let ReservedTxContext {
1809            context,
1810            mut send_reservation,
1811        } = self.build_tx_context(credential)?;
1812
1813        let captured_nonce = context.nonce;
1814        let captured_api_key_index = context.api_key_index;
1815        let mut rollback_guard =
1816            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
1817        let tx = UpdateLeverageTxInfo {
1818            context,
1819            market_index,
1820            initial_margin_fraction,
1821            margin_mode: margin_mode as u8,
1822            skip_nonce: 0,
1823        };
1824
1825        let signed = sign_tx(
1826            &tx,
1827            lighter_chain_id(self.config.environment),
1828            &credential.private_key()?,
1829            fresh_k(),
1830        );
1831        let tx_info_str = TxInfoJson::update_leverage(&tx, &signed);
1832        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
1833            .context("failed to wrap signed Lighter update_leverage tx_info JSON")?;
1834        rollback_guard.disarm();
1835        let captured_tx_hash = signed.tx_hash_hex();
1836
1837        let ws_client = self.ws_client.clone();
1838        let dispatch = self.dispatch.clone();
1839        let credential = credential.clone();
1840        let clock = self.clock;
1841
1842        let tx_rate_limiter = self.tx_rate_limiter.clone();
1843
1844        self.spawn_task("update_leverage", async move {
1845            send_reservation.wait_for_turn().await;
1846            await_tx_quota(&tx_rate_limiter).await;
1847            dispatch.enqueue_pending_sendtx(PendingSendTx {
1848                kind: PendingSendTxKind::Other,
1849                submitted_at: clock.get_time_ns(),
1850                nonce: captured_nonce,
1851                api_key_index: captured_api_key_index,
1852                tx_hash: captured_tx_hash,
1853            });
1854
1855            if let Err(e) = ws_client
1856                .send_tx(LighterTxType::UpdateLeverage as u8, tx_info)
1857                .await
1858            {
1859                let reason = format!("Lighter update_leverage dispatch failed: {e}");
1860                log::error!("{reason} for {instrument_id}");
1861                dispatch.remove_pending_sendtx_by_nonce(captured_nonce);
1862                rollback_tx_dispatch(&dispatch, &credential, None, captured_nonce);
1863            }
1864            send_reservation.release();
1865            Ok(())
1866        });
1867
1868        Ok(())
1869    }
1870}
1871
1872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1873enum AuthTokenRefreshOutcome {
1874    Rotated,
1875    Cancelled,
1876    Exhausted,
1877}
1878
1879#[derive(Debug, Clone, Copy)]
1880struct AuthTokenRefreshBackoff {
1881    initial_delay: Duration,
1882    max_delay: Duration,
1883    window: Duration,
1884}
1885
1886fn auth_token_rotation_channels(account_index: i64) -> [LighterWsChannel; 5] {
1887    [
1888        LighterWsChannel::AccountAllOrders(account_index),
1889        LighterWsChannel::AccountAllTrades(account_index),
1890        LighterWsChannel::AccountAllPositions(account_index),
1891        LighterWsChannel::AccountAllAssets(account_index),
1892        LighterWsChannel::UserStats(account_index),
1893    ]
1894}
1895
1896async fn refresh_auth_token_until_rotated<MintToken, Subscribe, SubscribeFuture>(
1897    credential: &Credential,
1898    channels: &[LighterWsChannel],
1899    cancellation_token: &CancellationToken,
1900    backoff: AuthTokenRefreshBackoff,
1901    mut mint_token: MintToken,
1902    mut subscribe: Subscribe,
1903) -> AuthTokenRefreshOutcome
1904where
1905    MintToken: FnMut(&Credential) -> anyhow::Result<String>,
1906    Subscribe: FnMut(LighterWsChannel, String) -> SubscribeFuture,
1907    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
1908{
1909    let retry_started = tokio::time::Instant::now();
1910    let mut retry_delay = backoff.initial_delay.min(backoff.max_delay);
1911    let mut attempt = 1_u32;
1912
1913    loop {
1914        match rotate_auth_token_once(credential, channels, &mut mint_token, &mut subscribe).await {
1915            Ok(()) => {
1916                log::debug!(
1917                    "Lighter auth-token rotated for account_index={}, attempts={attempt}",
1918                    credential.account_index(),
1919                );
1920                return AuthTokenRefreshOutcome::Rotated;
1921            }
1922            Err(e) => {
1923                log::error!("Lighter auth-token rotation attempt {attempt} failed: {e:#}");
1924            }
1925        }
1926
1927        let Some(remaining) = backoff.window.checked_sub(retry_started.elapsed()) else {
1928            log::error!(
1929                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
1930                credential.account_index(),
1931            );
1932            return AuthTokenRefreshOutcome::Exhausted;
1933        };
1934
1935        if remaining.as_nanos() == 0 {
1936            log::error!(
1937                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
1938                credential.account_index(),
1939            );
1940            return AuthTokenRefreshOutcome::Exhausted;
1941        }
1942
1943        let delay = retry_delay.min(remaining);
1944        log::warn!(
1945            "Retrying Lighter auth-token rotation in {:.3}s: account_index={}, attempts={attempt}",
1946            delay.as_secs_f64(),
1947            credential.account_index(),
1948        );
1949
1950        if !sleep_or_auth_token_refresh_cancelled(delay, cancellation_token).await {
1951            return AuthTokenRefreshOutcome::Cancelled;
1952        }
1953
1954        retry_delay = next_auth_token_refresh_retry_delay(retry_delay, backoff.max_delay);
1955        attempt = attempt.saturating_add(1);
1956    }
1957}
1958
1959async fn rotate_auth_token_once<MintToken, Subscribe, SubscribeFuture>(
1960    credential: &Credential,
1961    channels: &[LighterWsChannel],
1962    mint_token: &mut MintToken,
1963    subscribe: &mut Subscribe,
1964) -> anyhow::Result<()>
1965where
1966    MintToken: FnMut(&Credential) -> anyhow::Result<String>,
1967    Subscribe: FnMut(LighterWsChannel, String) -> SubscribeFuture,
1968    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
1969{
1970    let token =
1971        mint_token(credential).context("failed to mint Lighter auth token during rotation")?;
1972    let mut first_error = None;
1973
1974    for channel in channels {
1975        if let Err(e) = subscribe(channel.clone(), token.clone()).await {
1976            log::error!("Lighter auth-token rotation: re-subscribe failed for {channel:?}: {e}",);
1977            first_error.get_or_insert_with(|| format!("{channel:?}: {e}"));
1978        }
1979    }
1980
1981    if let Some(error) = first_error {
1982        anyhow::bail!("failed to re-subscribe Lighter account channels: {error}");
1983    }
1984
1985    Ok(())
1986}
1987
1988async fn sleep_or_auth_token_refresh_cancelled(
1989    duration: Duration,
1990    cancellation_token: &CancellationToken,
1991) -> bool {
1992    tokio::select! {
1993        () = cancellation_token.cancelled() => false,
1994        () = tokio::time::sleep(duration) => true,
1995    }
1996}
1997
1998fn auth_token_refresh_next_delay(outcome: AuthTokenRefreshOutcome) -> Option<Duration> {
1999    match outcome {
2000        AuthTokenRefreshOutcome::Rotated => Some(AUTH_TOKEN_REFRESH_INTERVAL),
2001        AuthTokenRefreshOutcome::Cancelled => None,
2002        AuthTokenRefreshOutcome::Exhausted => Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY),
2003    }
2004}
2005
2006fn next_auth_token_refresh_retry_delay(current: Duration, max: Duration) -> Duration {
2007    current.checked_mul(2).unwrap_or(max).min(max)
2008}
2009
2010#[derive(Debug)]
2011struct ReservedTxContext {
2012    context: TxContext,
2013    send_reservation: TxSendReservation,
2014}
2015
2016#[derive(Debug, Clone)]
2017struct TxSendSequencer {
2018    state: Arc<Mutex<TxSendSequencerState>>,
2019    version: Arc<AtomicU64>,
2020    changed: tokio::sync::watch::Sender<u64>,
2021}
2022
2023impl TxSendSequencer {
2024    fn new() -> Self {
2025        let (changed, _) = tokio::sync::watch::channel(0);
2026        Self {
2027            state: Arc::new(Mutex::new(TxSendSequencerState::default())),
2028            version: Arc::new(AtomicU64::new(0)),
2029            changed,
2030        }
2031    }
2032
2033    fn reserve(&self, account_index: i64, api_key_index: u8, nonce: i64) -> TxSendReservation {
2034        let key = TxSendKey {
2035            account_index,
2036            api_key_index,
2037        };
2038        self.state
2039            .lock()
2040            .expect(MUTEX_POISONED)
2041            .pending
2042            .entry(key)
2043            .or_default()
2044            .insert(nonce);
2045        self.notify_waiters();
2046
2047        TxSendReservation {
2048            sequencer: self.clone(),
2049            key,
2050            nonce,
2051            released: false,
2052        }
2053    }
2054
2055    async fn wait_for_turn(&self, key: TxSendKey, nonce: i64) {
2056        let mut changed = self.changed.subscribe();
2057
2058        loop {
2059            if self.ready_to_send(key, nonce) {
2060                return;
2061            }
2062
2063            if changed.changed().await.is_err() {
2064                tokio::task::yield_now().await;
2065            }
2066        }
2067    }
2068
2069    fn release(&self, key: TxSendKey, nonce: i64) {
2070        let mut state = self.state.lock().expect(MUTEX_POISONED);
2071        let should_notify = if let Some(pending) = state.pending.get_mut(&key) {
2072            let removed = pending.remove(&nonce);
2073            if pending.is_empty() {
2074                state.pending.remove(&key);
2075            }
2076            removed
2077        } else {
2078            false
2079        };
2080        drop(state);
2081
2082        if should_notify {
2083            self.notify_waiters();
2084        }
2085    }
2086
2087    fn ready_to_send(&self, key: TxSendKey, nonce: i64) -> bool {
2088        let state = self.state.lock().expect(MUTEX_POISONED);
2089        state
2090            .pending
2091            .get(&key)
2092            .and_then(|pending| pending.first())
2093            .is_none_or(|first| *first >= nonce)
2094    }
2095
2096    fn notify_waiters(&self) {
2097        let version = self.version.fetch_add(1, Ordering::AcqRel) + 1;
2098        let _ = self.changed.send(version);
2099    }
2100}
2101
2102#[derive(Debug, Default)]
2103struct TxSendSequencerState {
2104    pending: BTreeMap<TxSendKey, BTreeSet<i64>>,
2105}
2106
2107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2108struct TxSendKey {
2109    account_index: i64,
2110    api_key_index: u8,
2111}
2112
2113#[derive(Debug)]
2114struct TxSendReservation {
2115    sequencer: TxSendSequencer,
2116    key: TxSendKey,
2117    nonce: i64,
2118    released: bool,
2119}
2120
2121impl TxSendReservation {
2122    async fn wait_for_turn(&self) {
2123        self.sequencer.wait_for_turn(self.key, self.nonce).await;
2124    }
2125
2126    fn release(&mut self) {
2127        if self.released {
2128            return;
2129        }
2130
2131        self.sequencer.release(self.key, self.nonce);
2132        self.released = true;
2133    }
2134}
2135
2136impl Drop for TxSendReservation {
2137    fn drop(&mut self) {
2138        self.release();
2139    }
2140}
2141
2142async fn wait_for_tx_send_reservations(reservations: &[&TxSendReservation]) {
2143    let Some(first) = reservations.first() else {
2144        return;
2145    };
2146
2147    debug_assert!(
2148        reservations
2149            .iter()
2150            .all(|reservation| reservation.key == first.key),
2151        "batch send reservations must share one nonce stream",
2152    );
2153
2154    let nonce = reservations
2155        .iter()
2156        .map(|reservation| reservation.nonce)
2157        .min()
2158        .expect("reservations is non-empty");
2159    first.sequencer.wait_for_turn(first.key, nonce).await;
2160}
2161
2162fn release_prepared_create_reservations(prepared_orders: &mut [PreparedCreateOrder]) {
2163    for prepared in prepared_orders {
2164        prepared.send_reservation.release();
2165    }
2166}
2167
2168fn release_prepared_cancel_reservations(prepared_cancels: &mut [PreparedCancelOrder]) {
2169    for prepared in prepared_cancels {
2170        prepared.send_reservation.release();
2171    }
2172}
2173
2174struct PreparedCreateOrder {
2175    order: OrderAny,
2176    client_order_index: i64,
2177    tx_info: Box<serde_json::value::RawValue>,
2178    nonce: i64,
2179    api_key_index: u8,
2180    tx_hash: String,
2181    send_reservation: TxSendReservation,
2182}
2183
2184struct PreparedCancelOrder {
2185    client_order_id: ClientOrderId,
2186    strategy_id: StrategyId,
2187    instrument_id: InstrumentId,
2188    venue_order_id: Option<VenueOrderId>,
2189    tx_info: Box<serde_json::value::RawValue>,
2190    nonce: i64,
2191    api_key_index: u8,
2192    tx_hash: String,
2193    send_reservation: TxSendReservation,
2194}
2195
2196struct PreparedModifyOrder {
2197    client_order_id: ClientOrderId,
2198    strategy_id: StrategyId,
2199    instrument_id: InstrumentId,
2200    venue_order_id: Option<VenueOrderId>,
2201    tx_info: Box<serde_json::value::RawValue>,
2202    nonce: i64,
2203    api_key_index: u8,
2204    tx_hash: String,
2205    send_reservation: TxSendReservation,
2206}
2207
2208struct PreparedIntegratorApproval {
2209    tx_info: String,
2210    nonce: i64,
2211    api_key_index: u8,
2212    approval_expiry: i64,
2213    send_reservation: TxSendReservation,
2214}
2215
2216// Cross-check between a detected account tier and the configured REST quota:
2217// AboveTier when the override exceeds the tier limit, RaiseHint when the tier
2218// allows more than standard but no override is set.
2219#[derive(Debug, PartialEq, Eq)]
2220enum TierCrossCheck {
2221    AboveTier { documented: u32 },
2222    RaiseHint { documented: u32 },
2223}
2224
2225// Computes the active REST quota to report and any cross-check advisory from the
2226// detected tier and the raw override. A zero override resolves to the standard
2227// default (matching resolve_quota), so the reported quota always matches the
2228// limiter. Pure so the reporting decision is unit-testable without log capture.
2229fn tier_quota_report(
2230    tier: LighterAccountTier,
2231    rest_quota_per_min: Option<u32>,
2232    standard_rest: u32,
2233) -> (u32, Option<TierCrossCheck>) {
2234    let configured = rest_quota_per_min.filter(|&n| n > 0);
2235    let active_rest = configured.unwrap_or(standard_rest);
2236    let cross_check = match (tier.documented_rest_quota_per_min(), configured) {
2237        (Some(documented), Some(configured)) if configured > documented => {
2238            Some(TierCrossCheck::AboveTier { documented })
2239        }
2240        (Some(documented), None) if documented > standard_rest => {
2241            Some(TierCrossCheck::RaiseHint { documented })
2242        }
2243        _ => None,
2244    };
2245    (active_rest, cross_check)
2246}
2247
2248fn send_tx_batch_request(
2249    tx_types: &[u8],
2250    tx_infos: &[Box<serde_json::value::RawValue>],
2251) -> LighterSendTxBatchRequest {
2252    let tx_types =
2253        serde_json::to_string(tx_types).expect("tx_types JSON serialization cannot fail");
2254    let tx_infos: Vec<&str> = tx_infos.iter().map(|tx_info| tx_info.get()).collect();
2255    let tx_infos =
2256        serde_json::to_string(&tx_infos).expect("tx_infos JSON serialization cannot fail");
2257
2258    LighterSendTxBatchRequest::new(tx_types, tx_infos)
2259}
2260
2261struct TxDispatchGuard {
2262    dispatch: WsDispatchState,
2263    account_index: i64,
2264    api_key_index: u8,
2265    client_order_index: Option<i64>,
2266    client_order_id: Option<ClientOrderId>,
2267    nonce: i64,
2268    armed: bool,
2269}
2270
2271impl TxDispatchGuard {
2272    fn new(
2273        dispatch: WsDispatchState,
2274        credential: &Credential,
2275        client_order_index: Option<i64>,
2276        nonce: i64,
2277    ) -> Self {
2278        Self {
2279            dispatch,
2280            account_index: credential.account_index(),
2281            api_key_index: credential.api_key_index(),
2282            client_order_index,
2283            client_order_id: None,
2284            nonce,
2285            armed: true,
2286        }
2287    }
2288
2289    fn with_order_identity(mut self, client_order_id: ClientOrderId) -> Self {
2290        self.client_order_id = Some(client_order_id);
2291        self
2292    }
2293
2294    fn disarm(&mut self) {
2295        self.armed = false;
2296    }
2297}
2298
2299impl Drop for TxDispatchGuard {
2300    fn drop(&mut self) {
2301        if self.armed {
2302            rollback_tx_dispatch_indices(
2303                &self.dispatch,
2304                self.account_index,
2305                self.api_key_index,
2306                self.client_order_index,
2307                self.client_order_id.as_ref(),
2308                self.nonce,
2309            );
2310        }
2311    }
2312}
2313
2314// SendTxAck: pop the pending entry and advance the nonce baseline (the venue
2315// applied the nonce); the account-orders frame drives the order lifecycle.
2316// The advance is a monotonic max, so a misattributed pop is harmless.
2317// SendTxAck: remove by echoed tx_hash when present, pop head only for
2318// hashless acks. An echoed hash is authoritative: on a miss the entry was
2319// already consumed or never enqueued, and a head fallback would attribute
2320// the ack to the wrong tx.
2321fn handle_send_tx_ack(
2322    dispatch: &WsDispatchState,
2323    account_index: Option<i64>,
2324    code: i64,
2325    tx_hash: Option<&str>,
2326) -> Option<PendingSendTx> {
2327    let popped = match tx_hash {
2328        Some(hash) => {
2329            let matched = dispatch.remove_pending_sendtx_by_hash(hash);
2330            if matched.is_none() {
2331                log::warn!("Lighter sendTx ack unmatched: tx_hash={hash} code={code}");
2332            }
2333            matched
2334        }
2335        None => dispatch.pop_pending_sendtx_head(),
2336    };
2337
2338    if let (Some(pending), Some(account_index)) = (&popped, account_index) {
2339        let _ =
2340            dispatch
2341                .nonce_manager
2342                .ack_success(account_index, pending.api_key_index, pending.nonce);
2343    }
2344
2345    log::debug!(
2346        "Lighter sendTx ack: code={code} tx_hash={tx_hash:?} popped_nonce={:?}",
2347        popped.as_ref().map(|p| p.nonce),
2348    );
2349
2350    popped
2351}
2352
2353fn spawn_acked_order_probe(pending: &PendingSendTx, context: AckedOrderProbeContext) {
2354    let Some(probe) = AckedOrderProbe::from_pending(pending) else {
2355        return;
2356    };
2357
2358    get_runtime().spawn(async move {
2359        tokio::select! {
2360            () = context.cancellation_token.cancelled() => return,
2361            () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
2362        }
2363
2364        if let Err(e) = probe_acked_order(probe, &context).await {
2365            log::warn!("Lighter acked order no-op probe failed: {e:?}");
2366        }
2367    });
2368}
2369
2370#[derive(Clone)]
2371struct AckedOrderProbeContext {
2372    http_client: LighterHttpClient,
2373    registry: Arc<MarketRegistry>,
2374    credential: Credential,
2375    dispatch: WsDispatchState,
2376    emitter: ExecutionEventEmitter,
2377    account_id: AccountId,
2378    clock: &'static AtomicTime,
2379    cancellation_token: CancellationToken,
2380}
2381
2382async fn probe_acked_order(
2383    probe: AckedOrderProbe,
2384    context: &AckedOrderProbeContext,
2385) -> anyhow::Result<()> {
2386    let report = lookup_order_status_report(
2387        &context.http_client,
2388        &context.registry,
2389        &context.credential,
2390        context.account_id,
2391        Some(probe.instrument_id()),
2392        Some(&probe.client_order_id()),
2393        probe.venue_order_id().as_ref(),
2394        &context.dispatch,
2395        context.clock,
2396    )
2397    .await?;
2398
2399    if let Some(report) = &report {
2400        context.dispatch.seed_accepted_from_report(report);
2401    }
2402
2403    emit_ack_noop_rejection_if_missing(
2404        &probe,
2405        report.is_some(),
2406        &context.emitter,
2407        context.clock.get_time_ns(),
2408    );
2409    Ok(())
2410}
2411
2412fn emit_ack_noop_rejection_if_missing(
2413    probe: &AckedOrderProbe,
2414    order_found: bool,
2415    emitter: &ExecutionEventEmitter,
2416    now: UnixNanos,
2417) -> bool {
2418    if order_found {
2419        return false;
2420    }
2421
2422    match probe {
2423        AckedOrderProbe::Cancel {
2424            strategy_id,
2425            instrument_id,
2426            client_order_id,
2427            venue_order_id,
2428        } => {
2429            let reason = "Lighter cancel_order no-op: order not found after venue ack";
2430            log::warn!("{reason} for {client_order_id}");
2431            emitter.emit_order_cancel_rejected_event(
2432                *strategy_id,
2433                *instrument_id,
2434                *client_order_id,
2435                *venue_order_id,
2436                reason,
2437                now,
2438            );
2439        }
2440        AckedOrderProbe::Modify {
2441            strategy_id,
2442            instrument_id,
2443            client_order_id,
2444            venue_order_id,
2445        } => {
2446            let reason = "Lighter modify_order no-op: order not found after venue ack";
2447            log::warn!("{reason} for {client_order_id}");
2448            emitter.emit_order_modify_rejected_event(
2449                *strategy_id,
2450                *instrument_id,
2451                *client_order_id,
2452                *venue_order_id,
2453                reason,
2454                now,
2455            );
2456        }
2457    }
2458
2459    true
2460}
2461
2462#[derive(Debug, Clone)]
2463enum AckedOrderProbe {
2464    Cancel {
2465        strategy_id: StrategyId,
2466        instrument_id: InstrumentId,
2467        client_order_id: ClientOrderId,
2468        venue_order_id: Option<VenueOrderId>,
2469    },
2470    Modify {
2471        strategy_id: StrategyId,
2472        instrument_id: InstrumentId,
2473        client_order_id: ClientOrderId,
2474        venue_order_id: Option<VenueOrderId>,
2475    },
2476}
2477
2478impl AckedOrderProbe {
2479    fn from_pending(pending: &PendingSendTx) -> Option<Self> {
2480        match &pending.kind {
2481            PendingSendTxKind::Cancel {
2482                strategy_id,
2483                instrument_id,
2484                client_order_id,
2485                venue_order_id,
2486            } => Some(Self::Cancel {
2487                strategy_id: *strategy_id,
2488                instrument_id: *instrument_id,
2489                client_order_id: *client_order_id,
2490                venue_order_id: *venue_order_id,
2491            }),
2492            PendingSendTxKind::Modify {
2493                strategy_id,
2494                instrument_id,
2495                client_order_id,
2496                venue_order_id,
2497            } => Some(Self::Modify {
2498                strategy_id: *strategy_id,
2499                instrument_id: *instrument_id,
2500                client_order_id: *client_order_id,
2501                venue_order_id: *venue_order_id,
2502            }),
2503            PendingSendTxKind::Create { .. } | PendingSendTxKind::Other => None,
2504        }
2505    }
2506
2507    fn instrument_id(&self) -> InstrumentId {
2508        match self {
2509            Self::Cancel { instrument_id, .. } | Self::Modify { instrument_id, .. } => {
2510                *instrument_id
2511            }
2512        }
2513    }
2514
2515    fn client_order_id(&self) -> ClientOrderId {
2516        match self {
2517            Self::Cancel {
2518                client_order_id, ..
2519            }
2520            | Self::Modify {
2521                client_order_id, ..
2522            } => *client_order_id,
2523        }
2524    }
2525
2526    fn venue_order_id(&self) -> Option<VenueOrderId> {
2527        match self {
2528            Self::Cancel { venue_order_id, .. } | Self::Modify { venue_order_id, .. } => {
2529                *venue_order_id
2530            }
2531        }
2532    }
2533}
2534
2535// SendTxRejected: attribute by echoed tx_hash when present (authoritative,
2536// no head fallback on a miss); otherwise pop head for Ack or
2537// head-within-window for BareError. Create emits OrderRejected, cancel/modify
2538// emit their typed rejections, and Other recovers via reconciliation. All
2539// attributed rejections roll the nonce back when still the latest issuance.
2540// Returns true on an invalid-nonce code: the sequential stream is wedged and
2541// needs a hard refresh.
2542#[expect(
2543    clippy::too_many_arguments,
2544    reason = "consumer-loop sink that flattens one SendTxRejected message without a wrapper struct"
2545)]
2546fn handle_send_tx_rejection(
2547    dispatch: &WsDispatchState,
2548    emitter: &ExecutionEventEmitter,
2549    account_index: Option<i64>,
2550    now: UnixNanos,
2551    source: SendTxRejectionSource,
2552    code: Option<i64>,
2553    message: &str,
2554    tx_hash: Option<&str>,
2555) -> bool {
2556    let needs_nonce_resync = code == Some(LIGHTER_ERROR_CODE_INVALID_NONCE);
2557
2558    let pending = match tx_hash {
2559        Some(hash) => dispatch.remove_pending_sendtx_by_hash(hash),
2560        None => match source {
2561            SendTxRejectionSource::Ack => dispatch.pop_pending_sendtx_head(),
2562            SendTxRejectionSource::BareError => {
2563                dispatch.pop_pending_sendtx_within(now, SENDTX_BARE_ERROR_WINDOW_MS)
2564            }
2565        },
2566    };
2567    let Some(pending) = pending else {
2568        log::warn!(
2569            "Lighter sendTx rejection unattributed (source={source:?} code={code:?}): {message}",
2570        );
2571        return needs_nonce_resync;
2572    };
2573
2574    let reason = format!(
2575        "Lighter venue rejected sendTx (code={}): {message}",
2576        code.map_or_else(|| "?".into(), |c| c.to_string()),
2577    );
2578
2579    match &pending.kind {
2580        PendingSendTxKind::Create {
2581            order,
2582            client_order_index,
2583        } => {
2584            let cloid = order.client_order_id();
2585            log::error!(
2586                "{reason} attributed to cloid={cloid} nonce={} api_key_index={}",
2587                pending.nonce,
2588                pending.api_key_index,
2589            );
2590
2591            if let Some(account_index) = account_index {
2592                let _ = dispatch.nonce_manager.ack_failure_if_latest(
2593                    account_index,
2594                    pending.api_key_index,
2595                    pending.nonce,
2596                );
2597            }
2598            dispatch.forget_cloid(*client_order_index);
2599            dispatch.forget_order_identity(&cloid);
2600            emitter.emit_order_rejected(
2601                order,
2602                &reason,
2603                now,
2604                lighter_reason_indicates_post_only_rejection(message),
2605            );
2606        }
2607        PendingSendTxKind::Cancel {
2608            strategy_id,
2609            instrument_id,
2610            client_order_id,
2611            venue_order_id,
2612        } => {
2613            log::error!(
2614                "{reason} attributed to cancel cloid={client_order_id} nonce={} api_key_index={}",
2615                pending.nonce,
2616                pending.api_key_index,
2617            );
2618
2619            if let Some(account_index) = account_index {
2620                let _ = dispatch.nonce_manager.ack_failure_if_latest(
2621                    account_index,
2622                    pending.api_key_index,
2623                    pending.nonce,
2624                );
2625            }
2626            emitter.emit_order_cancel_rejected_event(
2627                *strategy_id,
2628                *instrument_id,
2629                *client_order_id,
2630                *venue_order_id,
2631                &reason,
2632                now,
2633            );
2634        }
2635        PendingSendTxKind::Modify {
2636            strategy_id,
2637            instrument_id,
2638            client_order_id,
2639            venue_order_id,
2640        } => {
2641            log::error!(
2642                "{reason} attributed to modify cloid={client_order_id} nonce={} api_key_index={}",
2643                pending.nonce,
2644                pending.api_key_index,
2645            );
2646
2647            if let Some(account_index) = account_index {
2648                let _ = dispatch.nonce_manager.ack_failure_if_latest(
2649                    account_index,
2650                    pending.api_key_index,
2651                    pending.nonce,
2652                );
2653            }
2654            emitter.emit_order_modify_rejected_event(
2655                *strategy_id,
2656                *instrument_id,
2657                *client_order_id,
2658                *venue_order_id,
2659                &reason,
2660                now,
2661            );
2662        }
2663        PendingSendTxKind::Other => {
2664            if let Some(account_index) = account_index {
2665                let _ = dispatch.nonce_manager.ack_failure_if_latest(
2666                    account_index,
2667                    pending.api_key_index,
2668                    pending.nonce,
2669                );
2670            }
2671            log::warn!(
2672                "{reason} on non-create sendTx (nonce={} api_key_index={})",
2673                pending.nonce,
2674                pending.api_key_index,
2675            );
2676        }
2677    }
2678
2679    needs_nonce_resync
2680}
2681
2682fn lighter_reason_indicates_post_only_rejection(reason: &str) -> bool {
2683    let normalized: String = reason
2684        .chars()
2685        .filter_map(|ch| {
2686            if ch == '-' || ch == '_' || ch.is_whitespace() {
2687                None
2688            } else {
2689                Some(ch.to_ascii_lowercase())
2690            }
2691        })
2692        .collect();
2693
2694    normalized.contains("postonly") || normalized.contains("postwouldexecute")
2695}
2696
2697// A sendTxBatch success covers every tx (single result code). Batch txs
2698// produce no WS acks, so this is the only baseline signal for batch flows.
2699fn advance_baseline_for_batch(
2700    dispatch: &WsDispatchState,
2701    credential: &Credential,
2702    nonces: impl Iterator<Item = i64>,
2703) {
2704    if let Some(max_nonce) = nonces.max() {
2705        let _ = dispatch.nonce_manager.ack_success(
2706            credential.account_index(),
2707            credential.api_key_index(),
2708            max_nonce,
2709        );
2710    }
2711}
2712
2713// Batch rejections surface as `LighterHttpError::Venue`, not WS frames, so
2714// the consumer-loop resync never sees them; realign allocation here so a
2715// wedged batch flow recovers without a reconnect.
2716async fn resync_nonce_after_invalid_nonce(
2717    http_client: &LighterHttpClient,
2718    dispatch: &WsDispatchState,
2719    credential: &Credential,
2720    error: &LighterHttpError,
2721) {
2722    if !matches!(
2723        error,
2724        LighterHttpError::Venue { code, .. } if *code == LIGHTER_ERROR_CODE_INVALID_NONCE
2725    ) {
2726        return;
2727    }
2728
2729    match http_client
2730        .get_next_nonce(credential.account_index(), credential.api_key_index())
2731        .await
2732    {
2733        Ok(response) => {
2734            dispatch.nonce_manager.refresh(
2735                credential.account_index(),
2736                credential.api_key_index(),
2737                response.nonce,
2738            );
2739            log::debug!(
2740                "Hard-refreshed Lighter nonce after invalid-nonce batch rejection: \
2741                 account_index={}, next_nonce={}",
2742                credential.account_index(),
2743                response.nonce,
2744            );
2745        }
2746        Err(e) => {
2747            log::error!("Failed to refresh Lighter nonce after invalid-nonce batch rejection: {e}");
2748        }
2749    }
2750}
2751
2752fn rollback_tx_dispatch(
2753    dispatch: &WsDispatchState,
2754    credential: &Credential,
2755    client_order_index: Option<i64>,
2756    nonce: i64,
2757) {
2758    rollback_tx_dispatch_indices(
2759        dispatch,
2760        credential.account_index(),
2761        credential.api_key_index(),
2762        client_order_index,
2763        None,
2764        nonce,
2765    );
2766}
2767
2768fn rollback_tx_dispatch_create(
2769    dispatch: &WsDispatchState,
2770    credential: &Credential,
2771    client_order_index: Option<i64>,
2772    client_order_id: &ClientOrderId,
2773    nonce: i64,
2774) {
2775    rollback_tx_dispatch_indices(
2776        dispatch,
2777        credential.account_index(),
2778        credential.api_key_index(),
2779        client_order_index,
2780        Some(client_order_id),
2781        nonce,
2782    );
2783}
2784
2785// Roll back only while still the latest issuance: decrementing past a newer
2786// signed tx would duplicate its nonce on the wire. Skipped rollbacks heal
2787// via the baseline advance or venue resync.
2788fn rollback_tx_dispatch_indices(
2789    dispatch: &WsDispatchState,
2790    account_index: i64,
2791    api_key_index: u8,
2792    client_order_index: Option<i64>,
2793    client_order_id: Option<&ClientOrderId>,
2794    nonce: i64,
2795) {
2796    let _ = dispatch
2797        .nonce_manager
2798        .ack_failure_if_latest(account_index, api_key_index, nonce);
2799
2800    if let Some(client_order_index) = client_order_index {
2801        dispatch.forget_cloid(client_order_index);
2802    }
2803
2804    if let Some(cloid) = client_order_id {
2805        dispatch.forget_order_identity(cloid);
2806    }
2807}
2808
2809fn integrator_attributes() -> L2TxAttributes {
2810    L2TxAttributes {
2811        integrator_account_index: LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
2812        integrator_taker_fee: 0,
2813        integrator_maker_fee: 0,
2814        skip_nonce: 0,
2815    }
2816}
2817
2818/// Format a `start_secs-end_secs` window for Lighter's `between_timestamps`
2819/// query parameter. Returns `None` when neither bound is set; an unset end
2820/// defaults to the current time so the venue scopes pagination to the
2821/// half-open window.
2822fn format_between_timestamps(
2823    start: Option<UnixNanos>,
2824    end: Option<UnixNanos>,
2825    ts_now: UnixNanos,
2826) -> Option<String> {
2827    let (start, end) = match (start, end) {
2828        (None, None) => return None,
2829        (Some(s), Some(e)) => (s, e),
2830        (Some(s), None) => (s, ts_now),
2831        (None, Some(e)) => (UnixNanos::from(0), e),
2832    };
2833    let start_secs = start.as_u64() / 1_000_000_000;
2834    let end_secs = end.as_u64() / 1_000_000_000;
2835    Some(format!("{start_secs}-{end_secs}"))
2836}
2837
2838#[async_trait(?Send)]
2839impl ExecutionClient for LighterExecutionClient {
2840    fn is_connected(&self) -> bool {
2841        self.core.is_connected()
2842    }
2843
2844    fn client_id(&self) -> ClientId {
2845        self.core.client_id
2846    }
2847
2848    fn account_id(&self) -> AccountId {
2849        self.core.account_id
2850    }
2851
2852    fn venue(&self) -> Venue {
2853        *LIGHTER_VENUE
2854    }
2855
2856    fn oms_type(&self) -> OmsType {
2857        self.core.oms_type
2858    }
2859
2860    fn get_account(&self) -> Option<AccountAny> {
2861        self.core.cache().account_owned(&self.core.account_id)
2862    }
2863
2864    fn generate_account_state(
2865        &self,
2866        balances: Vec<AccountBalance>,
2867        margins: Vec<MarginBalance>,
2868        reported: bool,
2869        ts_event: UnixNanos,
2870    ) -> anyhow::Result<()> {
2871        self.emitter
2872            .emit_account_state(balances, margins, reported, ts_event);
2873        Ok(())
2874    }
2875
2876    fn start(&mut self) -> anyhow::Result<()> {
2877        if self.core.is_started() {
2878            return Ok(());
2879        }
2880
2881        let sender = get_exec_event_sender();
2882        self.emitter.set_sender(sender);
2883        self.core.set_started();
2884
2885        log::info!(
2886            "Started Lighter execution client: client_id={}, account_id={}, environment={:?}, has_credentials={}",
2887            self.core.client_id,
2888            self.core.account_id,
2889            self.config.environment,
2890            self.has_credentials(),
2891        );
2892
2893        Ok(())
2894    }
2895
2896    fn stop(&mut self) -> anyhow::Result<()> {
2897        if self.core.is_stopped() {
2898            return Ok(());
2899        }
2900
2901        log::info!("Stopping Lighter execution client {}", self.core.client_id);
2902
2903        self.cancellation_token.cancel();
2904
2905        if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
2906            handle.abort();
2907        }
2908
2909        self.abort_pending_tasks();
2910
2911        self.core.set_disconnected();
2912        self.core.set_stopped();
2913
2914        log::info!("Lighter execution client stopped");
2915        Ok(())
2916    }
2917
2918    async fn connect(&mut self) -> anyhow::Result<()> {
2919        if self.core.is_connected() {
2920            return Ok(());
2921        }
2922
2923        // Without credentials the engine would accept the connection and
2924        // then deny every order per-submission. Fail before any WS/REST
2925        // work so reconciliation and strategies never start.
2926        if !self.has_credentials() {
2927            anyhow::bail!(
2928                "Lighter execution client requires credentials; \
2929                 set private_key, account_index, and api_key_index in the config \
2930                 (or the LIGHTER_{{MAINNET,TESTNET}}_* environment variables)"
2931            );
2932        }
2933
2934        log::info!(
2935            "Connecting Lighter execution client {}",
2936            self.core.client_id
2937        );
2938
2939        // Rotate the cancellation token before reconnect so a previous stop()
2940        // does not signal the new consumer task to exit immediately.
2941        if self.cancellation_token.is_cancelled() {
2942            self.cancellation_token = CancellationToken::new();
2943        }
2944
2945        // Reset the readiness gate and clear derived position/account caches
2946        // so a prior session's state cannot leak past the strict-await gate.
2947        // The Reconnected path (WS-layer transparent reconnect) is unaffected:
2948        // it does not re-enter `connect()`. Its next `account_all_positions`
2949        // frame replaces the position cache through the consumption loop.
2950        self.dispatch.account_streams_ready.reset();
2951        self.dispatch.clear_position_cache();
2952        self.dispatch.clear_account_state_cache();
2953
2954        self.ensure_instruments_initialized_async().await?;
2955        self.refresh_nonce().await?;
2956        self.detect_account_tier().await;
2957
2958        if let Err(e) = self.submit_integrator_auto_approval().await {
2959            // Bail on venue 21149 ("integrator is not approved") so the
2960            // operator catches it at startup rather than at first order.
2961            // Other failures are tolerated: approval is account-scoped and
2962            // may already be in place, or a reconnect can retry.
2963            let is_unapproved = e.chain().any(|cause| {
2964                matches!(
2965                    cause.downcast_ref::<LighterHttpError>(),
2966                    Some(LighterHttpError::Venue { code: 21149, .. }),
2967                )
2968            });
2969
2970            if is_unapproved {
2971                return Err(e.context(
2972                    "Lighter account is not integrator-approved (venue 21149); \
2973                     orders cannot be placed",
2974                ));
2975            }
2976            log::error!("Lighter integrator approval failed; continuing startup: {e:?}");
2977        }
2978
2979        if let Err(e) = self.refresh_nonce().await {
2980            log::debug!(
2981                "Failed to refresh Lighter nonce after integrator approval; continuing startup: {e:?}"
2982            );
2983        }
2984        self.spawn_ws_consumer().await?;
2985
2986        if let Err(e) = self.await_account_streams_ready(30.0).await {
2987            log::warn!("Connect failed after WS started, tearing down: {e}");
2988            self.cancellation_token.cancel();
2989
2990            if let Err(disconnect_err) = self.ws_client.disconnect().await {
2991                log::error!(
2992                    "Error disconnecting Lighter WebSocket during connect teardown: {disconnect_err}"
2993                );
2994            }
2995
2996            // Await the consumer task to completion before returning so a
2997            // queued marker from the failed session cannot call `mark_*`
2998            // on the shared readiness handle after a caller's retry has
2999            // reset it. On timeout we still abort and drain the handle to
3000            // completion rather than detaching, so the task is provably
3001            // dead before this function returns.
3002            let taken_handle = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take();
3003            if let Some(handle) = taken_handle {
3004                let abort_handle = handle.abort_handle();
3005                let mut handle = Box::pin(handle);
3006                tokio::select! {
3007                    join_res = &mut handle => match join_res {
3008                        Ok(()) => log::debug!(
3009                            "Lighter execution consumer task completed during connect teardown"
3010                        ),
3011                        Err(join_err) if join_err.is_cancelled() => log::debug!(
3012                            "Lighter execution consumer task cancelled during connect teardown"
3013                        ),
3014                        Err(join_err) => log::error!(
3015                            "Lighter execution consumer task error during connect teardown: {join_err}"
3016                        ),
3017                    },
3018                    () = tokio::time::sleep(WS_CONSUMER_SHUTDOWN_TIMEOUT) => {
3019                        log::warn!(
3020                            "Timeout waiting for Lighter execution consumer during connect teardown, aborting",
3021                        );
3022                        abort_handle.abort();
3023                        let _ = handle.await;
3024                    }
3025                }
3026            }
3027
3028            self.abort_pending_tasks();
3029            return Err(e);
3030        }
3031
3032        self.core.set_connected();
3033
3034        log::info!("Connected: client_id={}", self.core.client_id);
3035        Ok(())
3036    }
3037
3038    async fn disconnect(&mut self) -> anyhow::Result<()> {
3039        if self.core.is_disconnected() {
3040            return Ok(());
3041        }
3042
3043        log::info!(
3044            "Disconnecting Lighter execution client {}",
3045            self.core.client_id
3046        );
3047
3048        // Signal the consumption loop to drain.
3049        self.cancellation_token.cancel();
3050
3051        if let Err(e) = self.ws_client.disconnect().await {
3052            log::warn!("Error disconnecting Lighter WebSocket client: {e}");
3053        }
3054
3055        let ws_stream_handle = { self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() };
3056
3057        if let Some(handle) = ws_stream_handle {
3058            let abort_handle = handle.abort_handle();
3059            match tokio::time::timeout(WS_CONSUMER_SHUTDOWN_TIMEOUT, handle).await {
3060                Ok(Ok(())) => log::debug!("Lighter execution consumer task completed"),
3061                Ok(Err(e)) if e.is_cancelled() => {
3062                    log::debug!("Lighter execution consumer task cancelled");
3063                }
3064                Ok(Err(e)) => log::error!("Lighter execution consumer task error: {e}"),
3065                Err(_) => {
3066                    log::warn!("Timeout waiting for Lighter execution consumer task, aborting");
3067                    abort_handle.abort();
3068                }
3069            }
3070        }
3071
3072        self.abort_pending_tasks();
3073
3074        self.core.set_disconnected();
3075
3076        log::info!("Disconnected: client_id={}", self.core.client_id);
3077        Ok(())
3078    }
3079
3080    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
3081        let credential = self.credential.as_ref().ok_or_else(|| {
3082            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
3083        })?;
3084
3085        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
3086
3087        if order.is_closed() {
3088            log::warn!("Cannot submit closed order {}", order.client_order_id());
3089            return Ok(());
3090        }
3091
3092        let cached_instrument = self
3093            .core
3094            .cache()
3095            .instrument(&order.instrument_id())
3096            .cloned();
3097
3098        if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
3099            self.emitter.emit_order_denied(&order, &reason);
3100            return Ok(());
3101        }
3102
3103        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
3104        if let Err(e) = self.dispatch_signed_create_order(&order, credential, slippage_bps) {
3105            self.emitter
3106                .emit_order_denied(&order, &format!("Lighter submit_order failed: {e}"));
3107        }
3108
3109        Ok(())
3110    }
3111
3112    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
3113        let credential = self.credential.as_ref().ok_or_else(|| {
3114            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
3115        })?;
3116
3117        if cmd.order_list.client_order_ids.is_empty() {
3118            log::debug!("submit_order_list called with empty order list");
3119            return Ok(());
3120        }
3121
3122        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
3123
3124        if orders.len() > LIGHTER_MAX_BATCH_TX {
3125            let reason = format!(
3126                "Lighter sendTxBatch supports at most {LIGHTER_MAX_BATCH_TX} txs, was {}",
3127                orders.len(),
3128            );
3129
3130            for order in &orders {
3131                self.emitter.emit_order_denied(order, &reason);
3132            }
3133            return Ok(());
3134        }
3135
3136        if orders.iter().any(is_grouped_order) {
3137            let reason = format!(
3138                "Lighter submit_order_list supports only independent orders; \
3139                 grouped contingency lists remain out of scope (order_list_id={})",
3140                cmd.order_list.id,
3141            );
3142
3143            for order in &orders {
3144                self.emitter.emit_order_denied(order, &reason);
3145            }
3146            return Ok(());
3147        }
3148
3149        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
3150        let mut prepared_orders = Vec::with_capacity(orders.len());
3151
3152        for order in orders {
3153            if order.is_closed() {
3154                log::warn!("Cannot submit closed order {}", order.client_order_id());
3155                continue;
3156            }
3157
3158            let cached_instrument = self
3159                .core
3160                .cache()
3161                .instrument(&order.instrument_id())
3162                .cloned();
3163
3164            if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
3165                self.emitter.emit_order_denied(&order, &reason);
3166                continue;
3167            }
3168
3169            match self.prepare_signed_create_order(&order, credential, slippage_bps) {
3170                Ok(prepared) => prepared_orders.push(prepared),
3171                Err(e) => {
3172                    let reason = format!("Lighter submit_order_list failed: {e}");
3173
3174                    self.emitter.emit_order_denied(&order, &reason);
3175                }
3176            }
3177        }
3178
3179        if prepared_orders.is_empty() {
3180            log::warn!(
3181                "Lighter submit_order_list: no supported orders to dispatch for {}",
3182                cmd.order_list.id,
3183            );
3184            return Ok(());
3185        }
3186
3187        for prepared in &prepared_orders {
3188            self.emitter.emit_order_submitted(&prepared.order);
3189        }
3190
3191        let tx_types = vec![LighterTxType::CreateOrder as u8; prepared_orders.len()];
3192        let tx_infos: Vec<Box<serde_json::value::RawValue>> = prepared_orders
3193            .iter()
3194            .map(|prepared| prepared.tx_info.clone())
3195            .collect();
3196        let request = send_tx_batch_request(&tx_types, &tx_infos);
3197        let http_client = self.http_client.clone();
3198        let dispatch = self.dispatch.clone();
3199        let credential = credential.clone();
3200        let emitter = self.emitter.clone();
3201        let clock = self.clock;
3202
3203        self.spawn_task("submit_order_list", async move {
3204            let mut prepared_orders = prepared_orders;
3205            log::debug!(
3206                "Lighter submit_order_list: queueing {} CreateOrder txs",
3207                prepared_orders.len(),
3208            );
3209
3210            let reservations = prepared_orders
3211                .iter()
3212                .map(|prepared| &prepared.send_reservation)
3213                .collect::<Vec<_>>();
3214            wait_for_tx_send_reservations(&reservations).await;
3215
3216            match http_client.send_tx_batch(&request).await {
3217                Ok(_) => {
3218                    advance_baseline_for_batch(
3219                        &dispatch,
3220                        &credential,
3221                        prepared_orders.iter().map(|prepared| prepared.nonce),
3222                    );
3223                }
3224                Err(e) => {
3225                    let reason = format!("Lighter submit_order_list dispatch failed: {e}");
3226                    log::error!("{reason}");
3227
3228                    // Reverse order so each rollback is the then-latest nonce
3229                    for prepared in prepared_orders.iter().rev() {
3230                        let client_order_id = prepared.order.client_order_id();
3231                        rollback_tx_dispatch_create(
3232                            &dispatch,
3233                            &credential,
3234                            Some(prepared.client_order_index),
3235                            &client_order_id,
3236                            prepared.nonce,
3237                        );
3238
3239                        emitter.emit_order_rejected(
3240                            &prepared.order,
3241                            &reason,
3242                            clock.get_time_ns(),
3243                            false,
3244                        );
3245                    }
3246
3247                    resync_nonce_after_invalid_nonce(&http_client, &dispatch, &credential, &e)
3248                        .await;
3249                }
3250            }
3251            release_prepared_create_reservations(&mut prepared_orders);
3252            Ok(())
3253        });
3254
3255        Ok(())
3256    }
3257
3258    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
3259        let credential = self.credential.as_ref().ok_or_else(|| {
3260            anyhow::anyhow!("Lighter execution client cannot modify without credentials")
3261        })?;
3262        self.dispatch_signed_modify_order(&cmd, credential);
3263        Ok(())
3264    }
3265
3266    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
3267        let credential = self.credential.as_ref().ok_or_else(|| {
3268            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
3269        })?;
3270        self.dispatch_signed_cancel_order(&cmd, credential);
3271        Ok(())
3272    }
3273
3274    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
3275        // Iterate over open orders for the instrument and cancel each. The
3276        // venue offers a `CancelAllOrders` tx but it spans the whole account
3277        // rather than a single market; doing per-order cancels keeps scope
3278        // tight and avoids cancelling positions in unrelated markets.
3279        let cache = self.core.cache();
3280        let open_orders: Vec<ClientOrderId> = cache
3281            .orders_open(None, Some(&cmd.instrument_id), None, None, None)
3282            .into_iter()
3283            .map(|o| o.client_order_id())
3284            .collect();
3285
3286        for client_order_id in open_orders {
3287            let order_cmd = cancel_order_from_cancel_all(&cmd, client_order_id);
3288
3289            if let Err(e) = self.cancel_order(order_cmd) {
3290                log::warn!("cancel_all_orders: cancel for {client_order_id} failed: {e}");
3291            }
3292        }
3293        Ok(())
3294    }
3295
3296    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
3297        let credential = self.credential.as_ref().ok_or_else(|| {
3298            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
3299        })?;
3300
3301        if cmd.cancels.is_empty() {
3302            log::debug!("batch_cancel_orders called with empty cancel list");
3303            return Ok(());
3304        }
3305
3306        if cmd.cancels.len() > LIGHTER_MAX_BATCH_TX {
3307            let reason = format!(
3308                "Lighter sendTxBatch supports at most {LIGHTER_MAX_BATCH_TX} txs, was {}",
3309                cmd.cancels.len(),
3310            );
3311
3312            for cancel in &cmd.cancels {
3313                self.emitter.emit_order_cancel_rejected_event(
3314                    cancel.strategy_id,
3315                    cancel.instrument_id,
3316                    cancel.client_order_id,
3317                    cancel.venue_order_id,
3318                    &reason,
3319                    self.clock.get_time_ns(),
3320                );
3321            }
3322            return Ok(());
3323        }
3324
3325        let mut prepared_cancels = Vec::with_capacity(cmd.cancels.len());
3326        for cancel in &cmd.cancels {
3327            match self.prepare_signed_cancel_order(cancel, credential) {
3328                Ok(prepared) => prepared_cancels.push(prepared),
3329                Err(e) => {
3330                    let reason = format!("Lighter batch_cancel_orders failed: {e}");
3331                    log::warn!("{reason} for {}", cancel.client_order_id);
3332
3333                    self.emitter.emit_order_cancel_rejected_event(
3334                        cancel.strategy_id,
3335                        cancel.instrument_id,
3336                        cancel.client_order_id,
3337                        cancel.venue_order_id,
3338                        &reason,
3339                        self.clock.get_time_ns(),
3340                    );
3341                }
3342            }
3343        }
3344
3345        if prepared_cancels.is_empty() {
3346            log::warn!("Lighter batch_cancel_orders: no cancellable orders to dispatch");
3347            return Ok(());
3348        }
3349
3350        let tx_types = vec![LighterTxType::CancelOrder as u8; prepared_cancels.len()];
3351        let tx_infos: Vec<Box<serde_json::value::RawValue>> = prepared_cancels
3352            .iter()
3353            .map(|prepared| prepared.tx_info.clone())
3354            .collect();
3355        let request = send_tx_batch_request(&tx_types, &tx_infos);
3356        let http_client = self.http_client.clone();
3357        let dispatch = self.dispatch.clone();
3358        let credential = credential.clone();
3359        let emitter = self.emitter.clone();
3360        let clock = self.clock;
3361
3362        self.spawn_task("batch_cancel_orders", async move {
3363            let mut prepared_cancels = prepared_cancels;
3364            log::debug!(
3365                "Lighter batch_cancel_orders: queueing {} CancelOrder txs",
3366                prepared_cancels.len(),
3367            );
3368
3369            let reservations = prepared_cancels
3370                .iter()
3371                .map(|prepared| &prepared.send_reservation)
3372                .collect::<Vec<_>>();
3373            wait_for_tx_send_reservations(&reservations).await;
3374
3375            match http_client.send_tx_batch(&request).await {
3376                Ok(_) => {
3377                    advance_baseline_for_batch(
3378                        &dispatch,
3379                        &credential,
3380                        prepared_cancels.iter().map(|prepared| prepared.nonce),
3381                    );
3382                }
3383                Err(e) => {
3384                    let reason = format!("Lighter batch_cancel_orders dispatch failed: {e}");
3385                    log::error!("{reason}");
3386
3387                    // Reverse order so each rollback is the then-latest nonce
3388                    for prepared in prepared_cancels.iter().rev() {
3389                        rollback_tx_dispatch(&dispatch, &credential, None, prepared.nonce);
3390
3391                        emitter.emit_order_cancel_rejected_event(
3392                            prepared.strategy_id,
3393                            prepared.instrument_id,
3394                            prepared.client_order_id,
3395                            prepared.venue_order_id,
3396                            &reason,
3397                            clock.get_time_ns(),
3398                        );
3399                    }
3400
3401                    resync_nonce_after_invalid_nonce(&http_client, &dispatch, &credential, &e)
3402                        .await;
3403                }
3404            }
3405            release_prepared_cancel_reservations(&mut prepared_cancels);
3406            Ok(())
3407        });
3408
3409        Ok(())
3410    }
3411
3412    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
3413        // Lighter has no public REST endpoint that returns a snapshot of
3414        // account balances and margins; the only authoritative source is the
3415        // `account_all_assets` WebSocket stream. Replay the most recent
3416        // cached state so the engine sees something synchronously. The
3417        // cache is populated by the consumption loop on every venue push.
3418        let cached = self.dispatch.snapshot_account_state();
3419        match cached {
3420            Some(state) => {
3421                log::debug!("Lighter query_account replaying cached AccountState");
3422                self.emitter.send_account_state(state);
3423            }
3424            None => {
3425                log::warn!(
3426                    "Lighter query_account: no AccountState cached yet \
3427                     (account_all_assets stream has not pushed since connect)",
3428                );
3429            }
3430        }
3431        Ok(())
3432    }
3433
3434    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
3435        let credential = self
3436            .credential
3437            .as_ref()
3438            .ok_or_else(|| anyhow::anyhow!("Lighter query_order requires credentials"))?
3439            .clone();
3440        let registry = Arc::clone(&self.registry);
3441        let http_client = self.http_client.clone();
3442        let emitter = self.emitter.clone();
3443        let core_account_id = self.core.account_id;
3444        let dispatch = self.dispatch.clone();
3445        let clock = self.clock;
3446
3447        self.spawn_task("query_order", async move {
3448            let report = lookup_order_status_report(
3449                &http_client,
3450                &registry,
3451                &credential,
3452                core_account_id,
3453                Some(cmd.instrument_id),
3454                Some(&cmd.client_order_id),
3455                cmd.venue_order_id.as_ref(),
3456                &dispatch,
3457                clock,
3458            )
3459            .await?;
3460
3461            match report {
3462                Some(report) => {
3463                    log::debug!(
3464                        "Lighter query_order returning report for {}",
3465                        cmd.client_order_id
3466                    );
3467                    dispatch.seed_accepted_from_report(&report);
3468                    emitter.send_order_status_report(report);
3469                }
3470                None => {
3471                    log::warn!(
3472                        "Lighter query_order: no order found for {}",
3473                        cmd.client_order_id,
3474                    );
3475                }
3476            }
3477            Ok(())
3478        });
3479        Ok(())
3480    }
3481
3482    async fn generate_order_status_report(
3483        &self,
3484        cmd: &GenerateOrderStatusReport,
3485    ) -> anyhow::Result<Option<OrderStatusReport>> {
3486        let Some(credential) = &self.credential else {
3487            log::warn!("Lighter generate_order_status_report: no credentials");
3488            return Ok(None);
3489        };
3490
3491        if cmd.client_order_id.is_none() && cmd.venue_order_id.is_none() {
3492            log::warn!(
3493                "Lighter generate_order_status_report: must supply client_order_id or venue_order_id",
3494            );
3495            return Ok(None);
3496        }
3497        let report = lookup_order_status_report(
3498            &self.http_client,
3499            &self.registry,
3500            credential,
3501            self.core.account_id,
3502            cmd.instrument_id,
3503            cmd.client_order_id.as_ref(),
3504            cmd.venue_order_id.as_ref(),
3505            &self.dispatch,
3506            self.clock,
3507        )
3508        .await?;
3509
3510        if let Some(report) = &report {
3511            self.dispatch.seed_accepted_from_report(report);
3512        }
3513
3514        Ok(report)
3515    }
3516
3517    async fn generate_order_status_reports(
3518        &self,
3519        cmd: &GenerateOrderStatusReports,
3520    ) -> anyhow::Result<Vec<OrderStatusReport>> {
3521        let Some(credential) = &self.credential else {
3522            log::warn!("Lighter generate_order_status_reports: no credentials");
3523            return Ok(Vec::new());
3524        };
3525
3526        let auth = build_auth_token_for(credential)
3527            .context("failed to mint Lighter auth token for report fetch")?;
3528        let ts_init = self.clock.get_time_ns();
3529
3530        // Lighter exposes accountActiveOrders only per-market. Mass-status
3531        // requests with no scope iterate over account-active markets rather
3532        // than fanning out to every registered market, since the venue's REST
3533        // rate limit (60 req/min) would make a 180-market fan-out take
3534        // minutes. Account streams seed this set from live order, trade, and
3535        // position frames; if startup reconciliation reaches this path before
3536        // any market is known, one unscoped inactive-order page walk seeds it
3537        // from historical account activity.
3538        if cmd.instrument_id.is_none() && self.dispatch.active_markets_snapshot().is_empty() {
3539            seed_active_markets_from_inactive_orders(
3540                &self.http_client,
3541                &self.dispatch,
3542                credential,
3543                &auth,
3544                format_between_timestamps(cmd.start, cmd.end, ts_init),
3545            )
3546            .await;
3547        }
3548
3549        let market_indices = match cmd.instrument_id {
3550            Some(id) => match self.registry.market_index(&id) {
3551                Some(idx) => vec![idx],
3552                None => {
3553                    log::warn!(
3554                        "Lighter generate_order_status_reports: market_index unknown for {id}",
3555                    );
3556                    return Ok(Vec::new());
3557                }
3558            },
3559            None => self.dispatch.active_markets_snapshot(),
3560        };
3561
3562        if market_indices.is_empty() {
3563            log::debug!(
3564                "Lighter generate_order_status_reports: no active markets yet; returning empty",
3565            );
3566            return Ok(Vec::new());
3567        }
3568
3569        let mut active_reports: Vec<OrderStatusReport> = Vec::new();
3570        let mut inactive_reports: Vec<OrderStatusReport> = Vec::new();
3571
3572        // Active orders are by definition still open. Returning them
3573        // unconditionally even when `cmd.start` is set: an open order's
3574        // last activity can predate the lookback window without changing
3575        // the fact that the order is currently live and reconciliation
3576        // needs to know about it.
3577        for market_index in market_indices {
3578            let active = match self
3579                .http_client
3580                .get_account_active_orders(&LighterAccountActiveOrdersQuery {
3581                    authorization: None,
3582                    auth: Some(auth.clone()),
3583                    account_index: credential.account_index(),
3584                    market_id: market_index,
3585                })
3586                .await
3587            {
3588                Ok(response) => response,
3589                Err(e) => {
3590                    log::warn!(
3591                        "Lighter active orders fetch failed for market_index={market_index}: {}",
3592                        scrub_auth(&format!("{e:#}")),
3593                    );
3594                    continue;
3595                }
3596            };
3597
3598            for order in &active.orders {
3599                self.dispatch.note_active_market(order.market_index);
3600                if let Some(report) = parse_http_order_to_report(
3601                    order,
3602                    &self.registry,
3603                    self.core.account_id,
3604                    ts_init,
3605                    &self.dispatch.cloid_map,
3606                ) {
3607                    active_reports.push(report);
3608                }
3609            }
3610        }
3611
3612        // Inactive orders (filled / canceled) are required when the engine
3613        // asks for non-`open_only` reports during a wider reconciliation.
3614        // Pagination is followed because a single market can hold more than
3615        // 200 historical inactive orders for a long-running account. The
3616        // venue-side `between_timestamps` window is set when `cmd.start`
3617        // / `cmd.end` are present so the venue, not the client, scopes the
3618        // pagination: important under the 60 req/min REST quota.
3619        if !cmd.open_only {
3620            let inactive_markets: Vec<i16> = match cmd.instrument_id {
3621                Some(id) => self
3622                    .registry
3623                    .market_index(&id)
3624                    .map(|m| vec![m])
3625                    .unwrap_or_default(),
3626                None => self.dispatch.active_markets_snapshot(),
3627            };
3628
3629            let between_timestamps = format_between_timestamps(cmd.start, cmd.end, ts_init);
3630
3631            for market_id in inactive_markets {
3632                let mut cursor: Option<String> = None;
3633
3634                loop {
3635                    match self
3636                        .http_client
3637                        .get_account_inactive_orders(&LighterAccountInactiveOrdersQuery {
3638                            authorization: None,
3639                            auth: Some(auth.clone()),
3640                            account_index: credential.account_index(),
3641                            market_id: Some(market_id),
3642                            ask_filter: None,
3643                            between_timestamps: between_timestamps.clone(),
3644                            cursor: cursor.clone(),
3645                            limit: LIGHTER_REST_PAGE_SIZE,
3646                        })
3647                        .await
3648                    {
3649                        Ok(inactive) => {
3650                            for order in &inactive.orders {
3651                                self.dispatch.note_active_market(order.market_index);
3652                                if let Some(report) = parse_http_order_to_report(
3653                                    order,
3654                                    &self.registry,
3655                                    self.core.account_id,
3656                                    ts_init,
3657                                    &self.dispatch.cloid_map,
3658                                ) {
3659                                    inactive_reports.push(report);
3660                                }
3661                            }
3662
3663                            match inactive.next_cursor {
3664                                Some(next) if !next.is_empty() => cursor = Some(next),
3665                                _ => break,
3666                            }
3667                        }
3668                        Err(e) => {
3669                            log::warn!(
3670                                "Lighter inactive orders fetch failed for market_index={market_id}: {}",
3671                                scrub_auth(&format!("{e:#}")),
3672                            );
3673                            break;
3674                        }
3675                    }
3676                }
3677            }
3678        }
3679
3680        // Apply start/end only to inactive reports. Active reports are
3681        // always current and the engine needs them regardless of lookback.
3682        let inactive_reports: Vec<OrderStatusReport> = match (cmd.start, cmd.end) {
3683            (Some(start), Some(end)) => inactive_reports
3684                .into_iter()
3685                .filter(|r| r.ts_last >= start && r.ts_last <= end)
3686                .collect(),
3687            (Some(start), None) => inactive_reports
3688                .into_iter()
3689                .filter(|r| r.ts_last >= start)
3690                .collect(),
3691            (None, Some(end)) => inactive_reports
3692                .into_iter()
3693                .filter(|r| r.ts_last <= end)
3694                .collect(),
3695            (None, None) => inactive_reports,
3696        };
3697
3698        let mut reports = active_reports;
3699        reports.extend(inactive_reports);
3700
3701        for report in &reports {
3702            self.dispatch.seed_accepted_from_report(report);
3703        }
3704
3705        log::debug!("Generated {} Lighter order status reports", reports.len());
3706        Ok(reports)
3707    }
3708
3709    async fn generate_fill_reports(
3710        &self,
3711        cmd: GenerateFillReports,
3712    ) -> anyhow::Result<Vec<FillReport>> {
3713        let Some(credential) = &self.credential else {
3714            log::warn!("Lighter generate_fill_reports: no credentials");
3715            return Ok(Vec::new());
3716        };
3717
3718        let market_id = cmd
3719            .instrument_id
3720            .and_then(|id| self.registry.market_index(&id));
3721
3722        let auth = build_auth_token_for(credential)
3723            .context("failed to mint Lighter auth token for fill fetch")?;
3724
3725        let ts_init = self.clock.get_time_ns();
3726        let mut reports = Vec::new();
3727        let mut cursor: Option<String> = None;
3728
3729        loop {
3730            let query = LighterTradesQuery {
3731                authorization: None,
3732                auth: Some(auth.clone()),
3733                market_id,
3734                account_index: Some(credential.account_index()),
3735                order_index: None,
3736                sort_by: LighterTradeSortBy::Timestamp,
3737                sort_dir: Some(LighterSortDirection::Desc),
3738                cursor: cursor.clone(),
3739                from_timestamp: cmd.start.map(|ts| (ts.as_u64() / 1_000_000) as i64),
3740                ask_filter: None,
3741                role: None,
3742                trade_type: None,
3743                limit: LIGHTER_REST_PAGE_SIZE,
3744                aggregate: None,
3745            };
3746
3747            let response = match self.http_client.get_trades(&query).await {
3748                Ok(response) => response,
3749                Err(e) => {
3750                    // `{e:#}` preserves the venue's status/body across the
3751                    // outer context wrap; `scrub_auth` masks any `auth=`
3752                    // query value the HTTP layer's error included.
3753                    log::warn!(
3754                        "Lighter get_trades failed (market_id={:?}, account_index={}, from={:?}, cursor={:?}): {}",
3755                        query.market_id,
3756                        credential.account_index(),
3757                        query.from_timestamp,
3758                        cursor,
3759                        scrub_auth(&format!("{e:#}")),
3760                    );
3761                    return Err(anyhow::Error::new(e).context("failed to fetch Lighter fills"));
3762                }
3763            };
3764
3765            for trade in &response.trades {
3766                let Some(instrument_id) = self.registry.instrument_id(trade.market_id) else {
3767                    continue;
3768                };
3769                let Some(instrument) = self.core.cache().instrument(&instrument_id).cloned() else {
3770                    continue;
3771                };
3772
3773                match parse_ws_fill_report(
3774                    trade,
3775                    credential.account_index(),
3776                    &instrument,
3777                    self.core.account_id,
3778                    ts_init,
3779                ) {
3780                    Ok(Some(report)) => {
3781                        self.dispatch.note_active_market(trade.market_id);
3782
3783                        // Mass-status reconciliation must surface the original
3784                        // Nautilus cloid, not the venue's numeric echo.
3785                        let report = translate_fill_cloid(report, &self.dispatch.cloid_map);
3786                        if cmd.end.is_some_and(|end| report.ts_event > end) {
3787                            continue;
3788                        }
3789
3790                        if !self.dispatch.mark_trade_seen(report.trade_id) {
3791                            log::debug!(
3792                                "Lighter duplicate trade {} ignored in HTTP fill reports",
3793                                report.trade_id,
3794                            );
3795                            continue;
3796                        }
3797
3798                        reports.push(report);
3799                    }
3800                    Ok(None) => {}
3801                    Err(e) => log::warn!("Lighter fill parse failed: {e}"),
3802                }
3803            }
3804
3805            match response.next_cursor {
3806                Some(next) if !next.is_empty() => cursor = Some(next),
3807                _ => break,
3808            }
3809        }
3810
3811        log::debug!("Generated {} Lighter fill reports", reports.len());
3812        Ok(reports)
3813    }
3814
3815    async fn generate_position_status_reports(
3816        &self,
3817        cmd: &GeneratePositionStatusReports,
3818    ) -> anyhow::Result<Vec<PositionStatusReport>> {
3819        // No REST source; replay the WS-driven cache populated by the
3820        // consumption loop's `PositionSnapshot` arm.
3821        let reports = self.dispatch.snapshot_positions(cmd.instrument_id);
3822        log::debug!(
3823            "Lighter generate_position_status_reports: returning {} cached position reports",
3824            reports.len(),
3825        );
3826        Ok(reports)
3827    }
3828
3829    async fn generate_mass_status(
3830        &self,
3831        lookback_mins: Option<u64>,
3832    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
3833        let ts_init = self.clock.get_time_ns();
3834
3835        // Push lookback_mins into the REST queries themselves so the venue
3836        // can scope the response. Without this, pagination has to walk full
3837        // trade history before local filtering, which can stall startup
3838        // reconciliation under the venue's 60 req/min REST quota.
3839        let lookback_start: Option<UnixNanos> = lookback_mins.map(|mins| {
3840            let cutoff_ns = ts_init
3841                .as_u64()
3842                .saturating_sub(mins.saturating_mul(60).saturating_mul(1_000_000_000));
3843            UnixNanos::from(cutoff_ns)
3844        });
3845
3846        // open_only = false so the inactive-orders fan-out runs and surfaces
3847        // canceled / rejected / expired / filled orders that the engine
3848        // needs for reconciliation. The active markets set bounds the fan-out
3849        // to markets with known account activity.
3850        let order_cmd = GenerateOrderStatusReports::new(
3851            UUID4::new(),
3852            ts_init,
3853            false,
3854            None,
3855            lookback_start,
3856            None,
3857            None,
3858            None,
3859        );
3860        let fill_cmd = GenerateFillReports::new(
3861            UUID4::new(),
3862            ts_init,
3863            None,
3864            None,
3865            lookback_start,
3866            None,
3867            None,
3868            None,
3869        );
3870        let position_cmd =
3871            GeneratePositionStatusReports::new(UUID4::new(), ts_init, None, None, None, None, None);
3872
3873        // Each sub-call degrades independently; see `unwrap_reports_or_warn`.
3874        let order_reports = unwrap_reports_or_warn(
3875            "order",
3876            self.generate_order_status_reports(&order_cmd).await,
3877        );
3878        let fill_reports =
3879            unwrap_reports_or_warn("fill", self.generate_fill_reports(fill_cmd).await);
3880        let position_reports = unwrap_reports_or_warn(
3881            "position",
3882            self.generate_position_status_reports(&position_cmd).await,
3883        );
3884
3885        let mut mass_status = ExecutionMassStatus::new(
3886            self.core.client_id,
3887            self.core.account_id,
3888            *LIGHTER_VENUE,
3889            ts_init,
3890            None,
3891        );
3892        mass_status.add_order_reports(order_reports);
3893        mass_status.add_fill_reports(fill_reports);
3894        mass_status.add_position_reports(position_reports);
3895
3896        log::debug!(
3897            "Generated Lighter mass status: {} orders, {} fills, {} positions",
3898            mass_status.order_reports().len(),
3899            mass_status.fill_reports().len(),
3900            mass_status.position_reports().len(),
3901        );
3902
3903        Ok(Some(mass_status))
3904    }
3905}
3906
3907fn local_submit_denial_reason(
3908    order: &OrderAny,
3909    instrument: Option<&InstrumentAny>,
3910) -> Option<String> {
3911    if !is_lighter_supported_order_type(order.order_type()) {
3912        return Some(format!(
3913            "Unsupported order type for Lighter: {:?}",
3914            order.order_type()
3915        ));
3916    }
3917
3918    if is_lighter_limit_style_order(order.order_type()) && order.price().is_none() {
3919        return Some("Lighter limit-style orders require a limit price".to_string());
3920    }
3921
3922    if order.is_quote_quantity() {
3923        return Some(
3924            "Lighter orders do not support quote_quantity; submit base quantity instead"
3925                .to_string(),
3926        );
3927    }
3928
3929    if order.display_qty().is_some() {
3930        return Some("Lighter orders do not support display_qty iceberg instructions".to_string());
3931    }
3932
3933    if is_lighter_spot_order(order, instrument) && is_lighter_conditional_order(order.order_type())
3934    {
3935        return Some(format!(
3936            "Lighter spot markets do not support conditional order type {:?}",
3937            order.order_type()
3938        ));
3939    }
3940
3941    nautilus_to_lighter_tif(
3942        order.order_type(),
3943        order.time_in_force(),
3944        order.is_post_only(),
3945    )
3946    .err()
3947    .map(|e| e.to_string())
3948}
3949
3950fn is_grouped_order(order: &OrderAny) -> bool {
3951    matches!(
3952        order.contingency_type(),
3953        Some(contingency) if contingency != ContingencyType::NoContingency
3954    )
3955}
3956
3957fn is_lighter_spot_order(order: &OrderAny, instrument: Option<&InstrumentAny>) -> bool {
3958    instrument.is_some_and(|instrument| matches!(instrument, InstrumentAny::CurrencyPair(_)))
3959        || product_type_from_instrument_id(&order.instrument_id()) == Some(LighterProductType::Spot)
3960}
3961
3962fn is_lighter_supported_order_type(order_type: OrderType) -> bool {
3963    matches!(
3964        order_type,
3965        OrderType::Market
3966            | OrderType::Limit
3967            | OrderType::StopMarket
3968            | OrderType::StopLimit
3969            | OrderType::MarketIfTouched
3970            | OrderType::LimitIfTouched
3971    )
3972}
3973
3974fn is_lighter_limit_style_order(order_type: OrderType) -> bool {
3975    matches!(
3976        order_type,
3977        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
3978    )
3979}
3980
3981fn is_lighter_conditional_order(order_type: OrderType) -> bool {
3982    matches!(
3983        order_type,
3984        OrderType::StopMarket
3985            | OrderType::StopLimit
3986            | OrderType::MarketIfTouched
3987            | OrderType::LimitIfTouched
3988    )
3989}
3990
3991async fn seed_active_markets_from_inactive_orders(
3992    http_client: &LighterHttpClient,
3993    dispatch: &WsDispatchState,
3994    credential: &Credential,
3995    auth: &str,
3996    between_timestamps: Option<String>,
3997) {
3998    let mut cursor: Option<String> = None;
3999    let mut orders_seen = 0_usize;
4000
4001    loop {
4002        let response = match http_client
4003            .get_account_inactive_orders(&LighterAccountInactiveOrdersQuery {
4004                authorization: None,
4005                auth: Some(auth.to_string()),
4006                account_index: credential.account_index(),
4007                market_id: None,
4008                ask_filter: None,
4009                between_timestamps: between_timestamps.clone(),
4010                cursor: cursor.clone(),
4011                limit: LIGHTER_REST_PAGE_SIZE,
4012            })
4013            .await
4014        {
4015            Ok(response) => response,
4016            Err(e) => {
4017                log::warn!(
4018                    "Lighter active markets seed failed from inactive orders: {}",
4019                    scrub_auth(&format!("{e:#}")),
4020                );
4021                break;
4022            }
4023        };
4024
4025        for order in &response.orders {
4026            dispatch.note_active_market(order.market_index);
4027            orders_seen += 1;
4028        }
4029
4030        match response.next_cursor {
4031            Some(next) if !next.is_empty() => cursor = Some(next),
4032            _ => break,
4033        }
4034    }
4035
4036    if orders_seen > 0 {
4037        log::debug!("Seeded Lighter active markets from {orders_seen} inactive order report(s)");
4038    }
4039}
4040
4041fn cancel_order_from_cancel_all(
4042    cmd: &CancelAllOrders,
4043    client_order_id: ClientOrderId,
4044) -> CancelOrder {
4045    CancelOrder {
4046        trader_id: cmd.trader_id,
4047        client_id: cmd.client_id,
4048        strategy_id: cmd.strategy_id,
4049        instrument_id: cmd.instrument_id,
4050        client_order_id,
4051        venue_order_id: None,
4052        command_id: cmd.command_id,
4053        ts_init: cmd.ts_init,
4054        params: cmd.params.clone(),
4055        correlation_id: cmd.correlation_id,
4056        causation_id: cmd.causation_id,
4057    }
4058}
4059
4060fn validate_order_amount(
4061    instrument: &InstrumentAny,
4062    quantity: Quantity,
4063    price_ticks: u32,
4064    price_precision: u8,
4065) -> anyhow::Result<()> {
4066    if let Some(min_quantity) = instrument.min_quantity() {
4067        anyhow::ensure!(
4068            quantity >= min_quantity,
4069            "quantity `{quantity}` below Lighter min_base_amount `{min_quantity}` for {}",
4070            instrument.id(),
4071        );
4072    }
4073
4074    if let Some(min_notional) = instrument.min_notional() {
4075        let price = decimal_from_ticks(price_ticks, price_precision);
4076        let notional = quantity.as_decimal() * price;
4077        anyhow::ensure!(
4078            notional >= min_notional.as_decimal(),
4079            "order notional `{notional}` below Lighter min_quote_amount `{}` for {}",
4080            min_notional.as_decimal(),
4081            instrument.id(),
4082        );
4083    }
4084
4085    Ok(())
4086}
4087
4088fn decimal_from_ticks(ticks: u32, decimals: u8) -> Decimal {
4089    Decimal::from(ticks) / Decimal::from(10_i64.pow(u32::from(decimals)))
4090}
4091
4092/// Route a venue `account_orders` payload through the tracked-event path
4093/// when the cloid is known, otherwise fall back to the existing
4094/// [`OrderStatusReport`] flow used for externally-managed orders.
4095fn dispatch_lighter_order(
4096    order: &crate::http::models::LighterOrder,
4097    dispatch: &WsDispatchState,
4098    emitter: &ExecutionEventEmitter,
4099    registry: &Arc<MarketRegistry>,
4100    account_id: AccountId,
4101    trader_id: TraderId,
4102    ts_init: UnixNanos,
4103) {
4104    let instrument_id = match registry.instrument_id(order.market_index) {
4105        Some(id) => id,
4106        None => {
4107            log::debug!(
4108                "Lighter order frame dropped: no instrument for market_index={}",
4109                order.market_index,
4110            );
4111            return;
4112        }
4113    };
4114
4115    if let Some(idx) = registry.market_index(&instrument_id) {
4116        dispatch.note_active_market(idx);
4117    }
4118
4119    let instrument = match LIGHTER_INSTRUMENT_CACHE.get(&instrument_id) {
4120        Some(inst) => inst.value().clone(),
4121        None => {
4122            log::debug!("Lighter order frame dropped: instrument {instrument_id} not in cache",);
4123            return;
4124        }
4125    };
4126
4127    let resolved_cloid = resolve_cloid(order.client_order_id.as_str(), &dispatch.cloid_map);
4128    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
4129
4130    let identity = resolved_cloid.and_then(|cid| {
4131        dispatch
4132            .order_identities
4133            .get(&cid)
4134            .map(|entry| (cid, entry.value().clone()))
4135    });
4136
4137    if let Some((cloid, identity)) = identity {
4138        dispatch.venue_id_map.insert(cloid, venue_order_id);
4139
4140        // Pre-compute the parser's Open-frame context: accepted gate,
4141        // triggered gate, and shape diff against the stored snapshot.
4142        // The dispatcher owns the dispatch-state mutation and the parser
4143        // stays pure.
4144        let is_open_status =
4145            matches!(order.status, crate::common::enums::LighterOrderStatus::Open,);
4146        let current_shape = match lighter_order_shape(order, &instrument) {
4147            Ok(shape) => shape,
4148            Err(e) => {
4149                log::error!(
4150                    "Failed to compute Lighter order shape: error={e}, voi={venue_order_id}, cloid={cloid}",
4151                );
4152                return;
4153            }
4154        };
4155        let prior_shape = dispatch.snapshot_for(&cloid);
4156        let shape_changed = prior_shape
4157            .as_ref()
4158            .is_some_and(|prev| prev != &current_shape);
4159        let open_ctx = OpenFrameContext {
4160            accepted_already_emitted: dispatch.accepted_was_emitted(&cloid),
4161            triggered_already_emitted: dispatch.triggered_was_emitted(&cloid),
4162            shape_changed,
4163        };
4164
4165        match parse_lighter_order_event(
4166            order,
4167            &instrument,
4168            &identity,
4169            cloid,
4170            account_id,
4171            trader_id,
4172            open_ctx,
4173            ts_init,
4174        ) {
4175            Ok(event_opt) => {
4176                // Refresh the stored snapshot for any tracked Open frame
4177                // so a synthesised `OrderAccepted` (fill-before-open or
4178                // fresh-trigger path) leaves a baseline behind for the
4179                // next diff. Without this seed `shape_changed` would
4180                // stay permanently false and a real later modify would
4181                // be missed. Filled / Canceled / Expired / Rejected
4182                // frames skip the refresh; identity cleanup in
4183                // `dispatch_tracked_order_event` removes the snapshot on
4184                // terminal events.
4185                if is_open_status {
4186                    dispatch.store_snapshot(cloid, current_shape);
4187                }
4188
4189                if let Some(event) = event_opt {
4190                    dispatch_tracked_order_event(
4191                        event,
4192                        cloid,
4193                        venue_order_id,
4194                        &identity,
4195                        account_id,
4196                        trader_id,
4197                        emitter,
4198                        dispatch,
4199                        ts_init,
4200                    );
4201                }
4202            }
4203            Err(e) => {
4204                log::error!(
4205                    "Failed to parse Lighter order event: error={e}, voi={venue_order_id}, cloid={cloid}",
4206                );
4207            }
4208        }
4209    } else {
4210        match parse_ws_order_status_report(order, &instrument, account_id, ts_init) {
4211            Ok(mut report) => {
4212                report = translate_order_cloid(report, &dispatch.cloid_map);
4213
4214                if let Some(cloid) = &report.client_order_id {
4215                    dispatch.venue_id_map.insert(*cloid, report.venue_order_id);
4216                }
4217
4218                if report.order_status.is_closed() {
4219                    evict_terminal_mappings(&report, &dispatch.venue_id_map);
4220                }
4221
4222                log::debug!(
4223                    "Lighter OrderStatusReport: voi={} status={:?} cloid={:?}",
4224                    report.venue_order_id,
4225                    report.order_status,
4226                    report.client_order_id,
4227                );
4228                emitter.send_order_status_report(report);
4229            }
4230            Err(e) => {
4231                log::error!(
4232                    "Failed to parse Lighter order status report: error={e}, order_id={}",
4233                    order.order_id,
4234                );
4235            }
4236        }
4237    }
4238}
4239
4240/// Route a venue `account_trades` payload through the tracked-event path
4241/// when the cloid is known, otherwise fall back to the existing
4242/// [`FillReport`] flow. Drops duplicate fill frames keyed by `trade_id`.
4243#[expect(
4244    clippy::too_many_arguments,
4245    reason = "consumption-loop dispatch threads identity and emitter context"
4246)]
4247fn dispatch_lighter_trade(
4248    trade: &crate::http::models::LighterTrade,
4249    dispatch: &WsDispatchState,
4250    emitter: &ExecutionEventEmitter,
4251    registry: &Arc<MarketRegistry>,
4252    account_id: AccountId,
4253    trader_id: TraderId,
4254    account_index: Option<i64>,
4255    ts_init: UnixNanos,
4256) {
4257    let Some(account_index) = account_index else {
4258        log::debug!("Lighter trade frame dropped: no credential / account_index available",);
4259        return;
4260    };
4261
4262    let instrument_id = match registry.instrument_id(trade.market_id) {
4263        Some(id) => id,
4264        None => {
4265            log::debug!(
4266                "Lighter trade frame dropped: no instrument for market_id={}",
4267                trade.market_id,
4268            );
4269            return;
4270        }
4271    };
4272
4273    if let Some(idx) = registry.market_index(&instrument_id) {
4274        dispatch.note_active_market(idx);
4275    }
4276
4277    let instrument = match LIGHTER_INSTRUMENT_CACHE.get(&instrument_id) {
4278        Some(inst) => inst.value().clone(),
4279        None => {
4280            log::debug!("Lighter trade frame dropped: instrument {instrument_id} not in cache",);
4281            return;
4282        }
4283    };
4284
4285    let user_is_bidder = trade.bid_account_id == account_index;
4286    let user_is_asker = trade.ask_account_id == account_index;
4287    if !user_is_bidder && !user_is_asker {
4288        // Defensive: the handler already filters foreign trades, so this
4289        // branch is rare in practice. Drop silently.
4290        return;
4291    }
4292
4293    // Dedupe before dispatch so a duplicate frame on reconnect does not
4294    // double-book on either the tracked or untracked path.
4295    let trade_id = match parse_lighter_trade_id(trade) {
4296        Ok(id) => id,
4297        Err(e) => {
4298            log::error!("Lighter trade has invalid trade_id: {e}");
4299            return;
4300        }
4301    };
4302
4303    if !dispatch.mark_trade_seen(trade_id) {
4304        log::debug!("Lighter duplicate trade {trade_id} ignored (already routed)",);
4305        return;
4306    }
4307
4308    let raw_client_id = if user_is_bidder {
4309        trade
4310            .bid_client_id_str
4311            .as_deref()
4312            .map_or_else(|| trade.bid_client_id.to_string(), str::to_string)
4313    } else {
4314        trade
4315            .ask_client_id_str
4316            .as_deref()
4317            .map_or_else(|| trade.ask_client_id.to_string(), str::to_string)
4318    };
4319    let resolved_cloid = resolve_cloid(raw_client_id.as_str(), &dispatch.cloid_map);
4320
4321    let identity = resolved_cloid.and_then(|cid| {
4322        dispatch
4323            .order_identities
4324            .get(&cid)
4325            .map(|entry| (cid, entry.value().clone()))
4326    });
4327
4328    if let Some((cloid, identity)) = identity {
4329        // Synthesise an `OrderAccepted` first if one has not been
4330        // emitted yet: fills can race ahead of the matching `Open`
4331        // order frame.
4332        let venue_order_id = if user_is_bidder {
4333            trade.bid_id_str.as_deref().map_or_else(
4334                || VenueOrderId::new(trade.bid_id.to_string()),
4335                VenueOrderId::new,
4336            )
4337        } else {
4338            trade.ask_id_str.as_deref().map_or_else(
4339                || VenueOrderId::new(trade.ask_id.to_string()),
4340                VenueOrderId::new,
4341            )
4342        };
4343        ensure_accepted_emitted(
4344            cloid,
4345            venue_order_id,
4346            &identity,
4347            account_id,
4348            trader_id,
4349            emitter,
4350            dispatch,
4351            ts_init,
4352        );
4353
4354        match parse_lighter_order_filled(
4355            trade,
4356            &instrument,
4357            &identity,
4358            cloid,
4359            account_id,
4360            trader_id,
4361            account_index,
4362            ts_init,
4363        ) {
4364            Ok(Some(filled)) => {
4365                log::debug!(
4366                    "Lighter OrderFilled: voi={} qty={} px={} liq={:?} cloid={cloid}",
4367                    filled.venue_order_id,
4368                    filled.last_qty,
4369                    filled.last_px,
4370                    filled.liquidity_side,
4371                );
4372                emitter.send_order_event(OrderEventAny::Filled(filled));
4373            }
4374            Ok(None) => {}
4375            Err(e) => {
4376                // Fill never reached the engine; release the dedup marker so a replay can retry
4377                dispatch.unmark_trade_seen(&trade_id);
4378                log::error!("Failed to parse Lighter typed fill: error={e}, trade_id={trade_id}",);
4379            }
4380        }
4381    } else {
4382        match parse_ws_fill_report(trade, account_index, &instrument, account_id, ts_init) {
4383            Ok(Some(mut report)) => {
4384                report = translate_fill_cloid(report, &dispatch.cloid_map);
4385                log::debug!(
4386                    "Lighter FillReport: voi={} qty={} px={} liq={:?} cloid={:?}",
4387                    report.venue_order_id,
4388                    report.last_qty,
4389                    report.last_px,
4390                    report.liquidity_side,
4391                    report.client_order_id,
4392                );
4393                emitter.send_fill_report(report);
4394            }
4395            Ok(None) => {}
4396            Err(e) => {
4397                // Fill never reached the engine; release the dedup marker so a replay can retry
4398                dispatch.unmark_trade_seen(&trade_id);
4399                log::error!("Failed to parse Lighter fill report: error={e}, trade_id={trade_id}",);
4400            }
4401        }
4402    }
4403}
4404
4405/// Send a [`ParsedOrderEvent`] to the engine and update dispatch state for
4406/// the originating cloid. Cleans up [`WsDispatchState::order_identities`]
4407/// on terminal events so subsequent stale frames take the untracked path.
4408#[expect(
4409    clippy::too_many_arguments,
4410    reason = "shared cleanup point across the typed-event variants"
4411)]
4412#[expect(
4413    clippy::needless_pass_by_value,
4414    reason = "event is destructured into typed OrderEventAny variants that consume the payload"
4415)]
4416fn dispatch_tracked_order_event(
4417    event: ParsedOrderEvent,
4418    cloid: ClientOrderId,
4419    venue_order_id: VenueOrderId,
4420    identity: &OrderIdentity,
4421    account_id: AccountId,
4422    trader_id: TraderId,
4423    emitter: &ExecutionEventEmitter,
4424    dispatch: &WsDispatchState,
4425    ts_init: UnixNanos,
4426) {
4427    let is_terminal;
4428
4429    match event {
4430        ParsedOrderEvent::Accepted(e) => {
4431            if dispatch.accepted_was_emitted(&cloid) {
4432                log::debug!("Skipping duplicate OrderAccepted for {cloid}");
4433                return;
4434            }
4435            dispatch.mark_accepted_emitted(cloid);
4436            is_terminal = false;
4437            emitter.send_order_event(OrderEventAny::Accepted(e));
4438        }
4439        ParsedOrderEvent::Triggered(e) => {
4440            if !dispatch.mark_triggered_emitted(cloid) {
4441                log::debug!("Skipping duplicate OrderTriggered for {cloid}");
4442                return;
4443            }
4444            ensure_accepted_emitted(
4445                cloid,
4446                venue_order_id,
4447                identity,
4448                account_id,
4449                trader_id,
4450                emitter,
4451                dispatch,
4452                ts_init,
4453            );
4454            is_terminal = false;
4455            emitter.send_order_event(OrderEventAny::Triggered(e));
4456        }
4457        ParsedOrderEvent::Updated(e) => {
4458            // Modify-as-restate: the venue echoes the post-modify order as
4459            // `Open`; `accepted_was_emitted` already gated parsing to
4460            // produce `Updated` instead of duplicate `Accepted`. No need
4461            // to re-synthesise the accept here.
4462            is_terminal = false;
4463            emitter.send_order_event(OrderEventAny::Updated(e));
4464        }
4465        ParsedOrderEvent::Canceled(e) => {
4466            ensure_accepted_emitted(
4467                cloid,
4468                venue_order_id,
4469                identity,
4470                account_id,
4471                trader_id,
4472                emitter,
4473                dispatch,
4474                ts_init,
4475            );
4476            is_terminal = true;
4477            emitter.send_order_event(OrderEventAny::Canceled(e));
4478        }
4479        ParsedOrderEvent::Expired(e) => {
4480            ensure_accepted_emitted(
4481                cloid,
4482                venue_order_id,
4483                identity,
4484                account_id,
4485                trader_id,
4486                emitter,
4487                dispatch,
4488                ts_init,
4489            );
4490            is_terminal = true;
4491            emitter.send_order_event(OrderEventAny::Expired(e));
4492        }
4493        ParsedOrderEvent::Rejected(e) => {
4494            is_terminal = true;
4495            emitter.send_order_event(OrderEventAny::Rejected(e));
4496        }
4497    }
4498
4499    if is_terminal {
4500        dispatch.venue_id_map.remove(&cloid);
4501        dispatch.forget_order_identity(&cloid);
4502    }
4503}
4504
4505/// Synthesise an `OrderAccepted` event if one has not yet been emitted for
4506/// `cloid`. Mirrors the BitMEX dispatch helper of the same name.
4507#[expect(
4508    clippy::too_many_arguments,
4509    reason = "synthesised events need the full identity context to populate the event"
4510)]
4511fn ensure_accepted_emitted(
4512    cloid: ClientOrderId,
4513    venue_order_id: VenueOrderId,
4514    identity: &OrderIdentity,
4515    account_id: AccountId,
4516    trader_id: TraderId,
4517    emitter: &ExecutionEventEmitter,
4518    dispatch: &WsDispatchState,
4519    ts_init: UnixNanos,
4520) {
4521    if dispatch.accepted_was_emitted(&cloid) {
4522        return;
4523    }
4524    dispatch.mark_accepted_emitted(cloid);
4525    let accepted = OrderAccepted::new(
4526        trader_id,
4527        identity.strategy_id,
4528        identity.instrument_id,
4529        cloid,
4530        venue_order_id,
4531        account_id,
4532        UUID4::new(),
4533        ts_init,
4534        ts_init,
4535        false,
4536    );
4537    emitter.send_order_event(OrderEventAny::Accepted(accepted));
4538}
4539
4540#[cfg(test)]
4541mod tests {
4542    use std::{
4543        cell::RefCell,
4544        rc::Rc,
4545        sync::{Arc, atomic::AtomicUsize},
4546    };
4547
4548    use axum::{
4549        Router,
4550        routing::{get, post},
4551    };
4552    use nautilus_common::{
4553        cache::Cache,
4554        clock::TestClock,
4555        factories::OrderFactory,
4556        messages::{ExecutionEvent, ExecutionReport as EngineExecutionReport},
4557        testing::wait_until_async,
4558    };
4559    use nautilus_model::{
4560        data::QuoteTick,
4561        enums::{OrderStatus, TimeInForce},
4562        events::{OrderEventAny, OrderPendingCancel},
4563        identifiers::{
4564            InstrumentId, OrderListId, StrategyId, Symbol, TradeId, TraderId, VenueOrderId,
4565        },
4566        instruments::CryptoPerpetual,
4567        orders::{LimitOrder, OrderList},
4568        types::{Currency, Money, Price},
4569    };
4570    use rstest::rstest;
4571
4572    use super::*;
4573    use crate::{
4574        common::enums::{LighterEnvironment, LighterProductType},
4575        http::models::{LighterNextNonce, LighterSendTxBatchResponse},
4576        signing::tx::TX_HASH_BYTES,
4577    };
4578
4579    const TEST_PRIVATE_KEY: &str =
4580        "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
4581    const TEST_ACCOUNT_INDEX: u64 = 12345;
4582    const TEST_ACCOUNT_INDEX_I64: i64 = 12345;
4583    const TEST_API_KEY_INDEX: u8 = 5;
4584    const TEST_NEXT_NONCE: i64 = 42;
4585    const TEST_MARKET_INDEX: i16 = 0;
4586
4587    fn trader_id() -> TraderId {
4588        TraderId::from("TRADER-001")
4589    }
4590
4591    fn client_id() -> ClientId {
4592        ClientId::from("LIGHTER")
4593    }
4594
4595    fn account_id() -> AccountId {
4596        AccountId::from("LIGHTER-001")
4597    }
4598
4599    fn strategy_id() -> StrategyId {
4600        StrategyId::from("S-001")
4601    }
4602
4603    fn test_credential() -> Credential {
4604        Credential::new(TEST_API_KEY_INDEX, TEST_PRIVATE_KEY, TEST_ACCOUNT_INDEX).unwrap()
4605    }
4606
4607    fn test_config() -> LighterExecClientConfig {
4608        LighterExecClientConfig {
4609            trader_id: trader_id(),
4610            account_id: account_id(),
4611            account_index: Some(TEST_ACCOUNT_INDEX),
4612            api_key_index: Some(TEST_API_KEY_INDEX),
4613            private_key: Some(TEST_PRIVATE_KEY.to_string()),
4614            base_url_http: Some("http://127.0.0.1:1".to_string()),
4615            base_url_ws: Some("ws://127.0.0.1:1/stream".to_string()),
4616            proxy_url: None,
4617            environment: LighterEnvironment::Testnet,
4618            http_timeout_secs: 1,
4619            ws_timeout_secs: 1,
4620            market_order_slippage_bps: 50,
4621            rest_quota_per_min: None,
4622            sendtx_quota_per_min: None,
4623            transport_backend: Default::default(),
4624        }
4625    }
4626
4627    #[rstest]
4628    fn format_between_timestamps_uses_lighter_seconds_range() {
4629        let start = UnixNanos::from(1_700_000_000_123_456_789);
4630        let end = UnixNanos::from(1_700_003_600_987_654_321);
4631        let now = UnixNanos::from(1_700_007_200_000_000_000);
4632
4633        assert_eq!(
4634            format_between_timestamps(Some(start), Some(end), now),
4635            Some("1700000000-1700003600".to_string()),
4636        );
4637        assert_eq!(
4638            format_between_timestamps(Some(start), None, now),
4639            Some("1700000000-1700007200".to_string()),
4640        );
4641        assert_eq!(
4642            format_between_timestamps(None, Some(end), now),
4643            Some("0-1700003600".to_string()),
4644        );
4645        assert_eq!(format_between_timestamps(None, None, now), None);
4646    }
4647
4648    fn create_execution_client() -> (
4649        LighterExecutionClient,
4650        Rc<RefCell<Cache>>,
4651        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4652    ) {
4653        create_execution_client_with_config(test_config())
4654    }
4655
4656    fn create_execution_client_with_config(
4657        config: LighterExecClientConfig,
4658    ) -> (
4659        LighterExecutionClient,
4660        Rc<RefCell<Cache>>,
4661        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4662    ) {
4663        let cache = Rc::new(RefCell::new(Cache::default()));
4664        let core = ExecutionClientCore::new(
4665            trader_id(),
4666            client_id(),
4667            *LIGHTER_VENUE,
4668            OmsType::Netting,
4669            account_id(),
4670            AccountType::Margin,
4671            None,
4672            cache.clone(),
4673        );
4674
4675        let mut client = LighterExecutionClient::new(core, config).unwrap();
4676        client.dispatch.nonce_manager.refresh(
4677            TEST_ACCOUNT_INDEX_I64,
4678            TEST_API_KEY_INDEX,
4679            TEST_NEXT_NONCE,
4680        );
4681
4682        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
4683        client.emitter.set_sender(sender);
4684
4685        (client, cache, receiver)
4686    }
4687
4688    fn register_test_instrument(
4689        client: &LighterExecutionClient,
4690        cache: &Rc<RefCell<Cache>>,
4691    ) -> InstrumentId {
4692        let instrument_id =
4693            client
4694                .registry
4695                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
4696        let instrument = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
4697            instrument_id,
4698            Symbol::new("ETH-PERP"),
4699            Currency::from("ETH"),
4700            Currency::from("USDC"),
4701            Currency::from("USDC"),
4702            false,
4703            2,
4704            4,
4705            Price::from("0.01"),
4706            Quantity::from("0.0001"),
4707            None,
4708            None,
4709            None,
4710            None,
4711            None,
4712            Some(Money::from("10.000000 USDC")),
4713            None,
4714            None,
4715            None,
4716            None,
4717            None,
4718            None,
4719            None,
4720            None,
4721            UnixNanos::default(),
4722            UnixNanos::default(),
4723        ));
4724
4725        cache.borrow_mut().add_instrument(instrument).unwrap();
4726
4727        instrument_id
4728    }
4729
4730    fn test_order_factory() -> OrderFactory {
4731        let clock = Rc::new(RefCell::new(TestClock::new()));
4732        OrderFactory::new(
4733            trader_id(),
4734            strategy_id(),
4735            Some(0),
4736            Some(0),
4737            clock,
4738            false,
4739            false,
4740        )
4741    }
4742
4743    fn test_limit_order(
4744        factory: &mut OrderFactory,
4745        instrument_id: InstrumentId,
4746        client_order_id: &str,
4747    ) -> OrderAny {
4748        test_limit_order_with(
4749            factory,
4750            instrument_id,
4751            client_order_id,
4752            OrderSide::Buy,
4753            TimeInForce::Gtc,
4754            None,
4755            false,
4756        )
4757    }
4758
4759    fn test_limit_order_with(
4760        factory: &mut OrderFactory,
4761        instrument_id: InstrumentId,
4762        client_order_id: &str,
4763        side: OrderSide,
4764        tif: TimeInForce,
4765        expire_time: Option<UnixNanos>,
4766        reduce_only: bool,
4767    ) -> OrderAny {
4768        factory.limit(
4769            instrument_id,
4770            side,
4771            Quantity::from("0.1000"),
4772            Price::from("2361.31"),
4773            Some(tif),
4774            expire_time,
4775            Some(false),
4776            Some(reduce_only),
4777            None,
4778            None,
4779            None,
4780            None,
4781            None,
4782            None,
4783            None,
4784            Some(ClientOrderId::from(client_order_id)),
4785        )
4786    }
4787
4788    fn cache_order(cache: &Rc<RefCell<Cache>>, order: OrderAny) {
4789        cache
4790            .borrow_mut()
4791            .add_order(order, None, Some(client_id()), false)
4792            .unwrap();
4793    }
4794
4795    fn cache_accepted_order(
4796        cache: &Rc<RefCell<Cache>>,
4797        order: OrderAny,
4798        venue_order_id: VenueOrderId,
4799    ) -> (InstrumentId, ClientOrderId) {
4800        let instrument_id = order.instrument_id();
4801        let client_order_id = order.client_order_id();
4802        cache_order(cache, order);
4803
4804        let accepted = OrderEventAny::Accepted(OrderAccepted::new(
4805            trader_id(),
4806            strategy_id(),
4807            instrument_id,
4808            client_order_id,
4809            venue_order_id,
4810            account_id(),
4811            UUID4::new(),
4812            UnixNanos::default(),
4813            UnixNanos::default(),
4814            false,
4815        ));
4816        cache.borrow_mut().update_order(&accepted).unwrap();
4817
4818        (instrument_id, client_order_id)
4819    }
4820
4821    fn cache_pending_cancel_order(
4822        cache: &Rc<RefCell<Cache>>,
4823        order: OrderAny,
4824        venue_order_id: VenueOrderId,
4825    ) {
4826        let (instrument_id, client_order_id) = cache_accepted_order(cache, order, venue_order_id);
4827
4828        let pending_cancel = OrderEventAny::PendingCancel(OrderPendingCancel::new(
4829            trader_id(),
4830            strategy_id(),
4831            instrument_id,
4832            client_order_id,
4833            account_id(),
4834            UUID4::new(),
4835            UnixNanos::default(),
4836            UnixNanos::default(),
4837            false,
4838            Some(venue_order_id),
4839        ));
4840        cache.borrow_mut().update_order(&pending_cancel).unwrap();
4841    }
4842
4843    fn submit_order_list_command(orders: &[OrderAny], order_list_id: &str) -> SubmitOrderList {
4844        let order_list = OrderList::new(
4845            OrderListId::from(order_list_id),
4846            orders[0].instrument_id(),
4847            strategy_id(),
4848            orders.iter().map(|order| order.client_order_id()).collect(),
4849            UnixNanos::default(),
4850        );
4851        let order_inits = orders
4852            .iter()
4853            .map(|order| order.init_event().clone())
4854            .collect();
4855
4856        SubmitOrderList::new(
4857            trader_id(),
4858            Some(client_id()),
4859            strategy_id(),
4860            order_list,
4861            order_inits,
4862            None,
4863            None,
4864            None,
4865            UUID4::new(),
4866            UnixNanos::default(),
4867            None,
4868        )
4869    }
4870
4871    fn test_contingent_limit_order(
4872        instrument_id: InstrumentId,
4873        client_order_id: &str,
4874        order_list_id: &str,
4875    ) -> OrderAny {
4876        OrderAny::Limit(LimitOrder::new(
4877            trader_id(),
4878            strategy_id(),
4879            instrument_id,
4880            ClientOrderId::from(client_order_id),
4881            OrderSide::Buy,
4882            Quantity::from("0.1000"),
4883            Price::from("2361.31"),
4884            TimeInForce::Gtc,
4885            None,
4886            false,
4887            false,
4888            false,
4889            None,
4890            None,
4891            None,
4892            Some(ContingencyType::Oco),
4893            Some(OrderListId::from(order_list_id)),
4894            None,
4895            None,
4896            None,
4897            None,
4898            None,
4899            None,
4900            UUID4::new(),
4901            UnixNanos::default(),
4902        ))
4903    }
4904
4905    async fn recv_order_event(
4906        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4907    ) -> OrderEventAny {
4908        let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
4909            .await
4910            .expect("timed out waiting for execution event")
4911            .expect("execution event channel closed");
4912
4913        match event {
4914            ExecutionEvent::Order(event) => event,
4915            event => panic!("expected order event, was {event:?}"),
4916        }
4917    }
4918
4919    fn assert_nonce_reusable(dispatch: &WsDispatchState) {
4920        assert_eq!(
4921            dispatch
4922                .nonce_manager
4923                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
4924            Some(TEST_NEXT_NONCE - 1),
4925        );
4926        assert_eq!(
4927            dispatch
4928                .nonce_manager
4929                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
4930                .unwrap(),
4931            TEST_NEXT_NONCE,
4932        );
4933    }
4934
4935    #[tokio::test]
4936    async fn auth_token_rotation_retries_failed_mint() {
4937        let credential = test_credential();
4938        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
4939        let cancellation_token = CancellationToken::new();
4940        let mint_attempts = Arc::new(AtomicUsize::new(0));
4941        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
4942
4943        let outcome = tokio::time::timeout(
4944            Duration::from_secs(3),
4945            refresh_auth_token_until_rotated(
4946                &credential,
4947                &channels,
4948                &cancellation_token,
4949                AuthTokenRefreshBackoff {
4950                    initial_delay: Duration::from_millis(10),
4951                    max_delay: Duration::from_millis(20),
4952                    window: Duration::from_secs(1),
4953                },
4954                {
4955                    let mint_attempts = Arc::clone(&mint_attempts);
4956                    move |_| {
4957                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
4958                        if attempt == 0 {
4959                            Err(anyhow::anyhow!("mint unavailable"))
4960                        } else {
4961                            Ok(format!("token-{attempt}"))
4962                        }
4963                    }
4964                },
4965                {
4966                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
4967                    move |_channel, _token| {
4968                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
4969                        async move {
4970                            subscribe_attempts.fetch_add(1, Ordering::AcqRel);
4971                            Ok::<(), crate::websocket::error::LighterWsError>(())
4972                        }
4973                    }
4974                },
4975            ),
4976        )
4977        .await
4978        .expect("rotation retry must complete within the test window");
4979
4980        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
4981        assert_eq!(
4982            mint_attempts.load(Ordering::Acquire),
4983            2,
4984            "failed mint must retry before the next refresh interval",
4985        );
4986        assert_eq!(
4987            subscribe_attempts.load(Ordering::Acquire),
4988            channels.len(),
4989            "subscriptions must wait until token mint succeeds",
4990        );
4991    }
4992
4993    #[tokio::test]
4994    async fn auth_token_rotation_retries_failed_resubscribe() {
4995        let credential = test_credential();
4996        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
4997        let cancellation_token = CancellationToken::new();
4998        let mint_attempts = Arc::new(AtomicUsize::new(0));
4999        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
5000
5001        let outcome = tokio::time::timeout(
5002            Duration::from_secs(3),
5003            refresh_auth_token_until_rotated(
5004                &credential,
5005                &channels,
5006                &cancellation_token,
5007                AuthTokenRefreshBackoff {
5008                    initial_delay: Duration::from_millis(10),
5009                    max_delay: Duration::from_millis(20),
5010                    window: Duration::from_secs(1),
5011                },
5012                {
5013                    let mint_attempts = Arc::clone(&mint_attempts);
5014                    move |_| {
5015                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
5016                        Ok(format!("token-{attempt}"))
5017                    }
5018                },
5019                {
5020                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
5021                    move |_channel, _token| {
5022                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
5023                        async move {
5024                            let attempt = subscribe_attempts.fetch_add(1, Ordering::AcqRel);
5025                            if attempt == 0 {
5026                                Err(crate::websocket::error::LighterWsError::Client(
5027                                    "handler unavailable".to_string(),
5028                                ))
5029                            } else {
5030                                Ok(())
5031                            }
5032                        }
5033                    }
5034                },
5035            ),
5036        )
5037        .await
5038        .expect("rotation retry must complete within the test window");
5039
5040        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
5041        assert_eq!(
5042            mint_attempts.load(Ordering::Acquire),
5043            2,
5044            "failed resubscribe must trigger a fresh auth-token mint",
5045        );
5046        assert_eq!(
5047            subscribe_attempts.load(Ordering::Acquire),
5048            channels.len() * 2,
5049            "failed resubscribe must retry the private account-channel set",
5050        );
5051    }
5052
5053    #[tokio::test]
5054    async fn auth_token_rotation_exhausts_retry_window() {
5055        let credential = test_credential();
5056        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
5057        let cancellation_token = CancellationToken::new();
5058        let mint_attempts = Arc::new(AtomicUsize::new(0));
5059
5060        let refresh = refresh_auth_token_until_rotated(
5061            &credential,
5062            &channels,
5063            &cancellation_token,
5064            AuthTokenRefreshBackoff {
5065                initial_delay: Duration::from_millis(100),
5066                max_delay: Duration::from_millis(100),
5067                window: Duration::from_secs(1),
5068            },
5069            {
5070                let mint_attempts = Arc::clone(&mint_attempts);
5071                move |_| {
5072                    mint_attempts.fetch_add(1, Ordering::AcqRel);
5073                    Err(anyhow::anyhow!("mint unavailable"))
5074                }
5075            },
5076            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
5077        );
5078        let observe_retry = wait_until_async(
5079            || async { mint_attempts.load(Ordering::Acquire) > 1 },
5080            Duration::from_secs(2),
5081        );
5082
5083        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(3), async {
5084            tokio::join!(refresh, observe_retry)
5085        })
5086        .await
5087        .expect("rotation retry exhaustion must complete within the test window");
5088
5089        assert_eq!(outcome, AuthTokenRefreshOutcome::Exhausted);
5090        assert!(
5091            mint_attempts.load(Ordering::Acquire) > 1,
5092            "persistent mint failure must retry until the window is exhausted",
5093        );
5094    }
5095
5096    #[tokio::test]
5097    async fn auth_token_rotation_cancels_during_retry_backoff() {
5098        let credential = test_credential();
5099        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
5100        let cancellation_token = CancellationToken::new();
5101        let mint_attempts = Arc::new(AtomicUsize::new(0));
5102
5103        let cancel = cancellation_token.clone();
5104        let cancel_after_first_attempt = wait_until_async(
5105            || async { mint_attempts.load(Ordering::Acquire) > 0 },
5106            Duration::from_secs(2),
5107        );
5108
5109        let refresh = refresh_auth_token_until_rotated(
5110            &credential,
5111            &channels,
5112            &cancellation_token,
5113            AuthTokenRefreshBackoff {
5114                initial_delay: Duration::from_secs(2),
5115                max_delay: Duration::from_secs(2),
5116                window: Duration::from_secs(5),
5117            },
5118            {
5119                let mint_attempts = Arc::clone(&mint_attempts);
5120                move |_| {
5121                    mint_attempts.fetch_add(1, Ordering::AcqRel);
5122                    Err(anyhow::anyhow!("mint unavailable"))
5123                }
5124            },
5125            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
5126        );
5127
5128        let cancel_task = async move {
5129            cancel_after_first_attempt.await;
5130            cancel.cancel();
5131        };
5132
5133        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(6), async {
5134            tokio::join!(refresh, cancel_task)
5135        })
5136        .await
5137        .expect("rotation cancellation must complete within the test window");
5138
5139        assert_eq!(outcome, AuthTokenRefreshOutcome::Cancelled);
5140        assert_eq!(
5141            mint_attempts.load(Ordering::Acquire),
5142            1,
5143            "cancellation during backoff must stop before the next retry",
5144        );
5145    }
5146
5147    #[rstest]
5148    #[case::rotated(AuthTokenRefreshOutcome::Rotated, Some(AUTH_TOKEN_REFRESH_INTERVAL))]
5149    #[case::cancelled(AuthTokenRefreshOutcome::Cancelled, None)]
5150    #[case::exhausted(
5151        AuthTokenRefreshOutcome::Exhausted,
5152        Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY)
5153    )]
5154    fn auth_token_refresh_next_delay_matches_outcome(
5155        #[case] outcome: AuthTokenRefreshOutcome,
5156        #[case] expected: Option<Duration>,
5157    ) {
5158        assert_eq!(auth_token_refresh_next_delay(outcome), expected);
5159    }
5160
5161    #[rstest]
5162    fn auth_token_rotation_channels_match_private_account_streams() {
5163        assert_eq!(
5164            auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64),
5165            [
5166                LighterWsChannel::AccountAllOrders(TEST_ACCOUNT_INDEX_I64),
5167                LighterWsChannel::AccountAllTrades(TEST_ACCOUNT_INDEX_I64),
5168                LighterWsChannel::AccountAllPositions(TEST_ACCOUNT_INDEX_I64),
5169                LighterWsChannel::AccountAllAssets(TEST_ACCOUNT_INDEX_I64),
5170                LighterWsChannel::UserStats(TEST_ACCOUNT_INDEX_I64),
5171            ],
5172        );
5173    }
5174
5175    #[rstest]
5176    #[case::below_max(
5177        Duration::from_millis(10),
5178        Duration::from_millis(100),
5179        Duration::from_millis(20)
5180    )]
5181    #[case::at_max(
5182        Duration::from_millis(100),
5183        Duration::from_millis(100),
5184        Duration::from_millis(100)
5185    )]
5186    #[case::overflow_clamps(Duration::MAX, Duration::from_secs(300), Duration::from_secs(300))]
5187    fn next_auth_token_refresh_retry_delay_doubles_and_caps(
5188        #[case] current: Duration,
5189        #[case] max: Duration,
5190        #[case] expected: Duration,
5191    ) {
5192        assert_eq!(next_auth_token_refresh_retry_delay(current, max), expected);
5193    }
5194
5195    #[tokio::test]
5196    async fn tx_send_sequencer_blocks_higher_nonce_until_lower_batch_releases() {
5197        let sequencer = TxSendSequencer::new();
5198        let mut lower_a =
5199            sequencer.reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
5200        let mut lower_b = sequencer.reserve(
5201            TEST_ACCOUNT_INDEX_I64,
5202            TEST_API_KEY_INDEX,
5203            TEST_NEXT_NONCE + 1,
5204        );
5205        let mut higher = sequencer.reserve(
5206            TEST_ACCOUNT_INDEX_I64,
5207            TEST_API_KEY_INDEX,
5208            TEST_NEXT_NONCE + 2,
5209        );
5210        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
5211
5212        let higher_task = tokio::spawn(async move {
5213            higher.wait_for_turn().await;
5214            sent_tx.send(higher.nonce).unwrap();
5215            higher.release();
5216        });
5217
5218        assert!(
5219            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
5220                .await
5221                .is_err(),
5222            "higher nonce must wait while the lower batch is pending",
5223        );
5224
5225        {
5226            let lower_reservations = [&lower_a, &lower_b];
5227            tokio::time::timeout(
5228                Duration::from_millis(50),
5229                wait_for_tx_send_reservations(&lower_reservations),
5230            )
5231            .await
5232            .expect("lower batch must already have the send turn");
5233        }
5234
5235        lower_a.release();
5236        lower_b.release();
5237
5238        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
5239            .await
5240            .expect("higher nonce must proceed after lower batch releases")
5241            .expect("send channel must stay open");
5242        higher_task.await.unwrap();
5243
5244        assert_eq!(
5245            sent,
5246            TEST_NEXT_NONCE + 2,
5247            "higher nonce must send after the lower batch releases",
5248        );
5249    }
5250
5251    #[tokio::test]
5252    async fn tx_send_reservation_drop_releases_lower_nonce() {
5253        let sequencer = TxSendSequencer::new();
5254        let lower = sequencer.reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
5255        let mut higher = sequencer.reserve(
5256            TEST_ACCOUNT_INDEX_I64,
5257            TEST_API_KEY_INDEX,
5258            TEST_NEXT_NONCE + 1,
5259        );
5260        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
5261
5262        let higher_task = tokio::spawn(async move {
5263            higher.wait_for_turn().await;
5264            sent_tx.send(higher.nonce).unwrap();
5265            higher.release();
5266        });
5267
5268        assert!(
5269            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
5270                .await
5271                .is_err(),
5272            "higher nonce must wait while the lower reservation is pending",
5273        );
5274
5275        drop(lower);
5276
5277        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
5278            .await
5279            .expect("higher nonce must proceed after lower reservation drops")
5280            .expect("send channel must stay open");
5281        higher_task.await.unwrap();
5282
5283        assert_eq!(
5284            sent,
5285            TEST_NEXT_NONCE + 1,
5286            "higher nonce must send after the lower reservation drops",
5287        );
5288    }
5289
5290    #[tokio::test]
5291    async fn tx_send_sequencer_keeps_nonce_streams_independent() {
5292        let sequencer = TxSendSequencer::new();
5293        let _other_account = sequencer.reserve(
5294            TEST_ACCOUNT_INDEX_I64 + 1,
5295            TEST_API_KEY_INDEX,
5296            TEST_NEXT_NONCE,
5297        );
5298        let _other_api_key = sequencer.reserve(
5299            TEST_ACCOUNT_INDEX_I64,
5300            TEST_API_KEY_INDEX + 1,
5301            TEST_NEXT_NONCE,
5302        );
5303        let mut current = sequencer.reserve(
5304            TEST_ACCOUNT_INDEX_I64,
5305            TEST_API_KEY_INDEX,
5306            TEST_NEXT_NONCE + 10,
5307        );
5308
5309        tokio::time::timeout(Duration::from_millis(50), current.wait_for_turn())
5310            .await
5311            .expect("lower nonces for other keys must not block this key");
5312        current.release();
5313    }
5314
5315    #[rstest]
5316    fn tx_dispatch_guard_rolls_back_nonce_and_cloid_when_armed() {
5317        let dispatch = WsDispatchState::new();
5318        let credential = test_credential();
5319        dispatch
5320            .nonce_manager
5321            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
5322
5323        let cloid = ClientOrderId::from("O-GUARD-ARMED");
5324        let client_order_index = dispatch.derive_client_order_index(&cloid);
5325        dispatch.register_cloid(client_order_index, cloid);
5326        let nonce = dispatch
5327            .nonce_manager
5328            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5329            .unwrap();
5330
5331        {
5332            let _guard = TxDispatchGuard::new(
5333                dispatch.clone(),
5334                &credential,
5335                Some(client_order_index),
5336                nonce,
5337            );
5338        }
5339
5340        assert_nonce_reusable(&dispatch);
5341        assert!(dispatch.cloid_map.get(&client_order_index).is_none());
5342    }
5343
5344    #[rstest]
5345    fn tx_dispatch_guard_rolls_back_nonce_without_cloid_when_armed() {
5346        let dispatch = WsDispatchState::new();
5347        let credential = test_credential();
5348        dispatch
5349            .nonce_manager
5350            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
5351        let nonce = dispatch
5352            .nonce_manager
5353            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5354            .unwrap();
5355
5356        {
5357            let _guard = TxDispatchGuard::new(dispatch.clone(), &credential, None, nonce);
5358        }
5359
5360        assert_nonce_reusable(&dispatch);
5361        assert!(dispatch.cloid_map.is_empty());
5362    }
5363
5364    #[rstest]
5365    fn tx_dispatch_guard_preserves_nonce_and_cloid_when_disarmed() {
5366        let dispatch = WsDispatchState::new();
5367        let credential = test_credential();
5368        dispatch
5369            .nonce_manager
5370            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
5371
5372        let cloid = ClientOrderId::from("O-GUARD-DISARMED");
5373        let client_order_index = dispatch.derive_client_order_index(&cloid);
5374        dispatch.register_cloid(client_order_index, cloid);
5375        let nonce = dispatch
5376            .nonce_manager
5377            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5378            .unwrap();
5379
5380        {
5381            let mut guard = TxDispatchGuard::new(
5382                dispatch.clone(),
5383                &credential,
5384                Some(client_order_index),
5385                nonce,
5386            );
5387            guard.disarm();
5388        }
5389
5390        assert_eq!(
5391            dispatch
5392                .nonce_manager
5393                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
5394            Some(TEST_NEXT_NONCE),
5395        );
5396        assert_eq!(
5397            dispatch
5398                .cloid_map
5399                .get(&client_order_index)
5400                .map(|entry| *entry.value()),
5401            Some(cloid),
5402        );
5403    }
5404
5405    #[tokio::test]
5406    async fn submit_order_send_failure_emits_submitted_then_rejected_and_rolls_back() {
5407        let (client, cache, mut rx) = create_execution_client();
5408        let instrument_id = register_test_instrument(&client, &cache);
5409        let mut factory = test_order_factory();
5410        let order = test_limit_order(&mut factory, instrument_id, "O-SUBMIT-FAIL");
5411        let client_order_index = client
5412            .dispatch
5413            .derive_client_order_index(&order.client_order_id());
5414        cache_order(&cache, order.clone());
5415
5416        let command = SubmitOrder::from_order(
5417            &order,
5418            trader_id(),
5419            Some(client_id()),
5420            None,
5421            UUID4::new(),
5422            UnixNanos::default(),
5423        );
5424        client.submit_order(command).unwrap();
5425
5426        let submitted = recv_order_event(&mut rx).await;
5427        let rejected = recv_order_event(&mut rx).await;
5428
5429        match submitted {
5430            OrderEventAny::Submitted(event) => {
5431                assert_eq!(event.client_order_id, order.client_order_id());
5432                assert_eq!(event.instrument_id, instrument_id);
5433            }
5434            event => panic!("expected submitted event, was {event:?}"),
5435        }
5436
5437        match rejected {
5438            OrderEventAny::Rejected(event) => {
5439                assert_eq!(event.client_order_id, order.client_order_id());
5440                assert_eq!(event.instrument_id, instrument_id);
5441                assert!(
5442                    event
5443                        .reason
5444                        .as_str()
5445                        .contains("Lighter submit_order dispatch failed"),
5446                );
5447                assert!(event.reason.as_str().contains("handler unavailable"));
5448            }
5449            event => panic!("expected rejected event, was {event:?}"),
5450        }
5451
5452        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
5453        assert_nonce_reusable(&client.dispatch);
5454        assert_eq!(
5455            client.dispatch.pending_sendtx_len(),
5456            0,
5457            "local-send-failure must remove the pending entry by nonce",
5458        );
5459    }
5460
5461    #[tokio::test]
5462    async fn submit_sell_order_send_failure_dispatches_and_rolls_back() {
5463        // Mirror of the buy-side test for OrderSide::Sell; covers the
5464        // `is_ask=true` branch of the CreateOrderTxInfo payload.
5465        let (client, cache, mut rx) = create_execution_client();
5466        let instrument_id = register_test_instrument(&client, &cache);
5467        let mut factory = test_order_factory();
5468        let order = test_limit_order_with(
5469            &mut factory,
5470            instrument_id,
5471            "O-SUBMIT-FAIL-SELL",
5472            OrderSide::Sell,
5473            TimeInForce::Gtc,
5474            None,
5475            false,
5476        );
5477        let client_order_index = client
5478            .dispatch
5479            .derive_client_order_index(&order.client_order_id());
5480        cache_order(&cache, order.clone());
5481
5482        let command = SubmitOrder::from_order(
5483            &order,
5484            trader_id(),
5485            Some(client_id()),
5486            None,
5487            UUID4::new(),
5488            UnixNanos::default(),
5489        );
5490        client.submit_order(command).unwrap();
5491
5492        let _submitted = recv_order_event(&mut rx).await;
5493        let rejected = recv_order_event(&mut rx).await;
5494
5495        match rejected {
5496            OrderEventAny::Rejected(event) => {
5497                assert_eq!(event.client_order_id, order.client_order_id());
5498                assert!(
5499                    event
5500                        .reason
5501                        .as_str()
5502                        .contains("Lighter submit_order dispatch failed"),
5503                );
5504            }
5505            event => panic!("expected rejected event, was {event:?}"),
5506        }
5507
5508        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
5509        assert_nonce_reusable(&client.dispatch);
5510    }
5511
5512    #[tokio::test]
5513    async fn submit_gtd_order_with_explicit_expiry_dispatches_and_rolls_back() {
5514        // Covers the GTD branch in `order_expiry_for`: an explicit
5515        // expire_time must propagate as venue millis through the dispatch
5516        // path. Asserts the order reaches the dispatch step (rejected here
5517        // by handler unavailability, not by the adapter validating GTD).
5518        let (client, cache, mut rx) = create_execution_client();
5519        let instrument_id = register_test_instrument(&client, &cache);
5520        let mut factory = test_order_factory();
5521        let expiry = UnixNanos::from(1_900_000_000_000_000_000u64);
5522        let order = test_limit_order_with(
5523            &mut factory,
5524            instrument_id,
5525            "O-SUBMIT-FAIL-GTD",
5526            OrderSide::Buy,
5527            TimeInForce::Gtd,
5528            Some(expiry),
5529            false,
5530        );
5531        let client_order_index = client
5532            .dispatch
5533            .derive_client_order_index(&order.client_order_id());
5534        cache_order(&cache, order.clone());
5535
5536        let command = SubmitOrder::from_order(
5537            &order,
5538            trader_id(),
5539            Some(client_id()),
5540            None,
5541            UUID4::new(),
5542            UnixNanos::default(),
5543        );
5544        client.submit_order(command).unwrap();
5545
5546        let _submitted = recv_order_event(&mut rx).await;
5547        let rejected = recv_order_event(&mut rx).await;
5548
5549        match rejected {
5550            OrderEventAny::Rejected(event) => {
5551                assert!(
5552                    event
5553                        .reason
5554                        .as_str()
5555                        .contains("Lighter submit_order dispatch failed"),
5556                );
5557            }
5558            event => panic!("expected rejected event, was {event:?}"),
5559        }
5560
5561        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
5562        assert_nonce_reusable(&client.dispatch);
5563    }
5564
5565    #[tokio::test]
5566    async fn submit_reduce_only_order_dispatches_and_rolls_back() {
5567        // The adapter does not reject `reduce_only=true` locally; the flag
5568        // flows into `OrderInfo.reduce_only` and the venue enforces the
5569        // "must be reducing an existing position" rule. This pins the
5570        // adapter pass-through and confirms the dispatch path tolerates
5571        // the flag.
5572        let (client, cache, mut rx) = create_execution_client();
5573        let instrument_id = register_test_instrument(&client, &cache);
5574        let mut factory = test_order_factory();
5575        let order = test_limit_order_with(
5576            &mut factory,
5577            instrument_id,
5578            "O-SUBMIT-FAIL-REDUCE",
5579            OrderSide::Sell,
5580            TimeInForce::Gtc,
5581            None,
5582            true,
5583        );
5584        assert!(order.is_reduce_only());
5585        let client_order_index = client
5586            .dispatch
5587            .derive_client_order_index(&order.client_order_id());
5588        cache_order(&cache, order.clone());
5589
5590        let command = SubmitOrder::from_order(
5591            &order,
5592            trader_id(),
5593            Some(client_id()),
5594            None,
5595            UUID4::new(),
5596            UnixNanos::default(),
5597        );
5598        client.submit_order(command).unwrap();
5599
5600        let _submitted = recv_order_event(&mut rx).await;
5601        let rejected = recv_order_event(&mut rx).await;
5602
5603        match rejected {
5604            OrderEventAny::Rejected(event) => {
5605                assert!(
5606                    event
5607                        .reason
5608                        .as_str()
5609                        .contains("Lighter submit_order dispatch failed"),
5610                );
5611            }
5612            event => panic!("expected rejected event, was {event:?}"),
5613        }
5614
5615        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
5616        assert_nonce_reusable(&client.dispatch);
5617    }
5618
5619    async fn spawn_send_tx_batch_server() -> String {
5620        let body = serde_json::to_string(&LighterSendTxBatchResponse {
5621            code: 200,
5622            message: None,
5623            tx_hash: vec!["0xabc".to_string(), "0xdef".to_string()],
5624            predicted_execution_time_ms: 0,
5625            volume_quota_remaining: None,
5626        })
5627        .unwrap();
5628        let app = Router::new().route(
5629            "/api/v1/sendTxBatch",
5630            post(move || {
5631                let body = body.clone();
5632                async move { body }
5633            }),
5634        );
5635        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5636        let addr = listener.local_addr().unwrap();
5637        tokio::spawn(async move {
5638            axum::serve(listener, app).await.unwrap();
5639        });
5640
5641        format!("http://{addr}")
5642    }
5643
5644    #[tokio::test]
5645    async fn submit_order_list_success_advances_nonce_baseline() {
5646        let mut config = test_config();
5647        config.base_url_http = Some(spawn_send_tx_batch_server().await);
5648        let (client, cache, mut rx) = create_execution_client_with_config(config);
5649        let instrument_id = register_test_instrument(&client, &cache);
5650        let mut factory = test_order_factory();
5651        let order_a = test_limit_order(&mut factory, instrument_id, "O-LIST-OK-A");
5652        let order_b = test_limit_order(&mut factory, instrument_id, "O-LIST-OK-B");
5653        cache_order(&cache, order_a.clone());
5654        cache_order(&cache, order_b.clone());
5655
5656        let command = submit_order_list_command(&[order_a, order_b], "OL-OK");
5657        client.submit_order_list(command).unwrap();
5658
5659        let submitted_a = recv_order_event(&mut rx).await;
5660        let submitted_b = recv_order_event(&mut rx).await;
5661        assert!(matches!(submitted_a, OrderEventAny::Submitted(_)));
5662        assert!(matches!(submitted_b, OrderEventAny::Submitted(_)));
5663        wait_for_spawned_tasks(&client).await;
5664
5665        assert_eq!(
5666            client
5667                .dispatch
5668                .nonce_manager
5669                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
5670            Some(TEST_NEXT_NONCE + 1),
5671            "batch success must advance baseline to max batched nonce",
5672        );
5673        assert_eq!(
5674            client
5675                .dispatch
5676                .nonce_manager
5677                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5678                .unwrap(),
5679            TEST_NEXT_NONCE + 2,
5680        );
5681    }
5682
5683    async fn spawn_invalid_nonce_batch_server(venue_next_nonce: i64) -> String {
5684        let reject_body = serde_json::json!({
5685            "code": LIGHTER_ERROR_CODE_INVALID_NONCE,
5686            "message": "invalid nonce",
5687        })
5688        .to_string();
5689        let nonce_body = serde_json::to_string(&LighterNextNonce {
5690            code: 200,
5691            message: None,
5692            nonce: venue_next_nonce,
5693        })
5694        .unwrap();
5695        let app = Router::new()
5696            .route(
5697                "/api/v1/sendTxBatch",
5698                post(move || {
5699                    let body = reject_body.clone();
5700                    async move { body }
5701                }),
5702            )
5703            .route(
5704                "/api/v1/nextNonce",
5705                get(move || {
5706                    let body = nonce_body.clone();
5707                    async move { body }
5708                }),
5709            );
5710        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5711        let addr = listener.local_addr().unwrap();
5712        tokio::spawn(async move {
5713            axum::serve(listener, app).await.unwrap();
5714        });
5715
5716        format!("http://{addr}")
5717    }
5718
5719    #[tokio::test]
5720    async fn submit_order_list_invalid_nonce_rejection_hard_refreshes() {
5721        let venue_next_nonce = 200;
5722        let mut config = test_config();
5723        config.base_url_http = Some(spawn_invalid_nonce_batch_server(venue_next_nonce).await);
5724        let (client, cache, mut rx) = create_execution_client_with_config(config);
5725        let instrument_id = register_test_instrument(&client, &cache);
5726        let mut factory = test_order_factory();
5727        let order_a = test_limit_order(&mut factory, instrument_id, "O-LIST-WEDGE-A");
5728        let order_b = test_limit_order(&mut factory, instrument_id, "O-LIST-WEDGE-B");
5729        cache_order(&cache, order_a.clone());
5730        cache_order(&cache, order_b.clone());
5731
5732        let command = submit_order_list_command(&[order_a, order_b], "OL-WEDGE");
5733        client.submit_order_list(command).unwrap();
5734
5735        for _ in 0..2 {
5736            let event = recv_order_event(&mut rx).await;
5737            assert!(matches!(event, OrderEventAny::Submitted(_)));
5738        }
5739
5740        for _ in 0..2 {
5741            let event = recv_order_event(&mut rx).await;
5742            assert!(matches!(event, OrderEventAny::Rejected(_)));
5743        }
5744
5745        wait_for_spawned_tasks(&client).await;
5746
5747        // Realignment must move allocation below the local rollback point
5748        assert_eq!(
5749            client
5750                .dispatch
5751                .nonce_manager
5752                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5753                .unwrap(),
5754            venue_next_nonce,
5755            "batch invalid-nonce rejection must hard-refresh allocation",
5756        );
5757    }
5758
5759    #[tokio::test]
5760    async fn batch_cancel_orders_invalid_nonce_rejection_hard_refreshes() {
5761        let venue_next_nonce = 300;
5762        let mut config = test_config();
5763        config.base_url_http = Some(spawn_invalid_nonce_batch_server(venue_next_nonce).await);
5764        let (client, cache, _rx) = create_execution_client_with_config(config);
5765        let instrument_id = register_test_instrument(&client, &cache);
5766        let mut factory = test_order_factory();
5767        let cancels = ["O-WEDGE-CXL-A", "O-WEDGE-CXL-B"]
5768            .iter()
5769            .enumerate()
5770            .map(|(i, id)| {
5771                let order = test_limit_order(&mut factory, instrument_id, id);
5772                let client_order_id = order.client_order_id();
5773                let venue_order_id = VenueOrderId::from(format!("{}", 321 + i).as_str());
5774                cache_pending_cancel_order(&cache, order, venue_order_id);
5775
5776                CancelOrder::new(
5777                    trader_id(),
5778                    Some(client_id()),
5779                    strategy_id(),
5780                    instrument_id,
5781                    client_order_id,
5782                    Some(venue_order_id),
5783                    UUID4::new(),
5784                    UnixNanos::default(),
5785                    None,
5786                    None,
5787                )
5788            })
5789            .collect::<Vec<_>>();
5790
5791        let command = BatchCancelOrders::new(
5792            trader_id(),
5793            Some(client_id()),
5794            strategy_id(),
5795            instrument_id,
5796            cancels,
5797            UUID4::new(),
5798            UnixNanos::default(),
5799            None,
5800            None,
5801        );
5802        client.batch_cancel_orders(command).unwrap();
5803        wait_for_spawned_tasks(&client).await;
5804
5805        assert_eq!(
5806            client
5807                .dispatch
5808                .nonce_manager
5809                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5810                .unwrap(),
5811            venue_next_nonce,
5812            "batch invalid-nonce rejection must hard-refresh allocation",
5813        );
5814    }
5815
5816    #[tokio::test]
5817    async fn batch_cancel_orders_success_advances_nonce_baseline() {
5818        let mut config = test_config();
5819        config.base_url_http = Some(spawn_send_tx_batch_server().await);
5820        let (client, cache, _rx) = create_execution_client_with_config(config);
5821        let instrument_id = register_test_instrument(&client, &cache);
5822        let mut factory = test_order_factory();
5823        let cancels = ["O-BATCH-OK-A", "O-BATCH-OK-B"]
5824            .iter()
5825            .enumerate()
5826            .map(|(i, id)| {
5827                let order = test_limit_order(&mut factory, instrument_id, id);
5828                let client_order_id = order.client_order_id();
5829                cache_order(&cache, order);
5830
5831                CancelOrder::new(
5832                    trader_id(),
5833                    Some(client_id()),
5834                    strategy_id(),
5835                    instrument_id,
5836                    client_order_id,
5837                    Some(VenueOrderId::from(format!("{}", 123 + i).as_str())),
5838                    UUID4::new(),
5839                    UnixNanos::default(),
5840                    None,
5841                    None,
5842                )
5843            })
5844            .collect::<Vec<_>>();
5845
5846        let command = BatchCancelOrders::new(
5847            trader_id(),
5848            Some(client_id()),
5849            strategy_id(),
5850            instrument_id,
5851            cancels,
5852            UUID4::new(),
5853            UnixNanos::default(),
5854            None,
5855            None,
5856        );
5857        client.batch_cancel_orders(command).unwrap();
5858        wait_for_spawned_tasks(&client).await;
5859
5860        assert_eq!(
5861            client
5862                .dispatch
5863                .nonce_manager
5864                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
5865            Some(TEST_NEXT_NONCE + 1),
5866            "batch success must advance baseline to max batched nonce",
5867        );
5868        assert_eq!(
5869            client
5870                .dispatch
5871                .nonce_manager
5872                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
5873                .unwrap(),
5874            TEST_NEXT_NONCE + 2,
5875        );
5876    }
5877
5878    #[tokio::test]
5879    async fn submit_order_list_send_failure_emits_submitted_then_rejected_and_rolls_back() {
5880        let (client, cache, mut rx) = create_execution_client();
5881        let instrument_id = register_test_instrument(&client, &cache);
5882        let mut factory = test_order_factory();
5883        let order_a = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-A");
5884        let order_b = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-B");
5885        let index_a = client
5886            .dispatch
5887            .derive_client_order_index(&order_a.client_order_id());
5888        let index_b = client
5889            .dispatch
5890            .derive_client_order_index(&order_b.client_order_id());
5891        cache_order(&cache, order_a.clone());
5892        cache_order(&cache, order_b.clone());
5893
5894        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-FAIL");
5895        client.submit_order_list(command).unwrap();
5896
5897        let submitted_a = recv_order_event(&mut rx).await;
5898        let submitted_b = recv_order_event(&mut rx).await;
5899        let rejected_a = recv_order_event(&mut rx).await;
5900        let rejected_b = recv_order_event(&mut rx).await;
5901
5902        for (event, expected) in [
5903            (submitted_a, order_a.client_order_id()),
5904            (submitted_b, order_b.client_order_id()),
5905        ] {
5906            match event {
5907                OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, expected),
5908                other => panic!("expected Submitted, was {other:?}"),
5909            }
5910        }
5911
5912        let rejected_ids = [rejected_a, rejected_b].map(|event| match event {
5913            OrderEventAny::Rejected(e) => {
5914                assert!(
5915                    e.reason
5916                        .as_str()
5917                        .contains("Lighter submit_order_list dispatch failed"),
5918                );
5919                e.client_order_id
5920            }
5921            other => panic!("expected Rejected, was {other:?}"),
5922        });
5923
5924        assert!(rejected_ids.contains(&order_a.client_order_id()));
5925        assert!(rejected_ids.contains(&order_b.client_order_id()));
5926        assert!(client.dispatch.cloid_map.get(&index_a).is_none());
5927        assert!(client.dispatch.cloid_map.get(&index_b).is_none());
5928        assert_nonce_reusable(&client.dispatch);
5929        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
5930    }
5931
5932    #[tokio::test]
5933    async fn submit_order_list_over_max_batch_size_denies_all_without_dispatch() {
5934        let (client, cache, mut rx) = create_execution_client();
5935        let instrument_id = register_test_instrument(&client, &cache);
5936        let mut factory = test_order_factory();
5937        let mut orders = Vec::new();
5938
5939        for i in 0..=LIGHTER_MAX_BATCH_TX {
5940            let order = test_limit_order(&mut factory, instrument_id, &format!("O-LIST-MAX-{i}"));
5941            cache_order(&cache, order.clone());
5942            orders.push(order);
5943        }
5944
5945        let command = submit_order_list_command(&orders, "OL-MAX");
5946        client.submit_order_list(command).unwrap();
5947
5948        for order in &orders {
5949            match recv_order_event(&mut rx).await {
5950                OrderEventAny::Denied(e) => {
5951                    assert_eq!(e.client_order_id, order.client_order_id());
5952                    assert!(
5953                        e.reason
5954                            .as_str()
5955                            .contains("sendTxBatch supports at most 15 txs"),
5956                    );
5957                }
5958                other => panic!("expected Denied, was {other:?}"),
5959            }
5960        }
5961
5962        assert!(
5963            tokio::time::timeout(Duration::from_millis(50), rx.recv())
5964                .await
5965                .is_err(),
5966            "max-size denial must not emit extra events",
5967        );
5968        assert_nonce_reusable(&client.dispatch);
5969        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
5970    }
5971
5972    #[tokio::test]
5973    async fn submit_order_list_denies_unsupported_order_and_dispatches_supported() {
5974        let (client, cache, mut rx) = create_execution_client();
5975        let instrument_id = register_test_instrument(&client, &cache);
5976        let mut factory = test_order_factory();
5977        let valid = test_limit_order(&mut factory, instrument_id, "O-LIST-VALID");
5978        let unsupported = factory.limit(
5979            instrument_id,
5980            OrderSide::Buy,
5981            Quantity::from("0.1000"),
5982            Price::from("2361.31"),
5983            Some(TimeInForce::Gtc),
5984            None,
5985            Some(false),
5986            Some(false),
5987            None,
5988            Some(Quantity::from("0.0500")),
5989            None,
5990            None,
5991            None,
5992            None,
5993            None,
5994            Some(ClientOrderId::from("O-LIST-ICEBERG")),
5995        );
5996        let unsupported_index = client
5997            .dispatch
5998            .derive_client_order_index(&unsupported.client_order_id());
5999        cache_order(&cache, valid.clone());
6000        cache_order(&cache, unsupported.clone());
6001
6002        let command =
6003            submit_order_list_command(&[unsupported.clone(), valid.clone()], "OL-PARTIAL");
6004        client.submit_order_list(command).unwrap();
6005
6006        match recv_order_event(&mut rx).await {
6007            OrderEventAny::Denied(e) => {
6008                assert_eq!(e.client_order_id, unsupported.client_order_id());
6009                assert!(e.reason.as_str().contains("display_qty"));
6010            }
6011            other => panic!("expected Denied, was {other:?}"),
6012        }
6013
6014        match recv_order_event(&mut rx).await {
6015            OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, valid.client_order_id()),
6016            other => panic!("expected Submitted, was {other:?}"),
6017        }
6018
6019        match recv_order_event(&mut rx).await {
6020            OrderEventAny::Rejected(e) => {
6021                assert_eq!(e.client_order_id, valid.client_order_id());
6022                assert!(
6023                    e.reason
6024                        .as_str()
6025                        .contains("Lighter submit_order_list dispatch failed"),
6026                );
6027            }
6028            other => panic!("expected Rejected, was {other:?}"),
6029        }
6030
6031        assert!(client.dispatch.cloid_map.get(&unsupported_index).is_none());
6032        assert_nonce_reusable(&client.dispatch);
6033    }
6034
6035    #[tokio::test]
6036    async fn submit_order_list_grouped_contingency_denies_all_without_dispatch() {
6037        let (client, cache, mut rx) = create_execution_client();
6038        let instrument_id = register_test_instrument(&client, &cache);
6039        let order_a = test_contingent_limit_order(instrument_id, "O-LIST-OCO-A", "OL-OCO");
6040        let order_b = test_contingent_limit_order(instrument_id, "O-LIST-OCO-B", "OL-OCO");
6041        cache_order(&cache, order_a.clone());
6042        cache_order(&cache, order_b.clone());
6043
6044        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-OCO");
6045        client.submit_order_list(command).unwrap();
6046
6047        for order in [&order_a, &order_b] {
6048            match recv_order_event(&mut rx).await {
6049                OrderEventAny::Denied(e) => {
6050                    assert_eq!(e.client_order_id, order.client_order_id());
6051                    assert!(e.reason.as_str().contains("supports only independent"));
6052                }
6053                other => panic!("expected Denied, was {other:?}"),
6054            }
6055        }
6056
6057        assert!(
6058            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6059                .await
6060                .is_err(),
6061            "grouped list denial must not emit extra events",
6062        );
6063        assert_nonce_reusable(&client.dispatch);
6064        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6065    }
6066
6067    #[tokio::test]
6068    async fn cancel_order_send_failure_emits_cancel_rejected_and_rolls_back() {
6069        let (client, cache, mut rx) = create_execution_client();
6070        let instrument_id = register_test_instrument(&client, &cache);
6071        let mut factory = test_order_factory();
6072        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-FAIL");
6073        let client_order_id = order.client_order_id();
6074        let venue_order_id = VenueOrderId::from("123");
6075        cache_pending_cancel_order(&cache, order, venue_order_id);
6076
6077        let command = CancelOrder::new(
6078            trader_id(),
6079            Some(client_id()),
6080            strategy_id(),
6081            instrument_id,
6082            client_order_id,
6083            Some(venue_order_id),
6084            UUID4::new(),
6085            UnixNanos::default(),
6086            None,
6087            None,
6088        );
6089        client.cancel_order(command).unwrap();
6090
6091        let rejected = recv_order_event(&mut rx).await;
6092
6093        match rejected {
6094            OrderEventAny::CancelRejected(event) => {
6095                assert_eq!(event.client_order_id, client_order_id);
6096                assert_eq!(event.instrument_id, instrument_id);
6097                assert_eq!(event.venue_order_id, Some(venue_order_id));
6098                assert!(
6099                    event
6100                        .reason
6101                        .as_str()
6102                        .contains("Lighter cancel_order dispatch failed"),
6103                );
6104                assert!(event.reason.as_str().contains("handler unavailable"));
6105            }
6106            event => panic!("expected cancel rejected event, was {event:?}"),
6107        }
6108
6109        assert_nonce_reusable(&client.dispatch);
6110        assert_eq!(
6111            client.dispatch.pending_sendtx_len(),
6112            0,
6113            "local-send-failure must remove the pending cancel entry",
6114        );
6115    }
6116
6117    #[tokio::test]
6118    async fn cancel_order_prepare_failure_emits_cancel_rejected_without_dispatch() {
6119        let (client, cache, mut rx) = create_execution_client();
6120        let instrument_id = register_test_instrument(&client, &cache);
6121        let mut factory = test_order_factory();
6122        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NO-VOI");
6123        let client_order_id = order.client_order_id();
6124        cache_pending_cancel_order(&cache, order, VenueOrderId::from("123"));
6125
6126        let command = CancelOrder::new(
6127            trader_id(),
6128            Some(client_id()),
6129            strategy_id(),
6130            instrument_id,
6131            client_order_id,
6132            None,
6133            UUID4::new(),
6134            UnixNanos::default(),
6135            None,
6136            None,
6137        );
6138        client.cancel_order(command).unwrap();
6139
6140        let rejected = recv_order_event(&mut rx).await;
6141        match rejected {
6142            OrderEventAny::CancelRejected(event) => {
6143                assert_eq!(event.client_order_id, client_order_id);
6144                assert_eq!(event.instrument_id, instrument_id);
6145                assert_eq!(event.venue_order_id, None);
6146                assert!(
6147                    event
6148                        .reason
6149                        .as_str()
6150                        .contains("Lighter cancel_order failed")
6151                );
6152                assert!(
6153                    event
6154                        .reason
6155                        .as_str()
6156                        .contains("venue order_id not yet known")
6157                );
6158            }
6159            event => panic!("expected cancel rejected event, was {event:?}"),
6160        }
6161
6162        assert_nonce_reusable(&client.dispatch);
6163        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6164        assert!(
6165            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6166                .await
6167                .is_err(),
6168            "prepare failure must emit exactly one cancel rejection",
6169        );
6170    }
6171
6172    #[tokio::test]
6173    async fn cancel_order_prepare_failure_emits_cancel_rejected_when_order_uncached() {
6174        let (client, cache, mut rx) = create_execution_client();
6175        let instrument_id = register_test_instrument(&client, &cache);
6176        let client_order_id = ClientOrderId::from("O-CANCEL-NO-CACHE");
6177        let venue_order_id = VenueOrderId::from("123");
6178
6179        let command = CancelOrder::new(
6180            trader_id(),
6181            Some(client_id()),
6182            strategy_id(),
6183            instrument_id,
6184            client_order_id,
6185            Some(venue_order_id),
6186            UUID4::new(),
6187            UnixNanos::default(),
6188            None,
6189            None,
6190        );
6191        client.cancel_order(command).unwrap();
6192
6193        let rejected = recv_order_event(&mut rx).await;
6194        match rejected {
6195            OrderEventAny::CancelRejected(event) => {
6196                assert_eq!(event.client_order_id, client_order_id);
6197                assert_eq!(event.instrument_id, instrument_id);
6198                assert_eq!(event.venue_order_id, Some(venue_order_id));
6199                assert!(
6200                    event
6201                        .reason
6202                        .as_str()
6203                        .contains("Lighter cancel_order failed")
6204                );
6205                assert!(event.reason.as_str().contains("order not found in cache"));
6206            }
6207            event => panic!("expected cancel rejected event, was {event:?}"),
6208        }
6209
6210        assert_nonce_reusable(&client.dispatch);
6211        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6212        assert!(
6213            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6214                .await
6215                .is_err(),
6216            "prepare failure must emit exactly one cancel rejection",
6217        );
6218    }
6219
6220    #[tokio::test]
6221    async fn cancel_all_orders_prepare_failure_suppresses_cancel_rejected_for_open_order() {
6222        let (client, cache, mut rx) = create_execution_client();
6223        let instrument_id = register_test_instrument(&client, &cache);
6224        let mut factory = test_order_factory();
6225        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-ALL-NO-VOI");
6226        cache_accepted_order(&cache, order, VenueOrderId::from("123"));
6227
6228        let command = CancelAllOrders::new(
6229            trader_id(),
6230            Some(client_id()),
6231            strategy_id(),
6232            instrument_id,
6233            OrderSide::Buy,
6234            UUID4::new(),
6235            UnixNanos::default(),
6236            None,
6237            None,
6238        );
6239        client.cancel_all_orders(command).unwrap();
6240
6241        assert_nonce_reusable(&client.dispatch);
6242        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6243        assert!(
6244            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6245                .await
6246                .is_err(),
6247            "cancel-all prepare failure must not emit an invalid cancel rejection",
6248        );
6249    }
6250
6251    #[tokio::test]
6252    async fn cancel_order_nonce_prepare_failure_emits_cancel_rejected() {
6253        let mut config = test_config();
6254        config.base_url_http = Some(spawn_next_nonce_server(100).await);
6255        let (client, cache, mut rx) = create_execution_client_with_config(config);
6256        let instrument_id = register_test_instrument(&client, &cache);
6257        let mut factory = test_order_factory();
6258        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NONCE-FAIL");
6259        let client_order_id = order.client_order_id();
6260        let venue_order_id = VenueOrderId::from("123");
6261        cache_pending_cancel_order(&cache, order, venue_order_id);
6262
6263        let window = i64::from(client.dispatch.nonce_manager.skip_window());
6264        for _ in 0..window {
6265            client
6266                .dispatch
6267                .nonce_manager
6268                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
6269                .unwrap();
6270        }
6271
6272        let command = CancelOrder::new(
6273            trader_id(),
6274            Some(client_id()),
6275            strategy_id(),
6276            instrument_id,
6277            client_order_id,
6278            Some(venue_order_id),
6279            UUID4::new(),
6280            UnixNanos::default(),
6281            None,
6282            None,
6283        );
6284        client.cancel_order(command).unwrap();
6285
6286        let rejected = recv_order_event(&mut rx).await;
6287        match rejected {
6288            OrderEventAny::CancelRejected(event) => {
6289                assert_eq!(event.client_order_id, client_order_id);
6290                assert_eq!(event.venue_order_id, Some(venue_order_id));
6291                assert!(
6292                    event
6293                        .reason
6294                        .as_str()
6295                        .contains("failed to allocate Lighter nonce"),
6296                );
6297                assert!(event.reason.as_str().contains("skip-window exhausted"));
6298            }
6299            event => panic!("expected cancel rejected event, was {event:?}"),
6300        }
6301
6302        wait_for_spawned_tasks(&client).await;
6303        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6304        assert_eq!(
6305            client
6306                .dispatch
6307                .nonce_manager
6308                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
6309                .unwrap(),
6310            100,
6311        );
6312    }
6313
6314    #[tokio::test]
6315    async fn batch_cancel_orders_send_failure_emits_rejected_per_cancel_and_rolls_back() {
6316        let (client, cache, mut rx) = create_execution_client();
6317        let instrument_id = register_test_instrument(&client, &cache);
6318        let mut factory = test_order_factory();
6319        let cancels = ["O-BATCH-CANCEL-A", "O-BATCH-CANCEL-B"]
6320            .iter()
6321            .enumerate()
6322            .map(|(i, id)| {
6323                let order = test_limit_order(&mut factory, instrument_id, id);
6324                let client_order_id = order.client_order_id();
6325                let venue_order_id = VenueOrderId::from(format!("{}", 123 + i).as_str());
6326                cache_pending_cancel_order(&cache, order, venue_order_id);
6327
6328                CancelOrder::new(
6329                    trader_id(),
6330                    Some(client_id()),
6331                    strategy_id(),
6332                    instrument_id,
6333                    client_order_id,
6334                    Some(venue_order_id),
6335                    UUID4::new(),
6336                    UnixNanos::default(),
6337                    None,
6338                    None,
6339                )
6340            })
6341            .collect::<Vec<_>>();
6342
6343        let command = BatchCancelOrders::new(
6344            trader_id(),
6345            Some(client_id()),
6346            strategy_id(),
6347            instrument_id,
6348            cancels.clone(),
6349            UUID4::new(),
6350            UnixNanos::default(),
6351            None,
6352            None,
6353        );
6354        client.batch_cancel_orders(command).unwrap();
6355
6356        let first = recv_order_event(&mut rx).await;
6357        let second = recv_order_event(&mut rx).await;
6358        let rejected_ids = [first, second].map(|event| match event {
6359            OrderEventAny::CancelRejected(e) => {
6360                assert!(
6361                    e.reason
6362                        .as_str()
6363                        .contains("Lighter batch_cancel_orders dispatch failed"),
6364                );
6365                e.client_order_id
6366            }
6367            other => panic!("expected CancelRejected, was {other:?}"),
6368        });
6369
6370        for cancel in cancels {
6371            assert!(rejected_ids.contains(&cancel.client_order_id));
6372        }
6373        assert_nonce_reusable(&client.dispatch);
6374        assert_eq!(
6375            client.dispatch.pending_sendtx_len(),
6376            0,
6377            "local-send-failure must remove batch cancel pending entries",
6378        );
6379    }
6380
6381    #[tokio::test]
6382    async fn batch_cancel_orders_over_max_batch_size_rejects_each_cancel_without_dispatch() {
6383        let (client, cache, mut rx) = create_execution_client();
6384        let instrument_id = register_test_instrument(&client, &cache);
6385        let cancels = (0..=LIGHTER_MAX_BATCH_TX)
6386            .map(|i| {
6387                CancelOrder::new(
6388                    trader_id(),
6389                    Some(client_id()),
6390                    strategy_id(),
6391                    instrument_id,
6392                    ClientOrderId::from(format!("O-BATCH-CANCEL-MAX-{i}").as_str()),
6393                    Some(VenueOrderId::from(format!("{}", 1_000 + i).as_str())),
6394                    UUID4::new(),
6395                    UnixNanos::default(),
6396                    None,
6397                    None,
6398                )
6399            })
6400            .collect::<Vec<_>>();
6401
6402        let command = BatchCancelOrders::new(
6403            trader_id(),
6404            Some(client_id()),
6405            strategy_id(),
6406            instrument_id,
6407            cancels.clone(),
6408            UUID4::new(),
6409            UnixNanos::default(),
6410            None,
6411            None,
6412        );
6413        client.batch_cancel_orders(command).unwrap();
6414
6415        for cancel in &cancels {
6416            match recv_order_event(&mut rx).await {
6417                OrderEventAny::CancelRejected(e) => {
6418                    assert_eq!(e.client_order_id, cancel.client_order_id);
6419                    assert!(
6420                        e.reason
6421                            .as_str()
6422                            .contains("sendTxBatch supports at most 15 txs"),
6423                    );
6424                }
6425                other => panic!("expected CancelRejected, was {other:?}"),
6426            }
6427        }
6428        assert_nonce_reusable(&client.dispatch);
6429        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6430    }
6431
6432    #[rstest]
6433    fn cancel_order_from_cancel_all_preserves_tracing_ids() {
6434        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
6435        let client_order_id = ClientOrderId::from("O-CANCEL-ALL-CHILD");
6436        let command_id = UUID4::new();
6437        let correlation_id = UUID4::new();
6438        let causation_id = UUID4::new();
6439        let ts_init = UnixNanos::default();
6440        let mut cmd = CancelAllOrders::new(
6441            trader_id(),
6442            Some(client_id()),
6443            strategy_id(),
6444            instrument_id,
6445            OrderSide::Buy,
6446            command_id,
6447            ts_init,
6448            None,
6449            Some(correlation_id),
6450        );
6451        cmd.causation_id = Some(causation_id);
6452
6453        let order_cmd = cancel_order_from_cancel_all(&cmd, client_order_id);
6454
6455        assert_eq!(order_cmd.trader_id, trader_id());
6456        assert_eq!(order_cmd.client_id, Some(client_id()));
6457        assert_eq!(order_cmd.strategy_id, strategy_id());
6458        assert_eq!(order_cmd.instrument_id, instrument_id);
6459        assert_eq!(order_cmd.client_order_id, client_order_id);
6460        assert_eq!(order_cmd.venue_order_id, None);
6461        assert_eq!(order_cmd.command_id, command_id);
6462        assert_eq!(order_cmd.ts_init, ts_init);
6463        assert_eq!(order_cmd.params, None);
6464        assert_eq!(order_cmd.correlation_id, Some(correlation_id));
6465        assert_eq!(order_cmd.causation_id, Some(causation_id));
6466    }
6467
6468    #[tokio::test]
6469    async fn modify_order_send_failure_emits_modify_rejected_and_rolls_back() {
6470        let (client, cache, mut rx) = create_execution_client();
6471        let instrument_id = register_test_instrument(&client, &cache);
6472        let mut factory = test_order_factory();
6473        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-FAIL");
6474        let client_order_id = order.client_order_id();
6475        let venue_order_id = VenueOrderId::from("123");
6476        cache_order(&cache, order);
6477
6478        let command = ModifyOrder::new(
6479            trader_id(),
6480            Some(client_id()),
6481            strategy_id(),
6482            instrument_id,
6483            client_order_id,
6484            Some(venue_order_id),
6485            Some(Quantity::from("0.2000")),
6486            Some(Price::from("2362.00")),
6487            None,
6488            UUID4::new(),
6489            UnixNanos::default(),
6490            None,
6491            None,
6492        );
6493        client.modify_order(command).unwrap();
6494
6495        let rejected = recv_order_event(&mut rx).await;
6496
6497        match rejected {
6498            OrderEventAny::ModifyRejected(event) => {
6499                assert_eq!(event.client_order_id, client_order_id);
6500                assert_eq!(event.instrument_id, instrument_id);
6501                assert_eq!(event.venue_order_id, Some(venue_order_id));
6502                assert!(
6503                    event
6504                        .reason
6505                        .as_str()
6506                        .contains("Lighter modify_order dispatch failed"),
6507                );
6508                assert!(event.reason.as_str().contains("handler unavailable"));
6509            }
6510            event => panic!("expected modify rejected event, was {event:?}"),
6511        }
6512
6513        assert_nonce_reusable(&client.dispatch);
6514        assert_eq!(
6515            client.dispatch.pending_sendtx_len(),
6516            0,
6517            "local-send-failure must remove the pending modify entry",
6518        );
6519    }
6520
6521    #[tokio::test]
6522    async fn modify_order_prepare_failure_emits_modify_rejected_without_dispatch() {
6523        let (client, cache, mut rx) = create_execution_client();
6524        let instrument_id = register_test_instrument(&client, &cache);
6525        let client_order_id = ClientOrderId::from("O-MODIFY-NO-CACHE");
6526        let venue_order_id = VenueOrderId::from("123");
6527
6528        let command = ModifyOrder::new(
6529            trader_id(),
6530            Some(client_id()),
6531            strategy_id(),
6532            instrument_id,
6533            client_order_id,
6534            Some(venue_order_id),
6535            Some(Quantity::from("0.2000")),
6536            Some(Price::from("2362.00")),
6537            None,
6538            UUID4::new(),
6539            UnixNanos::default(),
6540            None,
6541            None,
6542        );
6543        client.modify_order(command).unwrap();
6544
6545        let rejected = recv_order_event(&mut rx).await;
6546        match rejected {
6547            OrderEventAny::ModifyRejected(event) => {
6548                assert_eq!(event.client_order_id, client_order_id);
6549                assert_eq!(event.instrument_id, instrument_id);
6550                assert_eq!(event.venue_order_id, Some(venue_order_id));
6551                assert!(
6552                    event
6553                        .reason
6554                        .as_str()
6555                        .contains("Lighter modify_order failed")
6556                );
6557                assert!(event.reason.as_str().contains("order not found in cache"));
6558            }
6559            event => panic!("expected modify rejected event, was {event:?}"),
6560        }
6561
6562        assert_nonce_reusable(&client.dispatch);
6563        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6564        assert!(
6565            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6566                .await
6567                .is_err(),
6568            "prepare failure must emit exactly one modify rejection",
6569        );
6570    }
6571
6572    #[tokio::test]
6573    async fn modify_order_prepare_failure_emits_modify_rejected_when_instrument_uncached() {
6574        let (client, cache, mut rx) = create_execution_client();
6575        let instrument_id =
6576            client
6577                .registry
6578                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
6579        let mut factory = test_order_factory();
6580        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NO-INSTRUMENT");
6581        let client_order_id = order.client_order_id();
6582        let venue_order_id = VenueOrderId::from("123");
6583        cache_order(&cache, order);
6584
6585        let command = ModifyOrder::new(
6586            trader_id(),
6587            Some(client_id()),
6588            strategy_id(),
6589            instrument_id,
6590            client_order_id,
6591            Some(venue_order_id),
6592            Some(Quantity::from("0.2000")),
6593            Some(Price::from("2362.00")),
6594            None,
6595            UUID4::new(),
6596            UnixNanos::default(),
6597            None,
6598            None,
6599        );
6600        client.modify_order(command).unwrap();
6601
6602        let rejected = recv_order_event(&mut rx).await;
6603        match rejected {
6604            OrderEventAny::ModifyRejected(event) => {
6605                assert_eq!(event.client_order_id, client_order_id);
6606                assert_eq!(event.instrument_id, instrument_id);
6607                assert_eq!(event.venue_order_id, Some(venue_order_id));
6608                assert!(
6609                    event
6610                        .reason
6611                        .as_str()
6612                        .contains("Lighter modify_order failed")
6613                );
6614                assert!(
6615                    event
6616                        .reason
6617                        .as_str()
6618                        .contains("instrument not found in cache")
6619                );
6620            }
6621            event => panic!("expected modify rejected event, was {event:?}"),
6622        }
6623
6624        assert_nonce_reusable(&client.dispatch);
6625        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6626        assert!(
6627            tokio::time::timeout(Duration::from_millis(50), rx.recv())
6628                .await
6629                .is_err(),
6630            "prepare failure must emit exactly one modify rejection",
6631        );
6632    }
6633
6634    #[tokio::test]
6635    async fn modify_stop_market_derives_price_from_trigger_without_explicit_price() {
6636        // A trigger-only STOP_MARKET carries no limit price; modifying its trigger
6637        // must derive the wire cap from the trigger rather than trip the price
6638        // guard. Prepare succeeds, so the only failure here is the test harness's
6639        // missing WS send handler.
6640        let (client, cache, mut rx) = create_execution_client();
6641        let instrument_id = register_test_instrument(&client, &cache);
6642        let mut factory = test_order_factory();
6643        let order = factory.stop_market(
6644            instrument_id,
6645            OrderSide::Sell,
6646            Quantity::from("0.1000"),
6647            Price::from("2300.00"), // trigger
6648            None,
6649            Some(TimeInForce::Gtc),
6650            None,
6651            Some(false),
6652            Some(false),
6653            None,
6654            None,
6655            None,
6656            None,
6657            None,
6658            None,
6659            Some(ClientOrderId::from("O-MODIFY-STOP-MARKET")),
6660        );
6661        let client_order_id = order.client_order_id();
6662        let venue_order_id = VenueOrderId::from("123");
6663        cache_order(&cache, order);
6664
6665        let command = ModifyOrder::new(
6666            trader_id(),
6667            Some(client_id()),
6668            strategy_id(),
6669            instrument_id,
6670            client_order_id,
6671            Some(venue_order_id),
6672            None,
6673            None,
6674            Some(Price::from("2310.00")),
6675            UUID4::new(),
6676            UnixNanos::default(),
6677            None,
6678            None,
6679        );
6680        client.modify_order(command).unwrap();
6681
6682        let rejected = recv_order_event(&mut rx).await;
6683        match rejected {
6684            OrderEventAny::ModifyRejected(event) => {
6685                assert!(
6686                    !event.reason.as_str().contains("requires a price"),
6687                    "trigger-only stop modify must not trip the price guard, was: {}",
6688                    event.reason,
6689                );
6690                assert!(
6691                    event.reason.as_str().contains("dispatch failed"),
6692                    "expected send-stage failure after a successful prepare, was: {}",
6693                    event.reason,
6694                );
6695            }
6696            event => panic!("expected modify rejected event, was {event:?}"),
6697        }
6698        assert_nonce_reusable(&client.dispatch);
6699    }
6700
6701    #[tokio::test]
6702    async fn modify_order_nonce_prepare_failure_emits_modify_rejected() {
6703        let mut config = test_config();
6704        config.base_url_http = Some(spawn_next_nonce_server(101).await);
6705        let (client, cache, mut rx) = create_execution_client_with_config(config);
6706        let instrument_id = register_test_instrument(&client, &cache);
6707        let mut factory = test_order_factory();
6708        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NONCE-FAIL");
6709        let client_order_id = order.client_order_id();
6710        let venue_order_id = VenueOrderId::from("123");
6711        cache_order(&cache, order);
6712
6713        let window = i64::from(client.dispatch.nonce_manager.skip_window());
6714        for _ in 0..window {
6715            client
6716                .dispatch
6717                .nonce_manager
6718                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
6719                .unwrap();
6720        }
6721
6722        let command = ModifyOrder::new(
6723            trader_id(),
6724            Some(client_id()),
6725            strategy_id(),
6726            instrument_id,
6727            client_order_id,
6728            Some(venue_order_id),
6729            Some(Quantity::from("0.2000")),
6730            Some(Price::from("2362.00")),
6731            None,
6732            UUID4::new(),
6733            UnixNanos::default(),
6734            None,
6735            None,
6736        );
6737        client.modify_order(command).unwrap();
6738
6739        let rejected = recv_order_event(&mut rx).await;
6740        match rejected {
6741            OrderEventAny::ModifyRejected(event) => {
6742                assert_eq!(event.client_order_id, client_order_id);
6743                assert_eq!(event.venue_order_id, Some(venue_order_id));
6744                assert!(
6745                    event
6746                        .reason
6747                        .as_str()
6748                        .contains("failed to allocate Lighter nonce"),
6749                );
6750                assert!(event.reason.as_str().contains("skip-window exhausted"));
6751            }
6752            event => panic!("expected modify rejected event, was {event:?}"),
6753        }
6754
6755        wait_for_spawned_tasks(&client).await;
6756        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
6757        assert_eq!(
6758            client
6759                .dispatch
6760                .nonce_manager
6761                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
6762                .unwrap(),
6763            101,
6764        );
6765    }
6766
6767    #[tokio::test]
6768    async fn update_leverage_requires_credentials() {
6769        let (mut client, _cache, _rx) = create_execution_client();
6770        client.credential = None;
6771        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
6772
6773        let err = client
6774            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
6775            .unwrap_err();
6776
6777        assert!(
6778            err.to_string()
6779                .contains("cannot update leverage without credentials"),
6780        );
6781    }
6782
6783    #[tokio::test]
6784    async fn update_leverage_requires_registered_instrument() {
6785        let (client, _cache, _rx) = create_execution_client();
6786        let unknown = InstrumentId::from("DOGE-PERP.LIGHTER");
6787
6788        let err = client
6789            .update_leverage(unknown, 500, LighterPositionMarginMode::Isolated)
6790            .unwrap_err();
6791
6792        assert!(
6793            err.to_string()
6794                .contains("no Lighter market_index registered")
6795        );
6796        // Pin that nonce was not burned on the rejected path: instrument
6797        // lookup must happen before `build_tx_context` allocates a nonce.
6798        assert_nonce_reusable(&client.dispatch);
6799    }
6800
6801    #[tokio::test]
6802    async fn update_leverage_dispatches_and_rolls_back_on_send_failure() {
6803        let (client, cache, _rx) = create_execution_client();
6804        let instrument_id = register_test_instrument(&client, &cache);
6805
6806        client
6807            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
6808            .unwrap();
6809
6810        wait_for_spawned_tasks(&client).await;
6811        assert_nonce_reusable(&client.dispatch);
6812    }
6813
6814    #[tokio::test]
6815    async fn update_leverage_rejects_zero_margin_fraction() {
6816        let (client, cache, _rx) = create_execution_client();
6817        let instrument_id = register_test_instrument(&client, &cache);
6818
6819        let err = client
6820            .update_leverage(instrument_id, 0, LighterPositionMarginMode::Cross)
6821            .unwrap_err();
6822        assert!(err.to_string().contains("must be in 1..=10_000"));
6823    }
6824
6825    #[tokio::test]
6826    async fn update_leverage_rejects_above_margin_fraction_tick() {
6827        let (client, cache, _rx) = create_execution_client();
6828        let instrument_id = register_test_instrument(&client, &cache);
6829
6830        let err = client
6831            .update_leverage(instrument_id, 10_001, LighterPositionMarginMode::Cross)
6832            .unwrap_err();
6833        assert!(err.to_string().contains("must be in 1..=10_000"));
6834    }
6835
6836    #[tokio::test]
6837    async fn update_leverage_accepts_minimum_margin_fraction() {
6838        // Pin the inclusive lower bound of the venue's `MarginFractionTick`
6839        // range. An exclusive `(1.., ...)` range check would fail this case.
6840        let (client, cache, _rx) = create_execution_client();
6841        let instrument_id = register_test_instrument(&client, &cache);
6842
6843        client
6844            .update_leverage(instrument_id, 1, LighterPositionMarginMode::Cross)
6845            .unwrap();
6846
6847        wait_for_spawned_tasks(&client).await;
6848        assert_nonce_reusable(&client.dispatch);
6849    }
6850
6851    #[tokio::test]
6852    async fn update_leverage_accepts_maximum_margin_fraction() {
6853        // Pin the inclusive upper bound of the venue's `MarginFractionTick`
6854        // range. An exclusive `(..10_000)` range check would fail this case.
6855        let (client, cache, _rx) = create_execution_client();
6856        let instrument_id = register_test_instrument(&client, &cache);
6857
6858        client
6859            .update_leverage(instrument_id, 10_000, LighterPositionMarginMode::Isolated)
6860            .unwrap();
6861
6862        wait_for_spawned_tasks(&client).await;
6863        assert_nonce_reusable(&client.dispatch);
6864    }
6865
6866    async fn wait_for_spawned_tasks(client: &LighterExecutionClient) {
6867        wait_until_async(
6868            || async { client.pending_tasks_all_finished() },
6869            Duration::from_secs(2),
6870        )
6871        .await;
6872    }
6873
6874    fn mark_all_streams_ready(client: &LighterExecutionClient) {
6875        let ready = &client.dispatch.account_streams_ready;
6876        ready.mark_orders();
6877        ready.mark_trades();
6878        ready.mark_positions();
6879        ready.mark_assets();
6880        ready.mark_user_stats();
6881    }
6882
6883    #[tokio::test]
6884    async fn await_account_streams_ready_times_out_when_no_frame_arrives() {
6885        // Drives the timeout branch `connect()` uses to tear the WS down
6886        // when at least one account stream has not delivered a first frame.
6887        let (client, _cache, _rx) = create_execution_client();
6888
6889        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
6890
6891        assert!(
6892            err.to_string().contains("Timeout")
6893                && err.to_string().contains("Lighter account streams"),
6894            "unexpected error message, was {err}",
6895        );
6896    }
6897
6898    #[tokio::test]
6899    async fn await_account_streams_ready_returns_when_all_streams_marked() {
6900        let (client, _cache, _rx) = create_execution_client();
6901        mark_all_streams_ready(&client);
6902
6903        client.await_account_streams_ready(0.05).await.unwrap();
6904    }
6905
6906    #[tokio::test]
6907    async fn await_account_streams_ready_returns_when_streams_arrive_mid_wait() {
6908        // Pins that the Notify-based wait wakes promptly when frames land
6909        // after the wait has started.
6910        let (client, _cache, _rx) = create_execution_client();
6911        let ready = Arc::clone(&client.dispatch.account_streams_ready);
6912
6913        let wait = client.await_account_streams_ready(1.0);
6914        let seed = async move {
6915            tokio::time::sleep(Duration::from_millis(20)).await;
6916            ready.mark_orders();
6917            ready.mark_trades();
6918            ready.mark_positions();
6919            ready.mark_assets();
6920            ready.mark_user_stats();
6921        };
6922
6923        let (result, ()) = tokio::join!(wait, seed);
6924        result.unwrap();
6925    }
6926
6927    #[tokio::test]
6928    async fn await_account_streams_ready_times_out_with_partial_marks() {
6929        // Three out of four streams marked must still time out: strict
6930        // await means every account stream has to deliver before connect
6931        // unblocks.
6932        let (client, _cache, _rx) = create_execution_client();
6933        let ready = &client.dispatch.account_streams_ready;
6934        ready.mark_orders();
6935        ready.mark_trades();
6936        ready.mark_positions();
6937
6938        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
6939        assert!(
6940            err.to_string().contains("assets"),
6941            "pending list should call out the missing stream, was {err}",
6942        );
6943    }
6944
6945    #[tokio::test]
6946    async fn await_account_streams_ready_after_reset_requires_new_marks() {
6947        // Pins the connect-retry contract: marks from a prior session
6948        // must not satisfy a fresh await once `reset()` has cleared the
6949        // gate. A regression that drops the reset() call from connect()
6950        // would let a retried session return immediately with stale flags.
6951        let (client, _cache, _rx) = create_execution_client();
6952        mark_all_streams_ready(&client);
6953        client.await_account_streams_ready(0.05).await.unwrap();
6954
6955        client.dispatch.account_streams_ready.reset();
6956
6957        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
6958        let msg = err.to_string();
6959        assert!(msg.contains("orders"), "pending list missing orders: {msg}");
6960        assert!(msg.contains("trades"), "pending list missing trades: {msg}");
6961        assert!(
6962            msg.contains("positions"),
6963            "pending list missing positions: {msg}",
6964        );
6965        assert!(msg.contains("assets"), "pending list missing assets: {msg}");
6966        assert!(
6967            msg.contains("user_stats"),
6968            "pending list missing user_stats: {msg}",
6969        );
6970    }
6971
6972    fn test_market_order(
6973        factory: &mut OrderFactory,
6974        instrument_id: InstrumentId,
6975        client_order_id: &str,
6976        side: OrderSide,
6977    ) -> OrderAny {
6978        factory.market(
6979            instrument_id,
6980            side,
6981            Quantity::from("0.1000"),
6982            Some(TimeInForce::Ioc),
6983            Some(false),
6984            Some(false),
6985            None,
6986            None,
6987            None,
6988            Some(ClientOrderId::from(client_order_id)),
6989        )
6990    }
6991
6992    fn add_test_quote(
6993        cache: &Rc<RefCell<Cache>>,
6994        instrument_id: InstrumentId,
6995        bid: &str,
6996        ask: &str,
6997    ) {
6998        let quote = QuoteTick::new(
6999            instrument_id,
7000            Price::from(bid),
7001            Price::from(ask),
7002            Quantity::from("1.0000"),
7003            Quantity::from("1.0000"),
7004            UnixNanos::default(),
7005            UnixNanos::default(),
7006        );
7007        cache.borrow_mut().add_quote(quote).unwrap();
7008    }
7009
7010    #[tokio::test]
7011    async fn submit_market_order_without_cached_quote_emits_denied() {
7012        let (client, cache, mut rx) = create_execution_client();
7013        let instrument_id = register_test_instrument(&client, &cache);
7014        let mut factory = test_order_factory();
7015        let order = test_market_order(
7016            &mut factory,
7017            instrument_id,
7018            "O-MARKET-NO-QUOTE",
7019            OrderSide::Buy,
7020        );
7021        cache_order(&cache, order.clone());
7022
7023        let command = SubmitOrder::from_order(
7024            &order,
7025            trader_id(),
7026            Some(client_id()),
7027            None,
7028            UUID4::new(),
7029            UnixNanos::default(),
7030        );
7031        // submit_order returns Err but also emits OrderDenied; consume both.
7032        let _ = client.submit_order(command);
7033
7034        let event = recv_order_event(&mut rx).await;
7035        match event {
7036            OrderEventAny::Denied(event) => {
7037                assert!(
7038                    event.reason.as_str().contains("no cached quote"),
7039                    "expected no-cached-quote in reason, was {:?}",
7040                    event.reason,
7041                );
7042            }
7043            event => panic!("expected denied event, was {event:?}"),
7044        }
7045        assert_nonce_reusable(&client.dispatch);
7046    }
7047
7048    #[tokio::test]
7049    async fn submit_market_buy_with_quote_uses_ask_widened_by_slippage() {
7050        let (client, cache, mut rx) = create_execution_client();
7051        let instrument_id = register_test_instrument(&client, &cache);
7052        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
7053
7054        let mut factory = test_order_factory();
7055        let order = test_market_order(
7056            &mut factory,
7057            instrument_id,
7058            "O-MARKET-QUOTED-BUY",
7059            OrderSide::Buy,
7060        );
7061        cache_order(&cache, order.clone());
7062
7063        let command = SubmitOrder::from_order(
7064            &order,
7065            trader_id(),
7066            Some(client_id()),
7067            None,
7068            UUID4::new(),
7069            UnixNanos::default(),
7070        );
7071        let _ = client.submit_order(command);
7072
7073        let submitted = recv_order_event(&mut rx).await;
7074        assert!(
7075            matches!(submitted, OrderEventAny::Submitted(_)),
7076            "expected submitted, was {submitted:?}",
7077        );
7078        let rejected = recv_order_event(&mut rx).await;
7079        match rejected {
7080            OrderEventAny::Rejected(event) => {
7081                assert!(
7082                    event
7083                        .reason
7084                        .as_str()
7085                        .contains("Lighter submit_order dispatch failed"),
7086                );
7087            }
7088            event => panic!("expected rejected event, was {event:?}"),
7089        }
7090        assert_nonce_reusable(&client.dispatch);
7091    }
7092
7093    #[tokio::test]
7094    async fn submit_order_with_sub_tick_quantity_emits_denied() {
7095        let (client, cache, mut rx) = create_execution_client();
7096        let instrument_id = register_test_instrument(&client, &cache);
7097        let mut factory = test_order_factory();
7098        // ETH-PERP size_precision=4; quantity 0.00001 truncates to 0 ticks.
7099        let order = factory.limit(
7100            instrument_id,
7101            OrderSide::Buy,
7102            Quantity::from("0.00001"),
7103            Price::from("2361.31"),
7104            Some(TimeInForce::Gtc),
7105            None,
7106            Some(false),
7107            Some(false),
7108            None,
7109            None,
7110            None,
7111            None,
7112            None,
7113            None,
7114            None,
7115            Some(ClientOrderId::from("O-SUB-TICK-QTY")),
7116        );
7117        cache_order(&cache, order.clone());
7118
7119        let command = SubmitOrder::from_order(
7120            &order,
7121            trader_id(),
7122            Some(client_id()),
7123            None,
7124            UUID4::new(),
7125            UnixNanos::default(),
7126        );
7127        let _ = client.submit_order(command);
7128
7129        let event = recv_order_event(&mut rx).await;
7130        match event {
7131            OrderEventAny::Denied(event) => {
7132                assert!(
7133                    event.reason.as_str().contains("rounds to 0 ticks"),
7134                    "expected rounds-to-0 in reason, was {:?}",
7135                    event.reason,
7136                );
7137            }
7138            event => panic!("expected denied event, was {event:?}"),
7139        }
7140        assert_nonce_reusable(&client.dispatch);
7141    }
7142
7143    #[tokio::test]
7144    async fn submit_order_below_min_notional_emits_denied() {
7145        let (client, cache, mut rx) = create_execution_client();
7146        let instrument_id = register_test_instrument(&client, &cache);
7147        let mut factory = test_order_factory();
7148        let order = factory.limit(
7149            instrument_id,
7150            OrderSide::Buy,
7151            Quantity::from("0.0010"),
7152            Price::from("2361.31"),
7153            Some(TimeInForce::Gtc),
7154            None,
7155            Some(false),
7156            Some(false),
7157            None,
7158            None,
7159            None,
7160            None,
7161            None,
7162            None,
7163            None,
7164            Some(ClientOrderId::from("O-BELOW-MIN-NOTIONAL")),
7165        );
7166        cache_order(&cache, order.clone());
7167
7168        let command = SubmitOrder::from_order(
7169            &order,
7170            trader_id(),
7171            Some(client_id()),
7172            None,
7173            UUID4::new(),
7174            UnixNanos::default(),
7175        );
7176        let _ = client.submit_order(command);
7177
7178        let event = recv_order_event(&mut rx).await;
7179        match event {
7180            OrderEventAny::Denied(event) => {
7181                assert!(
7182                    event.reason.as_str().contains("min_quote_amount"),
7183                    "expected min_quote_amount in reason, was {:?}",
7184                    event.reason,
7185                );
7186            }
7187            event => panic!("expected denied event, was {event:?}"),
7188        }
7189        assert_nonce_reusable(&client.dispatch);
7190    }
7191
7192    #[tokio::test]
7193    async fn submit_stop_market_with_sub_tick_trigger_emits_denied() {
7194        let (client, cache, mut rx) = create_execution_client();
7195        let instrument_id = register_test_instrument(&client, &cache);
7196        let mut factory = test_order_factory();
7197        // ETH-PERP price_precision=2; trigger 0.001 truncates to 0 ticks.
7198        let order = factory.stop_market(
7199            instrument_id,
7200            OrderSide::Buy,
7201            Quantity::from("0.1000"),
7202            Price::from("0.001"),
7203            None,
7204            Some(TimeInForce::Gtc),
7205            None,
7206            Some(false),
7207            Some(false),
7208            None,
7209            None,
7210            None,
7211            None,
7212            None,
7213            None,
7214            Some(ClientOrderId::from("O-STOP-SUB-TICK")),
7215        );
7216        cache_order(&cache, order.clone());
7217
7218        let command = SubmitOrder::from_order(
7219            &order,
7220            trader_id(),
7221            Some(client_id()),
7222            None,
7223            UUID4::new(),
7224            UnixNanos::default(),
7225        );
7226        let _ = client.submit_order(command);
7227
7228        let event = recv_order_event(&mut rx).await;
7229        match event {
7230            OrderEventAny::Denied(event) => {
7231                assert!(
7232                    event.reason.as_str().contains("rounds to 0 ticks"),
7233                    "expected rounds-to-0 in reason, was {:?}",
7234                    event.reason,
7235                );
7236            }
7237            event => panic!("expected denied event, was {event:?}"),
7238        }
7239        assert_nonce_reusable(&client.dispatch);
7240    }
7241
7242    #[tokio::test]
7243    async fn submit_stop_market_dispatches_using_trigger_widened_by_slippage() {
7244        let (client, cache, mut rx) = create_execution_client();
7245        let instrument_id = register_test_instrument(&client, &cache);
7246        let mut factory = test_order_factory();
7247        let order = factory.stop_market(
7248            instrument_id,
7249            OrderSide::Sell,
7250            Quantity::from("0.1000"),
7251            Price::from("2300.00"), // trigger
7252            None,
7253            Some(TimeInForce::Gtc),
7254            None,
7255            Some(false),
7256            Some(false),
7257            None,
7258            None,
7259            None,
7260            None,
7261            None,
7262            None,
7263            Some(ClientOrderId::from("O-STOP-MARKET")),
7264        );
7265        cache_order(&cache, order.clone());
7266
7267        let command = SubmitOrder::from_order(
7268            &order,
7269            trader_id(),
7270            Some(client_id()),
7271            None,
7272            UUID4::new(),
7273            UnixNanos::default(),
7274        );
7275        let _ = client.submit_order(command);
7276
7277        let submitted = recv_order_event(&mut rx).await;
7278        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
7279        let rejected = recv_order_event(&mut rx).await;
7280        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
7281        assert_nonce_reusable(&client.dispatch);
7282    }
7283
7284    #[tokio::test]
7285    async fn submit_market_order_respects_per_order_slippage_override() {
7286        // 0-bps override on a valid ask exercises the params path without
7287        // adding any widening.
7288        let (client, cache, mut rx) = create_execution_client();
7289        let instrument_id = register_test_instrument(&client, &cache);
7290        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
7291
7292        let mut factory = test_order_factory();
7293        let order = test_market_order(
7294            &mut factory,
7295            instrument_id,
7296            "O-MARKET-ZERO-SLIP",
7297            OrderSide::Buy,
7298        );
7299        cache_order(&cache, order.clone());
7300
7301        let params: Params =
7302            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 0})).unwrap();
7303        let mut command = SubmitOrder::from_order(
7304            &order,
7305            trader_id(),
7306            Some(client_id()),
7307            None,
7308            UUID4::new(),
7309            UnixNanos::default(),
7310        );
7311        command.params = Some(params);
7312        let _ = client.submit_order(command);
7313
7314        let submitted = recv_order_event(&mut rx).await;
7315        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
7316        let rejected = recv_order_event(&mut rx).await;
7317        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
7318        assert_nonce_reusable(&client.dispatch);
7319    }
7320
7321    #[tokio::test]
7322    async fn resolve_slippage_bps_prefers_params_over_config_default() {
7323        let (client, _cache, _rx) = create_execution_client();
7324        assert_eq!(client.resolve_slippage_bps(None), 50);
7325
7326        let override_params: Params =
7327            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 100})).unwrap();
7328        assert_eq!(client.resolve_slippage_bps(Some(&override_params)), 100);
7329
7330        let unrelated_params: Params =
7331            serde_json::from_value(serde_json::json!({"other_key": 999})).unwrap();
7332        assert_eq!(client.resolve_slippage_bps(Some(&unrelated_params)), 50);
7333    }
7334
7335    #[tokio::test]
7336    async fn submit_market_sell_with_quote_uses_bid_widened_by_slippage() {
7337        let (client, cache, mut rx) = create_execution_client();
7338        let instrument_id = register_test_instrument(&client, &cache);
7339        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
7340
7341        let mut factory = test_order_factory();
7342        let order = test_market_order(
7343            &mut factory,
7344            instrument_id,
7345            "O-MARKET-QUOTED-SELL",
7346            OrderSide::Sell,
7347        );
7348        cache_order(&cache, order.clone());
7349
7350        let command = SubmitOrder::from_order(
7351            &order,
7352            trader_id(),
7353            Some(client_id()),
7354            None,
7355            UUID4::new(),
7356            UnixNanos::default(),
7357        );
7358        let _ = client.submit_order(command);
7359
7360        let submitted = recv_order_event(&mut rx).await;
7361        assert!(
7362            matches!(submitted, OrderEventAny::Submitted(_)),
7363            "expected submitted, was {submitted:?}",
7364        );
7365        let rejected = recv_order_event(&mut rx).await;
7366        match rejected {
7367            OrderEventAny::Rejected(event) => {
7368                assert!(
7369                    event
7370                        .reason
7371                        .as_str()
7372                        .contains("Lighter submit_order dispatch failed"),
7373                );
7374            }
7375            event => panic!("expected rejected event, was {event:?}"),
7376        }
7377        assert_nonce_reusable(&client.dispatch);
7378    }
7379
7380    #[rstest]
7381    fn integrator_attributes_tags_nautilus_account_at_zero_fees() {
7382        let attrs = integrator_attributes();
7383        assert_eq!(
7384            attrs.integrator_account_index,
7385            LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
7386        );
7387        assert_eq!(attrs.integrator_taker_fee, 0);
7388        assert_eq!(attrs.integrator_maker_fee, 0);
7389        assert_eq!(attrs.skip_nonce, 0);
7390    }
7391
7392    use std::str::FromStr;
7393
7394    use nautilus_live::ExecutionEventEmitter;
7395    use rust_decimal::Decimal;
7396
7397    use crate::{
7398        common::enums::{
7399            LighterOrderKind, LighterOrderSide, LighterOrderStatus, LighterOrderTimeInForce,
7400            LighterTradeType, LighterTriggerStatus,
7401        },
7402        http::models::{LighterOrder, LighterTrade},
7403    };
7404
7405    fn dispatcher_emitter() -> (
7406        ExecutionEventEmitter,
7407        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7408    ) {
7409        let mut emitter = ExecutionEventEmitter::new(
7410            get_atomic_clock_realtime(),
7411            trader_id(),
7412            account_id(),
7413            AccountType::Margin,
7414            None,
7415        );
7416        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
7417        emitter.set_sender(sender);
7418        (emitter, receiver)
7419    }
7420
7421    /// Test rig that owns a `WsDispatchState`, a process-global instrument
7422    /// cache entry, a `MarketRegistry`, and an emitter wired to a receiver.
7423    /// Used by every dispatcher test to keep the call-site short.
7424    struct DispatcherRig {
7425        dispatch: WsDispatchState,
7426        registry: Arc<MarketRegistry>,
7427        emitter: ExecutionEventEmitter,
7428        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7429        instrument_id: InstrumentId,
7430        cloid: ClientOrderId,
7431    }
7432
7433    fn dispatcher_rig(cloid_suffix: &str) -> DispatcherRig {
7434        let registry = Arc::new(MarketRegistry::new());
7435        // All dispatcher tests share the same instrument (ETH-PERP) so
7436        // `LIGHTER_INSTRUMENT_CACHE` only ever holds one entry; per-test
7437        // isolation comes from the per-rig `WsDispatchState` and the
7438        // unique cloid built from `cloid_suffix`.
7439        let instrument_id = registry.insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
7440        let instrument = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
7441            instrument_id,
7442            Symbol::new("ETH-PERP"),
7443            Currency::from("ETH"),
7444            Currency::from("USDC"),
7445            Currency::from("USDC"),
7446            false,
7447            2,
7448            4,
7449            Price::from("0.01"),
7450            Quantity::from("0.0001"),
7451            None,
7452            None,
7453            None,
7454            None,
7455            None,
7456            Some(Money::from("10.000000 USDC")),
7457            None,
7458            None,
7459            None,
7460            None,
7461            None,
7462            None,
7463            None,
7464            None,
7465            UnixNanos::default(),
7466            UnixNanos::default(),
7467        ));
7468        LIGHTER_INSTRUMENT_CACHE.insert(instrument_id, instrument);
7469        let (emitter, rx) = dispatcher_emitter();
7470        DispatcherRig {
7471            dispatch: WsDispatchState::new(),
7472            registry,
7473            emitter,
7474            rx,
7475            instrument_id,
7476            cloid: ClientOrderId::new(format!("CLOID-{cloid_suffix}")),
7477        }
7478    }
7479
7480    fn register_identity(rig: &DispatcherRig) {
7481        rig.dispatch.register_order_identity(
7482            rig.cloid,
7483            OrderIdentity {
7484                instrument_id: rig.instrument_id,
7485                strategy_id: strategy_id(),
7486                order_side: OrderSide::Buy,
7487                order_type: OrderType::Limit,
7488            },
7489        );
7490    }
7491
7492    fn dispatcher_test_order(rig: &DispatcherRig, status: LighterOrderStatus) -> LighterOrder {
7493        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
7494        rig.dispatch.register_cloid(derived, rig.cloid);
7495
7496        LighterOrder {
7497            order_index: 281_476_929_510_110,
7498            client_order_index: derived,
7499            order_id: "281476929510110".to_string(),
7500            client_order_id: derived.to_string(),
7501            market_index: TEST_MARKET_INDEX,
7502            owner_account_index: TEST_ACCOUNT_INDEX_I64,
7503            initial_base_amount: Decimal::from_str("0.0050").unwrap(),
7504            price: Decimal::from_str("2352.74").unwrap(),
7505            nonce: 9_182_390_020,
7506            remaining_base_amount: Decimal::from_str("0.0050").unwrap(),
7507            is_ask: false,
7508            base_size: 50,
7509            base_price: 235_274,
7510            filled_base_amount: Decimal::ZERO,
7511            filled_quote_amount: Decimal::ZERO,
7512            side: Some(LighterOrderSide::Buy),
7513            order_type: LighterOrderKind::Limit,
7514            time_in_force: LighterOrderTimeInForce::GoodTillTime,
7515            reduce_only: false,
7516            trigger_price: Decimal::ZERO,
7517            order_expiry: 1_780_360_584_479,
7518            status,
7519            trigger_status: LighterTriggerStatus::Na,
7520            trigger_time: 0,
7521            parent_order_index: 0,
7522            parent_order_id: "0".to_string(),
7523            to_trigger_order_id_0: "0".to_string(),
7524            to_trigger_order_id_1: "0".to_string(),
7525            to_cancel_order_id_0: "0".to_string(),
7526            integrator_fee_collector_index: "0".to_string(),
7527            integrator_taker_fee: Decimal::ZERO,
7528            integrator_maker_fee: Decimal::ZERO,
7529            block_height: 227_535_532,
7530            timestamp: 1_777_941_383_576,
7531            created_at: 1_777_941_383_576,
7532            updated_at: 1_777_941_383_900,
7533            transaction_time: 1_777_941_383_576_735,
7534        }
7535    }
7536
7537    fn dispatcher_test_trade(rig: &DispatcherRig, user_is_bidder: bool) -> LighterTrade {
7538        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
7539        rig.dispatch.register_cloid(derived, rig.cloid);
7540        LighterTrade {
7541            trade_id: 19_209_006_902,
7542            trade_id_str: Some("19209006902".to_string()),
7543            tx_hash: "000000128b1ee814".to_string(),
7544            trade_type: LighterTradeType::Trade,
7545            market_id: TEST_MARKET_INDEX,
7546            size: Decimal::from_str("0.1336").unwrap(),
7547            price: Decimal::from_str("2352.73").unwrap(),
7548            usd_amount: Decimal::from_str("314.324728").unwrap(),
7549            ask_id: 281_476_929_510_102,
7550            ask_id_str: Some("281476929510102".to_string()),
7551            bid_id: 562_947_905_631_053,
7552            bid_id_str: Some("562947905631053".to_string()),
7553            ask_client_id: if user_is_bidder { 0 } else { derived },
7554            ask_client_id_str: Some(if user_is_bidder {
7555                "0".to_string()
7556            } else {
7557                derived.to_string()
7558            }),
7559            bid_client_id: if user_is_bidder { derived } else { 0 },
7560            bid_client_id_str: Some(if user_is_bidder {
7561                derived.to_string()
7562            } else {
7563                "0".to_string()
7564            }),
7565            ask_account_id: if user_is_bidder {
7566                91_249
7567            } else {
7568                TEST_ACCOUNT_INDEX_I64
7569            },
7570            bid_account_id: if user_is_bidder {
7571                TEST_ACCOUNT_INDEX_I64
7572            } else {
7573                91_249
7574            },
7575            is_maker_ask: false,
7576            block_height: 227_535_535,
7577            timestamp: 1_777_941_384_181,
7578            taker_fee: Some(196),
7579            taker_position_size_before: None,
7580            taker_entry_quote_before: None,
7581            taker_initial_margin_fraction_before: None,
7582            taker_position_sign_changed: None,
7583            maker_fee: Some(28),
7584            maker_position_size_before: None,
7585            maker_entry_quote_before: None,
7586            maker_initial_margin_fraction_before: None,
7587            maker_position_sign_changed: None,
7588            transaction_time: 1_777_941_384_181_586,
7589            ask_account_pnl: None,
7590            bid_account_pnl: None,
7591        }
7592    }
7593
7594    /// Drain all pending events from the rig's receiver. Useful when a
7595    /// test wants to assert what landed without timing-sensitive
7596    /// `recv_order_event` waits.
7597    fn drain_events(
7598        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7599    ) -> Vec<ExecutionEvent> {
7600        let mut events = Vec::new();
7601        while let Ok(event) = rx.try_recv() {
7602            events.push(event);
7603        }
7604        events
7605    }
7606
7607    #[rstest]
7608    fn dispatch_lighter_order_tracked_emits_accepted_then_silent_repeat() {
7609        let mut rig = dispatcher_rig("1");
7610        register_identity(&rig);
7611        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
7612
7613        dispatch_lighter_order(
7614            &order,
7615            &rig.dispatch,
7616            &rig.emitter,
7617            &rig.registry,
7618            account_id(),
7619            trader_id(),
7620            UnixNanos::from(1),
7621        );
7622        dispatch_lighter_order(
7623            &order,
7624            &rig.dispatch,
7625            &rig.emitter,
7626            &rig.registry,
7627            account_id(),
7628            trader_id(),
7629            UnixNanos::from(2),
7630        );
7631
7632        let events = drain_events(&mut rig.rx);
7633        assert_eq!(
7634            events.len(),
7635            1,
7636            "exactly one event expected, was {events:?}",
7637        );
7638
7639        match &events[0] {
7640            ExecutionEvent::Order(OrderEventAny::Accepted(e)) => {
7641                assert_eq!(e.client_order_id, rig.cloid);
7642                assert_eq!(e.venue_order_id.to_string(), "281476929510110");
7643            }
7644            other => panic!("expected Accepted, was {other:?}"),
7645        }
7646        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
7647        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_some());
7648    }
7649
7650    #[rstest]
7651    fn dispatch_lighter_order_tracked_emits_updated_on_shape_change() {
7652        let mut rig = dispatcher_rig("2");
7653        register_identity(&rig);
7654        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
7655
7656        dispatch_lighter_order(
7657            &order,
7658            &rig.dispatch,
7659            &rig.emitter,
7660            &rig.registry,
7661            account_id(),
7662            trader_id(),
7663            UnixNanos::from(1),
7664        );
7665        assert_eq!(drain_events(&mut rig.rx).len(), 1);
7666
7667        let mut modified = order;
7668        modified.price = Decimal::from_str("2400.00").unwrap();
7669
7670        dispatch_lighter_order(
7671            &modified,
7672            &rig.dispatch,
7673            &rig.emitter,
7674            &rig.registry,
7675            account_id(),
7676            trader_id(),
7677            UnixNanos::from(2),
7678        );
7679
7680        let events = drain_events(&mut rig.rx);
7681        assert_eq!(
7682            events.len(),
7683            1,
7684            "expected one Updated event, was {events:?}",
7685        );
7686
7687        match &events[0] {
7688            ExecutionEvent::Order(OrderEventAny::Updated(e)) => {
7689                assert_eq!(e.client_order_id, rig.cloid);
7690                assert_eq!(e.price, Some(Price::from("2400.00")));
7691            }
7692            other => panic!("expected Updated, was {other:?}"),
7693        }
7694        let snapshot = rig.dispatch.snapshot_for(&rig.cloid).expect("snapshot");
7695        assert_eq!(snapshot.price, Some(Price::from("2400.00")));
7696    }
7697
7698    #[rstest]
7699    fn dispatch_lighter_order_untracked_emits_report() {
7700        let mut rig = dispatcher_rig("3");
7701        // No identity registered: this is an external order.
7702        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
7703        order.client_order_id = "external-1".to_string();
7704        order.client_order_index = 0;
7705
7706        dispatch_lighter_order(
7707            &order,
7708            &rig.dispatch,
7709            &rig.emitter,
7710            &rig.registry,
7711            account_id(),
7712            trader_id(),
7713            UnixNanos::from(1),
7714        );
7715
7716        let events = drain_events(&mut rig.rx);
7717        assert_eq!(events.len(), 1);
7718        match &events[0] {
7719            ExecutionEvent::Report(report) => match report {
7720                EngineExecutionReport::Order(r) => {
7721                    assert_eq!(r.venue_order_id.to_string(), "281476929510110");
7722                }
7723                other => panic!("expected order report, was {other:?}"),
7724            },
7725            other => panic!("expected report, was {other:?}"),
7726        }
7727        assert!(
7728            !rig.dispatch
7729                .accepted_was_emitted(&ClientOrderId::new("external-1"))
7730        );
7731    }
7732
7733    #[rstest]
7734    fn dispatch_lighter_trade_tracked_synthesizes_accepted_before_filled() {
7735        // Fill-before-open: the trade arrives before the matching Open
7736        // frame. The dispatcher must synthesise `OrderAccepted` first so
7737        // the engine sees the lifecycle in order.
7738        let mut rig = dispatcher_rig("4");
7739        register_identity(&rig);
7740
7741        let trade = dispatcher_test_trade(&rig, true);
7742
7743        dispatch_lighter_trade(
7744            &trade,
7745            &rig.dispatch,
7746            &rig.emitter,
7747            &rig.registry,
7748            account_id(),
7749            trader_id(),
7750            Some(TEST_ACCOUNT_INDEX_I64),
7751            UnixNanos::from(1),
7752        );
7753
7754        let events = drain_events(&mut rig.rx);
7755        assert_eq!(
7756            events.len(),
7757            2,
7758            "expected Accepted then Filled, was {events:?}",
7759        );
7760
7761        match &events[0] {
7762            ExecutionEvent::Order(OrderEventAny::Accepted(_)) => {}
7763            other => panic!("first event should be Accepted, was {other:?}"),
7764        }
7765
7766        match &events[1] {
7767            ExecutionEvent::Order(OrderEventAny::Filled(e)) => {
7768                assert_eq!(e.client_order_id, rig.cloid);
7769                assert_eq!(e.last_qty, Quantity::from("0.1336"));
7770                assert_eq!(e.last_px, Price::from("2352.73"));
7771            }
7772            other => panic!("second event should be Filled, was {other:?}"),
7773        }
7774        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
7775    }
7776
7777    #[rstest]
7778    fn dispatch_lighter_trade_dedupes_repeated_trade_ids() {
7779        let mut rig = dispatcher_rig("5");
7780        register_identity(&rig);
7781        let trade = dispatcher_test_trade(&rig, true);
7782
7783        for _ in 0..3 {
7784            dispatch_lighter_trade(
7785                &trade,
7786                &rig.dispatch,
7787                &rig.emitter,
7788                &rig.registry,
7789                account_id(),
7790                trader_id(),
7791                Some(TEST_ACCOUNT_INDEX_I64),
7792                UnixNanos::from(1),
7793            );
7794        }
7795
7796        let events = drain_events(&mut rig.rx);
7797        // First call: Accepted + Filled. Subsequent calls deduped by trade_id.
7798        assert_eq!(
7799            events.len(),
7800            2,
7801            "expected dedup after first dispatch, was {events:?}"
7802        );
7803    }
7804
7805    #[rstest]
7806    fn dispatch_lighter_trade_parse_failure_rolls_back_dedup() {
7807        // A parse failure must not consume the dedup slot, else the fill is lost on replay
7808        let mut rig = dispatcher_rig("21");
7809        register_identity(&rig);
7810        let mut trade = dispatcher_test_trade(&rig, true);
7811        trade.timestamp = -1;
7812        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
7813
7814        dispatch_lighter_trade(
7815            &trade,
7816            &rig.dispatch,
7817            &rig.emitter,
7818            &rig.registry,
7819            account_id(),
7820            trader_id(),
7821            Some(TEST_ACCOUNT_INDEX_I64),
7822            UnixNanos::from(1),
7823        );
7824
7825        let events = drain_events(&mut rig.rx);
7826        assert!(
7827            !events
7828                .iter()
7829                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
7830            "malformed trade must not emit a fill, was {events:?}",
7831        );
7832        assert!(
7833            rig.dispatch.mark_trade_seen(trade_id),
7834            "parse-failed trade must be re-markable (dedup rolled back)",
7835        );
7836    }
7837
7838    #[rstest]
7839    fn dispatch_lighter_trade_untracked_parse_failure_rolls_back_dedup() {
7840        // Untracked path (no registered identity -> FillReport): dedup rollback must still hold
7841        let mut rig = dispatcher_rig("22");
7842        let mut trade = dispatcher_test_trade(&rig, true);
7843        trade.timestamp = -1;
7844        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
7845
7846        dispatch_lighter_trade(
7847            &trade,
7848            &rig.dispatch,
7849            &rig.emitter,
7850            &rig.registry,
7851            account_id(),
7852            trader_id(),
7853            Some(TEST_ACCOUNT_INDEX_I64),
7854            UnixNanos::from(1),
7855        );
7856
7857        let events = drain_events(&mut rig.rx);
7858        assert!(
7859            events.is_empty(),
7860            "untracked malformed trade must emit nothing, was {events:?}",
7861        );
7862        assert!(
7863            rig.dispatch.mark_trade_seen(trade_id),
7864            "untracked parse-failed trade must be re-markable (dedup rolled back)",
7865        );
7866    }
7867
7868    #[rstest]
7869    fn dispatch_tracked_order_event_terminal_cancel_removes_identity_and_snapshot() {
7870        let mut rig = dispatcher_rig("6");
7871        register_identity(&rig);
7872        rig.dispatch.mark_accepted_emitted(rig.cloid);
7873        rig.dispatch.store_snapshot(
7874            rig.cloid,
7875            crate::websocket::dispatch::OrderShapeSnapshot {
7876                quantity: Quantity::from("0.0050"),
7877                price: Some(Price::from("2352.74")),
7878                trigger_price: None,
7879            },
7880        );
7881
7882        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
7883        order.filled_base_amount = Decimal::ZERO;
7884        dispatch_lighter_order(
7885            &order,
7886            &rig.dispatch,
7887            &rig.emitter,
7888            &rig.registry,
7889            account_id(),
7890            trader_id(),
7891            UnixNanos::from(1),
7892        );
7893
7894        let events = drain_events(&mut rig.rx);
7895        let canceled = events
7896            .iter()
7897            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Canceled(_))))
7898            .expect("expected a Canceled event");
7899        if let ExecutionEvent::Order(OrderEventAny::Canceled(e)) = canceled {
7900            assert_eq!(e.client_order_id, rig.cloid);
7901        }
7902        assert!(!rig.dispatch.order_identities.contains_key(&rig.cloid));
7903        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_none());
7904        assert!(!rig.dispatch.accepted_was_emitted(&rig.cloid));
7905    }
7906
7907    #[rstest]
7908    fn dispatch_tracked_cancel_after_report_seed_skips_synthesized_accept() {
7909        let mut rig = dispatcher_rig("10");
7910        register_identity(&rig);
7911
7912        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
7913        let instrument = LIGHTER_INSTRUMENT_CACHE
7914            .get(&rig.instrument_id)
7915            .expect("instrument cached");
7916        let report = parse_ws_order_status_report(
7917            &report_order,
7918            instrument.value(),
7919            account_id(),
7920            UnixNanos::from(1),
7921        )
7922        .map(|report| translate_order_cloid(report, &rig.dispatch.cloid_map))
7923        .expect("report parses");
7924
7925        assert_eq!(report.order_status, OrderStatus::Accepted);
7926        rig.dispatch.seed_accepted_from_report(&report);
7927
7928        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
7929        cancel_order.filled_base_amount = Decimal::ZERO;
7930        dispatch_lighter_order(
7931            &cancel_order,
7932            &rig.dispatch,
7933            &rig.emitter,
7934            &rig.registry,
7935            account_id(),
7936            trader_id(),
7937            UnixNanos::from(2),
7938        );
7939
7940        let events = drain_events(&mut rig.rx);
7941        assert_eq!(
7942            events.len(),
7943            1,
7944            "report-seeded cancel should emit only Canceled, was {events:?}",
7945        );
7946        assert!(
7947            !events
7948                .iter()
7949                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_)))),
7950            "typed cancel must not synthesize a second Accepted",
7951        );
7952
7953        match &events[0] {
7954            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
7955                assert_eq!(e.client_order_id, rig.cloid);
7956                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
7957            }
7958            other => panic!("expected Canceled, was {other:?}"),
7959        }
7960    }
7961
7962    #[rstest]
7963    fn dispatch_tracked_cancel_after_submitted_report_seed_skips_synthesized_accept() {
7964        let mut rig = dispatcher_rig("11");
7965        register_identity(&rig);
7966
7967        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
7968        let instrument = LIGHTER_INSTRUMENT_CACHE
7969            .get(&rig.instrument_id)
7970            .expect("instrument cached");
7971        let mut report = parse_ws_order_status_report(
7972            &report_order,
7973            instrument.value(),
7974            account_id(),
7975            UnixNanos::from(1),
7976        )
7977        .map(|report| translate_order_cloid(report, &rig.dispatch.cloid_map))
7978        .expect("report parses");
7979        report.order_status = OrderStatus::Submitted;
7980
7981        rig.dispatch.seed_accepted_from_report(&report);
7982        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
7983
7984        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
7985        cancel_order.filled_base_amount = Decimal::ZERO;
7986
7987        dispatch_lighter_order(
7988            &cancel_order,
7989            &rig.dispatch,
7990            &rig.emitter,
7991            &rig.registry,
7992            account_id(),
7993            trader_id(),
7994            UnixNanos::from(2),
7995        );
7996
7997        let events = drain_events(&mut rig.rx);
7998        assert_eq!(
7999            events.len(),
8000            1,
8001            "Cancel after Submitted report should emit only Canceled, was {events:?}",
8002        );
8003
8004        match &events[0] {
8005            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
8006                assert_eq!(e.client_order_id, rig.cloid);
8007                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
8008            }
8009            other => panic!("expected Canceled, was {other:?}"),
8010        }
8011    }
8012
8013    #[rstest]
8014    fn dispatch_tracked_order_event_accept_dedup_is_idempotent() {
8015        let mut rig = dispatcher_rig("7");
8016        register_identity(&rig);
8017        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
8018
8019        // First dispatch emits Accepted. Second dispatch must be silent
8020        // (no shape change) and not re-emit Accepted.
8021        dispatch_lighter_order(
8022            &order,
8023            &rig.dispatch,
8024            &rig.emitter,
8025            &rig.registry,
8026            account_id(),
8027            trader_id(),
8028            UnixNanos::from(1),
8029        );
8030        dispatch_lighter_order(
8031            &order,
8032            &rig.dispatch,
8033            &rig.emitter,
8034            &rig.registry,
8035            account_id(),
8036            trader_id(),
8037            UnixNanos::from(2),
8038        );
8039
8040        let events = drain_events(&mut rig.rx);
8041        let accepted_count = events
8042            .iter()
8043            .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_))))
8044            .count();
8045        assert_eq!(accepted_count, 1, "Accepted must be emitted exactly once");
8046    }
8047
8048    #[rstest]
8049    fn dispatch_lighter_order_drops_when_instrument_uncached() {
8050        // Construct a rig but use a market_index the registry does not know.
8051        let registry = Arc::new(MarketRegistry::new());
8052        let (emitter, mut rx) = dispatcher_emitter();
8053        let dispatch = WsDispatchState::new();
8054        let cloid = ClientOrderId::new("CLOID-MISSING");
8055        dispatch.register_order_identity(
8056            cloid,
8057            OrderIdentity {
8058                instrument_id: InstrumentId::from("MISSING-PERP.LIGHTER"),
8059                strategy_id: strategy_id(),
8060                order_side: OrderSide::Buy,
8061                order_type: OrderType::Limit,
8062            },
8063        );
8064        let mut order = LighterOrder {
8065            order_index: 1,
8066            client_order_index: 1,
8067            order_id: "1".to_string(),
8068            client_order_id: "1".to_string(),
8069            market_index: 999, // not in registry
8070            owner_account_index: TEST_ACCOUNT_INDEX_I64,
8071            initial_base_amount: Decimal::ZERO,
8072            price: Decimal::ZERO,
8073            nonce: 0,
8074            remaining_base_amount: Decimal::ZERO,
8075            is_ask: false,
8076            base_size: 0,
8077            base_price: 0,
8078            filled_base_amount: Decimal::ZERO,
8079            filled_quote_amount: Decimal::ZERO,
8080            side: Some(LighterOrderSide::Buy),
8081            order_type: LighterOrderKind::Limit,
8082            time_in_force: LighterOrderTimeInForce::GoodTillTime,
8083            reduce_only: false,
8084            trigger_price: Decimal::ZERO,
8085            order_expiry: 0,
8086            status: LighterOrderStatus::Open,
8087            trigger_status: LighterTriggerStatus::Na,
8088            trigger_time: 0,
8089            parent_order_index: 0,
8090            parent_order_id: "0".to_string(),
8091            to_trigger_order_id_0: "0".to_string(),
8092            to_trigger_order_id_1: "0".to_string(),
8093            to_cancel_order_id_0: "0".to_string(),
8094            integrator_fee_collector_index: "0".to_string(),
8095            integrator_taker_fee: Decimal::ZERO,
8096            integrator_maker_fee: Decimal::ZERO,
8097            block_height: 0,
8098            timestamp: 0,
8099            created_at: 0,
8100            updated_at: 0,
8101            transaction_time: 0,
8102        };
8103        order.client_order_id = "1".to_string();
8104
8105        dispatch_lighter_order(
8106            &order,
8107            &dispatch,
8108            &emitter,
8109            &registry,
8110            account_id(),
8111            trader_id(),
8112            UnixNanos::from(1),
8113        );
8114
8115        let events = drain_events(&mut rx);
8116        assert!(
8117            events.is_empty(),
8118            "no event for uncached instrument, was {events:?}"
8119        );
8120        assert!(dispatch.order_identities.contains_key(&cloid));
8121    }
8122
8123    #[rstest]
8124    fn dispatch_lighter_trade_filters_non_account_trades_defensively() {
8125        let mut rig = dispatcher_rig("8");
8126        // Trade involves accounts 91249 and 91250, but the supplied
8127        // account_index is TEST_ACCOUNT_INDEX_I64 (12345). The handler is
8128        // the first defensive filter; this verifies the dispatcher path
8129        // also drops foreign trades cleanly.
8130        let mut trade = dispatcher_test_trade(&rig, true);
8131        trade.bid_account_id = 91_249;
8132        trade.ask_account_id = 91_250;
8133
8134        dispatch_lighter_trade(
8135            &trade,
8136            &rig.dispatch,
8137            &rig.emitter,
8138            &rig.registry,
8139            account_id(),
8140            trader_id(),
8141            Some(TEST_ACCOUNT_INDEX_I64),
8142            UnixNanos::from(1),
8143        );
8144
8145        let events = drain_events(&mut rig.rx);
8146        assert!(
8147            events.is_empty(),
8148            "foreign trade must produce no event, was {events:?}"
8149        );
8150        assert!(
8151            !rig.dispatch
8152                .seen_trade_ids
8153                .contains(&TradeId::new("19209006902"),)
8154        );
8155    }
8156
8157    #[rstest]
8158    fn register_cloid_in_submit_path_uses_probed_index_on_collision() {
8159        // Forcing a collision at the derived index for a fresh cloid must
8160        // result in `register_cloid` returning a different (probed) index;
8161        // the submit path uses this returned value as the venue-side
8162        // client_order_index, so it must be the probed one.
8163        let dispatch = WsDispatchState::new();
8164        let cloid = ClientOrderId::new("PROBE-CLOID");
8165        let derived = dispatch.derive_client_order_index(&cloid);
8166
8167        let intruder = ClientOrderId::new("INTRUDER");
8168        dispatch.cloid_map.insert(derived, intruder);
8169
8170        let chosen = dispatch.register_cloid(derived, cloid);
8171
8172        assert_ne!(chosen, derived);
8173        assert_eq!(
8174            dispatch.cloid_map.get(&derived).map(|e| *e.value()),
8175            Some(intruder),
8176        );
8177        assert_eq!(
8178            dispatch.cloid_map.get(&chosen).map(|e| *e.value()),
8179            Some(cloid),
8180        );
8181    }
8182
8183    #[rstest]
8184    fn dispatch_lighter_order_seeds_snapshot_after_synthesized_accept() {
8185        // After a synthesised `OrderAccepted` (fill-before-open), the
8186        // next `Open` frame must seed the shape snapshot even when the
8187        // parser returns None. Without the seed, shape_changed stays
8188        // permanently false and a later modify is lost.
8189        let mut rig = dispatcher_rig("9");
8190        register_identity(&rig);
8191
8192        let trade = dispatcher_test_trade(&rig, true);
8193        dispatch_lighter_trade(
8194            &trade,
8195            &rig.dispatch,
8196            &rig.emitter,
8197            &rig.registry,
8198            account_id(),
8199            trader_id(),
8200            Some(TEST_ACCOUNT_INDEX_I64),
8201            UnixNanos::from(1),
8202        );
8203        assert_eq!(drain_events(&mut rig.rx).len(), 2);
8204        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
8205        assert!(
8206            rig.dispatch.snapshot_for(&rig.cloid).is_none(),
8207            "synthesised Accept has no snapshot until the Open frame seeds one",
8208        );
8209
8210        // Open frame lands later (matches venue ordering). Parser
8211        // returns None (already accepted, shape unchanged) but the
8212        // dispatcher must still seed the snapshot baseline.
8213        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
8214        dispatch_lighter_order(
8215            &order,
8216            &rig.dispatch,
8217            &rig.emitter,
8218            &rig.registry,
8219            account_id(),
8220            trader_id(),
8221            UnixNanos::from(2),
8222        );
8223        assert!(
8224            rig.dispatch.snapshot_for(&rig.cloid).is_some(),
8225            "Open frame after synthesised accept must seed the snapshot",
8226        );
8227
8228        // A real modify must now fire Updated.
8229        let mut modified = order;
8230        modified.price = Decimal::from_str("2400.00").unwrap();
8231        dispatch_lighter_order(
8232            &modified,
8233            &rig.dispatch,
8234            &rig.emitter,
8235            &rig.registry,
8236            account_id(),
8237            trader_id(),
8238            UnixNanos::from(3),
8239        );
8240        let events = drain_events(&mut rig.rx);
8241        let updated = events
8242            .iter()
8243            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Updated(_))));
8244        assert!(
8245            updated.is_some(),
8246            "real modify must produce Updated, events={events:?}"
8247        );
8248    }
8249
8250    fn enqueue_create(client: &LighterExecutionClient, order: &OrderAny, nonce: i64) -> i64 {
8251        let client_order_index = client
8252            .dispatch
8253            .derive_client_order_index(&order.client_order_id());
8254        client
8255            .dispatch
8256            .register_cloid(client_order_index, order.client_order_id());
8257        client.dispatch.register_order_identity(
8258            order.client_order_id(),
8259            OrderIdentity {
8260                instrument_id: order.instrument_id(),
8261                strategy_id: order.strategy_id(),
8262                order_side: order.order_side(),
8263                order_type: order.order_type(),
8264            },
8265        );
8266        let now = UnixNanos::from(1_000_000_000);
8267        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8268            kind: PendingSendTxKind::Create {
8269                order: Box::new(order.clone()),
8270                client_order_index,
8271            },
8272            submitted_at: now,
8273            nonce,
8274            api_key_index: TEST_API_KEY_INDEX,
8275            tx_hash: format!("hash{nonce:02x}"),
8276        });
8277        client.dispatch.nonce_manager.refresh(
8278            TEST_ACCOUNT_INDEX_I64,
8279            TEST_API_KEY_INDEX,
8280            nonce + 1,
8281        );
8282        let _ = client
8283            .dispatch
8284            .nonce_manager
8285            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX);
8286        client_order_index
8287    }
8288
8289    fn enqueue_other(client: &LighterExecutionClient, nonce: i64) {
8290        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8291            kind: PendingSendTxKind::Other,
8292            submitted_at: UnixNanos::from(1_000_000_000),
8293            nonce,
8294            api_key_index: TEST_API_KEY_INDEX,
8295            tx_hash: format!("hash{nonce:02x}"),
8296        });
8297    }
8298
8299    fn enqueue_cancel(
8300        client: &LighterExecutionClient,
8301        instrument_id: InstrumentId,
8302        client_order_id: ClientOrderId,
8303        venue_order_id: VenueOrderId,
8304        nonce: i64,
8305    ) {
8306        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8307            kind: PendingSendTxKind::Cancel {
8308                strategy_id: strategy_id(),
8309                instrument_id,
8310                client_order_id,
8311                venue_order_id: Some(venue_order_id),
8312            },
8313            submitted_at: UnixNanos::from(1_000_000_000),
8314            nonce,
8315            api_key_index: TEST_API_KEY_INDEX,
8316            tx_hash: format!("hash{nonce:02x}"),
8317        });
8318    }
8319
8320    fn enqueue_modify(
8321        client: &LighterExecutionClient,
8322        instrument_id: InstrumentId,
8323        client_order_id: ClientOrderId,
8324        venue_order_id: VenueOrderId,
8325        nonce: i64,
8326    ) {
8327        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8328            kind: PendingSendTxKind::Modify {
8329                strategy_id: strategy_id(),
8330                instrument_id,
8331                client_order_id,
8332                venue_order_id: Some(venue_order_id),
8333            },
8334            submitted_at: UnixNanos::from(1_000_000_000),
8335            nonce,
8336            api_key_index: TEST_API_KEY_INDEX,
8337            tx_hash: format!("hash{nonce:02x}"),
8338        });
8339    }
8340
8341    #[tokio::test]
8342    async fn handle_send_tx_ack_removes_hash_matched_entry() {
8343        let (client, cache, mut rx) = create_execution_client();
8344        let instrument_id = register_test_instrument(&client, &cache);
8345        let mut factory = test_order_factory();
8346        let order_a = test_limit_order(&mut factory, instrument_id, "ACK-A");
8347        let order_b = test_limit_order(&mut factory, instrument_id, "ACK-B");
8348        enqueue_create(&client, &order_a, 10);
8349        enqueue_create(&client, &order_b, 11);
8350
8351        // Out-of-order ack: B's hash must remove B even though A is at head.
8352        let acked = handle_send_tx_ack(
8353            &client.dispatch,
8354            Some(TEST_ACCOUNT_INDEX_I64),
8355            200,
8356            Some("hash0b"),
8357        );
8358
8359        assert!(matches!(
8360            acked.map(|pending| pending.kind),
8361            Some(PendingSendTxKind::Create { .. }),
8362        ));
8363        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "only B pops");
8364        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
8365        match head.kind {
8366            PendingSendTxKind::Create { order, .. } => {
8367                assert_eq!(order.client_order_id(), order_a.client_order_id());
8368            }
8369            _ => panic!("expected Create kind"),
8370        }
8371        assert!(
8372            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8373                .await
8374                .is_err(),
8375            "ack must not emit an event",
8376        );
8377    }
8378
8379    #[tokio::test]
8380    async fn handle_send_tx_ack_unmatched_hash_pops_nothing() {
8381        let (client, cache, mut rx) = create_execution_client();
8382        let instrument_id = register_test_instrument(&client, &cache);
8383        let mut factory = test_order_factory();
8384        let order = test_limit_order(&mut factory, instrument_id, "ACK-UNMATCHED");
8385        enqueue_create(&client, &order, 10);
8386
8387        let acked = handle_send_tx_ack(
8388            &client.dispatch,
8389            Some(TEST_ACCOUNT_INDEX_I64),
8390            200,
8391            Some("0xabc"),
8392        );
8393
8394        assert!(acked.is_none());
8395        assert_eq!(
8396            client.dispatch.pending_sendtx_len(),
8397            1,
8398            "an echoed hash with no matching entry must not pop the head",
8399        );
8400        assert!(
8401            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8402                .await
8403                .is_err(),
8404            "unmatched ack must not emit an event",
8405        );
8406    }
8407
8408    #[tokio::test]
8409    async fn prepared_create_tx_hash_round_trips_through_ack() {
8410        // The hash prepare threads into the queue must be the lowercase hex
8411        // form a venue ack echoes, or every live ack goes unattributed.
8412        let (client, cache, _rx) = create_execution_client();
8413        let instrument_id = register_test_instrument(&client, &cache);
8414        let mut factory = test_order_factory();
8415        let order = test_limit_order(&mut factory, instrument_id, "ACK-REAL-HASH");
8416
8417        let credential = test_credential();
8418        let prepared = client
8419            .prepare_signed_create_order(&order, &credential, 0)
8420            .expect("prepare must sign");
8421
8422        assert_eq!(prepared.tx_hash.len(), TX_HASH_BYTES * 2);
8423        assert!(
8424            prepared
8425                .tx_hash
8426                .chars()
8427                .all(|c| matches!(c, '0'..='9' | 'a'..='f')),
8428            "tx_hash must be lowercase hex, was `{}`",
8429            prepared.tx_hash,
8430        );
8431
8432        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8433            kind: PendingSendTxKind::Create {
8434                order: Box::new(order),
8435                client_order_index: prepared.client_order_index,
8436            },
8437            submitted_at: UnixNanos::from(1_000_000_000),
8438            nonce: prepared.nonce,
8439            api_key_index: prepared.api_key_index,
8440            tx_hash: prepared.tx_hash.clone(),
8441        });
8442
8443        let acked = handle_send_tx_ack(
8444            &client.dispatch,
8445            Some(TEST_ACCOUNT_INDEX_I64),
8446            200,
8447            Some(&prepared.tx_hash),
8448        );
8449
8450        assert!(acked.is_some());
8451        assert_eq!(
8452            client.dispatch.pending_sendtx_len(),
8453            0,
8454            "venue echo of the signed hash must match the enqueued entry",
8455        );
8456    }
8457
8458    #[tokio::test]
8459    async fn handle_send_tx_ack_returns_cancel_for_noop_probe() {
8460        let (client, cache, mut rx) = create_execution_client();
8461        let instrument_id = register_test_instrument(&client, &cache);
8462        let client_order_id = ClientOrderId::from("ACK-CANCEL-NOOP");
8463        let venue_order_id = VenueOrderId::from("123");
8464        enqueue_cancel(&client, instrument_id, client_order_id, venue_order_id, 12);
8465
8466        let acked = handle_send_tx_ack(
8467            &client.dispatch,
8468            Some(TEST_ACCOUNT_INDEX_I64),
8469            200,
8470            Some("hash0c"),
8471        )
8472        .expect("acked cancel pending entry");
8473        let probe = AckedOrderProbe::from_pending(&acked).expect("cancel should schedule probe");
8474
8475        match probe {
8476            AckedOrderProbe::Cancel {
8477                strategy_id: actual_strategy_id,
8478                instrument_id: actual_instrument_id,
8479                client_order_id: actual_client_order_id,
8480                venue_order_id: actual_venue_order_id,
8481            } => {
8482                assert_eq!(actual_strategy_id, strategy_id());
8483                assert_eq!(actual_instrument_id, instrument_id);
8484                assert_eq!(actual_client_order_id, client_order_id);
8485                assert_eq!(actual_venue_order_id, Some(venue_order_id));
8486            }
8487            other => panic!("expected cancel probe, was {other:?}"),
8488        }
8489        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8490        assert!(
8491            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8492                .await
8493                .is_err(),
8494            "ack itself must not emit before the no-op probe runs",
8495        );
8496    }
8497
8498    #[tokio::test]
8499    async fn handle_send_tx_ack_returns_modify_for_noop_probe() {
8500        let (client, cache, mut rx) = create_execution_client();
8501        let instrument_id = register_test_instrument(&client, &cache);
8502        let client_order_id = ClientOrderId::from("ACK-MODIFY-NOOP");
8503        let venue_order_id = VenueOrderId::from("456");
8504        enqueue_modify(&client, instrument_id, client_order_id, venue_order_id, 13);
8505
8506        let acked = handle_send_tx_ack(
8507            &client.dispatch,
8508            Some(TEST_ACCOUNT_INDEX_I64),
8509            200,
8510            Some("hash0d"),
8511        )
8512        .expect("acked modify pending entry");
8513        let probe = AckedOrderProbe::from_pending(&acked).expect("modify should schedule probe");
8514
8515        match probe {
8516            AckedOrderProbe::Modify {
8517                strategy_id: actual_strategy_id,
8518                instrument_id: actual_instrument_id,
8519                client_order_id: actual_client_order_id,
8520                venue_order_id: actual_venue_order_id,
8521            } => {
8522                assert_eq!(actual_strategy_id, strategy_id());
8523                assert_eq!(actual_instrument_id, instrument_id);
8524                assert_eq!(actual_client_order_id, client_order_id);
8525                assert_eq!(actual_venue_order_id, Some(venue_order_id));
8526            }
8527            other => panic!("expected modify probe, was {other:?}"),
8528        }
8529        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
8530        assert!(
8531            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8532                .await
8533                .is_err(),
8534            "ack itself must not emit before the no-op probe runs",
8535        );
8536    }
8537
8538    #[tokio::test]
8539    async fn ack_noop_probe_missing_cancel_emits_cancel_rejected() {
8540        let (client, cache, mut rx) = create_execution_client();
8541        let instrument_id = register_test_instrument(&client, &cache);
8542        let client_order_id = ClientOrderId::from("ACK-CANCEL-MISSING");
8543        let venue_order_id = VenueOrderId::from("123");
8544        let probe = AckedOrderProbe::Cancel {
8545            strategy_id: strategy_id(),
8546            instrument_id,
8547            client_order_id,
8548            venue_order_id: Some(venue_order_id),
8549        };
8550
8551        assert!(emit_ack_noop_rejection_if_missing(
8552            &probe,
8553            false,
8554            &client.emitter,
8555            UnixNanos::from(1),
8556        ));
8557
8558        let event = recv_order_event(&mut rx).await;
8559        match event {
8560            OrderEventAny::CancelRejected(e) => {
8561                assert_eq!(e.client_order_id, client_order_id);
8562                assert_eq!(e.instrument_id, instrument_id);
8563                assert_eq!(e.venue_order_id, Some(venue_order_id));
8564                assert!(e.reason.as_str().contains("order not found"));
8565            }
8566            other => panic!("expected CancelRejected, was {other:?}"),
8567        }
8568    }
8569
8570    #[tokio::test]
8571    async fn ack_noop_probe_missing_modify_emits_modify_rejected() {
8572        let (client, cache, mut rx) = create_execution_client();
8573        let instrument_id = register_test_instrument(&client, &cache);
8574        let client_order_id = ClientOrderId::from("ACK-MODIFY-MISSING");
8575        let venue_order_id = VenueOrderId::from("456");
8576        let probe = AckedOrderProbe::Modify {
8577            strategy_id: strategy_id(),
8578            instrument_id,
8579            client_order_id,
8580            venue_order_id: Some(venue_order_id),
8581        };
8582
8583        assert!(emit_ack_noop_rejection_if_missing(
8584            &probe,
8585            false,
8586            &client.emitter,
8587            UnixNanos::from(1),
8588        ));
8589
8590        let event = recv_order_event(&mut rx).await;
8591        match event {
8592            OrderEventAny::ModifyRejected(e) => {
8593                assert_eq!(e.client_order_id, client_order_id);
8594                assert_eq!(e.instrument_id, instrument_id);
8595                assert_eq!(e.venue_order_id, Some(venue_order_id));
8596                assert!(e.reason.as_str().contains("order not found"));
8597            }
8598            other => panic!("expected ModifyRejected, was {other:?}"),
8599        }
8600    }
8601
8602    #[tokio::test]
8603    async fn ack_noop_probe_found_order_skips_rejection() {
8604        let (client, cache, mut rx) = create_execution_client();
8605        let instrument_id = register_test_instrument(&client, &cache);
8606        let probe = AckedOrderProbe::Cancel {
8607            strategy_id: strategy_id(),
8608            instrument_id,
8609            client_order_id: ClientOrderId::from("ACK-CANCEL-FOUND"),
8610            venue_order_id: Some(VenueOrderId::from("123")),
8611        };
8612
8613        assert!(!emit_ack_noop_rejection_if_missing(
8614            &probe,
8615            true,
8616            &client.emitter,
8617            UnixNanos::from(1),
8618        ));
8619        assert!(
8620            tokio::time::timeout(Duration::from_millis(50), rx.recv())
8621                .await
8622                .is_err(),
8623            "found acked order must not emit a no-op rejection",
8624        );
8625    }
8626
8627    #[tokio::test]
8628    async fn send_tx_acks_recover_skip_window_past_window_size() {
8629        let (client, _cache, _rx) = create_execution_client();
8630        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8631
8632        for _ in 0..window {
8633            let nonce = client
8634                .dispatch
8635                .nonce_manager
8636                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8637                .unwrap();
8638            enqueue_other(&client, nonce);
8639        }
8640        assert!(
8641            client
8642                .dispatch
8643                .nonce_manager
8644                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8645                .is_err(),
8646            "window must trip after {window} unacked txs",
8647        );
8648
8649        // Each venue ack reopens one slot, carrying issuance past 2x the
8650        // window without a refresh.
8651        for i in 0..window {
8652            let acked =
8653                handle_send_tx_ack(&client.dispatch, Some(TEST_ACCOUNT_INDEX_I64), 200, None);
8654            assert!(matches!(
8655                acked.map(|pending| pending.kind),
8656                Some(PendingSendTxKind::Other),
8657            ));
8658            let nonce = client
8659                .dispatch
8660                .nonce_manager
8661                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8662                .unwrap();
8663            assert_eq!(nonce, TEST_NEXT_NONCE + window + i);
8664            enqueue_other(&client, nonce);
8665        }
8666    }
8667
8668    #[tokio::test]
8669    async fn handle_send_tx_rejection_rolls_back_latest_nonce() {
8670        let (client, cache, mut rx) = create_execution_client();
8671        let instrument_id = register_test_instrument(&client, &cache);
8672        let mut factory = test_order_factory();
8673        let order = test_limit_order(&mut factory, instrument_id, "REJECT-LATEST");
8674
8675        let nonce = client
8676            .dispatch
8677            .nonce_manager
8678            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8679            .unwrap();
8680        let client_order_index = client
8681            .dispatch
8682            .derive_client_order_index(&order.client_order_id());
8683        client
8684            .dispatch
8685            .register_cloid(client_order_index, order.client_order_id());
8686        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8687            kind: PendingSendTxKind::Create {
8688                order: Box::new(order.clone()),
8689                client_order_index,
8690            },
8691            submitted_at: UnixNanos::from(1_000_000_000),
8692            nonce,
8693            api_key_index: TEST_API_KEY_INDEX,
8694            tx_hash: format!("hash{nonce:02x}"),
8695        });
8696
8697        handle_send_tx_rejection(
8698            &client.dispatch,
8699            &client.emitter,
8700            Some(TEST_ACCOUNT_INDEX_I64),
8701            UnixNanos::from(1_000_000_000),
8702            SendTxRejectionSource::Ack,
8703            Some(21702),
8704            "invalid price",
8705            None,
8706        );
8707
8708        let event = recv_order_event(&mut rx).await;
8709        assert!(matches!(event, OrderEventAny::Rejected(_)));
8710        assert_nonce_reusable(&client.dispatch);
8711    }
8712
8713    #[tokio::test]
8714    async fn handle_send_tx_rejection_with_newer_issuance_skips_rollback() {
8715        let (client, cache, mut rx) = create_execution_client();
8716        let instrument_id = register_test_instrument(&client, &cache);
8717        let mut factory = test_order_factory();
8718        let order = test_limit_order(&mut factory, instrument_id, "REJECT-STALE");
8719
8720        let rejected_nonce = client
8721            .dispatch
8722            .nonce_manager
8723            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8724            .unwrap();
8725        let newer_nonce = client
8726            .dispatch
8727            .nonce_manager
8728            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8729            .unwrap();
8730        let client_order_index = client
8731            .dispatch
8732            .derive_client_order_index(&order.client_order_id());
8733        client
8734            .dispatch
8735            .register_cloid(client_order_index, order.client_order_id());
8736        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
8737            kind: PendingSendTxKind::Create {
8738                order: Box::new(order.clone()),
8739                client_order_index,
8740            },
8741            submitted_at: UnixNanos::from(1_000_000_000),
8742            nonce: rejected_nonce,
8743            api_key_index: TEST_API_KEY_INDEX,
8744            tx_hash: format!("hash{rejected_nonce:02x}"),
8745        });
8746
8747        handle_send_tx_rejection(
8748            &client.dispatch,
8749            &client.emitter,
8750            Some(TEST_ACCOUNT_INDEX_I64),
8751            UnixNanos::from(1_000_000_000),
8752            SendTxRejectionSource::Ack,
8753            Some(21702),
8754            "invalid price",
8755            None,
8756        );
8757
8758        let event = recv_order_event(&mut rx).await;
8759        assert!(matches!(event, OrderEventAny::Rejected(_)));
8760        // The newer nonce is signed into an in-flight tx: the failed nonce
8761        // must not be freed for reissue.
8762        assert_eq!(
8763            client
8764                .dispatch
8765                .nonce_manager
8766                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
8767            Some(newer_nonce),
8768        );
8769        assert_eq!(
8770            client
8771                .dispatch
8772                .nonce_manager
8773                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8774                .unwrap(),
8775            newer_nonce + 1,
8776        );
8777    }
8778
8779    async fn spawn_next_nonce_server(nonce: i64) -> String {
8780        let body = serde_json::to_string(&LighterNextNonce {
8781            code: 200,
8782            message: None,
8783            nonce,
8784        })
8785        .unwrap();
8786        let app = Router::new().route(
8787            "/api/v1/nextNonce",
8788            get(move || {
8789                let body = body.clone();
8790                async move { body }
8791            }),
8792        );
8793        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8794        let addr = listener.local_addr().unwrap();
8795        tokio::spawn(async move {
8796            axum::serve(listener, app).await.unwrap();
8797        });
8798
8799        format!("http://{addr}")
8800    }
8801
8802    #[tokio::test]
8803    async fn skip_window_exhaustion_resyncs_baseline_from_venue() {
8804        let venue_next_nonce = 100;
8805        let mut config = test_config();
8806        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
8807        let (client, _cache, _rx) = create_execution_client_with_config(config);
8808        let credential = test_credential();
8809
8810        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8811        for _ in 0..window {
8812            client
8813                .dispatch
8814                .nonce_manager
8815                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8816                .unwrap();
8817        }
8818
8819        let err = client
8820            .build_tx_context(&credential)
8821            .expect_err("window must trip");
8822        assert!(
8823            err.to_string().contains("skip-window exhausted"),
8824            "unexpected error, was {err}",
8825        );
8826
8827        wait_for_spawned_tasks(&client).await;
8828
8829        assert_eq!(
8830            client
8831                .dispatch
8832                .nonce_manager
8833                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
8834            Some(venue_next_nonce - 1),
8835            "venue resync must advance the baseline",
8836        );
8837        assert!(
8838            !client.nonce_recovery_inflight.load(Ordering::Acquire),
8839            "recovery latch must release for the next exhaustion",
8840        );
8841        let context = client.build_tx_context(&credential).unwrap().context;
8842        assert_eq!(
8843            context.nonce, venue_next_nonce,
8844            "allocation must resume at the venue nonce",
8845        );
8846    }
8847
8848    #[tokio::test]
8849    async fn skip_window_recovery_fetch_failure_releases_latch() {
8850        // 404 fails the fetch fast; unroutable addresses retry past the task wait
8851        let app = Router::new();
8852        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8853        let addr = listener.local_addr().unwrap();
8854        tokio::spawn(async move {
8855            axum::serve(listener, app).await.unwrap();
8856        });
8857        let mut config = test_config();
8858        config.base_url_http = Some(format!("http://{addr}"));
8859        let (client, _cache, _rx) = create_execution_client_with_config(config);
8860        let credential = test_credential();
8861
8862        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8863        for _ in 0..window {
8864            client
8865                .dispatch
8866                .nonce_manager
8867                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8868                .unwrap();
8869        }
8870        client
8871            .build_tx_context(&credential)
8872            .expect_err("window must trip");
8873
8874        wait_for_spawned_tasks(&client).await;
8875
8876        assert_eq!(
8877            client
8878                .dispatch
8879                .nonce_manager
8880                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
8881            Some(TEST_NEXT_NONCE - 1),
8882            "failed venue fetch must leave the baseline unchanged",
8883        );
8884        assert!(
8885            !client.nonce_recovery_inflight.load(Ordering::Acquire),
8886            "failed venue fetch must release the recovery latch",
8887        );
8888    }
8889
8890    #[tokio::test]
8891    async fn skip_window_recovery_dedupes_concurrent_fetches() {
8892        let hits = Arc::new(AtomicUsize::new(0));
8893        let server_hits = Arc::clone(&hits);
8894        let body = serde_json::to_string(&LighterNextNonce {
8895            code: 200,
8896            message: None,
8897            nonce: 100,
8898        })
8899        .unwrap();
8900        let app = Router::new().route(
8901            "/api/v1/nextNonce",
8902            get(move || {
8903                server_hits.fetch_add(1, Ordering::AcqRel);
8904                let body = body.clone();
8905                async move { body }
8906            }),
8907        );
8908        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8909        let addr = listener.local_addr().unwrap();
8910        tokio::spawn(async move {
8911            axum::serve(listener, app).await.unwrap();
8912        });
8913
8914        let mut config = test_config();
8915        config.base_url_http = Some(format!("http://{addr}"));
8916        let (client, _cache, _rx) = create_execution_client_with_config(config);
8917        let credential = test_credential();
8918
8919        let window = i64::from(client.dispatch.nonce_manager.skip_window());
8920        for _ in 0..window {
8921            client
8922                .dispatch
8923                .nonce_manager
8924                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8925                .unwrap();
8926        }
8927
8928        // Back-to-back exhaustions land inside one HTTP round trip; the
8929        // latch must collapse them into a single fetch.
8930        client
8931            .build_tx_context(&credential)
8932            .expect_err("window must trip");
8933        client
8934            .build_tx_context(&credential)
8935            .expect_err("window must still be exhausted");
8936
8937        wait_for_spawned_tasks(&client).await;
8938
8939        assert_eq!(
8940            hits.load(Ordering::Acquire),
8941            1,
8942            "burst exhaustion must trigger a single venue fetch",
8943        );
8944        assert!(!client.nonce_recovery_inflight.load(Ordering::Acquire));
8945    }
8946
8947    #[tokio::test]
8948    async fn refresh_nonce_releases_recovery_latch() {
8949        let venue_next_nonce = 77;
8950        let mut config = test_config();
8951        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
8952        let (client, _cache, _rx) = create_execution_client_with_config(config);
8953
8954        // Simulate a recovery task aborted between latch set and clear
8955        client
8956            .nonce_recovery_inflight
8957            .store(true, Ordering::Release);
8958
8959        client.refresh_nonce().await.unwrap();
8960
8961        assert!(
8962            !client.nonce_recovery_inflight.load(Ordering::Acquire),
8963            "connect-time refresh must release a stuck recovery latch",
8964        );
8965        assert_eq!(
8966            client
8967                .dispatch
8968                .nonce_manager
8969                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
8970                .unwrap(),
8971            venue_next_nonce,
8972            "refresh must hard-reset allocation to the venue nonce",
8973        );
8974    }
8975
8976    #[tokio::test]
8977    async fn handle_send_tx_rejection_ack_create_emits_order_rejected() {
8978        let (client, cache, mut rx) = create_execution_client();
8979        let instrument_id = register_test_instrument(&client, &cache);
8980        let mut factory = test_order_factory();
8981        let order = test_limit_order(&mut factory, instrument_id, "REJECT-CREATE");
8982        let client_order_index = enqueue_create(&client, &order, 42);
8983
8984        handle_send_tx_rejection(
8985            &client.dispatch,
8986            &client.emitter,
8987            Some(TEST_ACCOUNT_INDEX_I64),
8988            UnixNanos::from(1_000_000_000),
8989            SendTxRejectionSource::Ack,
8990            Some(21702),
8991            "invalid price",
8992            None,
8993        );
8994
8995        let event = recv_order_event(&mut rx).await;
8996        match event {
8997            OrderEventAny::Rejected(e) => {
8998                assert_eq!(e.client_order_id, order.client_order_id());
8999                assert!(e.reason.as_str().contains("code=21702"));
9000                assert!(e.reason.as_str().contains("invalid price"));
9001                assert!(!e.due_post_only);
9002            }
9003            other => panic!("expected Rejected, was {other:?}"),
9004        }
9005        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9006        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
9007    }
9008
9009    #[tokio::test]
9010    async fn handle_send_tx_rejection_ack_create_sets_due_post_only() {
9011        let (client, cache, mut rx) = create_execution_client();
9012        let instrument_id = register_test_instrument(&client, &cache);
9013        let mut factory = test_order_factory();
9014        let order = test_limit_order(&mut factory, instrument_id, "REJECT-POST-ONLY");
9015        enqueue_create(&client, &order, 43);
9016
9017        handle_send_tx_rejection(
9018            &client.dispatch,
9019            &client.emitter,
9020            Some(TEST_ACCOUNT_INDEX_I64),
9021            UnixNanos::from(1_000_000_000),
9022            SendTxRejectionSource::Ack,
9023            Some(21700),
9024            "post-only order would execute",
9025            None,
9026        );
9027
9028        let event = recv_order_event(&mut rx).await;
9029        match event {
9030            OrderEventAny::Rejected(e) => {
9031                assert_eq!(e.client_order_id, order.client_order_id());
9032                assert!(e.due_post_only);
9033            }
9034            other => panic!("expected Rejected, was {other:?}"),
9035        }
9036        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9037    }
9038
9039    #[tokio::test]
9040    async fn handle_send_tx_rejection_cancel_emits_cancel_rejected() {
9041        let (client, cache, mut rx) = create_execution_client();
9042        let instrument_id = register_test_instrument(&client, &cache);
9043        let client_order_id = ClientOrderId::from("REJECT-CANCEL");
9044        let venue_order_id = VenueOrderId::from("123");
9045        let nonce = client
9046            .dispatch
9047            .nonce_manager
9048            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9049            .unwrap();
9050
9051        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
9052            kind: PendingSendTxKind::Cancel {
9053                strategy_id: strategy_id(),
9054                instrument_id,
9055                client_order_id,
9056                venue_order_id: Some(venue_order_id),
9057            },
9058            submitted_at: UnixNanos::from(1_000_000_000),
9059            nonce,
9060            api_key_index: TEST_API_KEY_INDEX,
9061            tx_hash: format!("hash{nonce:02x}"),
9062        });
9063
9064        handle_send_tx_rejection(
9065            &client.dispatch,
9066            &client.emitter,
9067            Some(TEST_ACCOUNT_INDEX_I64),
9068            UnixNanos::from(1_000_000_000),
9069            SendTxRejectionSource::Ack,
9070            Some(21727),
9071            "order is not cancelable",
9072            None,
9073        );
9074
9075        let event = recv_order_event(&mut rx).await;
9076        match event {
9077            OrderEventAny::CancelRejected(e) => {
9078                assert_eq!(e.client_order_id, client_order_id);
9079                assert_eq!(e.instrument_id, instrument_id);
9080                assert_eq!(e.venue_order_id, Some(venue_order_id));
9081                assert!(e.reason.as_str().contains("code=21727"));
9082                assert!(e.reason.as_str().contains("order is not cancelable"));
9083            }
9084            other => panic!("expected CancelRejected, was {other:?}"),
9085        }
9086        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9087        assert_nonce_reusable(&client.dispatch);
9088    }
9089
9090    #[tokio::test]
9091    async fn handle_send_tx_rejection_modify_emits_modify_rejected() {
9092        let (client, cache, mut rx) = create_execution_client();
9093        let instrument_id = register_test_instrument(&client, &cache);
9094        let client_order_id = ClientOrderId::from("REJECT-MODIFY");
9095        let venue_order_id = VenueOrderId::from("456");
9096        let nonce = client
9097            .dispatch
9098            .nonce_manager
9099            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9100            .unwrap();
9101
9102        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
9103            kind: PendingSendTxKind::Modify {
9104                strategy_id: strategy_id(),
9105                instrument_id,
9106                client_order_id,
9107                venue_order_id: Some(venue_order_id),
9108            },
9109            submitted_at: UnixNanos::from(1_000_000_000),
9110            nonce,
9111            api_key_index: TEST_API_KEY_INDEX,
9112            tx_hash: format!("hash{nonce:02x}"),
9113        });
9114
9115        handle_send_tx_rejection(
9116            &client.dispatch,
9117            &client.emitter,
9118            Some(TEST_ACCOUNT_INDEX_I64),
9119            UnixNanos::from(1_000_000_000),
9120            SendTxRejectionSource::Ack,
9121            Some(21702),
9122            "modify rejected by venue",
9123            None,
9124        );
9125
9126        let event = recv_order_event(&mut rx).await;
9127        match event {
9128            OrderEventAny::ModifyRejected(e) => {
9129                assert_eq!(e.client_order_id, client_order_id);
9130                assert_eq!(e.instrument_id, instrument_id);
9131                assert_eq!(e.venue_order_id, Some(venue_order_id));
9132                assert!(e.reason.as_str().contains("code=21702"));
9133                assert!(e.reason.as_str().contains("modify rejected by venue"));
9134            }
9135            other => panic!("expected ModifyRejected, was {other:?}"),
9136        }
9137        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9138        assert_nonce_reusable(&client.dispatch);
9139    }
9140
9141    #[tokio::test]
9142    async fn handle_send_tx_rejection_hash_match_attributes_past_the_head() {
9143        let (client, cache, mut rx) = create_execution_client();
9144        let instrument_id = register_test_instrument(&client, &cache);
9145        let mut factory = test_order_factory();
9146        let order_a = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-A");
9147        let order_b = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-B");
9148        enqueue_create(&client, &order_a, 10);
9149        enqueue_create(&client, &order_b, 11);
9150
9151        // A desynced or out-of-order rejection for B must not consume A.
9152        handle_send_tx_rejection(
9153            &client.dispatch,
9154            &client.emitter,
9155            Some(TEST_ACCOUNT_INDEX_I64),
9156            UnixNanos::from(1_000_000_000),
9157            SendTxRejectionSource::Ack,
9158            Some(21702),
9159            "invalid price",
9160            Some("hash0b"),
9161        );
9162
9163        let event = recv_order_event(&mut rx).await;
9164        match event {
9165            OrderEventAny::Rejected(e) => {
9166                assert_eq!(e.client_order_id, order_b.client_order_id());
9167            }
9168            other => panic!("expected Rejected, was {other:?}"),
9169        }
9170        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "A must survive");
9171        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
9172        match head.kind {
9173            PendingSendTxKind::Create { order, .. } => {
9174                assert_eq!(order.client_order_id(), order_a.client_order_id());
9175            }
9176            _ => panic!("expected Create kind"),
9177        }
9178    }
9179
9180    #[tokio::test]
9181    async fn handle_send_tx_rejection_unmatched_hash_pops_nothing() {
9182        let (client, cache, mut rx) = create_execution_client();
9183        let instrument_id = register_test_instrument(&client, &cache);
9184        let mut factory = test_order_factory();
9185        let order = test_limit_order(&mut factory, instrument_id, "REJECT-UNMATCHED");
9186        enqueue_create(&client, &order, 10);
9187
9188        handle_send_tx_rejection(
9189            &client.dispatch,
9190            &client.emitter,
9191            Some(TEST_ACCOUNT_INDEX_I64),
9192            UnixNanos::from(1_000_000_000),
9193            SendTxRejectionSource::Ack,
9194            Some(21702),
9195            "invalid price",
9196            Some("0xbeef"),
9197        );
9198
9199        assert_eq!(
9200            client.dispatch.pending_sendtx_len(),
9201            1,
9202            "an echoed hash with no matching entry must not pop the head",
9203        );
9204        assert!(
9205            tokio::time::timeout(Duration::from_millis(50), rx.recv())
9206                .await
9207                .is_err(),
9208            "unmatched rejection must not emit an event",
9209        );
9210    }
9211
9212    #[tokio::test]
9213    async fn handle_send_tx_rejection_bare_error_within_window_attributes() {
9214        let (client, cache, mut rx) = create_execution_client();
9215        let instrument_id = register_test_instrument(&client, &cache);
9216        let mut factory = test_order_factory();
9217        let order = test_limit_order(&mut factory, instrument_id, "BARE-IN");
9218        enqueue_create(&client, &order, 50);
9219
9220        let within_window = UnixNanos::from(1_000_000_000 + 500 * 1_000_000);
9221        handle_send_tx_rejection(
9222            &client.dispatch,
9223            &client.emitter,
9224            Some(TEST_ACCOUNT_INDEX_I64),
9225            within_window,
9226            SendTxRejectionSource::BareError,
9227            Some(21149),
9228            "integrator is not approved",
9229            None,
9230        );
9231
9232        let event = recv_order_event(&mut rx).await;
9233        assert!(matches!(event, OrderEventAny::Rejected(_)));
9234        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9235    }
9236
9237    #[tokio::test]
9238    async fn handle_send_tx_rejection_bare_error_outside_window_skips() {
9239        let (client, cache, mut rx) = create_execution_client();
9240        let instrument_id = register_test_instrument(&client, &cache);
9241        let mut factory = test_order_factory();
9242        let order = test_limit_order(&mut factory, instrument_id, "BARE-OUT");
9243        enqueue_create(&client, &order, 60);
9244
9245        let outside_window = UnixNanos::from(1_000_000_000 + 2_000 * 1_000_000);
9246        handle_send_tx_rejection(
9247            &client.dispatch,
9248            &client.emitter,
9249            Some(TEST_ACCOUNT_INDEX_I64),
9250            outside_window,
9251            SendTxRejectionSource::BareError,
9252            Some(99),
9253            "late error",
9254            None,
9255        );
9256
9257        assert_eq!(
9258            client.dispatch.pending_sendtx_len(),
9259            1,
9260            "head must remain queued past the 1s attribution window",
9261        );
9262        assert!(
9263            tokio::time::timeout(Duration::from_millis(50), rx.recv())
9264                .await
9265                .is_err(),
9266            "no event must be emitted outside the window",
9267        );
9268    }
9269
9270    #[tokio::test]
9271    async fn handle_send_tx_rejection_other_kind_rolls_back_latest_nonce() {
9272        let (client, _cache, _rx) = create_execution_client();
9273        let nonce = client
9274            .dispatch
9275            .nonce_manager
9276            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9277            .unwrap();
9278        enqueue_other(&client, nonce);
9279
9280        let needs_resync = handle_send_tx_rejection(
9281            &client.dispatch,
9282            &client.emitter,
9283            Some(TEST_ACCOUNT_INDEX_I64),
9284            UnixNanos::from(1_000_000_000),
9285            SendTxRejectionSource::Ack,
9286            Some(23000),
9287            "Too Many Requests",
9288            None,
9289        );
9290
9291        assert!(!needs_resync, "rate-limit rejection must not force resync");
9292        assert_nonce_reusable(&client.dispatch);
9293    }
9294
9295    #[tokio::test]
9296    async fn handle_send_tx_rejection_other_kind_skips_rollback_with_newer_issuance() {
9297        let (client, _cache, _rx) = create_execution_client();
9298        let rejected_nonce = client
9299            .dispatch
9300            .nonce_manager
9301            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9302            .unwrap();
9303        let newer_nonce = client
9304            .dispatch
9305            .nonce_manager
9306            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9307            .unwrap();
9308        enqueue_other(&client, rejected_nonce);
9309
9310        handle_send_tx_rejection(
9311            &client.dispatch,
9312            &client.emitter,
9313            Some(TEST_ACCOUNT_INDEX_I64),
9314            UnixNanos::from(1_000_000_000),
9315            SendTxRejectionSource::Ack,
9316            Some(23000),
9317            "Too Many Requests",
9318            None,
9319        );
9320
9321        assert_eq!(
9322            client
9323                .dispatch
9324                .nonce_manager
9325                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
9326            Some(newer_nonce),
9327            "non-latest rejection must leave last_issued alone",
9328        );
9329    }
9330
9331    #[tokio::test]
9332    async fn handle_send_tx_rejection_invalid_nonce_signals_resync() {
9333        let (client, _cache, _rx) = create_execution_client();
9334
9335        enqueue_other(&client, 70);
9336        let attributed = handle_send_tx_rejection(
9337            &client.dispatch,
9338            &client.emitter,
9339            Some(TEST_ACCOUNT_INDEX_I64),
9340            UnixNanos::from(1_000_000_000),
9341            SendTxRejectionSource::Ack,
9342            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
9343            "invalid nonce",
9344            None,
9345        );
9346        assert!(attributed, "attributed invalid nonce must signal resync");
9347
9348        let unattributed = handle_send_tx_rejection(
9349            &client.dispatch,
9350            &client.emitter,
9351            Some(TEST_ACCOUNT_INDEX_I64),
9352            UnixNanos::from(1_000_000_000),
9353            SendTxRejectionSource::Ack,
9354            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
9355            "invalid nonce",
9356            None,
9357        );
9358        assert!(
9359            unattributed,
9360            "unattributed invalid nonce must still signal resync",
9361        );
9362
9363        enqueue_other(&client, 71);
9364        let other_code = handle_send_tx_rejection(
9365            &client.dispatch,
9366            &client.emitter,
9367            Some(TEST_ACCOUNT_INDEX_I64),
9368            UnixNanos::from(1_000_000_000),
9369            SendTxRejectionSource::Ack,
9370            Some(21702),
9371            "invalid price",
9372            None,
9373        );
9374        assert!(!other_code, "other rejection codes must not force resync");
9375    }
9376
9377    #[tokio::test]
9378    async fn handle_send_tx_rejection_other_kind_logs_and_skips_emit() {
9379        let (client, _cache, mut rx) = create_execution_client();
9380        enqueue_other(&client, 70);
9381
9382        handle_send_tx_rejection(
9383            &client.dispatch,
9384            &client.emitter,
9385            Some(TEST_ACCOUNT_INDEX_I64),
9386            UnixNanos::from(1_000_000_000),
9387            SendTxRejectionSource::Ack,
9388            Some(21727),
9389            "invalid client order index",
9390            None,
9391        );
9392
9393        assert_eq!(client.dispatch.pending_sendtx_len(), 0, "Other head pops");
9394        assert!(
9395            tokio::time::timeout(Duration::from_millis(50), rx.recv())
9396                .await
9397                .is_err(),
9398            "Other-kind rejection must not emit OrderRejected",
9399        );
9400    }
9401
9402    #[tokio::test]
9403    async fn handle_send_tx_rejection_empty_queue_logs_warn() {
9404        let (client, _cache, mut rx) = create_execution_client();
9405
9406        handle_send_tx_rejection(
9407            &client.dispatch,
9408            &client.emitter,
9409            Some(TEST_ACCOUNT_INDEX_I64),
9410            UnixNanos::from(1_000_000_000),
9411            SendTxRejectionSource::Ack,
9412            Some(1),
9413            "no pending",
9414            None,
9415        );
9416
9417        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
9418        assert!(
9419            tokio::time::timeout(Duration::from_millis(50), rx.recv())
9420                .await
9421                .is_err(),
9422        );
9423    }
9424
9425    #[rstest]
9426    #[case::standard_no_override(LighterAccountTier::Standard, None, 60, None)]
9427    #[case::standard_zero_is_default(LighterAccountTier::Standard, Some(0), 60, None)]
9428    #[case::standard_override_above_tier(
9429        LighterAccountTier::Standard,
9430        Some(24_000),
9431        24_000,
9432        Some(TierCrossCheck::AboveTier { documented: 60 })
9433    )]
9434    #[case::premium_raise_hint(
9435        LighterAccountTier::Premium,
9436        None,
9437        60,
9438        Some(TierCrossCheck::RaiseHint { documented: 24_000 })
9439    )]
9440    #[case::premium_configured_no_advisory(LighterAccountTier::Premium, Some(24_000), 24_000, None)]
9441    #[case::unknown_no_advisory(LighterAccountTier::Unknown(7), None, 60, None)]
9442    fn test_tier_quota_report(
9443        #[case] tier: LighterAccountTier,
9444        #[case] configured: Option<u32>,
9445        #[case] expected_active: u32,
9446        #[case] expected_cross_check: Option<TierCrossCheck>,
9447    ) {
9448        assert_eq!(
9449            tier_quota_report(tier, configured, 60),
9450            (expected_active, expected_cross_check),
9451        );
9452    }
9453}