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    ops::ControlFlow,
32    sync::{
33        Arc,
34        atomic::{AtomicBool, AtomicU64, Ordering},
35    },
36    time::Duration,
37};
38
39use ahash::{AHashMap, AHashSet};
40use anyhow::Context;
41use async_trait::async_trait;
42#[cfg(test)]
43use nautilus_common::live::get_runtime;
44use nautilus_common::{
45    clients::{ExecutionClient, ExecutionReportTask},
46    enums::{LogColor, LogLevel},
47    live::runner::get_exec_event_sender,
48    log_debug,
49    messages::{
50        ExecutionReport,
51        execution::{
52            BatchCancelOrders, BatchModifyOrders, CancelAllOrders, CancelOrder,
53            GenerateFillReports, GenerateOrderStatusReport, GenerateOrderStatusReports,
54            GeneratePositionStatusReports, ModifyOrder, QueryAccount, QueryOrder, SubmitOrder,
55            SubmitOrderList,
56        },
57    },
58};
59use nautilus_core::{
60    DurationNanos, UUID4, UnixNanos,
61    datetime::unix_nanos_to_iso8601,
62    params::Params,
63    string::secret::SecretString,
64    time::{AtomicTime, get_atomic_clock_realtime},
65};
66use nautilus_live::{
67    ExecutionClientCore, ExecutionEventEmitter, SocketControlFactory,
68    book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS,
69    execution::failure::CommandFailure,
70    task::{TaskGroup, TaskGroupGuard, TaskJoinOutcome, TaskSlot, TaskSpawner, finish_task},
71};
72use nautilus_model::{
73    accounts::AccountAny,
74    enums::{
75        AccountType, ContingencyType, OmsType, OrderSide, OrderStatus, OrderType, PositionSide,
76        TimeInForce,
77    },
78    events::{OrderAccepted, OrderDeniedReason, OrderEventAny, OrderFilled, OrderTriggered},
79    identifiers::{
80        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
81        VenueOrderId,
82    },
83    instruments::{Instrument, InstrumentAny},
84    orders::{LIMIT_ORDER_TYPES, Order, OrderAny, STOP_ORDER_TYPES, TRIGGERABLE_ORDER_TYPES},
85    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
86    types::{AccountBalance, MarginBalance, Quantity},
87};
88use nautilus_network::error::SendError;
89use parking_lot::Mutex;
90use rust_decimal::Decimal;
91use serde::Deserialize;
92use tokio_util::sync::CancellationToken;
93use zeroize::Zeroizing;
94
95use crate::{
96    common::{
97        consts::{DISCONNECT_TIMEOUT, LIGHTER_ERROR_CODE_INVALID_NONCE, LIGHTER_MAX_BATCH_TX},
98        credential::{Credential, scrub_auth},
99        deployment,
100        enums::{
101            LighterAccountTier, LighterGroupingType, LighterPositionMarginMode, LighterProductType,
102            LighterTxStatus, LighterTxType,
103        },
104        parse::{parse_price_from_ticks, parse_quantity_from_ticks},
105        rate_limit::{LighterTxRateLimiter, await_tx_quota, build_tx_rate_limiter, resolve_quota},
106        symbol::{MarketRegistry, product_type_from_instrument_id},
107    },
108    config::LighterExecutionClientConfig,
109    http::{
110        client::{LIGHTER_REST_PAGE_SIZE, LighterHttpClient, LighterRawHttpClient},
111        error::LighterHttpError,
112        models::{
113            LighterAccountDetail, LighterOrder, LighterSendTxRequest, LighterTrade, LighterTx,
114        },
115        query::{
116            LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
117            LighterSortDirection, LighterTradeSortBy, LighterTradesQuery,
118        },
119    },
120    signing::{
121        auth_token::{build_auth_token_for, fresh_k},
122        nonce::NonceError,
123        tx::{
124            ApproveIntegratorTxInfo, CancelOrderTxInfo, CreateGroupedOrdersTxInfo,
125            CreateOrderTxInfo, L2TxAttributes, ModifyOrderTxInfo, OrderInfo, TxContext, TxInfoJson,
126            UpdateLeverageTxInfo, sign_tx,
127        },
128    },
129    websocket::{
130        LighterWsError, USER_STREAMS_ENDPOINT,
131        client::{LighterWebSocketClient, RetainedTaskSlot, TaskRetentionGuard},
132        dispatch::{
133            DeferredOrderEvents, LIGHTER_INSTRUMENT_CACHE, MAX_RECONCILIATION_PAGES, OrderIdentity,
134            OrderReportLookup, OrderShapeSnapshot, PendingOrderAction, PendingSendTx,
135            PendingSendTxKind, TradeDedupSource, WsDispatchState, cache_instruments_for_reports,
136            collect_order, collect_order_status_report, derive_market_order_price_ticks,
137            evict_terminal_mappings, lookup_create_order_status_report, lookup_order_status_report,
138            nautilus_to_lighter_order_type, nautilus_to_lighter_tif, order_expiry_for,
139            parse_http_order_to_report, price_to_ticks, quantity_to_ticks,
140        },
141        messages::{
142            AccountStream, ExecutionReport as LighterExecutionReport, LighterWsChannel,
143            NautilusWsMessage, SendTxRejectionSource, TX_BATCH_ID_PREFIX,
144        },
145        parse::{
146            LighterCommissionError, OpenFrameContext, ParsedOrderEvent, is_order_triggered,
147            lighter_order_shape, parse_lighter_order_event, parse_lighter_order_filled,
148            parse_lighter_order_updated, parse_lighter_trade_id, parse_ws_fill_report,
149            parse_ws_order_status_report,
150        },
151    },
152};
153
154/// Default `expired_at` window applied to a signed tx if the order does not
155/// supply its own GTD expiry: 5 minutes from wall-clock at submission time.
156const DEFAULT_TX_EXPIRY_MS: i64 = 5 * 60 * 1_000;
157
158/// Delay between venue lookups for an acknowledged order.
159const ACKED_ORDER_LOOKUP_DELAY: Duration = Duration::from_secs(2);
160const ACKED_ORDER_PROBE_ATTEMPTS: usize = 3;
161const BATCH_ACK_TIMEOUT: Duration = Duration::from_secs(10);
162
163const STRATEGY_REASON_MAX_CHARS: usize = 512;
164
165/// Refresh the auth token this far before its issuance deadline. The
166/// [`crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS`] is 7 hours;
167/// rotating at 6 hours leaves an hour of headroom for transient refresh
168/// failures.
169const AUTH_TOKEN_REFRESH_INTERVAL: std::time::Duration =
170    std::time::Duration::from_secs(6 * 60 * 60);
171
172// Refresh interval must stay below the token TTL, or rotation hands out already-expired tokens
173const _: () = assert!(
174    AUTH_TOKEN_REFRESH_INTERVAL.as_secs()
175        < crate::signing::auth_token::DEFAULT_AUTH_TOKEN_TTL_SECS as u64,
176    "AUTH_TOKEN_REFRESH_INTERVAL must stay below DEFAULT_AUTH_TOKEN_TTL_SECS",
177);
178
179// Retry budget after a scheduled rotation failure: 7 h TTL minus the 6 h
180// refresh cadence leaves one hour before the old token expires.
181const AUTH_TOKEN_REFRESH_RETRY_WINDOW: Duration = Duration::from_secs(60 * 60);
182const AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(30);
183
184// Also used as the cadence after a retry window is exhausted
185const AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY: Duration = Duration::from_secs(5 * 60);
186const AUTH_TOKEN_REFRESH_BACKOFF: AuthTokenRefreshBackoff = AuthTokenRefreshBackoff {
187    initial_delay: AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY,
188    max_delay: AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY,
189    window: AUTH_TOKEN_REFRESH_RETRY_WINDOW,
190};
191const NONCE_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(1);
192const NONCE_REFRESH_RETRY_MAX_DELAY: Duration = Duration::from_secs(30);
193// Bounds the startup account snapshot so a slow or failing `/account`
194// endpoint cannot stall connect for the HTTP retry budget.
195const ACCOUNT_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
196const REFERRAL_ATTRIBUTION_TIMEOUT: Duration = Duration::from_secs(10);
197
198/// Attribution window for a bare venue error frame. The frame carries no
199/// `tx_hash` or cloid; if the oldest pending sendTx was submitted within
200/// this window we attribute and emit `OrderRejected`. Outside the window
201/// the existing submit-timeout drives expiry.
202const SENDTX_BARE_ERROR_WINDOW_MS: u64 = 1_000;
203const INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS: i64 = 5 * 365 * 24 * 60 * 60 * 1_000;
204const INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK: u32 = 0;
205const NONCE_CONNECTION_EPOCH_UNAVAILABLE: u64 = u64::MAX;
206
207#[derive(Debug)]
208pub struct LighterExecutionClient {
209    core: ExecutionClientCore,
210    clock: &'static AtomicTime,
211    config: LighterExecutionClientConfig,
212    emitter: ExecutionEventEmitter,
213    credential: Option<Credential>,
214    http_client: LighterHttpClient,
215    ws_client: LighterWebSocketClient,
216    tx_rate_limiter: Arc<LighterTxRateLimiter>,
217    tx_send_sequencer: TxSendSequencer,
218    nonce_submission_gate: Arc<tokio::sync::RwLock<()>>,
219    nonce_ready_connection_epoch: Arc<AtomicU64>,
220    registry: Arc<MarketRegistry>,
221    socket_factory: SocketControlFactory,
222    pending_tasks: TaskGroup,
223    ws_stream_handle: TaskSlot<()>,
224    ws_disconnect_handle: TaskSlot<Result<(), LighterWsError>>,
225    ws_handler_retained: Arc<RetainedTaskSlot>,
226    auth_refresh_handle: TaskSlot<()>,
227    shutdown_errors: Vec<String>,
228    cancellation_token: CancellationToken,
229    dispatch: WsDispatchState,
230    nonce_recovery_inflight: Arc<AtomicBool>,
231    auth_refresh_notify: Arc<tokio::sync::Notify>,
232}
233
234impl LighterExecutionClient {
235    fn log_report_receipt(count: usize, report_type: &str, log_level: LogLevel) {
236        let plural = if count == 1 { "" } else { "s" };
237        let message = format!("Received {count} {report_type}{plural}");
238
239        match log_level {
240            LogLevel::Off => {}
241            LogLevel::Trace => log::trace!("{message}"),
242            LogLevel::Debug => log::debug!("{message}"),
243            LogLevel::Info => log::info!("{message}"),
244            LogLevel::Warning => log::warn!("{message}"),
245            LogLevel::Error => log::error!("{message}"),
246        }
247    }
248
249    /// Creates a new [`LighterExecutionClient`] instance.
250    ///
251    /// Resolves credentials from `config` or the matching environment
252    /// variables (see [`crate::common::credential`]). Missing credentials
253    /// degrade to an unauthenticated client that can bootstrap instruments
254    /// but cannot submit transactions; the constructor returns an error if
255    /// supplied values are malformed.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if the HTTP client fails to initialize or if any
260    /// supplied credential value cannot be parsed.
261    pub fn new(
262        core: ExecutionClientCore,
263        config: LighterExecutionClientConfig,
264    ) -> anyhow::Result<Self> {
265        anyhow::ensure!(
266            core.venue == config.resolved_venue(),
267            "Lighter execution core venue {} does not match configured venue {}",
268            core.venue,
269            config.resolved_venue(),
270        );
271
272        anyhow::ensure!(
273            config.account_id.get_issuer() == core.venue,
274            "Lighter account ID issuer {} does not match configured venue {}",
275            config.account_id.get_issuer(),
276            core.venue,
277        );
278
279        let credential = Credential::resolve_for_deployment(
280            config.private_key.clone().map(SecretString::into_inner),
281            config.account_index,
282            config.api_key_index,
283            config.deployment,
284            config.environment,
285        )
286        .context("failed to resolve Lighter credentials")?;
287
288        let registry = Arc::new(MarketRegistry::new_with_venue_and_settlement_currency(
289            core.venue,
290            config.settlement_currency(),
291        ));
292        let socket_factory = SocketControlFactory::new(core.client_id, Some(core.venue));
293
294        // One transaction limiter shared across the HTTP and WebSocket sendTx
295        // paths so their combined rate honors the single per-account venue bucket.
296        let tx_rate_limiter = build_tx_rate_limiter(config.sendtx_quota_per_min);
297
298        let raw_http = LighterRawHttpClient::new_with_quotas(
299            config.environment,
300            Some(config.http_url()),
301            config.http_timeout_secs,
302            config
303                .proxy_url
304                .as_ref()
305                .map(|value| value.expose_secret().to_owned()),
306            resolve_quota(config.rest_quota_per_min),
307            Some(Arc::clone(&tx_rate_limiter)),
308        )
309        .context("failed to construct Lighter raw HTTP client")?;
310
311        let http_client =
312            LighterHttpClient::from_raw_with_registry(raw_http, Arc::clone(&registry));
313
314        let ws_client = Self::create_ws_client(&config, Arc::clone(&registry), &socket_factory);
315
316        let clock = get_atomic_clock_realtime();
317        let emitter = ExecutionEventEmitter::new(
318            clock,
319            core.trader_id,
320            core.account_id,
321            AccountType::Margin,
322            None,
323        );
324        let pending_tasks = TaskGroup::new();
325
326        Ok(Self {
327            core,
328            clock,
329            config,
330            emitter,
331            credential,
332            http_client,
333            ws_client,
334            tx_rate_limiter,
335            tx_send_sequencer: TxSendSequencer::new(),
336            nonce_submission_gate: Arc::new(tokio::sync::RwLock::new(())),
337            nonce_ready_connection_epoch: Arc::new(AtomicU64::new(0)),
338            registry,
339            socket_factory,
340            cancellation_token: pending_tasks.cancellation_token(),
341            pending_tasks,
342            ws_stream_handle: TaskSlot::new(),
343            ws_disconnect_handle: TaskSlot::new(),
344            ws_handler_retained: Arc::new(RetainedTaskSlot::new()),
345            auth_refresh_handle: TaskSlot::new(),
346            shutdown_errors: Vec::new(),
347            dispatch: WsDispatchState::new(),
348            nonce_recovery_inflight: Arc::new(AtomicBool::new(false)),
349            auth_refresh_notify: Arc::new(tokio::sync::Notify::new()),
350        })
351    }
352
353    /// Returns a reference to the configuration.
354    #[must_use]
355    pub fn config(&self) -> &LighterExecutionClientConfig {
356        &self.config
357    }
358
359    /// Returns `true` when the client holds resolved Lighter credentials.
360    #[must_use]
361    pub fn has_credentials(&self) -> bool {
362        self.credential.is_some()
363    }
364
365    /// Returns `true` when every retained background task has completed.
366    /// Useful in tests to wait for fire-and-forget HTTP work.
367    #[must_use]
368    pub fn pending_tasks_all_finished(&self) -> bool {
369        self.pending_tasks.all_finished()
370    }
371
372    fn create_ws_client(
373        config: &LighterExecutionClientConfig,
374        registry: Arc<MarketRegistry>,
375        socket_factory: &SocketControlFactory,
376    ) -> LighterWebSocketClient {
377        let ws_client = LighterWebSocketClient::new(
378            Some(config.ws_url()),
379            config.environment,
380            registry,
381            config.transport_backend,
382            config.ws_timeout_secs,
383            // The execution socket carries no book channels; the shared default stays inert.
384            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
385            config
386                .proxy_url
387                .as_ref()
388                .map(|value| value.expose_secret().to_owned()),
389        );
390
391        ws_client.with_socket_control(socket_factory.control(USER_STREAMS_ENDPOINT))
392    }
393
394    fn take_ws_client(&mut self) -> LighterWebSocketClient {
395        let cached_instruments = self.ws_client.instruments_cache();
396        let ws_cache = cached_instruments
397            .iter()
398            .map(|entry| (*entry.key(), entry.value().clone()))
399            .collect();
400        let replacement = Self::create_ws_client(
401            &self.config,
402            Arc::clone(&self.registry),
403            &self.socket_factory,
404        );
405        replacement.cache_instruments(ws_cache);
406
407        std::mem::replace(&mut self.ws_client, replacement)
408    }
409
410    fn spawn_task<F>(&self, description: &'static str, fut: F)
411    where
412        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
413    {
414        let future = async move {
415            if let Err(e) = fut.await {
416                log::warn!("{description} failed: {e:?}");
417            }
418        };
419
420        if let Err(e) = self.pending_tasks.spawn(future) {
421            log::warn!("Skipping Lighter {description} after shutdown began: {e}");
422        }
423    }
424
425    fn begin_session_shutdown(&mut self) {
426        self.pending_tasks.begin_shutdown();
427        self.cancellation_token.cancel();
428        self.ws_client.begin_shutdown();
429        self.core.set_disconnected();
430
431        if self.ws_disconnect_handle.is_none()
432            && (self.ws_client.is_active() || self.ws_stream_handle.is_some())
433        {
434            let ws_client = self.take_ws_client();
435            let retained = Arc::clone(&self.ws_handler_retained);
436
437            if let Err(e) = self
438                .ws_disconnect_handle
439                .spawn(ws_client.disconnect_with_task_retention(retained))
440            {
441                log::error!("Failed to start Lighter WebSocket disconnect task: {e}");
442            }
443        }
444    }
445
446    fn session_tasks_finished(&self) -> bool {
447        self.ws_stream_handle.is_none()
448            && self.ws_disconnect_handle.is_none()
449            && self.ws_handler_retained.is_empty()
450            && self.auth_refresh_handle.is_none()
451            && self.pending_tasks.is_empty()
452            && self.shutdown_errors.is_empty()
453    }
454
455    async fn finish_session_shutdown(&mut self) -> anyhow::Result<()> {
456        self.pending_tasks.begin_shutdown();
457
458        Self::finish_disconnect_task(
459            &mut self.ws_disconnect_handle,
460            "WebSocket disconnect",
461            &mut self.shutdown_errors,
462        )
463        .await;
464
465        if let Err(e) = self.ws_handler_retained.finish().await {
466            self.shutdown_errors.push(e.to_string());
467        }
468        Self::finish_owned_task(
469            &mut self.ws_stream_handle,
470            "execution consumer",
471            &mut self.shutdown_errors,
472        )
473        .await;
474        Self::finish_owned_task(
475            &mut self.auth_refresh_handle,
476            "auth-token refresh",
477            &mut self.shutdown_errors,
478        )
479        .await;
480
481        if let Err(e) = self
482            .pending_tasks
483            .finish_shutdown(Duration::from_secs(1), DISCONNECT_TIMEOUT)
484            .await
485        {
486            self.shutdown_errors
487                .push(format!("client-owned tasks failed: {e}"));
488        }
489
490        let stale = self.dispatch.take_pending_sendtx();
491        warn_pending_sendtx_unknown(&stale, "session shutdown");
492        self.nonce_recovery_inflight.store(false, Ordering::Release);
493
494        if !self.shutdown_errors.is_empty() {
495            let errors = std::mem::take(&mut self.shutdown_errors);
496            anyhow::bail!("Failed to terminate Lighter tasks: {}", errors.join("; "));
497        }
498        Ok(())
499    }
500
501    async fn finish_owned_task(
502        slot: &mut TaskSlot<()>,
503        description: &str,
504        errors: &mut Vec<String>,
505    ) {
506        let Some(outcome) = finish_task(slot, DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
507            return;
508        };
509
510        match outcome {
511            TaskJoinOutcome::Completed(()) => {
512                log::debug!("Lighter {description} task completed");
513            }
514            TaskJoinOutcome::Aborted => {
515                log::debug!("Lighter {description} task cancelled");
516            }
517            TaskJoinOutcome::Failed(e) => {
518                errors.push(format!("{description} task failed: {e}"));
519            }
520            TaskJoinOutcome::Incomplete => {
521                errors.push(format!("{description} task did not stop after abort"));
522            }
523        }
524    }
525
526    async fn finish_disconnect_task(
527        slot: &mut TaskSlot<Result<(), LighterWsError>>,
528        description: &str,
529        errors: &mut Vec<String>,
530    ) {
531        let Some(outcome) = finish_task(slot, DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
532            return;
533        };
534
535        match outcome {
536            TaskJoinOutcome::Completed(Ok(())) | TaskJoinOutcome::Aborted => {}
537            TaskJoinOutcome::Completed(Err(e)) => {
538                errors.push(format!("{description} failed: {e}"));
539            }
540            TaskJoinOutcome::Failed(e) => {
541                errors.push(format!("{description} task failed: {e}"));
542            }
543            TaskJoinOutcome::Incomplete => {
544                errors.push(format!("{description} task did not stop after abort"));
545            }
546        }
547    }
548
549    async fn ensure_instruments_initialized_async(&self) -> anyhow::Result<()> {
550        if self.core.instruments_initialized() {
551            return Ok(());
552        }
553
554        let instruments = self
555            .http_client
556            .request_instruments()
557            .await
558            .context("failed to request Lighter instruments")?;
559
560        let ws_cache: Vec<(i64, InstrumentAny)> = instruments
561            .iter()
562            .filter_map(|instrument| {
563                self.registry
564                    .market_index(&instrument.id())
565                    .map(|market_index| (market_index, instrument.clone()))
566            })
567            .collect();
568        self.ws_client.cache_instruments(ws_cache);
569        cache_instruments_for_reports(&instruments);
570
571        log::debug!(
572            "Bootstrapped {} Lighter instruments ({} registry entries)",
573            instruments.len(),
574            self.registry.len(),
575        );
576
577        self.core.set_instruments_initialized();
578        Ok(())
579    }
580
581    async fn await_account_streams_ready(&self, timeout_secs: f64) -> anyhow::Result<()> {
582        let timeout = Duration::from_secs_f64(timeout_secs);
583        self.dispatch.account_streams_ready.await_all(timeout).await
584    }
585
586    async fn refresh_nonce(&self) -> anyhow::Result<()> {
587        let Some(credential) = &self.credential else {
588            return Ok(());
589        };
590        self.nonce_ready_connection_epoch
591            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
592        let _refresh_guard = self.nonce_submission_gate.write().await;
593
594        let response = self
595            .http_client
596            .get_next_nonce(credential.account_index(), credential.api_key_index())
597            .await
598            .context("failed to fetch Lighter nextNonce")?;
599
600        self.dispatch.nonce_manager.refresh(
601            credential.account_index(),
602            credential.api_key_index(),
603            response.nonce,
604        );
605
606        // Release the latch in case a disconnect aborted recovery mid-task
607        self.nonce_recovery_inflight.store(false, Ordering::Release);
608
609        log::debug!(
610            "Refreshed Lighter nonce baseline: account_index={}, api_key_index={}, next_nonce={}",
611            credential.account_index(),
612            credential.api_key_index(),
613            response.nonce,
614        );
615        Ok(())
616    }
617
618    async fn sync_nonce_from_venue(&self) -> anyhow::Result<()> {
619        let Some(credential) = &self.credential else {
620            return Ok(());
621        };
622
623        let _refresh_guard = self.nonce_submission_gate.write().await;
624        let response = self
625            .http_client
626            .get_next_nonce(credential.account_index(), credential.api_key_index())
627            .await
628            .context("failed to fetch Lighter nextNonce")?;
629        self.dispatch.nonce_manager.sync_from_venue(
630            credential.account_index(),
631            credential.api_key_index(),
632            response.nonce,
633        )?;
634        Ok(())
635    }
636
637    async fn fetch_account_detail(&self) -> Option<LighterAccountDetail> {
638        let Some(credential) = &self.credential else {
639            return None;
640        };
641        let account_index = credential.account_index();
642
643        match tokio::time::timeout(
644            ACCOUNT_SNAPSHOT_TIMEOUT,
645            self.http_client.get_account_detail(account_index),
646        )
647        .await
648        {
649            Ok(Ok(detail)) => Some(detail),
650            Ok(Err(e)) => {
651                log::warn!(
652                    "Failed to fetch Lighter account detail for account_index={account_index}; \
653                     continuing startup without account metadata: {e}"
654                );
655                None
656            }
657            Err(_) => {
658                log::warn!(
659                    "Lighter account detail timed out after {}s for account_index={account_index}; \
660                     continuing startup without account metadata",
661                    ACCOUNT_SNAPSHOT_TIMEOUT.as_secs()
662                );
663                None
664            }
665        }
666    }
667
668    // Logs the venue-reported account tier in blue. Informational only: the
669    // active quotas are resolved from config at construction, never raised here
670    // (the higher venue limits require registering the caller IP, so the tier
671    // alone does not guarantee them).
672    fn log_account_tier(&self, detail: &LighterAccountDetail) {
673        let account_index = detail.account_index;
674        let code = detail.account_type;
675        let tier = LighterAccountTier::from_code(code);
676        let standard_rest = LighterAccountTier::Standard
677            .documented_rest_quota_per_min()
678            .unwrap_or(60);
679        let (active_rest, cross_check) =
680            tier_quota_report(tier, self.config.rest_quota_per_min, standard_rest);
681
682        log_debug!(
683            "Lighter execution account {account_index} reported tier {tier} \
684             (account_type={code}); active REST quota {active_rest} req/min",
685            color = LogColor::Blue
686        );
687
688        match cross_check {
689            Some(TierCrossCheck::AboveTier { documented }) => {
690                log::warn!(
691                    "Configured Lighter rest_quota_per_min={active_rest} exceeds the {tier} tier \
692                     limit of {documented} req/min; the venue may reject requests unless the \
693                     caller IP is registered for the higher limit"
694                );
695            }
696            Some(TierCrossCheck::RaiseHint { documented }) => {
697                log_debug!(
698                    "Lighter {tier} tier permits up to {documented} REST req/min; set \
699                     rest_quota_per_min (and register the caller IP with Lighter) to use it",
700                    color = LogColor::Blue
701                );
702            }
703            None => {}
704        }
705    }
706
707    fn integrator_account_index(&self) -> Option<u64> {
708        deployment::integrator_account_index(self.config.deployment, self.config.environment)
709    }
710
711    async fn apply_referral_attribution(
712        &self,
713        detail: &LighterAccountDetail,
714    ) -> anyhow::Result<()> {
715        let Some(referral_code) =
716            deployment::referral_code(self.config.deployment, self.config.environment)
717        else {
718            return Ok(());
719        };
720
721        let Some(credential) = &self.credential else {
722            return Ok(());
723        };
724
725        anyhow::ensure!(
726            !detail.l1_address.trim().is_empty(),
727            "Lighter account detail returned an empty L1 address"
728        );
729
730        let auth_token = build_auth_token_for(credential)
731            .context("failed to mint Lighter auth token for referral attribution")?;
732        let referral_code = Zeroizing::new(referral_code.to_string());
733
734        self.http_client
735            .use_referral(&detail.l1_address, referral_code.as_str(), &auth_token)
736            .await
737            .context("failed to apply Lighter referral code")?;
738
739        log::debug!("Applied Robinhood Chain referral attribution");
740        Ok(())
741    }
742
743    /// Returns `Ok(true)` if this credential's `api_key_index` is maker-only.
744    /// Maker-only keys cannot submit `ApproveIntegrator`, so the caller skips
745    /// the integrator auto-approval when `true`.
746    async fn is_maker_only_api_key(&self, credential: &Credential) -> anyhow::Result<bool> {
747        let auth_token = build_auth_token_for(credential)
748            .context("failed to mint Lighter auth token for maker-only check")?;
749        let response = self
750            .http_client
751            .get_maker_only_api_keys(credential.account_index(), auth_token)
752            .await
753            .context("failed to query getMakerOnlyApiKeys")?;
754        let api_key_index = i64::from(credential.api_key_index());
755        Ok(response.api_key_indexes.contains(&api_key_index))
756    }
757
758    async fn submit_integrator_auto_approval(&self) -> anyhow::Result<()> {
759        let Some(integrator_account_index) = self.integrator_account_index() else {
760            return Ok(());
761        };
762
763        let Some(credential) = &self.credential else {
764            return Ok(());
765        };
766
767        let mut maker_only_check_failed = false;
768
769        match self.is_maker_only_api_key(credential).await {
770            Ok(true) => {
771                log::warn!(
772                    "Skipping Lighter integrator auto-approval: api_key_index={} is maker-only; \
773                     ensure the account has been approved by a non-maker-only key",
774                    credential.api_key_index(),
775                );
776                return Ok(());
777            }
778            Ok(false) => {}
779            Err(e) => {
780                maker_only_check_failed = true;
781
782                log::debug!(
783                    "Unable to determine whether the Lighter API key is maker-only; proceeding \
784                     with integrator auto-approval: {e:?}"
785                );
786            }
787        }
788
789        let mut approval =
790            self.prepare_integrator_auto_approval(credential, integrator_account_index)?;
791
792        let request = LighterSendTxRequest::new(
793            LighterTxType::ApproveIntegrator as u8,
794            approval.tx_info.clone(),
795        );
796
797        approval.send_reservation.wait_for_turn().await;
798
799        let response = match self.http_client.send_tx(&request).await {
800            Ok(response) => response,
801            Err(e) => {
802                if lighter_http_error_is_definite_api_rejection(&e) {
803                    let _ = self.dispatch.nonce_manager.ack_failure_if_latest(
804                        credential.account_index(),
805                        approval.api_key_index,
806                        approval.nonce,
807                    );
808                }
809                approval.send_reservation.release();
810                let hint = if maker_only_check_failed {
811                    " (maker-only pre-flight check failed earlier; venue may reject with 62007 \
812                     if this key is maker-only)"
813                } else {
814                    ""
815                };
816                return Err(anyhow::Error::new(e).context(format!(
817                    "failed to submit Lighter integrator approval nonce={} api_key_index={}{hint}",
818                    approval.nonce, approval.api_key_index,
819                )));
820            }
821        };
822
823        let _ = self.dispatch.nonce_manager.ack_success(
824            credential.account_index(),
825            approval.api_key_index,
826            approval.nonce,
827        );
828        approval.send_reservation.release();
829
830        log::debug!(
831            "Submitted Lighter integrator approval: integrator={}, nonce={}, \
832             api_key_index={}, approval_expiry={}, tx_hash={}",
833            integrator_account_index,
834            approval.nonce,
835            approval.api_key_index,
836            approval.approval_expiry,
837            response.tx_hash,
838        );
839        Ok(())
840    }
841
842    fn prepare_integrator_auto_approval(
843        &self,
844        credential: &Credential,
845        integrator_account_index: u64,
846    ) -> anyhow::Result<PreparedIntegratorApproval> {
847        let ReservedTxContext {
848            context,
849            send_reservation,
850        } = self.build_tx_context(credential)?;
851
852        let now_ms = (self.clock.get_time_ns().as_u64() as i64) / 1_000_000;
853        let approval_expiry = now_ms.saturating_add(INTEGRATOR_AUTO_APPROVAL_MAX_TTL_MS);
854        let nonce = context.nonce;
855        let api_key_index = context.api_key_index;
856
857        let tx = ApproveIntegratorTxInfo {
858            context,
859            integrator_account_index: integrator_account_index as i64,
860            max_perps_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
861            max_perps_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
862            max_spot_taker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
863            max_spot_maker_fee: INTEGRATOR_AUTO_APPROVAL_MAX_FEE_TICK,
864            approval_expiry,
865            skip_nonce: 0,
866        };
867
868        let signed = sign_tx(
869            &tx,
870            self.config.chain_id(),
871            &credential.private_key()?,
872            fresh_k(),
873        );
874
875        let tx_info = TxInfoJson::approve_integrator(&tx, &signed, "");
876
877        Ok(PreparedIntegratorApproval {
878            tx_info,
879            nonce,
880            api_key_index,
881            approval_expiry,
882            send_reservation,
883        })
884    }
885
886    async fn spawn_ws_consumer(&mut self) -> anyhow::Result<()> {
887        // Local clone owns the handler `task_handle` until post-connect
888        // setup succeeds. Transferring earlier would leave failures unable
889        // to drain the task through the clone's `disconnect()`.
890        let mut ws_guard = TaskRetentionGuard::new(
891            self.ws_client.clone(),
892            Arc::clone(&self.ws_handler_retained),
893        );
894        ws_guard
895            .client_mut()
896            .connect_with_cancellation(self.cancellation_token.clone())
897            .await
898            .context("failed to connect to Lighter WebSocket")?;
899
900        // Wrapped so any failure routes through the clone's `disconnect()`
901        // (which still owns the handler task); mirrors Hyperliquid's
902        // `post_ws` block.
903        let post_connect = async {
904            ws_guard
905                .client_mut()
906                .wait_until_active()
907                .await
908                .context("Lighter WebSocket did not reach active state")?;
909
910            if let Some(credential) = &self.credential {
911                let auth_token = build_auth_token_for(credential)
912                    .context("failed to mint Lighter auth token")?;
913                let account_index = credential.account_index();
914
915                ws_guard
916                    .client_mut()
917                    .set_execution_context(self.core.account_id, account_index)
918                    .await
919                    .map_err(|e| anyhow::anyhow!("failed to set Lighter execution context: {e}"))?;
920
921                // Subscribe to the five account-scoped streams the consumption
922                // loop converts into typed reports. The handler merges
923                // `account_all_assets` and `user_stats` into a single
924                // AccountState (see websocket/account_state.rs).
925                let channels = [
926                    LighterWsChannel::AccountAllOrders(account_index),
927                    LighterWsChannel::AccountAllTrades(account_index),
928                    LighterWsChannel::AccountAllPositions(account_index),
929                    LighterWsChannel::AccountAllAssets(account_index),
930                    LighterWsChannel::UserStats(account_index),
931                ];
932
933                for channel in channels {
934                    ws_guard
935                        .client_mut()
936                        .subscribe_account(channel.clone(), auth_token.clone())
937                        .await
938                        .map_err(|e| {
939                            anyhow::anyhow!(
940                                "failed to subscribe to Lighter account channel {channel:?}: {e}",
941                            )
942                        })?;
943                }
944
945                log::debug!("Subscribed to Lighter account streams: account_index={account_index}",);
946            } else {
947                log::warn!(
948                    "Lighter execution client has no credentials: account streams not subscribed; \
949                     typed execution reports will not flow"
950                );
951            }
952
953            Ok::<(), anyhow::Error>(())
954        };
955
956        if let Err(e) = post_connect.await {
957            log::warn!("Lighter post-connect setup failed, tearing down WS: {e}");
958            let ws_client = ws_guard.disarm();
959            let mut rollback_errors = Vec::new();
960
961            if let Err(e) = ws_client
962                .disconnect_with_task_retention(Arc::clone(&self.ws_handler_retained))
963                .await
964            {
965                rollback_errors.push(e.to_string());
966            }
967
968            if let Err(e) = self.ws_handler_retained.finish().await {
969                rollback_errors.push(e.to_string());
970            }
971
972            if rollback_errors.is_empty() {
973                return Err(e);
974            }
975            return Err(e.context(format!(
976                "Lighter post-connect rollback failed: {}",
977                rollback_errors.join("; ")
978            )));
979        }
980
981        let mut ws_client = ws_guard.disarm();
982        self.ws_client.set_task_slot(ws_client.take_task_slot());
983
984        let cancellation_token = self.cancellation_token.clone();
985        let emitter = self.emitter.clone();
986        let dispatch = self.dispatch.clone();
987        let credential_for_loop = self.credential.clone();
988        let http_client_for_loop = self.http_client.clone();
989        let registry_for_loop = Arc::clone(&self.registry);
990        let auth_refresh_notify = Arc::clone(&self.auth_refresh_notify);
991        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
992        let nonce_ready_connection_epoch = Arc::clone(&self.nonce_ready_connection_epoch);
993        let pending_tasks_for_loop = self
994            .pending_tasks
995            .spawner()
996            .context("Lighter task admission is closed")?;
997        let account_id_for_loop = self.core.account_id;
998        let clock_for_loop = self.clock;
999
1000        let orders_cached = {
1001            let cache = self.core.cache();
1002            cache
1003                .orders_open_refs(
1004                    Some(&self.core.venue),
1005                    None,
1006                    None,
1007                    Some(&self.core.account_id),
1008                    None,
1009                )
1010                .into_iter()
1011                .map(|order| order.clone())
1012                .collect()
1013        };
1014
1015        dispatch.seed_order_restorations(orders_cached);
1016
1017        let nonce_refresh_retry = NonceRefreshRetry {
1018            http_client: self.http_client.clone(),
1019            dispatch: self.dispatch.clone(),
1020            credential: self.credential.clone(),
1021            submission_gate: Arc::clone(&self.nonce_submission_gate),
1022            ready_connection_epoch: Arc::clone(&self.nonce_ready_connection_epoch),
1023            ws_client: ws_client.clone(),
1024            cancellation_token: self.cancellation_token.clone(),
1025            pending_tasks: pending_tasks_for_loop.clone(),
1026        };
1027
1028        let child_context = credential_for_loop
1029            .as_ref()
1030            .map(|credential| AckedOrderProbeContext {
1031                http_client: http_client_for_loop.clone(),
1032                registry: Arc::clone(&registry_for_loop),
1033                credential: credential.clone(),
1034                dispatch: dispatch.clone(),
1035                account_id: account_id_for_loop,
1036                clock: clock_for_loop,
1037                emitter: emitter.clone(),
1038                connection_epoch: ws_client.connection_epoch_atomic(),
1039                cancellation_token: cancellation_token.clone(),
1040                pending_tasks: pending_tasks_for_loop.clone(),
1041            });
1042
1043        self.ws_stream_handle.spawn(async move {
1044            log::debug!("Lighter execution WebSocket consumption loop started");
1045
1046            loop {
1047                tokio::select! {
1048                    () = cancellation_token.cancelled() => {
1049                        log::debug!("Lighter execution consumption loop cancelled");
1050                        break;
1051                    }
1052                    msg_opt = ws_client.next_event() => {
1053                        match msg_opt {
1054                            Some(NautilusWsMessage::ExecutionReports(reports)) => {
1055                                let mut order_count = 0_usize;
1056                                let mut fill_count = 0_usize;
1057                                let trader_id = emitter.trader_id();
1058                                let account_index = credential_for_loop
1059                                    .as_ref()
1060                                    .map(|c| c.account_index());
1061
1062                                for report in reports {
1063                                    match report {
1064                                        LighterExecutionReport::Order(order) => {
1065                                            order_count += 1;
1066                                            dispatch_lighter_order(
1067                                                &order,
1068                                                &dispatch,
1069                                                &emitter,
1070                                                &registry_for_loop,
1071                                                account_id_for_loop,
1072                                                trader_id,
1073                                                clock_for_loop.get_time_ns(),
1074                                            );
1075                                        }
1076                                        LighterExecutionReport::Fill(trade) => {
1077                                            fill_count += 1;
1078                                            dispatch_lighter_trade(
1079                                                &trade,
1080                                                &dispatch,
1081                                                &emitter,
1082                                                &registry_for_loop,
1083                                                account_id_for_loop,
1084                                                trader_id,
1085                                                account_index,
1086                                                clock_for_loop.get_time_ns(),
1087                                            );
1088                                        }
1089                                    }
1090                                }
1091
1092                                if let Some(context) = &child_context {
1093                                    spawn_child_recoveries(context);
1094                                }
1095                                log::debug!(
1096                                    "Lighter execution batch: orders={order_count} fills={fill_count}",
1097                                );
1098                            }
1099                            Some(NautilusWsMessage::PositionSnapshot {
1100                                reports,
1101                                skipped_market_ids,
1102                            }) => {
1103                                // Replace even when empty, but keep rows the
1104                                // handler could not parse or map.
1105                                for r in &reports {
1106                                    if let Some(idx) =
1107                                        registry_for_loop.market_index(&r.instrument_id)
1108                                    {
1109                                        dispatch.note_active_market(idx);
1110                                    }
1111                                }
1112                                let position_count = reports.len();
1113                                let retained_positions: Vec<InstrumentId> = skipped_market_ids
1114                                    .iter()
1115                                    .filter_map(|market_id| {
1116                                        registry_for_loop.instrument_id(*market_id)
1117                                    })
1118                                    .collect();
1119                                let removed = dispatch.replace_position_snapshot(
1120                                    &reports,
1121                                    &retained_positions,
1122                                    &skipped_market_ids,
1123                                );
1124                                log::debug!(
1125                                    "Lighter position snapshot: positions={position_count}, skipped_markets={}, removed={}",
1126                                    skipped_market_ids.len(),
1127                                    removed.len(),
1128                                );
1129                                emit_lighter_position_reports(
1130                                    reports,
1131                                    removed,
1132                                    &emitter,
1133                                    account_id_for_loop,
1134                                    clock_for_loop.get_time_ns(),
1135                                );
1136                            }
1137                            Some(NautilusWsMessage::PositionUpdate {
1138                                reports,
1139                                closed_market_ids,
1140                                skipped_market_ids,
1141                            }) => {
1142                                let mut covered_market_ids = closed_market_ids.clone();
1143
1144                                for report in &reports {
1145                                    if let Some(market_id) =
1146                                        registry_for_loop.market_index(&report.instrument_id)
1147                                    {
1148                                        dispatch.note_active_market(market_id);
1149                                        covered_market_ids.push(market_id);
1150                                    }
1151                                }
1152                                let position_count = reports.len();
1153                                let closed_positions: Vec<InstrumentId> = closed_market_ids
1154                                    .iter()
1155                                    .filter_map(|market_id| {
1156                                        registry_for_loop.instrument_id(*market_id)
1157                                    })
1158                                    .collect();
1159                                let removed = dispatch.apply_position_update(
1160                                    &reports,
1161                                    &closed_positions,
1162                                    &covered_market_ids,
1163                                    &skipped_market_ids,
1164                                );
1165                                log::debug!(
1166                                    "Lighter position update: positions={position_count}, closed_markets={}, skipped_markets={}, removed={}",
1167                                    closed_market_ids.len(),
1168                                    skipped_market_ids.len(),
1169                                    removed.len(),
1170                                );
1171                                emit_lighter_position_reports(
1172                                    reports,
1173                                    removed,
1174                                    &emitter,
1175                                    account_id_for_loop,
1176                                    clock_for_loop.get_time_ns(),
1177                                );
1178                            }
1179                            Some(NautilusWsMessage::AccountState(state)) => {
1180                                log::debug!(
1181                                    "Lighter AccountState: balances={} margins={}",
1182                                    state.balances.len(),
1183                                    state.margins.len(),
1184                                );
1185                                // Cache so query_account can serve a recent
1186                                // snapshot without a REST round-trip; Lighter
1187                                // does not currently expose a REST account
1188                                // endpoint that would make a fresh fetch
1189                                // possible.
1190                                dispatch.cache_account_state((*state).clone());
1191                                emitter.send_account_state(*state);
1192                            }
1193                            Some(NautilusWsMessage::Reconnected { connection_epoch }) => {
1194                                dispatch.invalidate_position_snapshot();
1195                                nonce_ready_connection_epoch.store(
1196                                    NONCE_CONNECTION_EPOCH_UNAVAILABLE,
1197                                    Ordering::Release,
1198                                );
1199                                // Subscriptions are restored by
1200                                // `LighterWebSocketClient`'s reconnect logic;
1201                                // the execution context is preserved by the
1202                                // handler across reconnects. Refresh the nonce
1203                                // baseline since the venue's expected next
1204                                // nonce may have advanced while we were
1205                                // disconnected.
1206                                log::debug!("Lighter WebSocket reconnected (execution stream)");
1207                                auth_refresh_notify.notify_one();
1208
1209                                // No cache touch here: the next venue
1210                                // `account_all_positions` snapshot is
1211                                // authoritative and drives the diff. A
1212                                // synthetic flat from the lifecycle would
1213                                // produce a false close+reopen on a healthy
1214                                // flap. Trade-off: between reconnect and the
1215                                // next snapshot (~<1s typically),
1216                                // `generate_position_status_reports` may
1217                                // return stale data.
1218
1219                                // Drained creates have unknown outcomes;
1220                                // reconciliation resolves them, so warn
1221                                // rather than emit rejections.
1222                                let _refresh_guard = nonce_submission_gate.write().await;
1223                                let disconnected_epoch = connection_epoch.saturating_sub(1);
1224                                let stale = dispatch.drain_pending_sendtx(disconnected_epoch);
1225                                warn_pending_sendtx_unknown(&stale, "reconnect");
1226
1227                                if let Some(credential) = &credential_for_loop {
1228                                    match http_client_for_loop
1229                                        .get_next_nonce(
1230                                            credential.account_index(),
1231                                            credential.api_key_index(),
1232                                        )
1233                                        .await
1234                                    {
1235                                        Ok(response) => {
1236                                            dispatch.nonce_manager.refresh(
1237                                                credential.account_index(),
1238                                                credential.api_key_index(),
1239                                                response.nonce,
1240                                            );
1241                                            nonce_ready_connection_epoch
1242                                                .store(connection_epoch, Ordering::Release);
1243                                            log::debug!(
1244                                                "Refreshed Lighter nonce after reconnect: \
1245                                                 account_index={}, next_nonce={}",
1246                                                credential.account_index(),
1247                                                response.nonce,
1248                                            );
1249                                        }
1250                                        Err(e) => {
1251                                            log::warn!(
1252                                                "Failed to refresh Lighter nonce after reconnect: {e}",
1253                                            );
1254                                            nonce_refresh_retry
1255                                                .clone()
1256                                                .spawn(connection_epoch);
1257                                        }
1258                                    }
1259                                }
1260                            }
1261                            Some(message @ (NautilusWsMessage::SendTxAck { .. } | NautilusWsMessage::SendTxBatchResult { .. })) => {
1262                                let account_index = credential_for_loop.as_ref().map(|c| c.account_index());
1263                                let acked = match message {
1264                                    NautilusWsMessage::SendTxAck { connection_epoch, tx_hash, code } => {
1265                                        handle_send_tx_ack_for_connection(&dispatch, account_index, connection_epoch, code, tx_hash.as_deref()).into_iter().collect()
1266                                    }
1267                                    NautilusWsMessage::SendTxBatchResult { connection_epoch, id, code, message, tx_hashes } => {
1268                                        let (acked, resync) = handle_send_tx_batch_result(
1269                                            &dispatch, &emitter, account_index, connection_epoch,
1270                                            clock_for_loop.get_time_ns(), &id, code, &message, &tx_hashes,
1271                                        );
1272
1273                                        if resync && ws_client.connection_epoch() == connection_epoch {
1274                                            nonce_ready_connection_epoch.store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
1275
1276                                            nonce_refresh_retry.clone().spawn(connection_epoch);
1277                                        }
1278                                        acked
1279                                    }
1280                                    _ => unreachable!("matched transaction response"),
1281                                };
1282
1283                                for pending in acked {
1284                                    let Some(credential) = credential_for_loop.clone() else { continue; };
1285
1286                                    if matches!(pending.kind, PendingSendTxKind::Modify { .. }) { continue; }
1287                                    spawn_acked_order_probe(
1288                                        &pending,
1289                                        AckedOrderProbeContext {
1290                                            http_client: http_client_for_loop.clone(),
1291                                            registry: Arc::clone(&registry_for_loop),
1292                                            credential,
1293                                            dispatch: dispatch.clone(),
1294                                            account_id: account_id_for_loop,
1295                                            clock: clock_for_loop,
1296                                            emitter: emitter.clone(),
1297                                            connection_epoch: ws_client.connection_epoch_atomic(),
1298                                            cancellation_token: cancellation_token.clone(),
1299                                            pending_tasks: pending_tasks_for_loop.clone(),
1300                                        },
1301                                    );
1302                                }
1303                            }
1304                            Some(NautilusWsMessage::SendTxRejected {
1305                                connection_epoch,
1306                                source,
1307                                code,
1308                                message,
1309                                tx_hash,
1310                            }) => {
1311                                let account_index = credential_for_loop
1312                                    .as_ref()
1313                                    .map(|c| c.account_index());
1314                                let needs_nonce_resync = handle_send_tx_rejection_for_connection(
1315                                    &dispatch,
1316                                    &emitter,
1317                                    account_index,
1318                                    connection_epoch,
1319                                    clock_for_loop.get_time_ns(),
1320                                    source,
1321                                    code,
1322                                    &message,
1323                                    tx_hash.as_deref(),
1324                                );
1325
1326                                // Invalid nonce means the sequential stream is
1327                                // wedged on a burned nonce; only a hard refresh
1328                                // moves allocation back down.
1329                                if needs_nonce_resync
1330                                    && let Some(credential) = &credential_for_loop
1331                                {
1332                                    if ws_client.connection_epoch() != connection_epoch {
1333                                        log::warn!(
1334                                            "Skipping stale Lighter nonce rejection from connection \
1335                                             epoch {connection_epoch}",
1336                                        );
1337                                        continue;
1338                                    }
1339                                    nonce_ready_connection_epoch.store(
1340                                        NONCE_CONNECTION_EPOCH_UNAVAILABLE,
1341                                        Ordering::Release,
1342                                    );
1343                                    let _refresh_guard = nonce_submission_gate.write().await;
1344
1345                                    match http_client_for_loop
1346                                        .get_next_nonce(
1347                                            credential.account_index(),
1348                                            credential.api_key_index(),
1349                                        )
1350                                        .await
1351                                    {
1352                                        Ok(response) => {
1353                                            dispatch.nonce_manager.refresh(
1354                                                credential.account_index(),
1355                                                credential.api_key_index(),
1356                                                response.nonce,
1357                                            );
1358                                            nonce_ready_connection_epoch
1359                                                .store(connection_epoch, Ordering::Release);
1360                                            log::debug!(
1361                                                "Hard-refreshed Lighter nonce after invalid-nonce \
1362                                                 rejection: account_index={}, next_nonce={}",
1363                                                credential.account_index(),
1364                                                response.nonce,
1365                                            );
1366                                        }
1367                                        Err(e) => {
1368                                            log::warn!(
1369                                                "Failed to refresh Lighter nonce after \
1370                                                 invalid-nonce rejection: {e}",
1371                                            );
1372                                            nonce_refresh_retry
1373                                                .clone()
1374                                                .spawn(connection_epoch);
1375                                        }
1376                                    }
1377                                }
1378                            }
1379                            Some(NautilusWsMessage::Raw(value)) => {
1380                                log::debug!("Unhandled Lighter raw frame on execution stream: {value}");
1381                            }
1382                            Some(NautilusWsMessage::AccountStreamFirstFrame(stream)) => {
1383                                // FIFO with preceding reports on the same
1384                                // channel: any typed reports the handler
1385                                // emitted for this frame have already been
1386                                // applied by the cases above, so marking
1387                                // here is safe to unblock `await_all`.
1388                                match stream {
1389                                    AccountStream::Orders => {
1390                                        dispatch.account_streams_ready.mark_orders();
1391                                    }
1392                                    AccountStream::Trades => {
1393                                        dispatch.account_streams_ready.mark_trades();
1394                                    }
1395                                    AccountStream::Positions => {
1396                                        dispatch.account_streams_ready.mark_positions();
1397                                    }
1398                                    AccountStream::Assets => {
1399                                        dispatch.account_streams_ready.mark_assets();
1400                                    }
1401                                    AccountStream::UserStats => {
1402                                        dispatch.account_streams_ready.mark_user_stats();
1403                                    }
1404                                }
1405                            }
1406                            // Public market data variants reach the execution
1407                            // stream only if the user shares a websocket clone
1408                            // with the data client (no production caller does).
1409                            Some(
1410                                NautilusWsMessage::Trades(_)
1411                                | NautilusWsMessage::Quote(_)
1412                                | NautilusWsMessage::Deltas(_)
1413                                | NautilusWsMessage::Depth(_)
1414                                | NautilusWsMessage::Bar(_)
1415                                | NautilusWsMessage::MarkPrice(_)
1416                                | NautilusWsMessage::IndexPrice(_)
1417                                | NautilusWsMessage::FundingRate(_),
1418                            ) => {}
1419                            None => {
1420                                log::debug!("Lighter execution next_event returned None");
1421                                tokio::select! {
1422                                    () = cancellation_token.cancelled() => {
1423                                        log::debug!(
1424                                            "Lighter execution consumption loop cancelled"
1425                                        );
1426                                        break;
1427                                    }
1428                                    () = tokio::time::sleep(Duration::from_secs(1)) => {}
1429                                }
1430                            }
1431                        }
1432                    }
1433                }
1434            }
1435
1436            log::debug!("Lighter execution WebSocket consumption loop finished");
1437        })?;
1438
1439        if let Some(credential) = &self.credential {
1440            self.spawn_auth_token_refresh(credential.clone())?;
1441        }
1442
1443        Ok(())
1444    }
1445
1446    fn spawn_auth_token_refresh(&mut self, credential: Credential) -> anyhow::Result<()> {
1447        let ws_client = self.ws_client.clone();
1448        let cancellation_token = self.cancellation_token.clone();
1449        let account_index = credential.account_index();
1450        let channels = auth_token_rotation_channels(account_index);
1451        let refresh_notify = Arc::clone(&self.auth_refresh_notify);
1452
1453        self.auth_refresh_handle.spawn(async move {
1454            log::debug!(
1455                "Lighter auth-token refresh task started: interval={}s, account_index={account_index}",
1456                AUTH_TOKEN_REFRESH_INTERVAL.as_secs(),
1457            );
1458
1459            let mut next_refresh_delay = AUTH_TOKEN_REFRESH_INTERVAL;
1460
1461            loop {
1462                tokio::select! {
1463                    biased;
1464                    () = cancellation_token.cancelled() => {
1465                        log::debug!("Lighter auth-token refresh task cancelled");
1466                        break;
1467                    }
1468                    () = refresh_notify.notified() => {
1469                        log::debug!(
1470                            "Refreshing Lighter auth token immediately after reconnect: account_index={account_index}",
1471                        );
1472                    }
1473                    () = tokio::time::sleep(next_refresh_delay) => {}
1474                }
1475
1476                let outcome = refresh_auth_token_until_rotated(
1477                    &credential,
1478                    &channels,
1479                    &cancellation_token,
1480                    AUTH_TOKEN_REFRESH_BACKOFF,
1481                    build_auth_token_for,
1482                    |channel, token| {
1483                        let ws_client = ws_client.clone();
1484                        async move { ws_client.subscribe_account(channel, token).await }
1485                    },
1486                )
1487                .await;
1488
1489                if let Some(delay) = auth_token_refresh_next_delay(outcome) {
1490                    if outcome == AuthTokenRefreshOutcome::Exhausted {
1491                        log::error!(
1492                            "Lighter auth-token rotation exhausted retry window; retrying again in {}s: account_index={account_index}",
1493                            delay.as_secs(),
1494                        );
1495                    }
1496                    next_refresh_delay = delay;
1497                } else {
1498                    log::debug!("Lighter auth-token refresh task cancelled");
1499                    break;
1500                }
1501            }
1502        })?;
1503        Ok(())
1504    }
1505
1506    // Per-order `params["market_order_slippage_bps"]` overrides the config default.
1507    fn resolve_slippage_bps(&self, params: Option<&Params>) -> u32 {
1508        params
1509            .and_then(|p| p.get_u64("market_order_slippage_bps"))
1510            .map_or(self.config.market_order_slippage_bps, |v| v as u32)
1511    }
1512
1513    fn build_tx_context(&self, credential: &Credential) -> anyhow::Result<ReservedTxContext> {
1514        let connection_epoch = self.ws_client.connection_epoch();
1515        anyhow::ensure!(
1516            self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
1517            "Lighter nonce refresh is pending for connection epoch {connection_epoch}",
1518        );
1519        let nonce_guard = Arc::clone(&self.nonce_submission_gate)
1520            .try_read_owned()
1521            .context("Lighter nonce refresh is in progress")?;
1522        anyhow::ensure!(
1523            self.ws_client.connection_epoch() == connection_epoch
1524                && self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
1525            "Lighter connection or nonce state changed during transaction preparation",
1526        );
1527
1528        let send_reservation = match self.tx_send_sequencer.reserve(
1529            credential.account_index(),
1530            credential.api_key_index(),
1531            || {
1532                self.dispatch
1533                    .nonce_manager
1534                    .next_nonce(credential.account_index(), credential.api_key_index())
1535            },
1536        ) {
1537            Ok(reservation) => reservation,
1538            Err(e @ NonceError::SkipWindowExhausted { .. }) => {
1539                self.spawn_nonce_window_recovery(credential);
1540                anyhow::bail!("failed to allocate Lighter nonce: {e}");
1541            }
1542            Err(e) => anyhow::bail!("failed to allocate Lighter nonce: {e}"),
1543        };
1544
1545        let nonce = send_reservation.nonce;
1546
1547        let now_ns = self.clock.get_time_ns().as_u64() as i64;
1548        let expired_at = (now_ns / 1_000_000).saturating_add(DEFAULT_TX_EXPIRY_MS);
1549        let send_reservation = send_reservation.with_nonce_ownership(nonce_guard, connection_epoch);
1550
1551        let context = TxContext {
1552            account_index: credential.account_index(),
1553            api_key_index: credential.api_key_index(),
1554            nonce,
1555            expired_at,
1556        };
1557
1558        Ok(ReservedTxContext {
1559            context,
1560            send_reservation,
1561        })
1562    }
1563
1564    fn spawn_nonce_window_recovery(&self, credential: &Credential) {
1565        if self.nonce_recovery_inflight.swap(true, Ordering::AcqRel) {
1566            return;
1567        }
1568
1569        let inflight = Arc::clone(&self.nonce_recovery_inflight);
1570        let http_client = self.http_client.clone();
1571        let dispatch = self.dispatch.clone();
1572        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
1573        let account_index = credential.account_index();
1574        let api_key_index = credential.api_key_index();
1575
1576        self.spawn_task("nonce_window_recovery", async move {
1577            let _refresh_guard = nonce_submission_gate.write().await;
1578            let result = http_client
1579                .get_next_nonce(account_index, api_key_index)
1580                .await;
1581            inflight.store(false, Ordering::Release);
1582
1583            match result {
1584                Ok(response) => {
1585                    // Monotonic sync, not `refresh`: a hard reset could
1586                    // reissue nonces already signed into in-flight txs.
1587                    let _ = dispatch.nonce_manager.sync_from_venue(
1588                        account_index,
1589                        api_key_index,
1590                        response.nonce,
1591                    );
1592                    log::debug!(
1593                        "Resynced Lighter nonce baseline after skip-window exhaustion: \
1594                         account_index={account_index}, api_key_index={api_key_index}, \
1595                         next_nonce={}",
1596                        response.nonce,
1597                    );
1598                }
1599                Err(e) => {
1600                    log::error!(
1601                        "Failed to resync Lighter nonce after skip-window exhaustion: {e}",
1602                    );
1603                }
1604            }
1605            Ok(())
1606        });
1607    }
1608
1609    fn dispatch_create_order_plan(
1610        &self,
1611        plan: &CreateOrderPlan,
1612        credential: &Credential,
1613    ) -> anyhow::Result<()> {
1614        let context = self.fanout_dispatch_context(credential)?;
1615        let prepared = context.sign_create_order(plan)?;
1616        self.spawn_task("submit_order", async move {
1617            context.send_create_order(prepared).await;
1618            Ok(())
1619        });
1620
1621        Ok(())
1622    }
1623
1624    fn prepare_create_order_plan(
1625        &self,
1626        order: &OrderAny,
1627        slippage_bps: u32,
1628    ) -> anyhow::Result<CreateOrderPlan> {
1629        let instrument_id = order.instrument_id();
1630        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1631            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
1632        })?;
1633
1634        let instrument = self.core.cache().try_instrument(&instrument_id)?.clone();
1635
1636        let order_kind = nautilus_to_lighter_order_type(order.order_type())?;
1637
1638        let tif = nautilus_to_lighter_tif(
1639            order.order_type(),
1640            order.time_in_force(),
1641            order.is_post_only(),
1642        )?;
1643        let now_ms = (self.clock.get_time_ns().as_u64() / 1_000_000) as i64;
1644        let order_expiry = order_expiry_for(
1645            order.order_type(),
1646            &order.time_in_force(),
1647            order.expire_time(),
1648            now_ms,
1649            self.config.use_gtd,
1650        )?;
1651
1652        let base_amount = quantity_to_ticks(&order.quantity(), instrument.size_precision())?;
1653
1654        // `quantity_to_ticks` floors sub-precision quantities to 0.
1655        anyhow::ensure!(
1656            base_amount > 0,
1657            "quantity `{}` rounds to 0 ticks at size_precision {}",
1658            order.quantity(),
1659            instrument.size_precision(),
1660        );
1661        let price_precision = instrument.price_precision();
1662        let is_buy = matches!(order.order_side(), OrderSide::Buy);
1663
1664        // Lighter requires `price` on market-style orders as the worst
1665        // acceptable cap; derive it from far-side quote or trigger.
1666        let price_ticks = match order.order_type() {
1667            OrderType::Market => {
1668                let quote = self
1669                    .core
1670                    .cache()
1671                    .quote(&instrument_id)
1672                    .copied()
1673                    .ok_or_else(|| {
1674                        anyhow::anyhow!(
1675                            "no cached quote for {instrument_id}: subscribe to quotes before submitting MARKET orders",
1676                        )
1677                    })?;
1678                let base = if is_buy {
1679                    quote.ask_price.as_decimal()
1680                } else {
1681                    quote.bid_price.as_decimal()
1682                };
1683                derive_market_order_price_ticks(base, is_buy, price_precision, slippage_bps)?
1684            }
1685            OrderType::StopMarket | OrderType::MarketIfTouched => {
1686                let trigger = order.trigger_price().ok_or_else(|| {
1687                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type(),)
1688                })?;
1689                derive_market_order_price_ticks(
1690                    trigger.as_decimal(),
1691                    is_buy,
1692                    price_precision,
1693                    slippage_bps,
1694                )?
1695            }
1696            _ => order
1697                .price()
1698                .map(|p| price_to_ticks(&p, price_precision))
1699                .transpose()?
1700                .unwrap_or(0),
1701        };
1702
1703        let trigger_price_ticks = order
1704            .trigger_price()
1705            .map(|p| price_to_ticks(&p, price_precision))
1706            .transpose()?
1707            .unwrap_or(0);
1708
1709        // Conditional types: `price_to_ticks` floors sub-tick triggers to 0,
1710        // which Lighter would then reject.
1711        if matches!(
1712            order.order_type(),
1713            OrderType::StopMarket
1714                | OrderType::StopLimit
1715                | OrderType::MarketIfTouched
1716                | OrderType::LimitIfTouched
1717        ) {
1718            anyhow::ensure!(
1719                trigger_price_ticks > 0,
1720                "trigger_price `{:?}` rounds to 0 ticks at precision {price_precision}",
1721                order.trigger_price(),
1722            );
1723        }
1724        validate_order_amount(&instrument, order.quantity(), price_ticks, price_precision)?;
1725
1726        Ok(CreateOrderPlan {
1727            order: order.clone(),
1728            market_index,
1729            base_amount,
1730            price: price_ticks,
1731            order_type: order_kind as u8,
1732            time_in_force: tif as u8,
1733            trigger_price: trigger_price_ticks,
1734            order_expiry,
1735        })
1736    }
1737
1738    fn prepare_create_group_plan(
1739        &self,
1740        orders: &[OrderAny],
1741        slippage_bps: u32,
1742    ) -> anyhow::Result<CreateGroupPlan> {
1743        let (grouping_type, indices) = grouped_order_indices(orders)?;
1744        let mut plans = Vec::with_capacity(indices.len());
1745        for index in indices {
1746            let order = &orders[index];
1747            let instrument = self
1748                .core
1749                .cache()
1750                .instrument(&order.instrument_id())
1751                .cloned();
1752
1753            if let Some(reason) = local_submit_denial_reason(order, instrument.as_ref()) {
1754                anyhow::bail!("{reason}");
1755            }
1756
1757            anyhow::ensure!(
1758                !is_lighter_spot_order(order, instrument.as_ref()),
1759                "Lighter grouped orders require perpetual markets"
1760            );
1761            plans.push(self.prepare_create_order_plan(order, slippage_bps)?);
1762        }
1763
1764        prepare_group_bodies(grouping_type, &mut plans, self.config.use_gtd)?;
1765        Ok(CreateGroupPlan {
1766            grouping_type,
1767            orders: plans,
1768        })
1769    }
1770
1771    fn fanout_dispatch_context(
1772        &self,
1773        credential: &Credential,
1774    ) -> anyhow::Result<FanoutDispatchContext> {
1775        let pending_tasks = self
1776            .pending_tasks
1777            .spawner()
1778            .context("Lighter task admission is closed")?;
1779        Ok(FanoutDispatchContext {
1780            clock: self.clock,
1781            chain_id: self.config.chain_id(),
1782            integrator_account_index: self.integrator_account_index(),
1783            emitter: self.emitter.clone(),
1784            credential: credential.clone(),
1785            http_client: self.http_client.clone(),
1786            registry: Arc::clone(&self.registry),
1787            ws_client: self.ws_client.clone(),
1788            tx_rate_limiter: Arc::clone(&self.tx_rate_limiter),
1789            tx_send_sequencer: self.tx_send_sequencer.clone(),
1790            nonce_submission_gate: Arc::clone(&self.nonce_submission_gate),
1791            nonce_ready_connection_epoch: Arc::clone(&self.nonce_ready_connection_epoch),
1792            dispatch: self.dispatch.clone(),
1793            nonce_recovery_inflight: Arc::clone(&self.nonce_recovery_inflight),
1794            pending_tasks,
1795        })
1796    }
1797
1798    fn dispatch_signed_cancel_order(&self, cmd: &CancelOrder, credential: &Credential) {
1799        let context = match self.fanout_dispatch_context(credential) {
1800            Ok(context) => context,
1801            Err(e) => {
1802                log::warn!("Skipping Lighter cancel_order after shutdown began: {e}");
1803                return;
1804            }
1805        };
1806        self.dispatch
1807            .set_pending_order_action(cmd.client_order_id, PendingOrderAction::Cancel);
1808        let emit_cancel_rejected = self.can_emit_order_cancel_rejected(&cmd.client_order_id);
1809        if !emit_cancel_rejected {
1810            self.dispatch
1811                .clear_pending_order_action_if(&cmd.client_order_id, PendingOrderAction::Cancel);
1812        }
1813
1814        let prepared = match self
1815            .prepare_cancel_order_plan(cmd)
1816            .and_then(|plan| context.sign_cancel_order(&plan))
1817        {
1818            Ok(prepared) => prepared,
1819            Err(e) => {
1820                self.dispatch.clear_pending_order_action_if(
1821                    &cmd.client_order_id,
1822                    PendingOrderAction::Cancel,
1823                );
1824                let reason = format!("Lighter cancel_order failed: {e}");
1825                if emit_cancel_rejected {
1826                    log::warn!("{reason} for {}", cmd.client_order_id);
1827                    self.emitter.emit_order_cancel_rejected_event(
1828                        cmd.strategy_id,
1829                        cmd.instrument_id,
1830                        cmd.client_order_id,
1831                        cmd.venue_order_id,
1832                        &reason,
1833                        self.clock.get_time_ns(),
1834                    );
1835                } else {
1836                    log::warn!(
1837                        "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
1838                        cmd.client_order_id,
1839                    );
1840                }
1841                return;
1842            }
1843        };
1844
1845        self.spawn_task("cancel_order", async move {
1846            context
1847                .send_cancel_order(prepared, emit_cancel_rejected)
1848                .await;
1849            Ok(())
1850        });
1851    }
1852
1853    fn dispatch_cancel_orders(&self, cancels: &[CancelOrder]) -> anyhow::Result<()> {
1854        let credential = self.credential.as_ref().ok_or_else(|| {
1855            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
1856        })?;
1857
1858        if cancels.is_empty() {
1859            log::debug!("batch_cancel_orders called with empty cancel list");
1860            return Ok(());
1861        }
1862
1863        let context = self.fanout_dispatch_context(credential)?;
1864
1865        let emit_cancel_rejected: Vec<bool> = cancels
1866            .iter()
1867            .map(|cancel| self.can_emit_order_cancel_rejected(&cancel.client_order_id))
1868            .collect();
1869
1870        let mut plans = Vec::with_capacity(cancels.len());
1871
1872        for (cancel, emit_cancel_rejected) in cancels.iter().zip(emit_cancel_rejected) {
1873            let action = PendingOrderAction::cancel(emit_cancel_rejected);
1874            self.dispatch
1875                .set_pending_order_action(cancel.client_order_id, action);
1876
1877            match self.prepare_cancel_order_plan(cancel) {
1878                Ok(plan) => {
1879                    let group = self
1880                        .prepare_oco_cancel_plans(cancel)
1881                        .unwrap_or_else(|| vec![plan]);
1882                    plans.extend(group.into_iter().map(|plan| {
1883                        let emit = self.can_emit_order_cancel_rejected(&plan.client_order_id);
1884                        (plan, emit)
1885                    }));
1886                }
1887                Err(e) => {
1888                    self.dispatch
1889                        .clear_pending_order_action_if(&cancel.client_order_id, action);
1890                    let reason = format!("Lighter cancel_order failed: {e}");
1891
1892                    if emit_cancel_rejected {
1893                        self.emitter.emit_order_cancel_rejected_event(
1894                            cancel.strategy_id,
1895                            cancel.instrument_id,
1896                            cancel.client_order_id,
1897                            cancel.venue_order_id,
1898                            &reason,
1899                            self.clock.get_time_ns(),
1900                        );
1901                    } else {
1902                        log::warn!(
1903                            "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
1904                            cancel.client_order_id,
1905                        );
1906                    }
1907                }
1908            }
1909        }
1910
1911        let mut seen = AHashSet::new();
1912        plans.retain(|(plan, _)| seen.insert(plan.client_order_id));
1913        for (plan, emit) in &plans {
1914            self.dispatch
1915                .set_pending_order_action(plan.client_order_id, PendingOrderAction::cancel(*emit));
1916        }
1917
1918        if plans.is_empty() {
1919            return Ok(());
1920        }
1921
1922        self.spawn_task("batch_cancel_orders", async move {
1923            context
1924                .dispatch_batch_plans(
1925                    plans
1926                        .into_iter()
1927                        .map(|(plan, emit)| BatchOrderPlan::Cancel(plan, emit))
1928                        .collect(),
1929                )
1930                .await;
1931            Ok(())
1932        });
1933
1934        Ok(())
1935    }
1936
1937    fn can_emit_order_cancel_rejected(&self, client_order_id: &ClientOrderId) -> bool {
1938        self.core
1939            .cache()
1940            .order(client_order_id)
1941            .is_none_or(|order| order.is_pending_cancel())
1942    }
1943
1944    fn prepare_cancel_order_plan(&self, cmd: &CancelOrder) -> anyhow::Result<CancelOrderPlan> {
1945        let market_index = self
1946            .registry
1947            .market_index(&cmd.instrument_id)
1948            .ok_or_else(|| {
1949                anyhow::anyhow!(
1950                    "no Lighter market_index registered for instrument {}",
1951                    cmd.instrument_id,
1952                )
1953            })?;
1954
1955        self.core.cache().try_order(&cmd.client_order_id)?;
1956
1957        // Lighter cancel_order targets a single order by venue order_id.
1958        // The map is populated on the first OrderStatusReport for the cloid.
1959        let voi = cmd
1960            .venue_order_id
1961            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
1962            .ok_or_else(|| {
1963                anyhow::anyhow!(
1964                    "cannot cancel Lighter order {}: venue order_id not yet known \
1965                     (await OrderAccepted before issuing cancel)",
1966                    cmd.client_order_id,
1967                )
1968            })?;
1969
1970        let venue_index: i64 = voi
1971            .as_str()
1972            .parse()
1973            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
1974
1975        Ok(CancelOrderPlan {
1976            client_order_id: cmd.client_order_id,
1977            strategy_id: cmd.strategy_id,
1978            instrument_id: cmd.instrument_id,
1979            venue_order_id: Some(voi),
1980            market_index,
1981            venue_index,
1982        })
1983    }
1984
1985    fn dispatch_signed_modify_order(&self, cmd: &ModifyOrder, credential: &Credential) {
1986        let probe_context = match self.fanout_dispatch_context(credential) {
1987            Ok(context) => context.probe_context(),
1988            Err(e) => {
1989                log::warn!("Lighter modify probe unavailable: {e}");
1990                return;
1991            }
1992        };
1993
1994        if !self.admit_modify_order(cmd) {
1995            return;
1996        }
1997
1998        let prepared = match self.prepare_signed_modify_order(cmd, credential) {
1999            Ok(prepared) => prepared,
2000            Err(e) => {
2001                let mut events = self.dispatch.order_events.lock();
2002                self.dispatch
2003                    .clear_pending_modify_if_command(&cmd.client_order_id, cmd.command_id);
2004                let reason = format!("Lighter modify_order failed: {e}");
2005                log::warn!("{reason} for {}", cmd.client_order_id);
2006                self.emitter.emit_order_modify_rejected_event(
2007                    cmd.strategy_id,
2008                    cmd.instrument_id,
2009                    cmd.client_order_id,
2010                    cmd.venue_order_id,
2011                    &reason,
2012                    self.clock.get_time_ns(),
2013                );
2014
2015                if let Some(deferred) = events.remove(&cmd.client_order_id) {
2016                    flush_modify_events(
2017                        cmd.client_order_id,
2018                        deferred,
2019                        &self.dispatch,
2020                        &self.emitter,
2021                        &mut events,
2022                    );
2023                }
2024
2025                return;
2026            }
2027        };
2028        let PreparedModifyOrder {
2029            client_order_id,
2030            strategy_id,
2031            instrument_id,
2032            venue_order_id,
2033            tx_info,
2034            nonce,
2035            api_key_index,
2036            tx_hash,
2037            mut send_reservation,
2038        } = prepared;
2039        let connection_epoch = send_reservation.connection_epoch;
2040
2041        let ws_client = self.ws_client.clone();
2042        let dispatch = self.dispatch.clone();
2043        let credential = credential.clone();
2044        let emitter = self.emitter.clone();
2045        let clock = self.clock;
2046
2047        let tx_rate_limiter = self.tx_rate_limiter.clone();
2048
2049        self.spawn_task("modify_order", async move {
2050            send_reservation.wait_for_turn().await;
2051            await_tx_quota(&tx_rate_limiter).await;
2052
2053            let pending = PendingSendTx {
2054                batch_id: None,
2055                connection_epoch,
2056                kind: PendingSendTxKind::Modify {
2057                    strategy_id,
2058                    instrument_id,
2059                    client_order_id,
2060                    venue_order_id,
2061                },
2062                submitted_at: clock.get_time_ns(),
2063                nonce,
2064                api_key_index,
2065                tx_hash,
2066            };
2067
2068            dispatch.enqueue_pending_sendtx(pending.clone());
2069            spawn_acked_order_probe(&pending, probe_context);
2070
2071            if let Err(e) = ws_client
2072                .send_tx_on_connection(LighterTxType::ModifyOrder as u8, tx_info, connection_epoch)
2073                .await
2074            {
2075                let failure = classify_lighter_ws_command_failure("modify_order", &e);
2076                let reason = command_failure_reason(&failure);
2077                if matches!(&failure, CommandFailure::Ambiguous(_)) {
2078                    log::warn!(
2079                        "Lighter modify_order dispatch outcome unknown for {client_order_id}: {reason}; \
2080                         retaining pending state for venue reconciliation; diagnostic={e:?}",
2081                    );
2082                } else {
2083                    log::warn!("{reason} for {client_order_id}; diagnostic={e:?}");
2084                    dispatch.remove_pending_sendtx_by_nonce(connection_epoch, nonce);
2085                    rollback_tx_dispatch(&dispatch, &credential, None, nonce);
2086                    reject_modify_order(&dispatch, &emitter, &pending, reason, clock.get_time_ns());
2087                }
2088            }
2089            send_reservation.release();
2090            Ok(())
2091        });
2092    }
2093
2094    fn admit_modify_order(&self, cmd: &ModifyOrder) -> bool {
2095        let _events = self.dispatch.order_events.lock();
2096
2097        let publication_pending = self
2098            .dispatch
2099            .order_identity(&cmd.client_order_id)
2100            .is_some_and(|identity| {
2101                identity
2102                    .publication_pending(self.core.cache().order(&cmd.client_order_id).as_deref())
2103            });
2104
2105        if !publication_pending && self.dispatch.set_pending_modify_action(cmd) {
2106            return true;
2107        }
2108
2109        self.emitter.emit_order_modify_rejected_event(
2110            cmd.strategy_id,
2111            cmd.instrument_id,
2112            cmd.client_order_id,
2113            cmd.venue_order_id,
2114            "Lighter permits one unresolved modification per order; wait for its update or rejection",
2115            self.clock.get_time_ns(),
2116        );
2117        false
2118    }
2119
2120    fn prepare_signed_modify_order(
2121        &self,
2122        cmd: &ModifyOrder,
2123        credential: &Credential,
2124    ) -> anyhow::Result<PreparedModifyOrder> {
2125        let plan = self.prepare_modify_order_plan(cmd)?;
2126        let context = self.fanout_dispatch_context(credential)?;
2127        let reserved = self.build_tx_context(credential)?;
2128        context.sign_modify_order(&plan, reserved)
2129    }
2130
2131    fn prepare_modify_order_plan(&self, cmd: &ModifyOrder) -> anyhow::Result<ModifyOrderPlan> {
2132        let market_index = self
2133            .registry
2134            .market_index(&cmd.instrument_id)
2135            .ok_or_else(|| {
2136                anyhow::anyhow!(
2137                    "no Lighter market_index registered for instrument {}",
2138                    cmd.instrument_id,
2139                )
2140            })?;
2141
2142        let voi = cmd
2143            .venue_order_id
2144            .or_else(|| self.dispatch.lookup_venue_order_id(&cmd.client_order_id))
2145            .ok_or_else(|| {
2146                anyhow::anyhow!(
2147                    "cannot modify Lighter order {}: venue order_id not yet known \
2148                     (await OrderAccepted before issuing modify)",
2149                    cmd.client_order_id,
2150                )
2151            })?;
2152
2153        let venue_index: i64 = voi
2154            .as_str()
2155            .parse()
2156            .with_context(|| format!("Lighter venue_order_id `{voi}` is not an integer index"))?;
2157
2158        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
2159        let instrument = self
2160            .core
2161            .cache()
2162            .try_instrument(&cmd.instrument_id)?
2163            .clone();
2164
2165        let new_qty = cmd.quantity.unwrap_or(order.quantity());
2166        anyhow::ensure!(
2167            !(order.parent_order_id().is_some()
2168                && self
2169                    .dispatch
2170                    .snapshot_for(&cmd.client_order_id)
2171                    .is_some_and(|shape| shape.quantity.is_zero())),
2172            "Lighter cannot modify an OTO child while it waits for parent execution"
2173        );
2174        let price_precision = instrument.price_precision();
2175        let new_trigger = cmd.trigger_price.or(order.trigger_price());
2176        let triggered = order.is_triggered() == Some(true)
2177            || self.dispatch.triggered_was_emitted(&cmd.client_order_id);
2178        anyhow::ensure!(
2179            !triggered || cmd.trigger_price.is_none(),
2180            "Lighter cannot modify a trigger price after the order has triggered"
2181        );
2182
2183        // Market-style stops carry no limit price; Lighter still needs a worst-
2184        // acceptable `price` cap, derived from the trigger and slippage like submit.
2185        let price_ticks = match order.order_type() {
2186            OrderType::StopMarket | OrderType::MarketIfTouched => {
2187                let trigger = new_trigger.ok_or_else(|| {
2188                    anyhow::anyhow!("{:?} orders require a trigger_price", order.order_type())
2189                })?;
2190                let is_buy = matches!(order.order_side(), OrderSide::Buy);
2191                let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
2192
2193                derive_market_order_price_ticks(
2194                    trigger.as_decimal(),
2195                    is_buy,
2196                    price_precision,
2197                    slippage_bps,
2198                )?
2199            }
2200            _ => {
2201                let new_price = cmd.price.or(order.price()).ok_or_else(|| {
2202                    anyhow::anyhow!("modify_order requires a price (none on order or command)")
2203                })?;
2204
2205                price_to_ticks(&new_price, price_precision)?
2206            }
2207        };
2208
2209        let base_amount = quantity_to_ticks(&new_qty, instrument.size_precision())?;
2210        anyhow::ensure!(
2211            base_amount > 0,
2212            "quantity `{new_qty}` rounds to 0 ticks at size_precision {}",
2213            instrument.size_precision(),
2214        );
2215
2216        let trigger_price_ticks = match new_trigger.filter(|_| !triggered) {
2217            Some(trigger) if !trigger.is_zero() => price_to_ticks(&trigger, price_precision)?,
2218            _ => 0,
2219        };
2220
2221        if !triggered
2222            && matches!(
2223                order.order_type(),
2224                OrderType::StopMarket
2225                    | OrderType::StopLimit
2226                    | OrderType::MarketIfTouched
2227                    | OrderType::LimitIfTouched
2228            )
2229        {
2230            anyhow::ensure!(
2231                trigger_price_ticks > 0,
2232                "trigger_price `{new_trigger:?}` rounds to 0 ticks at precision {price_precision}",
2233            );
2234        }
2235        validate_order_amount(&instrument, new_qty, price_ticks, price_precision)?;
2236        self.dispatch.set_pending_modify_shape(
2237            cmd,
2238            OrderShapeSnapshot {
2239                quantity: parse_quantity_from_ticks(base_amount, instrument.size_precision())?,
2240                price: LIMIT_ORDER_TYPES
2241                    .contains(&order.order_type())
2242                    .then(|| parse_price_from_ticks(price_ticks, price_precision))
2243                    .transpose()?,
2244                trigger_price: STOP_ORDER_TYPES
2245                    .contains(&order.order_type())
2246                    .then(|| {
2247                        if triggered {
2248                            Ok(None)
2249                        } else {
2250                            parse_price_from_ticks(trigger_price_ticks, price_precision).map(Some)
2251                        }
2252                    })
2253                    .transpose()?
2254                    .flatten(),
2255            },
2256        );
2257
2258        Ok(ModifyOrderPlan {
2259            command_id: cmd.command_id,
2260            client_order_id: cmd.client_order_id,
2261            strategy_id: cmd.strategy_id,
2262            instrument_id: cmd.instrument_id,
2263            venue_order_id: Some(voi),
2264            market_index,
2265            venue_index,
2266            base_amount,
2267            price: price_ticks,
2268            trigger_price: trigger_price_ticks,
2269        })
2270    }
2271
2272    /// Submit Lighter's native `UpdateLeverage` tx (`tx_type = 20`).
2273    ///
2274    /// Changes the initial margin fraction and the position margin mode for
2275    /// the given market. `initial_margin_fraction` is in venue ticks
2276    /// (1e-4 fraction): `500` = 5% initial margin = 20x leverage,
2277    /// `1000` = 10% = 10x, etc. Valid range is `1..=10_000`
2278    /// (the upstream `MarginFractionTick` cap).
2279    ///
2280    /// Nautilus does not expose a `set_leverage` command on the execution
2281    /// trait, so this method is callable directly from strategy or bootstrap
2282    /// code.
2283    ///
2284    /// # Errors
2285    ///
2286    /// Returns an error if credentials are missing, the instrument is not
2287    /// registered, `initial_margin_fraction` is outside `1..=10_000`, or
2288    /// the dispatch pre-flight (nonce allocation, signing) fails. Transport
2289    /// errors after dispatch are logged but not returned synchronously.
2290    pub fn update_leverage(
2291        &self,
2292        instrument_id: InstrumentId,
2293        initial_margin_fraction: u16,
2294        margin_mode: LighterPositionMarginMode,
2295    ) -> anyhow::Result<()> {
2296        let credential = self.credential.as_ref().ok_or_else(|| {
2297            anyhow::anyhow!("Lighter execution client cannot update leverage without credentials")
2298        })?;
2299
2300        let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
2301            anyhow::anyhow!("no Lighter market_index registered for instrument {instrument_id}")
2302        })?;
2303
2304        anyhow::ensure!(
2305            (1..=10_000).contains(&initial_margin_fraction),
2306            "initial_margin_fraction must be in 1..=10_000, was {initial_margin_fraction}",
2307        );
2308
2309        let ReservedTxContext {
2310            context,
2311            mut send_reservation,
2312        } = self.build_tx_context(credential)?;
2313
2314        let connection_epoch = send_reservation.connection_epoch;
2315
2316        let captured_nonce = context.nonce;
2317        let captured_api_key_index = context.api_key_index;
2318        let mut rollback_guard =
2319            TxDispatchGuard::new(self.dispatch.clone(), credential, None, captured_nonce);
2320
2321        let tx = UpdateLeverageTxInfo {
2322            context,
2323            market_index,
2324            initial_margin_fraction,
2325            margin_mode: margin_mode as u8,
2326            skip_nonce: 0,
2327        };
2328
2329        let signed = sign_tx(
2330            &tx,
2331            self.config.chain_id(),
2332            &credential.private_key()?,
2333            fresh_k(),
2334        );
2335
2336        let tx_info_str = TxInfoJson::update_leverage(&tx, &signed);
2337        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
2338            .context("failed to wrap signed Lighter update_leverage tx_info JSON")?;
2339
2340        rollback_guard.disarm();
2341        let captured_tx_hash = signed.tx_hash_hex();
2342
2343        let ws_client = self.ws_client.clone();
2344        let dispatch = self.dispatch.clone();
2345        let credential = credential.clone();
2346        let clock = self.clock;
2347
2348        let tx_rate_limiter = self.tx_rate_limiter.clone();
2349
2350        self.spawn_task("update_leverage", async move {
2351            send_reservation.wait_for_turn().await;
2352            await_tx_quota(&tx_rate_limiter).await;
2353            dispatch.enqueue_pending_sendtx(PendingSendTx {
2354                batch_id: None,
2355                connection_epoch,
2356                kind: PendingSendTxKind::Other,
2357                submitted_at: clock.get_time_ns(),
2358                nonce: captured_nonce,
2359                api_key_index: captured_api_key_index,
2360                tx_hash: captured_tx_hash,
2361            });
2362
2363            if let Err(e) = ws_client
2364                .send_tx_on_connection(
2365                    LighterTxType::UpdateLeverage as u8,
2366                    tx_info,
2367                    connection_epoch,
2368                )
2369                .await
2370            {
2371                let failure = classify_lighter_ws_command_failure("update_leverage", &e);
2372                let reason = command_failure_reason(&failure);
2373                if matches!(&failure, CommandFailure::Ambiguous(_)) {
2374                    log::warn!(
2375                        "Lighter update_leverage dispatch outcome unknown for {instrument_id}: \
2376                         {reason}; retaining pending nonce for venue reconciliation; diagnostic={e:?}",
2377                    );
2378                } else {
2379                    log::error!("{reason} for {instrument_id}; diagnostic={e:?}");
2380                    dispatch.remove_pending_sendtx_by_nonce(connection_epoch, captured_nonce);
2381                    rollback_tx_dispatch(&dispatch, &credential, None, captured_nonce);
2382                }
2383            }
2384            send_reservation.release();
2385            Ok(())
2386        });
2387
2388        Ok(())
2389    }
2390}
2391
2392fn emit_lighter_position_reports(
2393    reports: Vec<PositionStatusReport>,
2394    removed: Vec<InstrumentId>,
2395    emitter: &ExecutionEventEmitter,
2396    account_id: AccountId,
2397    now: UnixNanos,
2398) {
2399    for report in reports {
2400        log::debug!(
2401            "Lighter PositionStatusReport: instrument={} side={:?} qty={}",
2402            report.instrument_id,
2403            report.position_side,
2404            report.quantity,
2405        );
2406        emitter.send_position_report(report);
2407    }
2408
2409    // Emit Flat so the engine observes positions the venue reports as closed
2410    for instrument_id in removed {
2411        let flat = PositionStatusReport::new(
2412            account_id,
2413            instrument_id,
2414            PositionSide::Flat,
2415            Quantity::zero(0),
2416            now,
2417            now,
2418            Some(UUID4::new()),
2419            None,
2420            None,
2421        );
2422        emitter.send_position_report(flat);
2423    }
2424}
2425
2426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2427enum AuthTokenRefreshOutcome {
2428    Rotated,
2429    Cancelled,
2430    Exhausted,
2431}
2432
2433#[derive(Debug, Clone, Copy)]
2434struct AuthTokenRefreshBackoff {
2435    initial_delay: Duration,
2436    max_delay: Duration,
2437    window: Duration,
2438}
2439
2440#[derive(Clone)]
2441struct NonceRefreshRetry {
2442    http_client: LighterHttpClient,
2443    dispatch: WsDispatchState,
2444    credential: Option<Credential>,
2445    submission_gate: Arc<tokio::sync::RwLock<()>>,
2446    ready_connection_epoch: Arc<AtomicU64>,
2447    ws_client: LighterWebSocketClient,
2448    cancellation_token: CancellationToken,
2449    pending_tasks: TaskSpawner,
2450}
2451
2452impl NonceRefreshRetry {
2453    fn spawn(self, connection_epoch: u64) {
2454        let pending_tasks = self.pending_tasks.clone();
2455
2456        let future = async move {
2457            let Some(credential) = self.credential else {
2458                return;
2459            };
2460            let mut retry_delay = NONCE_REFRESH_RETRY_INITIAL_DELAY;
2461
2462            loop {
2463                tokio::select! {
2464                    () = self.cancellation_token.cancelled() => return,
2465                    () = tokio::time::sleep(retry_delay) => {}
2466                }
2467
2468                if self.ws_client.connection_epoch() != connection_epoch
2469                    || self.ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
2470                {
2471                    return;
2472                }
2473
2474                let _refresh_guard = tokio::select! {
2475                    () = self.cancellation_token.cancelled() => return,
2476                    guard = self.submission_gate.write() => guard,
2477                };
2478
2479                if self.ws_client.connection_epoch() != connection_epoch
2480                    || self.ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
2481                {
2482                    return;
2483                }
2484
2485                let result = tokio::select! {
2486                    () = self.cancellation_token.cancelled() => return,
2487                    result = self.http_client.get_next_nonce(
2488                        credential.account_index(),
2489                        credential.api_key_index(),
2490                    ) => result,
2491                };
2492
2493                match result {
2494                    Ok(response) => {
2495                        if self.ws_client.connection_epoch() != connection_epoch {
2496                            return;
2497                        }
2498                        self.dispatch.nonce_manager.refresh(
2499                            credential.account_index(),
2500                            credential.api_key_index(),
2501                            response.nonce,
2502                        );
2503                        self.ready_connection_epoch
2504                            .store(connection_epoch, Ordering::Release);
2505                        log::info!(
2506                            "Recovered Lighter nonce refresh: account_index={}, \
2507                             connection_epoch={connection_epoch}, next_nonce={}",
2508                            credential.account_index(),
2509                            response.nonce,
2510                        );
2511                        return;
2512                    }
2513                    Err(e) => {
2514                        log::warn!(
2515                            "Failed to retry Lighter nonce refresh for connection epoch \
2516                             {connection_epoch}: {e}",
2517                        );
2518                    }
2519                }
2520
2521                retry_delay = retry_delay
2522                    .saturating_mul(2)
2523                    .min(NONCE_REFRESH_RETRY_MAX_DELAY);
2524            }
2525        };
2526
2527        if let Err(e) = pending_tasks.spawn(future) {
2528            log::debug!("Skipping Lighter nonce refresh retry during shutdown: {e}");
2529        }
2530    }
2531}
2532
2533fn auth_token_rotation_channels(account_index: i64) -> [LighterWsChannel; 5] {
2534    [
2535        LighterWsChannel::AccountAllOrders(account_index),
2536        LighterWsChannel::AccountAllTrades(account_index),
2537        LighterWsChannel::AccountAllPositions(account_index),
2538        LighterWsChannel::AccountAllAssets(account_index),
2539        LighterWsChannel::UserStats(account_index),
2540    ]
2541}
2542
2543async fn refresh_auth_token_until_rotated<MintToken, Subscribe, SubscribeFuture>(
2544    credential: &Credential,
2545    channels: &[LighterWsChannel],
2546    cancellation_token: &CancellationToken,
2547    backoff: AuthTokenRefreshBackoff,
2548    mut mint_token: MintToken,
2549    mut subscribe: Subscribe,
2550) -> AuthTokenRefreshOutcome
2551where
2552    MintToken: FnMut(&Credential) -> anyhow::Result<SecretString>,
2553    Subscribe: FnMut(LighterWsChannel, SecretString) -> SubscribeFuture,
2554    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
2555{
2556    let retry_started = tokio::time::Instant::now();
2557    let mut retry_delay = backoff.initial_delay.min(backoff.max_delay);
2558    let mut attempt = 1_u32;
2559
2560    loop {
2561        match rotate_auth_token_once(credential, channels, &mut mint_token, &mut subscribe).await {
2562            Ok(()) => {
2563                log::debug!(
2564                    "Lighter auth-token rotated for account_index={}, attempts={attempt}",
2565                    credential.account_index(),
2566                );
2567                return AuthTokenRefreshOutcome::Rotated;
2568            }
2569            Err(e) => {
2570                log::warn!("Lighter auth-token rotation attempt {attempt} failed: {e:#}");
2571            }
2572        }
2573
2574        let Some(remaining) = backoff.window.checked_sub(retry_started.elapsed()) else {
2575            log::error!(
2576                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
2577                credential.account_index(),
2578            );
2579            return AuthTokenRefreshOutcome::Exhausted;
2580        };
2581
2582        if remaining.as_nanos() == 0 {
2583            log::error!(
2584                "Lighter auth-token rotation retry window exhausted: account_index={}, attempts={attempt}",
2585                credential.account_index(),
2586            );
2587            return AuthTokenRefreshOutcome::Exhausted;
2588        }
2589
2590        let delay = retry_delay.min(remaining);
2591        log::warn!(
2592            "Retrying Lighter auth-token rotation in {:.3}s: account_index={}, attempts={attempt}",
2593            delay.as_secs_f64(),
2594            credential.account_index(),
2595        );
2596
2597        if !sleep_or_auth_token_refresh_cancelled(delay, cancellation_token).await {
2598            return AuthTokenRefreshOutcome::Cancelled;
2599        }
2600
2601        retry_delay = next_auth_token_refresh_retry_delay(retry_delay, backoff.max_delay);
2602        attempt = attempt.saturating_add(1);
2603    }
2604}
2605
2606async fn rotate_auth_token_once<MintToken, Subscribe, SubscribeFuture>(
2607    credential: &Credential,
2608    channels: &[LighterWsChannel],
2609    mint_token: &mut MintToken,
2610    subscribe: &mut Subscribe,
2611) -> anyhow::Result<()>
2612where
2613    MintToken: FnMut(&Credential) -> anyhow::Result<SecretString>,
2614    Subscribe: FnMut(LighterWsChannel, SecretString) -> SubscribeFuture,
2615    SubscribeFuture: Future<Output = Result<(), crate::websocket::error::LighterWsError>>,
2616{
2617    let token =
2618        mint_token(credential).context("failed to mint Lighter auth token during rotation")?;
2619    let mut first_error = None;
2620
2621    for channel in channels {
2622        if let Err(e) = subscribe(channel.clone(), token.clone()).await {
2623            log::debug!("Lighter auth-token rotation: re-subscribe failed for {channel:?}: {e}",);
2624            first_error.get_or_insert_with(|| format!("{channel:?}: {e}"));
2625        }
2626    }
2627
2628    if let Some(error) = first_error {
2629        anyhow::bail!("failed to re-subscribe Lighter account channels: {error}");
2630    }
2631
2632    Ok(())
2633}
2634
2635async fn sleep_or_auth_token_refresh_cancelled(
2636    duration: Duration,
2637    cancellation_token: &CancellationToken,
2638) -> bool {
2639    tokio::select! {
2640        () = cancellation_token.cancelled() => false,
2641        () = tokio::time::sleep(duration) => true,
2642    }
2643}
2644
2645fn auth_token_refresh_next_delay(outcome: AuthTokenRefreshOutcome) -> Option<Duration> {
2646    match outcome {
2647        AuthTokenRefreshOutcome::Rotated => Some(AUTH_TOKEN_REFRESH_INTERVAL),
2648        AuthTokenRefreshOutcome::Cancelled => None,
2649        AuthTokenRefreshOutcome::Exhausted => Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY),
2650    }
2651}
2652
2653fn next_auth_token_refresh_retry_delay(current: Duration, max: Duration) -> Duration {
2654    current.checked_mul(2).unwrap_or(max).min(max)
2655}
2656
2657#[derive(Debug)]
2658struct ReservedTxContext {
2659    context: TxContext,
2660    send_reservation: TxSendReservation,
2661}
2662
2663#[derive(Debug, Clone)]
2664struct TxSendSequencer {
2665    state: Arc<Mutex<TxSendSequencerState>>,
2666    version: Arc<AtomicU64>,
2667    changed: tokio::sync::watch::Sender<u64>,
2668}
2669
2670impl TxSendSequencer {
2671    fn new() -> Self {
2672        let (changed, _) = tokio::sync::watch::channel(0);
2673        Self {
2674            state: Arc::new(Mutex::new(TxSendSequencerState::default())),
2675            version: Arc::new(AtomicU64::new(0)),
2676            changed,
2677        }
2678    }
2679
2680    fn reserve(
2681        &self,
2682        account_index: i64,
2683        api_key_index: u8,
2684        allocate_nonce: impl FnOnce() -> Result<i64, NonceError>,
2685    ) -> Result<TxSendReservation, NonceError> {
2686        let key = TxSendKey {
2687            account_index,
2688            api_key_index,
2689        };
2690
2691        let mut state = self.state.lock();
2692        let nonce = allocate_nonce()?;
2693        *state
2694            .pending
2695            .entry(key)
2696            .or_default()
2697            .entry(nonce)
2698            .or_default() += 1;
2699        drop(state);
2700        self.notify_waiters();
2701        Ok(TxSendReservation {
2702            sequencer: self.clone(),
2703            key,
2704            nonce,
2705            released: false,
2706            nonce_guard: None,
2707            connection_epoch: 0,
2708        })
2709    }
2710
2711    async fn wait_for_turn(&self, key: TxSendKey, nonce: i64) {
2712        let mut changed = self.changed.subscribe();
2713
2714        loop {
2715            if self.ready_to_send(key, nonce) {
2716                return;
2717            }
2718
2719            if changed.changed().await.is_err() {
2720                tokio::task::yield_now().await;
2721            }
2722        }
2723    }
2724
2725    fn release(&self, key: TxSendKey, nonce: i64) {
2726        let mut state = self.state.lock();
2727
2728        let should_notify = if let Some(pending) = state.pending.get_mut(&key) {
2729            let removed = if let Some(count) = pending.get_mut(&nonce) {
2730                *count -= 1;
2731                if *count == 0 {
2732                    pending.remove(&nonce);
2733                }
2734
2735                true
2736            } else {
2737                false
2738            };
2739
2740            if pending.is_empty() {
2741                state.pending.remove(&key);
2742            }
2743            removed
2744        } else {
2745            false
2746        };
2747        drop(state);
2748
2749        if should_notify {
2750            self.notify_waiters();
2751        }
2752    }
2753
2754    fn ready_to_send(&self, key: TxSendKey, nonce: i64) -> bool {
2755        let state = self.state.lock();
2756        state
2757            .pending
2758            .get(&key)
2759            .and_then(|pending| pending.first_key_value())
2760            .is_none_or(|(first, _)| *first >= nonce)
2761    }
2762
2763    fn notify_waiters(&self) {
2764        let version = self.version.fetch_add(1, Ordering::AcqRel) + 1;
2765        let _ = self.changed.send(version);
2766    }
2767}
2768
2769#[derive(Debug, Default)]
2770struct TxSendSequencerState {
2771    pending: BTreeMap<TxSendKey, BTreeMap<i64, usize>>,
2772}
2773
2774#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2775struct TxSendKey {
2776    account_index: i64,
2777    api_key_index: u8,
2778}
2779
2780#[derive(Debug)]
2781struct TxSendReservation {
2782    sequencer: TxSendSequencer,
2783    key: TxSendKey,
2784    nonce: i64,
2785    released: bool,
2786    nonce_guard: Option<tokio::sync::OwnedRwLockReadGuard<()>>,
2787    connection_epoch: u64,
2788}
2789
2790impl TxSendReservation {
2791    fn with_nonce_ownership(
2792        mut self,
2793        nonce_guard: tokio::sync::OwnedRwLockReadGuard<()>,
2794        connection_epoch: u64,
2795    ) -> Self {
2796        self.nonce_guard = Some(nonce_guard);
2797        self.connection_epoch = connection_epoch;
2798        self
2799    }
2800
2801    async fn wait_for_turn(&self) {
2802        self.sequencer.wait_for_turn(self.key, self.nonce).await;
2803    }
2804
2805    fn release(&mut self) {
2806        if self.released {
2807            return;
2808        }
2809
2810        self.sequencer.release(self.key, self.nonce);
2811        self.nonce_guard.take();
2812        self.released = true;
2813    }
2814}
2815
2816impl Drop for TxSendReservation {
2817    fn drop(&mut self) {
2818        self.release();
2819    }
2820}
2821
2822#[cfg(test)]
2823async fn wait_for_tx_send_reservations(reservations: &[&TxSendReservation]) {
2824    let Some(first) = reservations.first() else {
2825        return;
2826    };
2827
2828    debug_assert!(
2829        reservations
2830            .iter()
2831            .all(|reservation| reservation.key == first.key),
2832        "batch send reservations must share one nonce stream",
2833    );
2834
2835    let nonce = reservations
2836        .iter()
2837        .map(|reservation| reservation.nonce)
2838        .min()
2839        .expect("reservations is non-empty");
2840    first.sequencer.wait_for_turn(first.key, nonce).await;
2841}
2842
2843struct PreparedCreateOrder {
2844    order: OrderAny,
2845    client_order_index: i64,
2846    tx_info: Box<serde_json::value::RawValue>,
2847    nonce: i64,
2848    api_key_index: u8,
2849    tx_hash: String,
2850    send_reservation: TxSendReservation,
2851}
2852
2853struct CreateOrderPlan {
2854    order: OrderAny,
2855    market_index: i64,
2856    base_amount: i64,
2857    price: u32,
2858    order_type: u8,
2859    time_in_force: u8,
2860    trigger_price: u32,
2861    order_expiry: i64,
2862}
2863
2864impl CreateOrderPlan {
2865    fn order_info(&self, client_order_index: i64) -> OrderInfo {
2866        OrderInfo {
2867            market_index: self.market_index,
2868            client_order_index,
2869            base_amount: self.base_amount,
2870            price: self.price,
2871            is_ask: self.order.order_side() == OrderSide::Sell,
2872            order_type: self.order_type,
2873            time_in_force: self.time_in_force,
2874            reduce_only: self.order.is_reduce_only(),
2875            trigger_price: self.trigger_price,
2876            order_expiry: self.order_expiry,
2877        }
2878    }
2879}
2880
2881struct CreateGroupPlan {
2882    grouping_type: LighterGroupingType,
2883    orders: Vec<CreateOrderPlan>,
2884}
2885
2886struct PreparedCancelOrder {
2887    client_order_id: ClientOrderId,
2888    strategy_id: StrategyId,
2889    instrument_id: InstrumentId,
2890    venue_order_id: Option<VenueOrderId>,
2891    tx_info: Box<serde_json::value::RawValue>,
2892    nonce: i64,
2893    api_key_index: u8,
2894    tx_hash: String,
2895    send_reservation: TxSendReservation,
2896}
2897
2898struct CancelOrderPlan {
2899    client_order_id: ClientOrderId,
2900    strategy_id: StrategyId,
2901    instrument_id: InstrumentId,
2902    venue_order_id: Option<VenueOrderId>,
2903    market_index: i64,
2904    venue_index: i64,
2905}
2906
2907#[derive(Clone)]
2908struct FanoutDispatchContext {
2909    clock: &'static AtomicTime,
2910    chain_id: u32,
2911    integrator_account_index: Option<u64>,
2912    emitter: ExecutionEventEmitter,
2913    credential: Credential,
2914    http_client: LighterHttpClient,
2915    registry: Arc<MarketRegistry>,
2916    ws_client: LighterWebSocketClient,
2917    tx_rate_limiter: Arc<LighterTxRateLimiter>,
2918    tx_send_sequencer: TxSendSequencer,
2919    nonce_submission_gate: Arc<tokio::sync::RwLock<()>>,
2920    nonce_ready_connection_epoch: Arc<AtomicU64>,
2921    dispatch: WsDispatchState,
2922    nonce_recovery_inflight: Arc<AtomicBool>,
2923    pending_tasks: TaskSpawner,
2924}
2925
2926impl FanoutDispatchContext {
2927    async fn dispatch_batch_plans(&self, plans: Vec<BatchOrderPlan>) {
2928        for chunk in plans.chunks(LIGHTER_MAX_BATCH_TX) {
2929            let mut batch = Vec::with_capacity(chunk.len());
2930            let mut remaining = chunk.iter().peekable();
2931            let mut retry_delay = NONCE_REFRESH_RETRY_INITIAL_DELAY;
2932
2933            while let Some(plan) = remaining.peek() {
2934                match self.sign_batch_plan(plan) {
2935                    Ok(prepared) => {
2936                        batch.push(prepared);
2937                        retry_delay = NONCE_REFRESH_RETRY_INITIAL_DELAY;
2938                    }
2939                    Err(e)
2940                        if matches!(
2941                            e.downcast_ref::<NonceError>(),
2942                            Some(NonceError::SkipWindowExhausted { .. })
2943                        ) || e.is::<tokio::sync::TryLockError>()
2944                            || self.nonce_ready_connection_epoch.load(Ordering::Acquire)
2945                                != self.ws_client.connection_epoch() =>
2946                    {
2947                        self.send_batch(std::mem::take(&mut batch)).await;
2948                        tokio::time::sleep(retry_delay).await;
2949                        retry_delay = (retry_delay * 2).min(NONCE_REFRESH_RETRY_MAX_DELAY);
2950                        continue;
2951                    }
2952                    Err(e) => self.reject_batch_plan(plan, &e.to_string()),
2953                }
2954
2955                remaining.next();
2956            }
2957
2958            self.send_batch(batch).await;
2959        }
2960    }
2961
2962    fn probe_context(&self) -> AckedOrderProbeContext {
2963        AckedOrderProbeContext {
2964            http_client: self.http_client.clone(),
2965            registry: Arc::clone(&self.registry),
2966            credential: self.credential.clone(),
2967            dispatch: self.dispatch.clone(),
2968            account_id: self.emitter.account_id(),
2969            clock: self.clock,
2970            emitter: self.emitter.clone(),
2971            connection_epoch: self.ws_client.connection_epoch_atomic(),
2972            cancellation_token: self.pending_tasks.cancellation_token(),
2973            pending_tasks: self.pending_tasks.clone(),
2974        }
2975    }
2976
2977    fn sign_batch_plan(&self, plan: &BatchOrderPlan) -> anyhow::Result<PreparedBatchTx> {
2978        match plan {
2979            BatchOrderPlan::Create(plan) => self.sign_create_order(plan).map(PreparedBatchTx::from),
2980            BatchOrderPlan::CreateGroup(plan) => self.sign_create_group(plan),
2981            BatchOrderPlan::Modify(plan) => {
2982                let reserved = self.build_tx_context()?;
2983                self.sign_modify_order(plan, reserved)
2984                    .map(PreparedBatchTx::from)
2985            }
2986            BatchOrderPlan::Cancel(plan, emit) => self
2987                .sign_cancel_order(plan)
2988                .map(|prepared| PreparedBatchTx::from((prepared, *emit))),
2989        }
2990    }
2991
2992    fn reject_batch_plan(&self, plan: &BatchOrderPlan, error: &str) {
2993        match plan {
2994            BatchOrderPlan::CreateGroup(plan) => {
2995                for leg in &plan.orders {
2996                    self.emitter.emit_order_denied(
2997                        &leg.order,
2998                        &format!("Lighter grouped submit failed: {error}"),
2999                    );
3000                }
3001            }
3002            BatchOrderPlan::Create(plan) => {
3003                let reason = OrderDeniedReason::SubmitFailed {
3004                    detail: format!("Lighter submit_order_list failed: {error}"),
3005                };
3006
3007                self.emitter
3008                    .emit_order_denied(&plan.order, &reason.to_string());
3009            }
3010            BatchOrderPlan::Modify(plan) => {
3011                let mut events = self.dispatch.order_events.lock();
3012
3013                if self
3014                    .dispatch
3015                    .clear_pending_modify_if_command(&plan.client_order_id, plan.command_id)
3016                {
3017                    self.emitter.emit_order_modify_rejected_event(
3018                        plan.strategy_id,
3019                        plan.instrument_id,
3020                        plan.client_order_id,
3021                        plan.venue_order_id,
3022                        &format!("Lighter modify_order failed: {error}"),
3023                        self.clock.get_time_ns(),
3024                    );
3025
3026                    if let Some(deferred) = events.remove(&plan.client_order_id) {
3027                        flush_modify_events(
3028                            plan.client_order_id,
3029                            deferred,
3030                            &self.dispatch,
3031                            &self.emitter,
3032                            &mut events,
3033                        );
3034                    }
3035                }
3036            }
3037            BatchOrderPlan::Cancel(plan, emit) => {
3038                self.dispatch.clear_pending_order_action_if(
3039                    &plan.client_order_id,
3040                    PendingOrderAction::cancel(*emit),
3041                );
3042                let reason = format!("Lighter cancel_order failed: {error}");
3043
3044                if *emit {
3045                    self.emitter.emit_order_cancel_rejected_event(
3046                        plan.strategy_id,
3047                        plan.instrument_id,
3048                        plan.client_order_id,
3049                        plan.venue_order_id,
3050                        &reason,
3051                        self.clock.get_time_ns(),
3052                    );
3053                } else {
3054                    log::warn!(
3055                        "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
3056                        plan.client_order_id
3057                    );
3058                }
3059            }
3060        }
3061    }
3062
3063    fn build_tx_context(&self) -> anyhow::Result<ReservedTxContext> {
3064        let connection_epoch = self.ws_client.connection_epoch();
3065        anyhow::ensure!(
3066            self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
3067            "Lighter nonce refresh is pending for connection epoch {connection_epoch}",
3068        );
3069        let nonce_guard = Arc::clone(&self.nonce_submission_gate)
3070            .try_read_owned()
3071            .context("Lighter nonce refresh is in progress")?;
3072        anyhow::ensure!(
3073            self.ws_client.connection_epoch() == connection_epoch
3074                && self.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch,
3075            "Lighter connection or nonce state changed during transaction preparation",
3076        );
3077
3078        let send_reservation = match self.tx_send_sequencer.reserve(
3079            self.credential.account_index(),
3080            self.credential.api_key_index(),
3081            || {
3082                self.dispatch.nonce_manager.next_nonce(
3083                    self.credential.account_index(),
3084                    self.credential.api_key_index(),
3085                )
3086            },
3087        ) {
3088            Ok(reservation) => reservation,
3089            Err(e @ NonceError::SkipWindowExhausted { .. }) => {
3090                self.spawn_nonce_window_recovery();
3091                let reason = format!("failed to allocate Lighter nonce: {e}");
3092                return Err(anyhow::Error::new(e).context(reason));
3093            }
3094            Err(e) => anyhow::bail!("failed to allocate Lighter nonce: {e}"),
3095        };
3096
3097        let nonce = send_reservation.nonce;
3098
3099        let now_ns = self.clock.get_time_ns().as_u64() as i64;
3100        let expired_at = (now_ns / 1_000_000).saturating_add(DEFAULT_TX_EXPIRY_MS);
3101        let send_reservation = send_reservation.with_nonce_ownership(nonce_guard, connection_epoch);
3102
3103        Ok(ReservedTxContext {
3104            context: TxContext {
3105                account_index: self.credential.account_index(),
3106                api_key_index: self.credential.api_key_index(),
3107                nonce,
3108                expired_at,
3109            },
3110            send_reservation,
3111        })
3112    }
3113
3114    fn spawn_nonce_window_recovery(&self) {
3115        if self.nonce_recovery_inflight.swap(true, Ordering::AcqRel) {
3116            return;
3117        }
3118
3119        let inflight = Arc::clone(&self.nonce_recovery_inflight);
3120        let http_client = self.http_client.clone();
3121        let dispatch = self.dispatch.clone();
3122        let nonce_submission_gate = Arc::clone(&self.nonce_submission_gate);
3123        let account_index = self.credential.account_index();
3124        let api_key_index = self.credential.api_key_index();
3125
3126        let future = async move {
3127            let _refresh_guard = nonce_submission_gate.write().await;
3128            let result = http_client
3129                .get_next_nonce(account_index, api_key_index)
3130                .await;
3131            inflight.store(false, Ordering::Release);
3132
3133            match result {
3134                Ok(response) => {
3135                    let _ = dispatch.nonce_manager.sync_from_venue(
3136                        account_index,
3137                        api_key_index,
3138                        response.nonce,
3139                    );
3140                    log::debug!(
3141                        "Resynced Lighter nonce baseline after skip-window exhaustion: \
3142                         account_index={account_index}, api_key_index={api_key_index}, \
3143                         next_nonce={}",
3144                        response.nonce,
3145                    );
3146                }
3147                Err(e) => {
3148                    log::error!("Failed to resync Lighter nonce after skip-window exhaustion: {e}");
3149                }
3150            }
3151        };
3152
3153        if let Err(e) = self.pending_tasks.spawn(future) {
3154            self.nonce_recovery_inflight.store(false, Ordering::Release);
3155            log::warn!("Skipping Lighter nonce recovery after shutdown began: {e}");
3156        }
3157    }
3158
3159    fn sign_create_order(&self, plan: &CreateOrderPlan) -> anyhow::Result<PreparedCreateOrder> {
3160        let cloid = plan.order.client_order_id();
3161        let client_order_index = self.dispatch.register_create_identity(&plan.order)?;
3162
3163        let ReservedTxContext {
3164            context,
3165            send_reservation,
3166        } = match self.build_tx_context() {
3167            Ok(context) => context,
3168            Err(e) => {
3169                self.dispatch.forget_cloid(client_order_index);
3170                self.dispatch.forget_order_identity(&cloid);
3171                return Err(e);
3172            }
3173        };
3174
3175        let nonce = context.nonce;
3176        let api_key_index = context.api_key_index;
3177        let mut rollback_guard = TxDispatchGuard::new(
3178            self.dispatch.clone(),
3179            &self.credential,
3180            Some(client_order_index),
3181            nonce,
3182        )
3183        .with_order_identity(cloid);
3184
3185        let tx = CreateOrderTxInfo {
3186            context,
3187            order: plan.order_info(client_order_index),
3188            attributes: integrator_attributes(self.integrator_account_index),
3189        };
3190
3191        let signed = sign_tx(
3192            &tx,
3193            self.chain_id,
3194            &self.credential.private_key()?,
3195            fresh_k(),
3196        );
3197
3198        let tx_info =
3199            serde_json::value::RawValue::from_string(TxInfoJson::create_order(&tx, &signed))
3200                .context("failed to wrap signed Lighter tx_info JSON")?;
3201
3202        rollback_guard.disarm();
3203
3204        Ok(PreparedCreateOrder {
3205            order: plan.order.clone(),
3206            client_order_index,
3207            tx_info,
3208            nonce,
3209            api_key_index,
3210            tx_hash: signed.tx_hash_hex(),
3211            send_reservation,
3212        })
3213    }
3214
3215    fn sign_create_group(&self, plan: &CreateGroupPlan) -> anyhow::Result<PreparedBatchTx> {
3216        let reserved = self.build_tx_context()?;
3217        let mut guards = Vec::with_capacity(plan.orders.len());
3218        let mut orders = Vec::with_capacity(plan.orders.len());
3219        let mut bodies = Vec::with_capacity(plan.orders.len());
3220
3221        let mut nonce_guard = TxDispatchGuard::new(
3222            self.dispatch.clone(),
3223            &self.credential,
3224            None,
3225            reserved.context.nonce,
3226        );
3227
3228        for leg in &plan.orders {
3229            let index = self.dispatch.register_create_identity(&leg.order)?;
3230            guards.push(
3231                TxDispatchGuard::new(
3232                    self.dispatch.clone(),
3233                    &self.credential,
3234                    Some(index),
3235                    reserved.context.nonce,
3236                )
3237                .with_order_identity(leg.order.client_order_id()),
3238            );
3239            bodies.push(leg.order_info(index));
3240            orders.push((leg.order.clone(), index));
3241        }
3242
3243        let tx = CreateGroupedOrdersTxInfo {
3244            context: reserved.context,
3245            grouping_type: plan.grouping_type,
3246            orders: bodies,
3247            attributes: integrator_attributes(self.integrator_account_index),
3248        };
3249
3250        let signed = sign_tx(
3251            &tx,
3252            self.chain_id,
3253            &self.credential.private_key()?,
3254            fresh_k(),
3255        );
3256        let tx_info = serde_json::value::RawValue::from_string(TxInfoJson::create_grouped_orders(
3257            &tx, &signed,
3258        ))
3259        .context("failed to wrap signed Lighter grouped tx_info JSON")?;
3260
3261        for guard in &mut guards {
3262            guard.disarm();
3263        }
3264
3265        nonce_guard.disarm();
3266        Ok(PreparedBatchTx {
3267            tx_type: LighterTxType::CreateGroupedOrders as u8,
3268            tx_info,
3269            pending: PendingSendTx {
3270                batch_id: None,
3271                connection_epoch: reserved.send_reservation.connection_epoch,
3272                kind: PendingSendTxKind::CreateGroup { orders },
3273                submitted_at: UnixNanos::default(),
3274                nonce: reserved.context.nonce,
3275                api_key_index: reserved.context.api_key_index,
3276                tx_hash: signed.tx_hash_hex(),
3277            },
3278            send_reservation: reserved.send_reservation,
3279        })
3280    }
3281
3282    async fn send_create_order(&self, prepared: PreparedCreateOrder) {
3283        let PreparedCreateOrder {
3284            order,
3285            client_order_index,
3286            tx_info,
3287            nonce,
3288            api_key_index,
3289            tx_hash,
3290            mut send_reservation,
3291        } = prepared;
3292        let connection_epoch = send_reservation.connection_epoch;
3293        let client_order_id = order.client_order_id();
3294
3295        self.emitter.emit_order_submitted(&order);
3296        log::debug!("Lighter submit_order: queueing CreateOrder tx for {client_order_id}");
3297        send_reservation.wait_for_turn().await;
3298        await_tx_quota(&self.tx_rate_limiter).await;
3299        self.dispatch.enqueue_pending_sendtx(PendingSendTx {
3300            batch_id: None,
3301            connection_epoch,
3302            kind: PendingSendTxKind::Create {
3303                order: Box::new(order.clone()),
3304                client_order_index,
3305            },
3306            submitted_at: self.clock.get_time_ns(),
3307            nonce,
3308            api_key_index,
3309            tx_hash,
3310        });
3311
3312        if let Err(e) = self
3313            .ws_client
3314            .send_tx_on_connection(LighterTxType::CreateOrder as u8, tx_info, connection_epoch)
3315            .await
3316        {
3317            let failure = classify_lighter_ws_command_failure("submit_order", &e);
3318            let reason = command_failure_reason(&failure);
3319            if matches!(&failure, CommandFailure::Ambiguous(_)) {
3320                log::warn!(
3321                    "Lighter submit_order dispatch outcome unknown for {client_order_id}: {reason}; \
3322                     retaining pending state for venue reconciliation; diagnostic={e:?}",
3323                );
3324            } else {
3325                log::warn!("{reason} for {client_order_id}; diagnostic={e:?}");
3326                self.dispatch
3327                    .remove_pending_sendtx_by_nonce(connection_epoch, nonce);
3328                rollback_tx_dispatch_create(
3329                    &self.dispatch,
3330                    &self.credential,
3331                    Some(client_order_index),
3332                    &client_order_id,
3333                    nonce,
3334                );
3335                self.emitter
3336                    .emit_order_rejected(&order, reason, self.clock.get_time_ns(), false);
3337            }
3338        }
3339        send_reservation.release();
3340    }
3341
3342    fn sign_modify_order(
3343        &self,
3344        plan: &ModifyOrderPlan,
3345        reserved: ReservedTxContext,
3346    ) -> anyhow::Result<PreparedModifyOrder> {
3347        let ReservedTxContext {
3348            context,
3349            send_reservation,
3350        } = reserved;
3351
3352        let captured_nonce = context.nonce;
3353        let captured_api_key_index = context.api_key_index;
3354
3355        let mut rollback_guard = TxDispatchGuard::new(
3356            self.dispatch.clone(),
3357            &self.credential,
3358            None,
3359            captured_nonce,
3360        );
3361
3362        let tx = ModifyOrderTxInfo {
3363            context,
3364            market_index: plan.market_index,
3365            index: plan.venue_index,
3366            base_amount: plan.base_amount,
3367            price: plan.price,
3368            trigger_price: plan.trigger_price,
3369            attributes: integrator_attributes(self.integrator_account_index),
3370        };
3371
3372        let signed = sign_tx(
3373            &tx,
3374            self.chain_id,
3375            &self.credential.private_key()?,
3376            fresh_k(),
3377        );
3378
3379        let tx_info_str = TxInfoJson::modify_order(&tx, &signed);
3380        let tx_info = serde_json::value::RawValue::from_string(tx_info_str)
3381            .context("failed to wrap signed Lighter modify tx_info JSON")?;
3382
3383        self.dispatch.set_pending_modify_nonce(
3384            &plan.client_order_id,
3385            plan.command_id,
3386            send_reservation.connection_epoch,
3387            captured_nonce,
3388        );
3389        rollback_guard.disarm();
3390
3391        Ok(PreparedModifyOrder {
3392            client_order_id: plan.client_order_id,
3393            strategy_id: plan.strategy_id,
3394            instrument_id: plan.instrument_id,
3395            venue_order_id: plan.venue_order_id,
3396            tx_info,
3397            nonce: captured_nonce,
3398            api_key_index: captured_api_key_index,
3399            tx_hash: signed.tx_hash_hex(),
3400            send_reservation,
3401        })
3402    }
3403
3404    fn sign_cancel_order(&self, plan: &CancelOrderPlan) -> anyhow::Result<PreparedCancelOrder> {
3405        let ReservedTxContext {
3406            context,
3407            send_reservation,
3408        } = self.build_tx_context()?;
3409
3410        let nonce = context.nonce;
3411        let api_key_index = context.api_key_index;
3412        let mut rollback_guard =
3413            TxDispatchGuard::new(self.dispatch.clone(), &self.credential, None, nonce);
3414
3415        let tx = CancelOrderTxInfo {
3416            context,
3417            market_index: plan.market_index,
3418            index: plan.venue_index,
3419            skip_nonce: 0,
3420        };
3421
3422        let signed = sign_tx(
3423            &tx,
3424            self.chain_id,
3425            &self.credential.private_key()?,
3426            fresh_k(),
3427        );
3428
3429        let tx_info =
3430            serde_json::value::RawValue::from_string(TxInfoJson::cancel_order(&tx, &signed))
3431                .context("failed to wrap signed Lighter cancel tx_info JSON")?;
3432
3433        rollback_guard.disarm();
3434
3435        Ok(PreparedCancelOrder {
3436            client_order_id: plan.client_order_id,
3437            strategy_id: plan.strategy_id,
3438            instrument_id: plan.instrument_id,
3439            venue_order_id: plan.venue_order_id,
3440            tx_info,
3441            nonce,
3442            api_key_index,
3443            tx_hash: signed.tx_hash_hex(),
3444            send_reservation,
3445        })
3446    }
3447
3448    async fn send_cancel_order(
3449        &self,
3450        mut prepared: PreparedCancelOrder,
3451        emit_cancel_rejected: bool,
3452    ) {
3453        let connection_epoch = prepared.send_reservation.connection_epoch;
3454        prepared.send_reservation.wait_for_turn().await;
3455        await_tx_quota(&self.tx_rate_limiter).await;
3456        self.enqueue_cancel_order(&prepared, emit_cancel_rejected);
3457        if let Err(e) = self
3458            .ws_client
3459            .send_tx_on_connection(
3460                LighterTxType::CancelOrder as u8,
3461                prepared.tx_info.clone(),
3462                connection_epoch,
3463            )
3464            .await
3465        {
3466            self.handle_cancel_send_failure(&prepared, emit_cancel_rejected, &e);
3467        }
3468
3469        prepared.send_reservation.release();
3470    }
3471
3472    async fn send_batch(&self, mut batch: Vec<PreparedBatchTx>) {
3473        let mut batch_ids = Vec::new();
3474
3475        while !batch.is_empty() {
3476            // Other commands can allocate nonces concurrently. Only consecutive
3477            // nonces from one connection can share a venue batch.
3478            let count = batch
3479                .windows(2)
3480                .position(|pair| {
3481                    pair[1].pending.nonce != pair[0].pending.nonce + 1
3482                        || pair[1].send_reservation.connection_epoch
3483                            != pair[0].send_reservation.connection_epoch
3484                })
3485                .map_or(batch.len(), |index| index + 1);
3486
3487            let mut group: Vec<_> = batch.drain(..count).collect();
3488            let connection_epoch = group[0].send_reservation.connection_epoch;
3489            let id = format!("{TX_BATCH_ID_PREFIX}{}", group[0].pending.tx_hash);
3490            group[0].send_reservation.wait_for_turn().await;
3491            await_tx_quota(&self.tx_rate_limiter).await;
3492
3493            let data = crate::websocket::messages::LighterWsSendTxBatch {
3494                id: id.clone(),
3495                tx_types: serde_json::to_string(
3496                    &group
3497                        .iter()
3498                        .map(|prepared| prepared.tx_type)
3499                        .collect::<Vec<_>>(),
3500                )
3501                .expect("transaction types serialize"),
3502                tx_infos: serde_json::to_string(
3503                    &group
3504                        .iter()
3505                        .map(|prepared| prepared.tx_info.get())
3506                        .collect::<Vec<_>>(),
3507                )
3508                .expect("transaction strings serialize"),
3509            };
3510
3511            for prepared in &mut group {
3512                prepared.pending.batch_id = Some(id.clone());
3513                prepared.pending.submitted_at = self.clock.get_time_ns();
3514                self.enqueue_batch_tx(prepared);
3515            }
3516
3517            if let Err(e) = self
3518                .ws_client
3519                .send_tx_batch_on_connection(data, connection_epoch)
3520                .await
3521            {
3522                for prepared in group.iter().rev() {
3523                    self.handle_batch_send_failure(prepared, &e);
3524                }
3525            }
3526
3527            for prepared in &mut group {
3528                prepared.send_reservation.release();
3529            }
3530
3531            batch_ids.push(id);
3532        }
3533
3534        // Release every signed reservation before waiting so nonce refresh can proceed
3535        let wait = async {
3536            while self.dispatch.pending_sendtx.lock().iter().any(|pending| {
3537                pending
3538                    .batch_id
3539                    .as_ref()
3540                    .is_some_and(|id| batch_ids.contains(id))
3541            }) {
3542                tokio::time::sleep(Duration::from_millis(10)).await;
3543            }
3544        };
3545
3546        if tokio::time::timeout(BATCH_ACK_TIMEOUT, wait).await.is_err() {
3547            log::warn!("Lighter batch acknowledgement timed out: ids={batch_ids:?}");
3548        }
3549    }
3550
3551    fn enqueue_batch_tx(&self, prepared: &PreparedBatchTx) {
3552        for (order, _) in prepared.pending.kind.creates() {
3553            self.emitter.emit_order_submitted(order);
3554        }
3555
3556        if let PendingSendTxKind::Cancel {
3557            client_order_id,
3558            emit_cancel_rejected,
3559            ..
3560        } = &prepared.pending.kind
3561        {
3562            self.dispatch.set_pending_cancel_nonce(
3563                client_order_id,
3564                prepared.send_reservation.connection_epoch,
3565                prepared.pending.nonce,
3566                *emit_cancel_rejected,
3567            );
3568        }
3569
3570        self.dispatch
3571            .enqueue_pending_sendtx(prepared.pending.clone());
3572        if matches!(prepared.pending.kind, PendingSendTxKind::Modify { .. }) {
3573            spawn_acked_order_probe(&prepared.pending, self.probe_context());
3574        }
3575    }
3576
3577    fn handle_batch_send_failure(&self, prepared: &PreparedBatchTx, error: &LighterWsError) {
3578        let action = match prepared.pending.kind {
3579            PendingSendTxKind::Create { .. } | PendingSendTxKind::CreateGroup { .. } => {
3580                "submit_order"
3581            }
3582            PendingSendTxKind::Modify { .. } => "modify_order",
3583            PendingSendTxKind::Cancel { .. } => "cancel_order",
3584            PendingSendTxKind::Other => {
3585                unreachable!("order batches contain only order transactions")
3586            }
3587        };
3588
3589        let failure = classify_lighter_ws_command_failure(action, error);
3590        let reason = command_failure_reason(&failure);
3591
3592        if matches!(failure, CommandFailure::Ambiguous(_)) {
3593            log::warn!("{reason}; retaining batch transaction for reconciliation");
3594            return;
3595        }
3596
3597        if let Some(pending) = self.dispatch.remove_pending_sendtx_by_hash(
3598            prepared.pending.connection_epoch,
3599            &prepared.pending.tx_hash,
3600        ) {
3601            reject_pending_sendtx(
3602                &self.dispatch,
3603                &self.emitter,
3604                Some(self.credential.account_index()),
3605                &pending,
3606                reason,
3607                self.clock.get_time_ns(),
3608                false,
3609            );
3610        }
3611    }
3612
3613    fn enqueue_cancel_order(&self, prepared: &PreparedCancelOrder, emit_cancel_rejected: bool) {
3614        self.dispatch.set_pending_cancel_nonce(
3615            &prepared.client_order_id,
3616            prepared.send_reservation.connection_epoch,
3617            prepared.nonce,
3618            emit_cancel_rejected,
3619        );
3620        self.dispatch.enqueue_pending_sendtx(PendingSendTx {
3621            batch_id: None,
3622            connection_epoch: prepared.send_reservation.connection_epoch,
3623            kind: if emit_cancel_rejected {
3624                PendingSendTxKind::Cancel {
3625                    emit_cancel_rejected,
3626                    strategy_id: prepared.strategy_id,
3627                    instrument_id: prepared.instrument_id,
3628                    client_order_id: prepared.client_order_id,
3629                    venue_order_id: prepared.venue_order_id,
3630                }
3631            } else {
3632                PendingSendTxKind::Other
3633            },
3634            submitted_at: self.clock.get_time_ns(),
3635            nonce: prepared.nonce,
3636            api_key_index: prepared.api_key_index,
3637            tx_hash: prepared.tx_hash.clone(),
3638        });
3639    }
3640
3641    fn handle_cancel_send_failure(
3642        &self,
3643        prepared: &PreparedCancelOrder,
3644        emit_cancel_rejected: bool,
3645        error: &LighterWsError,
3646    ) {
3647        let failure = classify_lighter_ws_command_failure("cancel_order", error);
3648        let reason = command_failure_reason(&failure);
3649
3650        if matches!(failure, CommandFailure::Ambiguous(_)) {
3651            log::warn!(
3652                "Lighter cancel_order outcome unknown for {}: {reason}; retaining pending state for venue reconciliation",
3653                prepared.client_order_id
3654            );
3655            return;
3656        }
3657
3658        log::warn!(
3659            "{reason} for {}; diagnostic={error:?}",
3660            prepared.client_order_id
3661        );
3662
3663        self.dispatch.remove_pending_sendtx_by_nonce(
3664            prepared.send_reservation.connection_epoch,
3665            prepared.nonce,
3666        );
3667        let current = self.dispatch.clear_pending_cancel_if_nonce(
3668            &prepared.client_order_id,
3669            prepared.send_reservation.connection_epoch,
3670            prepared.nonce,
3671        );
3672        rollback_tx_dispatch(&self.dispatch, &self.credential, None, prepared.nonce);
3673
3674        if current && emit_cancel_rejected {
3675            self.emitter.emit_order_cancel_rejected_event(
3676                prepared.strategy_id,
3677                prepared.instrument_id,
3678                prepared.client_order_id,
3679                prepared.venue_order_id,
3680                reason,
3681                self.clock.get_time_ns(),
3682            );
3683        } else {
3684            log::warn!(
3685                "{reason} for {}; suppressing OrderCancelRejected because order is not PendingCancel",
3686                prepared.client_order_id
3687            );
3688        }
3689    }
3690}
3691
3692struct PreparedModifyOrder {
3693    client_order_id: ClientOrderId,
3694    strategy_id: StrategyId,
3695    instrument_id: InstrumentId,
3696    venue_order_id: Option<VenueOrderId>,
3697    tx_info: Box<serde_json::value::RawValue>,
3698    nonce: i64,
3699    api_key_index: u8,
3700    tx_hash: String,
3701    send_reservation: TxSendReservation,
3702}
3703
3704struct ModifyOrderPlan {
3705    command_id: UUID4,
3706    client_order_id: ClientOrderId,
3707    strategy_id: StrategyId,
3708    instrument_id: InstrumentId,
3709    venue_order_id: Option<VenueOrderId>,
3710    market_index: i64,
3711    venue_index: i64,
3712    base_amount: i64,
3713    price: u32,
3714    trigger_price: u32,
3715}
3716
3717enum BatchOrderPlan {
3718    Create(Box<CreateOrderPlan>),
3719    CreateGroup(CreateGroupPlan),
3720    Modify(ModifyOrderPlan),
3721    Cancel(CancelOrderPlan, bool),
3722}
3723
3724struct PreparedBatchTx {
3725    tx_type: u8,
3726    tx_info: Box<serde_json::value::RawValue>,
3727    pending: PendingSendTx,
3728    send_reservation: TxSendReservation,
3729}
3730
3731impl From<PreparedCreateOrder> for PreparedBatchTx {
3732    fn from(prepared: PreparedCreateOrder) -> Self {
3733        Self {
3734            tx_type: LighterTxType::CreateOrder as u8,
3735            tx_info: prepared.tx_info,
3736            pending: PendingSendTx {
3737                batch_id: None,
3738                connection_epoch: prepared.send_reservation.connection_epoch,
3739                kind: PendingSendTxKind::Create {
3740                    order: Box::new(prepared.order),
3741                    client_order_index: prepared.client_order_index,
3742                },
3743                submitted_at: UnixNanos::default(),
3744                nonce: prepared.nonce,
3745                api_key_index: prepared.api_key_index,
3746                tx_hash: prepared.tx_hash,
3747            },
3748            send_reservation: prepared.send_reservation,
3749        }
3750    }
3751}
3752
3753impl From<PreparedModifyOrder> for PreparedBatchTx {
3754    fn from(prepared: PreparedModifyOrder) -> Self {
3755        Self {
3756            tx_type: LighterTxType::ModifyOrder as u8,
3757            tx_info: prepared.tx_info,
3758            pending: PendingSendTx {
3759                batch_id: None,
3760                connection_epoch: prepared.send_reservation.connection_epoch,
3761                kind: PendingSendTxKind::Modify {
3762                    client_order_id: prepared.client_order_id,
3763                    strategy_id: prepared.strategy_id,
3764                    instrument_id: prepared.instrument_id,
3765                    venue_order_id: prepared.venue_order_id,
3766                },
3767                submitted_at: UnixNanos::default(),
3768                nonce: prepared.nonce,
3769                api_key_index: prepared.api_key_index,
3770                tx_hash: prepared.tx_hash,
3771            },
3772            send_reservation: prepared.send_reservation,
3773        }
3774    }
3775}
3776
3777impl From<(PreparedCancelOrder, bool)> for PreparedBatchTx {
3778    fn from((prepared, emit_cancel_rejected): (PreparedCancelOrder, bool)) -> Self {
3779        Self {
3780            tx_type: LighterTxType::CancelOrder as u8,
3781            tx_info: prepared.tx_info,
3782            pending: PendingSendTx {
3783                batch_id: None,
3784                connection_epoch: prepared.send_reservation.connection_epoch,
3785                kind: PendingSendTxKind::Cancel {
3786                    emit_cancel_rejected,
3787                    client_order_id: prepared.client_order_id,
3788                    strategy_id: prepared.strategy_id,
3789                    instrument_id: prepared.instrument_id,
3790                    venue_order_id: prepared.venue_order_id,
3791                },
3792                submitted_at: UnixNanos::default(),
3793                nonce: prepared.nonce,
3794                api_key_index: prepared.api_key_index,
3795                tx_hash: prepared.tx_hash,
3796            },
3797            send_reservation: prepared.send_reservation,
3798        }
3799    }
3800}
3801
3802struct PreparedIntegratorApproval {
3803    tx_info: String,
3804    nonce: i64,
3805    api_key_index: u8,
3806    approval_expiry: i64,
3807    send_reservation: TxSendReservation,
3808}
3809
3810// Cross-check between a detected account tier and the configured REST quota:
3811// AboveTier when the override exceeds the tier limit, RaiseHint when the tier
3812// allows more than standard but no override is set.
3813#[derive(Debug, PartialEq, Eq)]
3814enum TierCrossCheck {
3815    AboveTier { documented: u32 },
3816    RaiseHint { documented: u32 },
3817}
3818
3819// Computes the active REST quota to report and any cross-check advisory from the
3820// detected tier and the raw override. A zero override resolves to the standard
3821// default (matching resolve_quota), so the reported quota always matches the
3822// limiter. Pure so the reporting decision is unit-testable without log capture.
3823fn tier_quota_report(
3824    tier: LighterAccountTier,
3825    rest_quota_per_min: Option<u32>,
3826    standard_rest: u32,
3827) -> (u32, Option<TierCrossCheck>) {
3828    let configured = rest_quota_per_min.filter(|&n| n > 0);
3829    let active_rest = configured.unwrap_or(standard_rest);
3830    let cross_check = match (tier.documented_rest_quota_per_min(), configured) {
3831        (Some(documented), Some(configured)) if configured > documented => {
3832            Some(TierCrossCheck::AboveTier { documented })
3833        }
3834        (Some(documented), None) if documented > standard_rest => {
3835            Some(TierCrossCheck::RaiseHint { documented })
3836        }
3837        _ => None,
3838    };
3839    (active_rest, cross_check)
3840}
3841
3842struct TxDispatchGuard {
3843    dispatch: WsDispatchState,
3844    account_index: i64,
3845    api_key_index: u8,
3846    client_order_index: Option<i64>,
3847    client_order_id: Option<ClientOrderId>,
3848    nonce: i64,
3849    armed: bool,
3850}
3851
3852impl TxDispatchGuard {
3853    fn new(
3854        dispatch: WsDispatchState,
3855        credential: &Credential,
3856        client_order_index: Option<i64>,
3857        nonce: i64,
3858    ) -> Self {
3859        Self {
3860            dispatch,
3861            account_index: credential.account_index(),
3862            api_key_index: credential.api_key_index(),
3863            client_order_index,
3864            client_order_id: None,
3865            nonce,
3866            armed: true,
3867        }
3868    }
3869
3870    fn with_order_identity(mut self, client_order_id: ClientOrderId) -> Self {
3871        self.client_order_id = Some(client_order_id);
3872        self
3873    }
3874
3875    fn disarm(&mut self) {
3876        self.armed = false;
3877    }
3878}
3879
3880impl Drop for TxDispatchGuard {
3881    fn drop(&mut self) {
3882        if self.armed {
3883            rollback_tx_dispatch_indices(
3884                &self.dispatch,
3885                self.account_index,
3886                self.api_key_index,
3887                self.client_order_index,
3888                self.client_order_id.as_ref(),
3889                self.nonce,
3890            );
3891        }
3892    }
3893}
3894
3895// SendTxAck: remove by echoed tx_hash when present. A hashless ack is safe to
3896// attribute only when one transaction is pending. An echoed hash is
3897// authoritative: on a miss the entry was already consumed or never enqueued,
3898// and a fallback would attribute the ack to the wrong tx.
3899fn handle_send_tx_ack_for_connection(
3900    dispatch: &WsDispatchState,
3901    account_index: Option<i64>,
3902    connection_epoch: u64,
3903    code: i64,
3904    tx_hash: Option<&str>,
3905) -> Option<PendingSendTx> {
3906    let popped = match tx_hash {
3907        Some(hash) => {
3908            let matched = dispatch.remove_pending_sendtx_by_hash(connection_epoch, hash);
3909            if matched.is_none() {
3910                log::warn!("Lighter sendTx ack unmatched: tx_hash={hash} code={code}");
3911            }
3912            matched
3913        }
3914        None => dispatch.pop_pending_sendtx_if_only(connection_epoch),
3915    };
3916
3917    if let (Some(pending), Some(account_index)) = (&popped, account_index) {
3918        let _ =
3919            dispatch
3920                .nonce_manager
3921                .ack_success(account_index, pending.api_key_index, pending.nonce);
3922    }
3923
3924    log::debug!(
3925        "Lighter sendTx ack: code={code} tx_hash={tx_hash:?} popped_nonce={:?}",
3926        popped.as_ref().map(|p| p.nonce),
3927    );
3928
3929    popped
3930}
3931
3932#[expect(
3933    clippy::too_many_arguments,
3934    reason = "batch response shares the single-transaction rejection sink"
3935)]
3936fn handle_send_tx_batch_result(
3937    dispatch: &WsDispatchState,
3938    emitter: &ExecutionEventEmitter,
3939    account_index: Option<i64>,
3940    connection_epoch: u64,
3941    now: UnixNanos,
3942    id: &str,
3943    code: i64,
3944    message: &str,
3945    tx_hashes: &[String],
3946) -> (Vec<PendingSendTx>, bool) {
3947    let mut pending: Vec<_> = dispatch
3948        .pending_sendtx
3949        .lock()
3950        .iter()
3951        .filter(|pending| {
3952            pending.connection_epoch == connection_epoch && pending.batch_id.as_deref() == Some(id)
3953        })
3954        .cloned()
3955        .collect();
3956
3957    if pending.is_empty() {
3958        log::warn!("Ignoring unmatched Lighter batch response: id={id}");
3959        return (Vec::new(), false);
3960    }
3961
3962    if code == 200 {
3963        let acked = pending
3964            .iter()
3965            .filter(|pending| tx_hashes.contains(&pending.tx_hash))
3966            .filter_map(|pending| {
3967                handle_send_tx_ack_for_connection(
3968                    dispatch,
3969                    account_index,
3970                    connection_epoch,
3971                    code,
3972                    Some(&pending.tx_hash),
3973                )
3974            })
3975            .collect();
3976
3977        if pending
3978            .iter()
3979            .any(|pending| !tx_hashes.contains(&pending.tx_hash))
3980        {
3981            log::warn!(
3982                "Incomplete Lighter batch acknowledgement: id={id}; retaining unmatched transactions for reconciliation"
3983            );
3984        }
3985
3986        return (acked, false);
3987    }
3988
3989    // Roll back newest first so a rejected contiguous batch releases every nonce
3990    pending.sort_unstable_by_key(|pending| std::cmp::Reverse(pending.nonce));
3991    let mut resync = false;
3992    for pending in pending {
3993        resync |= handle_send_tx_rejection_for_connection(
3994            dispatch,
3995            emitter,
3996            account_index,
3997            connection_epoch,
3998            now,
3999            SendTxRejectionSource::Ack,
4000            Some(code),
4001            message,
4002            Some(&pending.tx_hash),
4003        );
4004    }
4005
4006    (Vec::new(), resync)
4007}
4008
4009fn spawn_acked_order_probe(pending: &PendingSendTx, context: AckedOrderProbeContext) {
4010    if let PendingSendTxKind::CreateGroup { orders } = &pending.kind {
4011        for (order, index) in orders {
4012            let mut leg = pending.clone();
4013
4014            leg.kind = PendingSendTxKind::Create {
4015                order: Box::new(order.clone()),
4016                client_order_index: *index,
4017            };
4018
4019            spawn_acked_order_probe(&leg, context.clone());
4020        }
4021
4022        return;
4023    }
4024
4025    let action = (matches!(pending.kind, PendingSendTxKind::Modify { .. })
4026        || (pending.batch_id.is_some()
4027            && matches!(pending.kind, PendingSendTxKind::Cancel { .. })))
4028    .then(|| pending.clone());
4029    let probe = AckedOrderProbe::from_pending(pending);
4030    if action.is_none() && probe.is_none() {
4031        return;
4032    }
4033
4034    let pending_tasks = context.pending_tasks.clone();
4035
4036    let future = async move {
4037        tokio::select! {
4038            () = context.cancellation_token.cancelled() => return,
4039            () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
4040        }
4041
4042        if let Some(pending) = action {
4043            if let Err(e) = probe_acked_action(&pending, &context).await {
4044                log::warn!("Lighter acknowledged action probe failed: {e}");
4045            }
4046
4047            return;
4048        }
4049
4050        if let Some(probe) = probe
4051            && let Err(e) = probe_acked_order(&probe, &context).await
4052        {
4053            log::warn!("Lighter acknowledged order probe failed: {e:?}");
4054        }
4055    };
4056
4057    if let Err(e) = pending_tasks.spawn(future) {
4058        log::debug!("Skipping Lighter acknowledged-order probe during shutdown: {e}");
4059    }
4060}
4061
4062#[derive(Clone)]
4063struct AckedOrderProbeContext {
4064    http_client: LighterHttpClient,
4065    registry: Arc<MarketRegistry>,
4066    credential: Credential,
4067    dispatch: WsDispatchState,
4068    account_id: AccountId,
4069    clock: &'static AtomicTime,
4070    emitter: ExecutionEventEmitter,
4071    connection_epoch: Arc<AtomicU64>,
4072    cancellation_token: CancellationToken,
4073    pending_tasks: TaskSpawner,
4074}
4075
4076async fn probe_acked_action(
4077    pending: &PendingSendTx,
4078    context: &AckedOrderProbeContext,
4079) -> anyhow::Result<()> {
4080    let (client_order_id, _) = acked_action_identity(pending)?;
4081    for attempt in 1..=ACKED_ORDER_PROBE_ATTEMPTS {
4082        if !acked_action_is_pending(pending, context) {
4083            return Ok(());
4084        }
4085
4086        match context.http_client.get_tx(pending.tx_hash.clone()).await {
4087            Ok(tx) => match resolve_acked_action(pending, context, tx).await {
4088                Ok(true) => return Ok(()),
4089                Ok(false) => {}
4090                Err(e) => log::warn!("Lighter action confirmation unavailable: {e}"),
4091            },
4092            Err(LighterHttpError::Venue { code: 21500, .. }) => {}
4093            Err(e) => log::warn!("Lighter acknowledged action transaction lookup failed: {e}"),
4094        }
4095
4096        if attempt < ACKED_ORDER_PROBE_ATTEMPTS {
4097            tokio::select! {
4098                () = context.cancellation_token.cancelled() => return Ok(()),
4099                () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
4100            }
4101        }
4102    }
4103
4104    log::warn!(
4105        "Lighter acknowledged action outcome unresolved for {client_order_id}; retaining pending action for reconciliation"
4106    );
4107    Ok(())
4108}
4109
4110fn acked_action_identity(
4111    pending: &PendingSendTx,
4112) -> anyhow::Result<(ClientOrderId, LighterTxType)> {
4113    match pending.kind {
4114        PendingSendTxKind::Cancel {
4115            client_order_id, ..
4116        } => Ok((client_order_id, LighterTxType::CancelOrder)),
4117        PendingSendTxKind::Modify {
4118            client_order_id, ..
4119        } => Ok((client_order_id, LighterTxType::ModifyOrder)),
4120        _ => anyhow::bail!("acknowledged action requires a cancel or modify transaction"),
4121    }
4122}
4123
4124fn acked_action_is_pending(pending: &PendingSendTx, context: &AckedOrderProbeContext) -> bool {
4125    let submission = match pending.kind {
4126        PendingSendTxKind::Cancel {
4127            client_order_id, ..
4128        } => context.dispatch.pending_cancel_nonce(&client_order_id),
4129        PendingSendTxKind::Modify {
4130            client_order_id, ..
4131        } => context.dispatch.pending_modify_nonce(&client_order_id),
4132        _ => return false,
4133    };
4134
4135    (matches!(pending.kind, PendingSendTxKind::Modify { .. })
4136        || context.connection_epoch.load(Ordering::Acquire) == pending.connection_epoch)
4137        && submission == Some((pending.connection_epoch, pending.nonce))
4138}
4139
4140async fn resolve_acked_action(
4141    pending: &PendingSendTx,
4142    context: &AckedOrderProbeContext,
4143    tx: LighterTx,
4144) -> anyhow::Result<bool> {
4145    let (_, tx_type) = acked_action_identity(pending)?;
4146    anyhow::ensure!(
4147        tx_hash_matches(&tx.hash, &pending.tx_hash)
4148            && tx.tx_type == tx_type as u8
4149            && tx.account_index == context.credential.account_index()
4150            && tx.api_key_index == pending.api_key_index
4151            && tx.nonce == pending.nonce,
4152        "Lighter transaction lookup did not match acknowledged action identity"
4153    );
4154
4155    let is_modify = tx_type == LighterTxType::ModifyOrder;
4156
4157    let current = if is_modify {
4158        acked_action_is_pending(pending, context)
4159    } else {
4160        context.connection_epoch.load(Ordering::Acquire) == pending.connection_epoch
4161    };
4162
4163    if !current {
4164        return Ok(true);
4165    }
4166
4167    let event = match parse_acked_tx_event(&tx.event_info) {
4168        Ok(event) => event,
4169        Err(e) if tx.status == LighterTxStatus::Failed && tx_type == LighterTxType::ModifyOrder => {
4170            log::warn!("Lighter failed transaction carried invalid event_info: {e}");
4171            AckedTxEvent::default()
4172        }
4173        Err(e) => return Err(e),
4174    };
4175
4176    if tx.status == LighterTxStatus::Failed || !event.app_error.is_empty() {
4177        let (action, detail) = if is_modify {
4178            ("modify", acked_tx_error_detail(&event.app_error))
4179        } else {
4180            ("cancel", event.app_error.as_str())
4181        };
4182
4183        let reason = format!("Lighter {action} transaction failed: {detail}");
4184        log::warn!("{reason} for nonce={}", pending.nonce);
4185
4186        match pending.kind {
4187            PendingSendTxKind::Modify { .. } => reject_modify_order(
4188                &context.dispatch,
4189                &context.emitter,
4190                pending,
4191                &reason,
4192                context.clock.get_time_ns(),
4193            ),
4194            PendingSendTxKind::Cancel { .. } => reject_cancel_order(
4195                &context.dispatch,
4196                &context.emitter,
4197                pending,
4198                &reason,
4199                context.clock.get_time_ns(),
4200            ),
4201            _ => unreachable!("validated acknowledged action"),
4202        }
4203
4204        // Execution failures consume their nonce, unlike pre-admission rejections
4205        return Ok(true);
4206    }
4207
4208    if tx.status == LighterTxStatus::Executed {
4209        recover_acked_action_report(pending, context).await?;
4210        return Ok(true);
4211    }
4212
4213    Ok(false)
4214}
4215
4216async fn recover_acked_action_report(
4217    pending: &PendingSendTx,
4218    context: &AckedOrderProbeContext,
4219) -> anyhow::Result<()> {
4220    let (instrument_id, client_order_id, venue_order_id) = match pending.kind {
4221        PendingSendTxKind::Cancel {
4222            instrument_id,
4223            client_order_id,
4224            venue_order_id,
4225            ..
4226        }
4227        | PendingSendTxKind::Modify {
4228            instrument_id,
4229            client_order_id,
4230            venue_order_id,
4231            ..
4232        } => (instrument_id, client_order_id, venue_order_id),
4233        _ => return Ok(()),
4234    };
4235
4236    let lookup = OrderReportLookup::new(
4237        &context.registry,
4238        &context.dispatch,
4239        Some(instrument_id),
4240        Some(&client_order_id),
4241        venue_order_id.as_ref(),
4242    )?;
4243
4244    if matches!(pending.kind, PendingSendTxKind::Modify { .. }) {
4245        let Some(order) = collect_order(
4246            &context.http_client,
4247            &context.registry,
4248            &context.credential,
4249            &lookup,
4250        )
4251        .await?
4252        else {
4253            anyhow::bail!("Lighter executed modification order is not available yet");
4254        };
4255
4256        return confirm_modify_order(pending, context, &order);
4257    }
4258
4259    anyhow::ensure!(
4260        !context.dispatch.has_pending_modify(&client_order_id),
4261        "Lighter cancellation waits for modification confirmation"
4262    );
4263    let report = collect_order_status_report(
4264        &context.http_client,
4265        &context.registry,
4266        &context.credential,
4267        context.account_id,
4268        &lookup,
4269        context.clock,
4270        &context.dispatch,
4271    )
4272    .await?;
4273
4274    if !acked_action_is_pending(pending, context) {
4275        return Ok(());
4276    }
4277
4278    let Some(report) = report else {
4279        return Ok(());
4280    };
4281
4282    let report = lookup.translate(&context.dispatch, report);
4283    let current = if report.order_status.is_closed() {
4284        context.dispatch.clear_pending_cancel_if_nonce(
4285            &client_order_id,
4286            pending.connection_epoch,
4287            pending.nonce,
4288        )
4289    } else {
4290        context.dispatch.pending_cancel_nonce(&client_order_id)
4291            == Some((pending.connection_epoch, pending.nonce))
4292    };
4293
4294    if !current {
4295        return Ok(());
4296    }
4297
4298    let report = if report.order_status.is_closed() {
4299        report
4300    } else {
4301        context.dispatch.preserve_pending_order_status(report)
4302    };
4303
4304    context.emitter.send_order_status_report(report);
4305    Ok(())
4306}
4307
4308fn confirm_modify_order(
4309    pending: &PendingSendTx,
4310    context: &AckedOrderProbeContext,
4311    order: &crate::http::models::LighterOrder,
4312) -> anyhow::Result<()> {
4313    let (cloid, _) = acked_action_identity(pending)?;
4314    let mut events = context.dispatch.order_events.lock();
4315    if !acked_action_is_pending(pending, context) {
4316        return Ok(());
4317    }
4318
4319    let identity = context
4320        .dispatch
4321        .order_identity(&cloid)
4322        .context("missing modified order identity")?;
4323    let instrument = LIGHTER_INSTRUMENT_CACHE
4324        .get(&identity.instrument_id)
4325        .context("missing modified order instrument")?;
4326    let shape = lighter_order_shape(order, &instrument, identity.order_type)?;
4327    anyhow::ensure!(
4328        context.dispatch.pending_modify_shape(&cloid).as_ref() == Some(&shape),
4329        "Lighter executed modification shape is not available yet"
4330    );
4331    let updated = parse_lighter_order_updated(
4332        order,
4333        &instrument,
4334        &identity,
4335        cloid,
4336        context.account_id,
4337        context.emitter.trader_id(),
4338        context.clock.get_time_ns(),
4339    )?;
4340    context.dispatch.store_snapshot(cloid, shape);
4341    identity.publish_update(updated.event_id);
4342    context
4343        .emitter
4344        .send_order_event(OrderEventAny::Updated(updated));
4345    context
4346        .dispatch
4347        .clear_pending_modify_if_nonce(&cloid, pending.connection_epoch, pending.nonce);
4348    let mut deferred = events.remove(&cloid).unwrap_or_default();
4349    if deferred.order.is_none() {
4350        deferred.store_order(order);
4351    }
4352
4353    flush_modify_events(
4354        cloid,
4355        deferred,
4356        &context.dispatch,
4357        &context.emitter,
4358        &mut events,
4359    );
4360    drop(events);
4361    spawn_child_recoveries(context);
4362    Ok(())
4363}
4364
4365fn flush_modify_events(
4366    cloid: ClientOrderId,
4367    deferred: DeferredOrderEvents,
4368    dispatch: &WsDispatchState,
4369    emitter: &ExecutionEventEmitter,
4370    events: &mut AHashMap<ClientOrderId, DeferredOrderEvents>,
4371) {
4372    if dispatch
4373        .order_identity(&cloid)
4374        .is_some_and(|identity| identity.child_fill_baseline().is_some())
4375        && let Some(order) = deferred
4376            .order
4377            .as_ref()
4378            .filter(|order| is_order_triggered(order))
4379    {
4380        dispatch_deferred_order(cloid, order, dispatch, emitter);
4381    }
4382
4383    events.insert(cloid, deferred);
4384    publish_child_recovery(cloid, events, dispatch, emitter);
4385}
4386
4387fn flush_order_terminal(
4388    cloid: ClientOrderId,
4389    deferred: DeferredOrderEvents,
4390    dispatch: &WsDispatchState,
4391    emitter: &ExecutionEventEmitter,
4392) {
4393    for fill in deferred.fills {
4394        if dispatch.mark_trade_seen(fill.trade_id) {
4395            dispatch_lighter_fill(fill, emitter, dispatch);
4396        }
4397    }
4398
4399    let Some(order) = deferred.order else {
4400        return;
4401    };
4402
4403    dispatch_deferred_order(cloid, &order, dispatch, emitter);
4404}
4405
4406fn dispatch_deferred_order(
4407    cloid: ClientOrderId,
4408    order: &crate::http::models::LighterOrder,
4409    dispatch: &WsDispatchState,
4410    emitter: &ExecutionEventEmitter,
4411) {
4412    let Some(identity) = dispatch.order_identity(&cloid) else {
4413        return;
4414    };
4415
4416    let Some(instrument) = LIGHTER_INSTRUMENT_CACHE.get(&identity.instrument_id) else {
4417        return;
4418    };
4419
4420    let ts_init = get_atomic_clock_realtime().get_time_ns();
4421
4422    let context = OpenFrameContext {
4423        accepted_already_emitted: dispatch.accepted_was_emitted(&cloid),
4424        triggered_already_emitted: dispatch.triggered_was_emitted(&cloid),
4425        shape_changed: false,
4426    };
4427
4428    match parse_lighter_order_event(
4429        order,
4430        &instrument,
4431        &identity,
4432        cloid,
4433        emitter.account_id(),
4434        emitter.trader_id(),
4435        context,
4436        ts_init,
4437    ) {
4438        Ok(Some(event)) => dispatch_tracked_order_event(
4439            event,
4440            cloid,
4441            VenueOrderId::new(&order.order_id),
4442            &identity,
4443            emitter.account_id(),
4444            emitter.trader_id(),
4445            emitter,
4446            dispatch,
4447            ts_init,
4448        ),
4449        Ok(None) => {}
4450        Err(e) => log::error!("Failed to parse deferred Lighter order for {cloid}: {e}"),
4451    }
4452
4453    if order.status == crate::common::enums::LighterOrderStatus::Filled {
4454        dispatch.clear_pending_order_action(&cloid);
4455        dispatch.retire_order_identity(&cloid);
4456    }
4457}
4458
4459async fn probe_acked_order(
4460    probe: &AckedOrderProbe,
4461    context: &AckedOrderProbeContext,
4462) -> anyhow::Result<()> {
4463    if matches!(&probe, AckedOrderProbe::Create { .. }) {
4464        return probe_acked_create(probe, context).await;
4465    }
4466
4467    let report = lookup_order_status_report(
4468        &context.http_client,
4469        &context.registry,
4470        &context.credential,
4471        context.account_id,
4472        Some(probe.instrument_id()),
4473        Some(&probe.client_order_id()),
4474        probe.venue_order_id().as_ref(),
4475        &context.dispatch,
4476        context.clock,
4477    )
4478    .await?;
4479
4480    if let Some(report) = &report {
4481        context.dispatch.seed_accepted_from_report(report);
4482    }
4483
4484    warn_if_acked_order_missing(probe, report.is_some());
4485    Ok(())
4486}
4487
4488async fn probe_acked_create(
4489    probe: &AckedOrderProbe,
4490    context: &AckedOrderProbeContext,
4491) -> anyhow::Result<()> {
4492    let AckedOrderProbe::Create {
4493        order,
4494        client_order_index,
4495        connection_epoch,
4496        nonce,
4497        ..
4498    } = probe
4499    else {
4500        unreachable!("create probe called with non-create transaction")
4501    };
4502
4503    let client_order_id = order.client_order_id();
4504    let mut transaction_executed = false;
4505
4506    for attempt in 1..=ACKED_ORDER_PROBE_ATTEMPTS {
4507        if context.connection_epoch.load(Ordering::Acquire) != *connection_epoch {
4508            log::warn!(
4509                "Lighter acknowledged create outcome unresolved after reconnect for {client_order_id}; retaining identity for reconciliation",
4510            );
4511            return Ok(());
4512        }
4513
4514        if !context.dispatch.create_submission_is_pending(
4515            &client_order_id,
4516            *client_order_index,
4517            *nonce,
4518        ) {
4519            return Ok(());
4520        }
4521
4522        if probe_acked_create_report(probe, context, attempt).await {
4523            return Ok(());
4524        }
4525
4526        match probe_acked_create_tx(probe, context, attempt).await? {
4527            ControlFlow::Break(()) => return Ok(()),
4528            ControlFlow::Continue(executed) => transaction_executed |= executed,
4529        }
4530
4531        if attempt < ACKED_ORDER_PROBE_ATTEMPTS {
4532            tokio::select! {
4533                () = context.cancellation_token.cancelled() => return Ok(()),
4534                () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {}
4535            }
4536        }
4537    }
4538
4539    if transaction_executed {
4540        let _ = context.dispatch.confirm_create_submission(
4541            &client_order_id,
4542            *client_order_index,
4543            *nonce,
4544        );
4545        log::warn!(
4546            "Lighter acknowledged create transaction executed without a queryable order for {client_order_id}; retaining identity for reconciliation",
4547        );
4548    } else {
4549        log::warn!(
4550            "Lighter acknowledged create outcome unresolved after {ACKED_ORDER_PROBE_ATTEMPTS} transaction lookups for {client_order_id}; retaining identity for reconciliation",
4551        );
4552    }
4553    Ok(())
4554}
4555
4556async fn probe_acked_create_report(
4557    probe: &AckedOrderProbe,
4558    context: &AckedOrderProbeContext,
4559    attempt: usize,
4560) -> bool {
4561    let AckedOrderProbe::Create {
4562        order,
4563        client_order_index,
4564        nonce,
4565        ..
4566    } = probe
4567    else {
4568        unreachable!("create report lookup called with non-create transaction")
4569    };
4570
4571    let client_order_id = order.client_order_id();
4572
4573    let report = match lookup_create_order_status_report(
4574        &context.http_client,
4575        &context.registry,
4576        &context.credential,
4577        context.account_id,
4578        order.instrument_id(),
4579        client_order_id,
4580        *client_order_index,
4581        *nonce,
4582        &context.dispatch,
4583        context.clock,
4584    )
4585    .await
4586    {
4587        Ok(report) => report,
4588        Err(e) => {
4589            log::warn!(
4590                "Lighter acknowledged create order lookup failed for {client_order_id} on attempt {attempt}: {e:?}",
4591            );
4592            None
4593        }
4594    };
4595
4596    if let Some(report) = report {
4597        if context.dispatch.observe_create_submission(
4598            &client_order_id,
4599            *client_order_index,
4600            *nonce,
4601            report.venue_order_id,
4602        ) {
4603            context.dispatch.seed_accepted_from_report(&report);
4604            context.emitter.send_order_status_report(report);
4605        }
4606
4607        return true;
4608    }
4609
4610    false
4611}
4612
4613async fn probe_acked_create_tx(
4614    probe: &AckedOrderProbe,
4615    context: &AckedOrderProbeContext,
4616    attempt: usize,
4617) -> anyhow::Result<ControlFlow<(), bool>> {
4618    let AckedOrderProbe::Create {
4619        order,
4620        client_order_index,
4621        connection_epoch,
4622        nonce,
4623        api_key_index,
4624        tx_hash,
4625    } = probe
4626    else {
4627        unreachable!("create transaction resolution called with non-create transaction")
4628    };
4629
4630    let client_order_id = order.client_order_id();
4631
4632    let tx = match context.http_client.get_tx(tx_hash.clone()).await {
4633        Ok(tx) => tx,
4634        Err(LighterHttpError::Venue { code: 21500, .. }) => {
4635            return Ok(ControlFlow::Continue(false));
4636        }
4637        Err(e) => {
4638            log::warn!(
4639                "Lighter acknowledged create transaction lookup failed for {client_order_id} on attempt {attempt}: {e}",
4640            );
4641            return Ok(ControlFlow::Continue(false));
4642        }
4643    };
4644
4645    validate_acked_create_tx(
4646        &tx,
4647        context.credential.account_index(),
4648        *api_key_index,
4649        *client_order_index,
4650        *nonce,
4651        tx_hash,
4652    )?;
4653
4654    let event = parse_acked_tx_event(&tx.event_info).unwrap_or_else(|e| {
4655        log::warn!(
4656            "Lighter create transaction carried invalid event_info for {client_order_id}: {e}",
4657        );
4658        AckedTxEvent::default()
4659    });
4660
4661    if tx.status == LighterTxStatus::Failed || !event.app_error.is_empty() {
4662        let detail = acked_tx_error_detail(&event.app_error);
4663        let reason = format!(
4664            "Lighter sequencer rejected acknowledged create transaction {tx_hash}: {detail}",
4665        );
4666        reject_create_order(
4667            &context.dispatch,
4668            &context.emitter,
4669            order,
4670            *client_order_index,
4671            *nonce,
4672            &reason,
4673            context.clock.get_time_ns(),
4674            lighter_reason_indicates_post_only_rejection(detail),
4675            Some((&context.connection_epoch, *connection_epoch)),
4676        );
4677        return Ok(ControlFlow::Break(()));
4678    }
4679
4680    Ok(ControlFlow::Continue(
4681        tx.status == LighterTxStatus::Executed,
4682    ))
4683}
4684
4685#[derive(Deserialize)]
4686struct AckedCreateTxInfo {
4687    #[serde(rename = "ClientOrderIndex")]
4688    client_order_index: i64,
4689}
4690
4691#[derive(Deserialize)]
4692struct AckedCreateGroupTxInfo {
4693    #[serde(rename = "Orders")]
4694    orders: Vec<AckedCreateTxInfo>,
4695}
4696
4697#[derive(Default, Deserialize)]
4698struct AckedTxEvent {
4699    #[serde(default, rename = "ae")]
4700    app_error: String,
4701}
4702
4703fn validate_acked_create_tx(
4704    tx: &crate::http::models::LighterTx,
4705    account_index: i64,
4706    api_key_index: u8,
4707    client_order_index: i64,
4708    nonce: i64,
4709    tx_hash: &str,
4710) -> anyhow::Result<()> {
4711    anyhow::ensure!(
4712        tx_hash_matches(&tx.hash, tx_hash)
4713            && matches!(tx.tx_type, value if value == LighterTxType::CreateOrder as u8 || value == LighterTxType::CreateGroupedOrders as u8)
4714            && tx.account_index == account_index
4715            && tx.api_key_index == api_key_index
4716            && tx.nonce == nonce,
4717        "Lighter transaction lookup did not match acknowledged create identity",
4718    );
4719
4720    if tx.tx_type == LighterTxType::CreateGroupedOrders as u8 {
4721        let info: AckedCreateGroupTxInfo = serde_json::from_str(&tx.info)
4722            .context("failed to parse Lighter grouped transaction info")?;
4723        anyhow::ensure!(
4724            info.orders
4725                .iter()
4726                .any(|order| order.client_order_index == client_order_index),
4727            "Lighter transaction lookup did not include acknowledged client_order_index {client_order_index}"
4728        );
4729        return Ok(());
4730    }
4731
4732    let info: AckedCreateTxInfo = serde_json::from_str(&tx.info)
4733        .context("failed to parse Lighter create transaction info")?;
4734    anyhow::ensure!(
4735        info.client_order_index == client_order_index,
4736        "Lighter transaction lookup returned client_order_index {} for acknowledged create {client_order_index}",
4737        info.client_order_index,
4738    );
4739    Ok(())
4740}
4741
4742fn tx_hash_matches(left: &str, right: &str) -> bool {
4743    let left = left
4744        .strip_prefix("0x")
4745        .or_else(|| left.strip_prefix("0X"))
4746        .unwrap_or(left);
4747    let right = right
4748        .strip_prefix("0x")
4749        .or_else(|| right.strip_prefix("0X"))
4750        .unwrap_or(right);
4751    left.eq_ignore_ascii_case(right)
4752}
4753
4754fn parse_acked_tx_event(event_info: &str) -> anyhow::Result<AckedTxEvent> {
4755    if event_info.trim().is_empty() {
4756        return Ok(AckedTxEvent::default());
4757    }
4758
4759    serde_json::from_str(event_info).context("failed to parse Lighter transaction event")
4760}
4761
4762fn acked_tx_error_detail(app_error: &str) -> &str {
4763    if app_error.is_empty() {
4764        "transaction failed without an application error"
4765    } else {
4766        app_error
4767    }
4768}
4769
4770fn warn_if_acked_order_missing(probe: &AckedOrderProbe, order_found: bool) {
4771    if order_found {
4772        return;
4773    }
4774
4775    match probe {
4776        AckedOrderProbe::Create { .. } => {}
4777        AckedOrderProbe::Cancel {
4778            client_order_id, ..
4779        } => {
4780            log::warn!(
4781                "Lighter cancel_order outcome unresolved: order not found after venue ack for {client_order_id}",
4782            );
4783        }
4784    }
4785}
4786
4787#[derive(Debug, Clone)]
4788enum AckedOrderProbe {
4789    Create {
4790        order: Box<OrderAny>,
4791        client_order_index: i64,
4792        connection_epoch: u64,
4793        nonce: i64,
4794        api_key_index: u8,
4795        tx_hash: String,
4796    },
4797    Cancel {
4798        instrument_id: InstrumentId,
4799        client_order_id: ClientOrderId,
4800        venue_order_id: Option<VenueOrderId>,
4801    },
4802}
4803
4804impl AckedOrderProbe {
4805    fn from_pending(pending: &PendingSendTx) -> Option<Self> {
4806        match &pending.kind {
4807            PendingSendTxKind::Create {
4808                order,
4809                client_order_index,
4810            } => Some(Self::Create {
4811                order: order.clone(),
4812                client_order_index: *client_order_index,
4813                connection_epoch: pending.connection_epoch,
4814                nonce: pending.nonce,
4815                api_key_index: pending.api_key_index,
4816                tx_hash: pending.tx_hash.clone(),
4817            }),
4818            PendingSendTxKind::Cancel {
4819                instrument_id,
4820                client_order_id,
4821                venue_order_id,
4822                ..
4823            } => Some(Self::Cancel {
4824                instrument_id: *instrument_id,
4825                client_order_id: *client_order_id,
4826                venue_order_id: *venue_order_id,
4827            }),
4828            PendingSendTxKind::CreateGroup { .. }
4829            | PendingSendTxKind::Modify { .. }
4830            | PendingSendTxKind::Other => None,
4831        }
4832    }
4833
4834    fn instrument_id(&self) -> InstrumentId {
4835        match self {
4836            Self::Create { order, .. } => order.instrument_id(),
4837            Self::Cancel { instrument_id, .. } => *instrument_id,
4838        }
4839    }
4840
4841    fn client_order_id(&self) -> ClientOrderId {
4842        match self {
4843            Self::Create { order, .. } => order.client_order_id(),
4844            Self::Cancel {
4845                client_order_id, ..
4846            } => *client_order_id,
4847        }
4848    }
4849
4850    fn venue_order_id(&self) -> Option<VenueOrderId> {
4851        match self {
4852            Self::Create { .. } => None,
4853            Self::Cancel { venue_order_id, .. } => *venue_order_id,
4854        }
4855    }
4856}
4857
4858// SendTxRejected: attribute by echoed tx_hash when present (authoritative,
4859// no fallback on a miss); otherwise pop only when one transaction is pending
4860// for Ack, or head-within-window for BareError. Create emits OrderRejected,
4861// cancel/modify emit their typed rejections, and Other recovers via
4862// reconciliation. All attributed rejections roll the nonce back when still
4863// the latest issuance.
4864// Returns true on an invalid-nonce code: the sequential stream is wedged and
4865// needs a hard refresh.
4866#[expect(
4867    clippy::too_many_arguments,
4868    reason = "shared terminal create transition keeps cleanup and event attribution together"
4869)]
4870fn reject_create_order(
4871    dispatch: &WsDispatchState,
4872    emitter: &ExecutionEventEmitter,
4873    order: &OrderAny,
4874    client_order_index: i64,
4875    nonce: i64,
4876    reason: &str,
4877    now: UnixNanos,
4878    due_post_only: bool,
4879    connection_epoch: Option<(&AtomicU64, u64)>,
4880) -> bool {
4881    let cloid = order.client_order_id();
4882    if !dispatch.reject_create_submission(&cloid, client_order_index, nonce, connection_epoch) {
4883        log::warn!(
4884            "Ignored stale Lighter create rejection for cloid={cloid} client_order_index={client_order_index} nonce={nonce}",
4885        );
4886        return false;
4887    }
4888    emitter.emit_order_rejected(order, reason, now, due_post_only);
4889    true
4890}
4891
4892#[expect(
4893    clippy::too_many_arguments,
4894    reason = "consumer-loop sink that flattens one SendTxRejected message without a wrapper struct"
4895)]
4896fn handle_send_tx_rejection_for_connection(
4897    dispatch: &WsDispatchState,
4898    emitter: &ExecutionEventEmitter,
4899    account_index: Option<i64>,
4900    connection_epoch: u64,
4901    now: UnixNanos,
4902    source: SendTxRejectionSource,
4903    code: Option<i64>,
4904    message: &str,
4905    tx_hash: Option<&str>,
4906) -> bool {
4907    let needs_nonce_resync = code == Some(LIGHTER_ERROR_CODE_INVALID_NONCE);
4908    let failure = venue_rejection_failure(code, message);
4909    let reason = command_failure_reason(&failure);
4910
4911    let pending = match tx_hash {
4912        Some(hash) => dispatch.remove_pending_sendtx_by_hash(connection_epoch, hash),
4913        None => match source {
4914            SendTxRejectionSource::Ack => dispatch.pop_pending_sendtx_if_only(connection_epoch),
4915            SendTxRejectionSource::BareError => dispatch.pop_pending_sendtx_if_only_within(
4916                connection_epoch,
4917                now,
4918                SENDTX_BARE_ERROR_WINDOW_MS,
4919            ),
4920        },
4921    };
4922    let Some(pending) = pending else {
4923        log::warn!(
4924            "Lighter sendTx rejection unattributed (source={source:?} code={code:?}): {message:?}",
4925        );
4926        return needs_nonce_resync;
4927    };
4928
4929    reject_pending_sendtx(
4930        dispatch,
4931        emitter,
4932        account_index,
4933        &pending,
4934        reason,
4935        now,
4936        lighter_reason_indicates_post_only_rejection(message),
4937    );
4938    needs_nonce_resync
4939}
4940
4941fn reject_pending_sendtx(
4942    dispatch: &WsDispatchState,
4943    emitter: &ExecutionEventEmitter,
4944    account_index: Option<i64>,
4945    pending: &PendingSendTx,
4946    reason: &str,
4947    now: UnixNanos,
4948    due_post_only: bool,
4949) {
4950    log::warn!(
4951        "{reason}, nonce={} api_key_index={}",
4952        pending.nonce,
4953        pending.api_key_index
4954    );
4955
4956    if let Some(account_index) = account_index {
4957        let _ = dispatch.nonce_manager.ack_failure_if_latest(
4958            account_index,
4959            pending.api_key_index,
4960            pending.nonce,
4961        );
4962    }
4963
4964    for (order, client_order_index) in pending.kind.creates() {
4965        reject_create_order(
4966            dispatch,
4967            emitter,
4968            order,
4969            client_order_index,
4970            pending.nonce,
4971            reason,
4972            now,
4973            due_post_only,
4974            None,
4975        );
4976    }
4977
4978    match &pending.kind {
4979        PendingSendTxKind::Create { .. } | PendingSendTxKind::CreateGroup { .. } => {}
4980        PendingSendTxKind::Cancel { .. } => {
4981            reject_cancel_order(dispatch, emitter, pending, reason, now);
4982        }
4983        PendingSendTxKind::Modify { .. } => {
4984            reject_modify_order(dispatch, emitter, pending, reason, now);
4985        }
4986        PendingSendTxKind::Other => {}
4987    }
4988}
4989
4990fn reject_cancel_order(
4991    dispatch: &WsDispatchState,
4992    emitter: &ExecutionEventEmitter,
4993    pending: &PendingSendTx,
4994    reason: &str,
4995    now: UnixNanos,
4996) {
4997    let PendingSendTxKind::Cancel {
4998        emit_cancel_rejected,
4999        strategy_id,
5000        instrument_id,
5001        client_order_id,
5002        venue_order_id,
5003    } = pending.kind
5004    else {
5005        return;
5006    };
5007
5008    if dispatch.clear_pending_cancel_if_nonce(
5009        &client_order_id,
5010        pending.connection_epoch,
5011        pending.nonce,
5012    ) && emit_cancel_rejected
5013    {
5014        emitter.emit_order_cancel_rejected_event(
5015            strategy_id,
5016            instrument_id,
5017            client_order_id,
5018            venue_order_id,
5019            reason,
5020            now,
5021        );
5022    }
5023}
5024
5025fn reject_modify_order(
5026    dispatch: &WsDispatchState,
5027    emitter: &ExecutionEventEmitter,
5028    pending: &PendingSendTx,
5029    reason: &str,
5030    now: UnixNanos,
5031) {
5032    let PendingSendTxKind::Modify {
5033        strategy_id,
5034        instrument_id,
5035        client_order_id,
5036        venue_order_id,
5037    } = pending.kind
5038    else {
5039        return;
5040    };
5041
5042    let mut events = dispatch.order_events.lock();
5043    if dispatch.clear_pending_modify_if_nonce(
5044        &client_order_id,
5045        pending.connection_epoch,
5046        pending.nonce,
5047    ) {
5048        emitter.emit_order_modify_rejected_event(
5049            strategy_id,
5050            instrument_id,
5051            client_order_id,
5052            venue_order_id,
5053            reason,
5054            now,
5055        );
5056
5057        if let Some(deferred) = events.remove(&client_order_id) {
5058            flush_modify_events(client_order_id, deferred, dispatch, emitter, &mut events);
5059        }
5060    }
5061}
5062
5063fn lighter_reason_indicates_post_only_rejection(reason: &str) -> bool {
5064    let normalized: String = reason
5065        .chars()
5066        .filter_map(|ch| {
5067            if ch == '-' || ch == '_' || ch.is_whitespace() {
5068                None
5069            } else {
5070                Some(ch.to_ascii_lowercase())
5071            }
5072        })
5073        .collect();
5074
5075    normalized.contains("postonly") || normalized.contains("postwouldexecute")
5076}
5077
5078fn lighter_http_error_is_definite_api_rejection(error: &LighterHttpError) -> bool {
5079    match error {
5080        LighterHttpError::RateLimit(_) | LighterHttpError::Venue { .. } => true,
5081        LighterHttpError::Http { status, .. } => *status < 500,
5082        LighterHttpError::Network(_)
5083        | LighterHttpError::HistoryIncomplete { .. }
5084        | LighterHttpError::Parse(_) => false,
5085    }
5086}
5087
5088fn rollback_tx_dispatch(
5089    dispatch: &WsDispatchState,
5090    credential: &Credential,
5091    client_order_index: Option<i64>,
5092    nonce: i64,
5093) {
5094    rollback_tx_dispatch_indices(
5095        dispatch,
5096        credential.account_index(),
5097        credential.api_key_index(),
5098        client_order_index,
5099        None,
5100        nonce,
5101    );
5102}
5103
5104fn rollback_tx_dispatch_create(
5105    dispatch: &WsDispatchState,
5106    credential: &Credential,
5107    client_order_index: Option<i64>,
5108    client_order_id: &ClientOrderId,
5109    nonce: i64,
5110) {
5111    rollback_tx_dispatch_indices(
5112        dispatch,
5113        credential.account_index(),
5114        credential.api_key_index(),
5115        client_order_index,
5116        Some(client_order_id),
5117        nonce,
5118    );
5119}
5120
5121// Roll back only while still the latest issuance: decrementing past a newer
5122// signed tx would duplicate its nonce on the wire. Skipped rollbacks heal
5123// via the baseline advance or venue resync.
5124fn rollback_tx_dispatch_indices(
5125    dispatch: &WsDispatchState,
5126    account_index: i64,
5127    api_key_index: u8,
5128    client_order_index: Option<i64>,
5129    client_order_id: Option<&ClientOrderId>,
5130    nonce: i64,
5131) {
5132    let _ = dispatch
5133        .nonce_manager
5134        .ack_failure_if_latest(account_index, api_key_index, nonce);
5135
5136    if let Some(client_order_index) = client_order_index {
5137        dispatch.forget_cloid(client_order_index);
5138    }
5139
5140    if let Some(cloid) = client_order_id {
5141        dispatch.forget_order_identity(cloid);
5142    }
5143}
5144
5145fn integrator_attributes(integrator_account_index: Option<u64>) -> L2TxAttributes {
5146    integrator_account_index.map_or_else(L2TxAttributes::default, |integrator_account_index| {
5147        L2TxAttributes {
5148            integrator_account_index,
5149            ..Default::default()
5150        }
5151    })
5152}
5153
5154fn command_failure_reason(failure: &CommandFailure) -> &str {
5155    match failure {
5156        CommandFailure::NotSent(reason)
5157        | CommandFailure::Ambiguous(reason)
5158        | CommandFailure::VenueRejected(reason) => reason,
5159    }
5160}
5161
5162fn warn_pending_sendtx_unknown(pending_sendtx: &[PendingSendTx], context: &str) {
5163    if pending_sendtx.is_empty() {
5164        return;
5165    }
5166
5167    log::warn!(
5168        "Discarded {} pending sendTx entries during {context}; order state recovers via \
5169         reconciliation",
5170        pending_sendtx.len(),
5171    );
5172
5173    for pending in pending_sendtx {
5174        for (order, _) in pending.kind.creates() {
5175            log::warn!(
5176                "Lighter sendTx outcome unknown during {context} for {}",
5177                order.client_order_id(),
5178            );
5179        }
5180    }
5181}
5182
5183fn classify_lighter_ws_command_failure(action: &str, error: &LighterWsError) -> CommandFailure {
5184    let fallback = format!("Lighter {action} dispatch failed");
5185    let reason = sanitize_strategy_reason(&format!("{fallback}: {error}"))
5186        .unwrap_or_else(|| fallback.clone());
5187
5188    match error {
5189        LighterWsError::SendTxOutcomeUnknown(_)
5190        | LighterWsError::Network(_)
5191        | LighterWsError::Parse(_)
5192        | LighterWsError::Transport(SendError::WriteTimeout | SendError::BrokenPipe(_)) => {
5193            CommandFailure::ambiguous(reason)
5194        }
5195        LighterWsError::Transport(
5196            SendError::InvalidInput(_)
5197            | SendError::BufferFull
5198            | SendError::Closed
5199            | SendError::Timeout
5200            | SendError::ConnectionChanged,
5201        )
5202        | LighterWsError::Authentication(_)
5203        | LighterWsError::Client(_) => CommandFailure::not_sent(reason),
5204    }
5205}
5206
5207fn venue_rejection_failure(code: Option<i64>, message: &str) -> CommandFailure {
5208    let clean_message = sanitize_strategy_reason(message);
5209    let reason = match (code, clean_message) {
5210        (Some(code), Some(message)) => format!("LIGHTER_{code}: {message}"),
5211        (Some(code), None) => format!("LIGHTER_{code}"),
5212        (None, Some(message)) => message,
5213        (None, None) => "Lighter venue rejected sendTx".to_string(),
5214    };
5215    let reason = sanitize_strategy_reason(&reason)
5216        .unwrap_or_else(|| "Lighter venue rejected sendTx".to_string());
5217    CommandFailure::venue_rejected(reason)
5218}
5219
5220fn sanitize_strategy_reason(input: &str) -> Option<String> {
5221    let mut output = String::new();
5222    let mut inside_markup = false;
5223    let mut pending_space = false;
5224    let mut char_count = 0;
5225    let mut chars = input.chars().peekable();
5226
5227    while let Some(ch) = chars.next() {
5228        if inside_markup {
5229            if ch == '>' {
5230                inside_markup = false;
5231            }
5232            continue;
5233        }
5234
5235        match ch {
5236            '<' if chars.peek().is_some_and(|next| {
5237                next.is_ascii_alphabetic() || matches!(next, '/' | '!' | '?')
5238            }) =>
5239            {
5240                inside_markup = true;
5241                pending_space = !output.is_empty();
5242            }
5243            _ if ch.is_control() || is_unicode_format_control(ch) || ch.is_whitespace() => {
5244                pending_space = !output.is_empty();
5245            }
5246            _ => {
5247                if pending_space && char_count < STRATEGY_REASON_MAX_CHARS {
5248                    output.push(' ');
5249                    char_count += 1;
5250                }
5251                pending_space = false;
5252
5253                if char_count == STRATEGY_REASON_MAX_CHARS {
5254                    break;
5255                }
5256                output.push(ch);
5257                char_count += 1;
5258            }
5259        }
5260    }
5261
5262    let output = output.trim().to_string();
5263    (!output.is_empty()).then_some(output)
5264}
5265
5266fn is_unicode_format_control(ch: char) -> bool {
5267    matches!(
5268        ch,
5269        '\u{00ad}'
5270            | '\u{061c}'
5271            | '\u{06dd}'
5272            | '\u{070f}'
5273            | '\u{08e2}'
5274            | '\u{180e}'
5275            | '\u{feff}'
5276            | '\u{fff9}'..='\u{fffb}'
5277            | '\u{0600}'..='\u{0605}'
5278            | '\u{0890}'..='\u{0891}'
5279            | '\u{200b}'..='\u{200f}'
5280            | '\u{202a}'..='\u{202e}'
5281            | '\u{2060}'..='\u{206f}'
5282            | '\u{110bd}'
5283            | '\u{110cd}'
5284            | '\u{13430}'..='\u{1343f}'
5285            | '\u{1bca0}'..='\u{1bca3}'
5286            | '\u{1d173}'..='\u{1d17a}'
5287            | '\u{e0001}'
5288            | '\u{e0020}'..='\u{e007f}'
5289    )
5290}
5291
5292/// Format a `start_secs-end_secs` window for Lighter's `between_timestamps`
5293/// query parameter. Returns `None` when neither bound is set; an unset end
5294/// defaults to the current time so the venue scopes pagination to the
5295/// half-open window.
5296fn format_between_timestamps(
5297    start: Option<UnixNanos>,
5298    end: Option<UnixNanos>,
5299    ts_now: UnixNanos,
5300) -> Option<String> {
5301    let (start, end) = match (start, end) {
5302        (None, None) => return None,
5303        (Some(s), Some(e)) => (s, e),
5304        (Some(s), None) => (s, ts_now),
5305        (None, Some(e)) => (UnixNanos::from(0), e),
5306    };
5307    let start_secs = start.as_u64() / 1_000_000_000;
5308    let end_secs = end.as_u64() / 1_000_000_000;
5309    Some(format!("{start_secs}-{end_secs}"))
5310}
5311
5312#[async_trait(?Send)]
5313impl ExecutionClient for LighterExecutionClient {
5314    fn is_connected(&self) -> bool {
5315        self.core.is_connected()
5316    }
5317
5318    fn client_id(&self) -> ClientId {
5319        self.core.client_id
5320    }
5321
5322    fn account_id(&self) -> AccountId {
5323        self.core.account_id
5324    }
5325
5326    fn venue(&self) -> Venue {
5327        self.core.venue
5328    }
5329
5330    fn oms_type(&self) -> OmsType {
5331        self.core.oms_type
5332    }
5333
5334    fn get_account(&self) -> Option<AccountAny> {
5335        self.core.cache().account_owned(&self.core.account_id)
5336    }
5337
5338    fn generate_account_state(
5339        &self,
5340        balances: Vec<AccountBalance>,
5341        margins: Vec<MarginBalance>,
5342        reported: bool,
5343        ts_event: UnixNanos,
5344        info: Option<Params>,
5345    ) -> anyhow::Result<()> {
5346        self.emitter
5347            .emit_account_state(balances, margins, reported, ts_event, info);
5348        Ok(())
5349    }
5350
5351    fn start(&mut self) -> anyhow::Result<()> {
5352        if self.core.is_started() {
5353            return Ok(());
5354        }
5355
5356        let sender = get_exec_event_sender();
5357        self.emitter.set_sender(sender);
5358        self.core.set_started();
5359
5360        log::info!(
5361            "Started Lighter execution client: client_id={}, account_id={}, environment={:?}, has_credentials={}",
5362            self.core.client_id,
5363            self.core.account_id,
5364            self.config.environment,
5365            self.has_credentials(),
5366        );
5367
5368        Ok(())
5369    }
5370
5371    fn stop(&mut self) -> anyhow::Result<()> {
5372        if self.core.is_stopped() && self.core.is_disconnected() && self.session_tasks_finished() {
5373            return Ok(());
5374        }
5375
5376        log::info!("Stopping Lighter execution client {}", self.core.client_id);
5377
5378        self.begin_session_shutdown();
5379
5380        self.core.set_stopped();
5381
5382        log::info!("Lighter execution client stopped");
5383        Ok(())
5384    }
5385
5386    fn reset(&mut self) -> anyhow::Result<()> {
5387        log::debug!("Resetting Lighter execution client {}", self.core.client_id);
5388        self.begin_session_shutdown();
5389        Ok(())
5390    }
5391
5392    fn dispose(&mut self) -> anyhow::Result<()> {
5393        log::debug!("Disposing Lighter execution client {}", self.core.client_id);
5394        self.stop()
5395    }
5396
5397    async fn connect(&mut self) -> anyhow::Result<()> {
5398        if self.core.is_connected() && self.pending_tasks.is_open() {
5399            return Ok(());
5400        }
5401
5402        // Without credentials the engine would accept the connection and
5403        // then deny every order per-submission. Fail before any WS/REST
5404        // work so reconciliation and strategies never start.
5405        if !self.has_credentials() {
5406            anyhow::bail!(
5407                "Lighter execution client requires credentials; \
5408                 set private_key, account_index, and api_key_index in the config \
5409                 or use the deployment-specific credential environment variables"
5410            );
5411        }
5412
5413        log::info!(
5414            "Connecting Lighter execution client {}",
5415            self.core.client_id
5416        );
5417
5418        // Synchronous stop/reset can only initiate teardown. Complete it before
5419        // publishing a replacement socket or sharing its connection epoch.
5420        if !self.session_tasks_finished() || !self.pending_tasks.is_open() {
5421            self.begin_session_shutdown();
5422            self.finish_session_shutdown().await?;
5423        }
5424
5425        if !self.pending_tasks.is_open() {
5426            self.pending_tasks
5427                .start_generation()
5428                .map_err(|e| anyhow::anyhow!("Failed to start Lighter task generation: {e}"))?;
5429            self.cancellation_token = self.pending_tasks.cancellation_token();
5430        }
5431
5432        let ws_client = self.ws_client.clone();
5433        let setup_guard = TaskGroupGuard::new(&[&self.pending_tasks], move || {
5434            ws_client.begin_shutdown();
5435        });
5436        self.auth_refresh_notify = Arc::new(tokio::sync::Notify::new());
5437
5438        // Reset the readiness gate and clear derived position/account caches
5439        // so a prior session's state cannot leak past the strict-await gate.
5440        // The Reconnected path (WS-layer transparent reconnect) is unaffected:
5441        // it does not re-enter `connect()`. Its next `account_all_positions`
5442        // frame replaces the position cache through the consumption loop.
5443        self.dispatch.account_streams_ready.reset();
5444        self.dispatch.clear_position_cache();
5445        self.dispatch.clear_account_state_cache();
5446
5447        self.ensure_instruments_initialized_async().await?;
5448        self.refresh_nonce().await?;
5449
5450        // Auto-approval is an HTTP transaction prepared before the replacement WebSocket exists
5451        self.nonce_ready_connection_epoch
5452            .store(self.ws_client.connection_epoch(), Ordering::Release);
5453        let account_detail = self.fetch_account_detail().await;
5454
5455        if let Some(detail) = &account_detail {
5456            self.log_account_tier(detail);
5457
5458            match tokio::time::timeout(
5459                REFERRAL_ATTRIBUTION_TIMEOUT,
5460                self.apply_referral_attribution(detail),
5461            )
5462            .await
5463            {
5464                Ok(Ok(())) => {}
5465                Ok(Err(e)) => {
5466                    log::warn!(
5467                        "Robinhood Chain referral attribution failed; continuing startup: {e:?}"
5468                    );
5469                }
5470                Err(_) => {
5471                    log::warn!(
5472                        "Robinhood Chain referral attribution timed out after {}s; continuing \
5473                         startup",
5474                        REFERRAL_ATTRIBUTION_TIMEOUT.as_secs()
5475                    );
5476                }
5477            }
5478        }
5479
5480        if let Err(e) = self.submit_integrator_auto_approval().await {
5481            // Bail on venue 21149 ("integrator is not approved") so the
5482            // operator catches it at startup rather than at first order.
5483            // Other failures are tolerated: approval is account-scoped and
5484            // may already be in place, or a reconnect can retry.
5485            let is_unapproved = e.chain().any(|cause| {
5486                matches!(
5487                    cause.downcast_ref::<LighterHttpError>(),
5488                    Some(LighterHttpError::Venue { code: 21149, .. }),
5489                )
5490            });
5491
5492            if is_unapproved {
5493                self.nonce_ready_connection_epoch
5494                    .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
5495                return Err(e.context(
5496                    "Lighter account is not integrator-approved (venue 21149); \
5497                     orders cannot be placed",
5498                ));
5499            }
5500
5501            log::warn!("Lighter integrator approval failed; continuing startup: {e:?}");
5502        }
5503
5504        self.nonce_ready_connection_epoch
5505            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
5506
5507        if let Err(e) = self.sync_nonce_from_venue().await {
5508            log::debug!(
5509                "Failed to sync Lighter nonce after integrator approval; continuing startup: {e:?}"
5510            );
5511        }
5512
5513        if let Err(e) = self.spawn_ws_consumer().await {
5514            self.begin_session_shutdown();
5515            return match self.finish_session_shutdown().await {
5516                Ok(()) => Err(e),
5517                Err(shutdown_error) => Err(anyhow::anyhow!(
5518                    "{e}; failed to roll back partial Lighter connection: {shutdown_error}"
5519                )),
5520            };
5521        }
5522        self.nonce_ready_connection_epoch
5523            .store(self.ws_client.connection_epoch(), Ordering::Release);
5524
5525        if let Err(e) = self.await_account_streams_ready(30.0).await {
5526            log::warn!("Connect failed after WS started, tearing down: {e}");
5527            self.begin_session_shutdown();
5528
5529            if let Err(shutdown_error) = self.finish_session_shutdown().await {
5530                log::warn!("Failed to finish partial Lighter connection: {shutdown_error}");
5531            }
5532
5533            return Err(e);
5534        }
5535
5536        setup_guard.disarm();
5537        self.core.set_connected();
5538
5539        log::info!("Connected: client_id={}", self.core.client_id);
5540        Ok(())
5541    }
5542
5543    async fn disconnect(&mut self) -> anyhow::Result<()> {
5544        if self.core.is_disconnected() && self.session_tasks_finished() {
5545            return Ok(());
5546        }
5547
5548        log::info!(
5549            "Disconnecting Lighter execution client {}",
5550            self.core.client_id
5551        );
5552
5553        self.begin_session_shutdown();
5554        let tasks_result = self.finish_session_shutdown().await;
5555
5556        self.core.set_disconnected();
5557
5558        log::info!("Disconnected: client_id={}", self.core.client_id);
5559        tasks_result
5560    }
5561
5562    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
5563        let credential = self.credential.as_ref().ok_or_else(|| {
5564            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
5565        })?;
5566
5567        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
5568
5569        if order.is_closed() {
5570            log::warn!("Cannot submit closed order {}", order.client_order_id());
5571            return Ok(());
5572        }
5573
5574        let cached_instrument = self
5575            .core
5576            .cache()
5577            .instrument(&order.instrument_id())
5578            .cloned();
5579
5580        if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
5581            self.emitter.emit_order_denied(&order, &reason);
5582            return Ok(());
5583        }
5584
5585        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
5586        let plan = match self.prepare_create_order_plan(&order, slippage_bps) {
5587            Ok(plan) => plan,
5588            Err(e) => {
5589                let reason = OrderDeniedReason::ValidationFailed {
5590                    detail: format!("Lighter submit_order failed: {e}"),
5591                };
5592                self.emitter.emit_order_denied(&order, &reason.to_string());
5593                return Ok(());
5594            }
5595        };
5596
5597        if let Err(e) = self.dispatch_create_order_plan(&plan, credential) {
5598            let reason = OrderDeniedReason::SubmitFailed {
5599                detail: format!("Lighter submit_order failed: {e}"),
5600            };
5601            self.emitter.emit_order_denied(&order, &reason.to_string());
5602        }
5603
5604        Ok(())
5605    }
5606
5607    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
5608        let credential = self.credential.as_ref().ok_or_else(|| {
5609            anyhow::anyhow!("Lighter execution client cannot submit without credentials")
5610        })?;
5611
5612        if cmd.order_list.client_order_ids.is_empty() {
5613            log::debug!("submit_order_list called with empty order list");
5614            return Ok(());
5615        }
5616
5617        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
5618
5619        if orders.iter().any(is_grouped_order) {
5620            let result = self
5621                .prepare_create_group_plan(&orders, self.resolve_slippage_bps(cmd.params.as_ref()))
5622                .and_then(|plan| {
5623                    let context = self.fanout_dispatch_context(credential)?;
5624                    self.spawn_task("submit_grouped_orders", async move {
5625                        context
5626                            .dispatch_batch_plans(vec![BatchOrderPlan::CreateGroup(plan)])
5627                            .await;
5628                        Ok(())
5629                    });
5630
5631                    Ok(())
5632                });
5633
5634            if let Err(e) = result {
5635                let reason = OrderDeniedReason::UnsupportedOrderList {
5636                    detail: format!("Lighter grouped submit failed: {e}"),
5637                }
5638                .to_string();
5639
5640                for order in &orders {
5641                    self.emitter.emit_order_denied(order, &reason);
5642                }
5643            }
5644            return Ok(());
5645        }
5646
5647        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
5648        let mut plans = Vec::with_capacity(orders.len());
5649
5650        for order in orders {
5651            if order.is_closed() {
5652                log::warn!("Cannot submit closed order {}", order.client_order_id());
5653                continue;
5654            }
5655
5656            let cached_instrument = self
5657                .core
5658                .cache()
5659                .instrument(&order.instrument_id())
5660                .cloned();
5661
5662            if let Some(reason) = local_submit_denial_reason(&order, cached_instrument.as_ref()) {
5663                self.emitter.emit_order_denied(&order, &reason);
5664                continue;
5665            }
5666
5667            match self.prepare_create_order_plan(&order, slippage_bps) {
5668                Ok(plan) => plans.push(plan),
5669                Err(e) => {
5670                    let reason = OrderDeniedReason::ValidationFailed {
5671                        detail: format!("Lighter submit_order_list failed: {e}"),
5672                    }
5673                    .to_string();
5674
5675                    self.emitter.emit_order_denied(&order, &reason);
5676                }
5677            }
5678        }
5679
5680        if plans.is_empty() {
5681            log::warn!(
5682                "Lighter submit_order_list: no supported orders to dispatch for {}",
5683                cmd.order_list.id,
5684            );
5685            return Ok(());
5686        }
5687
5688        let context = self.fanout_dispatch_context(credential)?;
5689        self.spawn_task("submit_order_list", async move {
5690            context
5691                .dispatch_batch_plans(
5692                    plans
5693                        .into_iter()
5694                        .map(|plan| BatchOrderPlan::Create(Box::new(plan)))
5695                        .collect(),
5696                )
5697                .await;
5698            Ok(())
5699        });
5700
5701        Ok(())
5702    }
5703
5704    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
5705        let credential = self.credential.as_ref().ok_or_else(|| {
5706            anyhow::anyhow!("Lighter execution client cannot modify without credentials")
5707        })?;
5708        self.dispatch_signed_modify_order(&cmd, credential);
5709        Ok(())
5710    }
5711
5712    fn batch_modify_orders(&self, cmd: BatchModifyOrders) -> anyhow::Result<()> {
5713        let credential = self.credential.as_ref().ok_or_else(|| {
5714            anyhow::anyhow!("Lighter execution client cannot modify without credentials")
5715        })?;
5716
5717        let context = self.fanout_dispatch_context(credential)?;
5718        let mut plans = Vec::with_capacity(cmd.modifies.len());
5719        for modify in &cmd.modifies {
5720            if !self.admit_modify_order(modify) {
5721                continue;
5722            }
5723
5724            match self.prepare_modify_order_plan(modify) {
5725                Ok(plan) => plans.push(BatchOrderPlan::Modify(plan)),
5726                Err(e) => {
5727                    let mut events = self.dispatch.order_events.lock();
5728                    self.dispatch.clear_pending_modify_if_command(
5729                        &modify.client_order_id,
5730                        modify.command_id,
5731                    );
5732                    self.emitter.emit_order_modify_rejected_event(
5733                        modify.strategy_id,
5734                        modify.instrument_id,
5735                        modify.client_order_id,
5736                        modify.venue_order_id,
5737                        &format!("Lighter modify_order failed: {e}"),
5738                        self.clock.get_time_ns(),
5739                    );
5740
5741                    if let Some(deferred) = events.remove(&modify.client_order_id) {
5742                        flush_modify_events(
5743                            modify.client_order_id,
5744                            deferred,
5745                            &self.dispatch,
5746                            &self.emitter,
5747                            &mut events,
5748                        );
5749                    }
5750                }
5751            }
5752        }
5753
5754        if !plans.is_empty() {
5755            self.spawn_task("batch_modify_orders", async move {
5756                context.dispatch_batch_plans(plans).await;
5757                Ok(())
5758            });
5759        }
5760
5761        Ok(())
5762    }
5763
5764    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
5765        let credential = self.credential.as_ref().ok_or_else(|| {
5766            anyhow::anyhow!("Lighter execution client cannot cancel without credentials")
5767        })?;
5768
5769        if let Some(plans) = self.prepare_oco_cancel_plans(&cmd) {
5770            let context = self.fanout_dispatch_context(credential)?;
5771
5772            let plans = plans
5773                .into_iter()
5774                .map(|plan| {
5775                    let emit = self.can_emit_order_cancel_rejected(&plan.client_order_id);
5776                    self.dispatch.set_pending_order_action(
5777                        plan.client_order_id,
5778                        PendingOrderAction::cancel(emit),
5779                    );
5780                    BatchOrderPlan::Cancel(plan, emit)
5781                })
5782                .collect();
5783
5784            self.spawn_task("cancel_oco_orders", async move {
5785                context.dispatch_batch_plans(plans).await;
5786                Ok(())
5787            });
5788
5789            return Ok(());
5790        }
5791
5792        self.dispatch_signed_cancel_order(&cmd, credential);
5793        Ok(())
5794    }
5795
5796    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
5797        // Native cancel-all has no side filter, and the local signing schema
5798        // carries no market restriction, so batch explicit per-order cancellations.
5799        let cancels: Vec<_> = self
5800            .core
5801            .cache()
5802            .orders_open(
5803                None,
5804                Some(&cmd.instrument_id),
5805                None,
5806                Some(&self.core.account_id),
5807                cmd.order_side,
5808            )
5809            .into_iter()
5810            .map(|order| {
5811                cancel_order_from_cancel_all(&cmd, order.client_order_id(), order.strategy_id())
5812            })
5813            .collect();
5814
5815        if cancels.is_empty() {
5816            return Ok(());
5817        }
5818
5819        self.dispatch_cancel_orders(&cancels)
5820    }
5821
5822    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
5823        self.dispatch_cancel_orders(&cmd.cancels)
5824    }
5825
5826    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
5827        // Lighter has no public REST endpoint that returns a snapshot of
5828        // account balances and margins; the only authoritative source is the
5829        // `account_all_assets` WebSocket stream. Replay the most recent
5830        // cached state so the engine sees something synchronously. The
5831        // cache is populated by the consumption loop on every venue push.
5832        let cached = self.dispatch.snapshot_account_state();
5833        match cached {
5834            Some(state) => {
5835                log::debug!("Lighter query_account replaying cached AccountState");
5836                self.emitter.send_account_state(state);
5837            }
5838            None => {
5839                log::warn!(
5840                    "Lighter query_account: no AccountState cached yet \
5841                     (account_all_assets stream has not pushed since connect)",
5842                );
5843            }
5844        }
5845        Ok(())
5846    }
5847
5848    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
5849        let credential = self
5850            .credential
5851            .as_ref()
5852            .ok_or_else(|| anyhow::anyhow!("Lighter query_order requires credentials"))?
5853            .clone();
5854        let registry = Arc::clone(&self.registry);
5855        let http_client = self.http_client.clone();
5856        let emitter = self.emitter.clone();
5857        let core_account_id = self.core.account_id;
5858        let dispatch = self.dispatch.clone();
5859        let clock = self.clock;
5860
5861        if let Some(order) = self.core.cache().order_owned(&cmd.client_order_id) {
5862            self.dispatch.seed_order_restoration(order);
5863        }
5864
5865        let _ = order_shape_pending(&self.core, &self.dispatch, &cmd.client_order_id);
5866        let child_context = self.child_probe_context();
5867        self.spawn_task("query_order", async move {
5868            let lookup = OrderReportLookup::new(
5869                &registry,
5870                &dispatch,
5871                Some(cmd.instrument_id),
5872                Some(&cmd.client_order_id),
5873                cmd.venue_order_id.as_ref(),
5874            )?;
5875
5876            let report = if let Some(order) =
5877                collect_order(&http_client, &registry, &credential, &lookup).await?
5878            {
5879                if !dispatch.restore_cached_order(
5880                    VenueOrderId::new(&order.order_id),
5881                    order.client_order_index,
5882                    cmd.instrument_id,
5883                ) {
5884                    return Ok(());
5885                }
5886
5887                let parsed = lookup.parse(
5888                    &order,
5889                    &registry,
5890                    core_account_id,
5891                    clock.get_time_ns(),
5892                    &dispatch,
5893                )?;
5894                let report = lookup.finish(&dispatch, parsed);
5895                if dispatch
5896                    .order_identity(&cmd.client_order_id)
5897                    .is_some_and(|identity| identity.child_fill_baseline().is_some())
5898                {
5899                    if let Some(context) = &child_context {
5900                        spawn_child_recovery(cmd.client_order_id, context);
5901                    }
5902
5903                    return Ok(());
5904                }
5905
5906                Some(report)
5907            } else {
5908                None
5909            };
5910
5911            let events = dispatch.order_events.lock();
5912            if dispatch
5913                .order_identity(&cmd.client_order_id)
5914                .is_some_and(|identity| {
5915                    position_fills_pending(&identity, &events, &dispatch)
5916                        || dispatch.any_order_identity(|_, other| {
5917                            other.strategy_id == identity.strategy_id
5918                                && other.instrument_id == identity.instrument_id
5919                                && other.has_publication()
5920                        })
5921                })
5922                || dispatch.has_pending_modify(&cmd.client_order_id)
5923                || dispatch
5924                    .order_identity(&cmd.client_order_id)
5925                    .is_some_and(|identity| identity.has_publication())
5926            {
5927                return Ok(());
5928            }
5929
5930            match report {
5931                Some(report) => {
5932                    log::debug!(
5933                        "Lighter query_order returning report for {}",
5934                        cmd.client_order_id
5935                    );
5936                    dispatch.seed_accepted_from_report(&report);
5937                    emitter.send_order_status_report(report);
5938                }
5939                None => {
5940                    log::warn!(
5941                        "Lighter query_order: no order found for {}",
5942                        cmd.client_order_id,
5943                    );
5944                }
5945            }
5946            Ok(())
5947        });
5948        Ok(())
5949    }
5950
5951    fn generate_order_status_report_task(
5952        &self,
5953        cmd: &GenerateOrderStatusReport,
5954    ) -> Option<ExecutionReportTask<Option<OrderStatusReport>>> {
5955        let credential = self.credential.clone();
5956        let http = self.http_client.clone();
5957        let registry = Arc::clone(&self.registry);
5958        let account_id = self.core.account_id;
5959        let clock = self.clock;
5960        let core = self.core.clone();
5961        let dispatch = self.dispatch.clone();
5962        let cancellation_token = self.cancellation_token.clone();
5963        let command = cmd.clone();
5964        let child_context = self.child_probe_context();
5965        let lookup_registry = Arc::clone(&registry);
5966
5967        let lookup = OrderReportLookup::new(
5968            &registry,
5969            &dispatch,
5970            cmd.instrument_id,
5971            cmd.client_order_id.as_ref(),
5972            cmd.venue_order_id.as_ref(),
5973        );
5974        Some(ExecutionReportTask::new(
5975            async move {
5976                let Some(credential) = credential else {
5977                    log::warn!("Lighter generate_order_status_report: no credentials");
5978                    return Ok(None);
5979                };
5980
5981                if command.client_order_id.is_none() && command.venue_order_id.is_none() {
5982                    log::warn!(
5983                        "Lighter generate_order_status_report: must supply client_order_id or venue_order_id"
5984                    );
5985                    return Ok(None);
5986                }
5987
5988                let lookup = lookup?;
5989                let order = collect_order(&http, &lookup_registry, &credential, &lookup).await?;
5990                Ok(order.map(|order| (lookup, order)))
5991            },
5992            move |collected| {
5993                anyhow::ensure!(
5994                    !cancellation_token.is_cancelled(),
5995                    "Lighter report session stopped"
5996                );
5997
5998                let report = collected
5999                    .map(|(lookup, order)| -> anyhow::Result<OrderStatusReport> {
6000                        let parsed = lookup.parse(
6001                            &order,
6002                            &registry,
6003                            account_id,
6004                            clock.get_time_ns(),
6005                            &dispatch,
6006                        )?;
6007                        let report = &parsed.0;
6008                        let pending = core
6009                            .cache()
6010                            .client_order_id(&report.venue_order_id)
6011                            .copied()
6012                            .is_some_and(|cloid| dispatch.has_pending_modify(&cloid));
6013
6014                        if !pending {
6015                            restore_reconciled_order(
6016                                &core,
6017                                &dispatch,
6018                                report.venue_order_id,
6019                                order.client_order_index,
6020                                report.order_status.is_closed(),
6021                            );
6022                        }
6023
6024                        let report = lookup.finish(&dispatch, parsed);
6025                        if let Some(context) = &child_context {
6026                            request_child_recovery(&core, context, &report);
6027                        }
6028
6029                        Ok(report)
6030                    })
6031                    .transpose()?
6032                    .filter(|report| order_report_ready(&core, &dispatch, report));
6033                if let Some(report) = &report {
6034                    dispatch.seed_accepted_from_report(report);
6035                }
6036
6037                Ok(report)
6038            },
6039        ))
6040    }
6041
6042    fn generate_order_status_reports_task(
6043        &self,
6044        cmd: &GenerateOrderStatusReports,
6045    ) -> Option<ExecutionReportTask<Vec<OrderStatusReport>>> {
6046        let client = self.report_client();
6047        let core = self.core.clone();
6048        let dispatch = self.dispatch.clone();
6049        let cancellation_token = self.cancellation_token.clone();
6050        let child_context = self.child_probe_context();
6051        let command = cmd.clone();
6052        let collection_command = command.clone();
6053        Some(ExecutionReportTask::new(
6054            async move {
6055                Ok(client
6056                    .collect_order_status_reports(&collection_command)
6057                    .await)
6058            },
6059            move |sweep| {
6060                anyhow::ensure!(
6061                    !cancellation_token.is_cancelled(),
6062                    "Lighter report session stopped"
6063                );
6064
6065                if let Some(context) = &child_context {
6066                    for (index, report) in &sweep.orders {
6067                        if !core
6068                            .cache()
6069                            .client_order_id(&report.venue_order_id)
6070                            .is_some_and(|cloid| dispatch.has_pending_modify(cloid))
6071                        {
6072                            restore_reconciled_order(
6073                                &core,
6074                                &dispatch,
6075                                report.venue_order_id,
6076                                *index,
6077                                report.order_status.is_closed(),
6078                            );
6079                        }
6080
6081                        request_child_recovery(&core, context, report);
6082                    }
6083                }
6084
6085                finish_order_reports(&core, &dispatch, sweep, &command)
6086            },
6087        ))
6088    }
6089
6090    fn generate_fill_reports_task(
6091        &self,
6092        cmd: &GenerateFillReports,
6093    ) -> Option<ExecutionReportTask<Vec<FillReport>>> {
6094        let client = self.report_client();
6095        let instruments = self.report_instruments(cmd.instrument_id);
6096        let core = self.core.clone();
6097        let dispatch = self.dispatch.clone();
6098        let cancellation_token = self.cancellation_token.clone();
6099        let child_context = self.child_probe_context();
6100        let command = cmd.clone();
6101        let cmd_log_level = cmd.log_receipt_level;
6102        Some(ExecutionReportTask::new(
6103            async move {
6104                let mut markets = BTreeSet::new();
6105                let result = client
6106                    .collect_fill_reports(&command, &instruments, &mut markets)
6107                    .await;
6108                Ok((markets, result))
6109            },
6110            move |(markets, result)| {
6111                anyhow::ensure!(
6112                    !cancellation_token.is_cancelled(),
6113                    "Lighter report session stopped"
6114                );
6115                let reports = finish_fill_reports(&core, &dispatch, markets, result)?.reports;
6116
6117                if let Some(context) = &child_context {
6118                    spawn_child_recoveries(context);
6119                }
6120
6121                Self::log_report_receipt(reports.len(), "FillReport", cmd_log_level);
6122                Ok(reports)
6123            },
6124        ))
6125    }
6126
6127    fn generate_position_status_reports_task(
6128        &self,
6129        cmd: &GeneratePositionStatusReports,
6130    ) -> Option<ExecutionReportTask<Vec<PositionStatusReport>>> {
6131        let core = self.core.clone();
6132        let dispatch = self.dispatch.clone();
6133        let cancellation_token = self.cancellation_token.clone();
6134        let registry = Arc::clone(&self.registry);
6135        let command = cmd.clone();
6136        Some(ExecutionReportTask::new(async { Ok(()) }, move |()| {
6137            anyhow::ensure!(
6138                !cancellation_token.is_cancelled(),
6139                "Lighter report session stopped"
6140            );
6141            let (reports, complete, _) =
6142                cached_position_reports(&core, &dispatch, &registry, &command)?;
6143            anyhow::ensure!(
6144                complete,
6145                "Lighter position snapshot does not cover the requested instrument scope"
6146            );
6147            Self::log_report_receipt(
6148                reports.len(),
6149                "PositionStatusReport",
6150                command.log_receipt_level,
6151            );
6152            Ok(reports)
6153        }))
6154    }
6155
6156    async fn generate_order_status_report(
6157        &self,
6158        cmd: &GenerateOrderStatusReport,
6159    ) -> anyhow::Result<Option<OrderStatusReport>> {
6160        let Some(credential) = &self.credential else {
6161            log::warn!("Lighter generate_order_status_report: no credentials");
6162            return Ok(None);
6163        };
6164
6165        if cmd.client_order_id.is_none() && cmd.venue_order_id.is_none() {
6166            log::warn!(
6167                "Lighter generate_order_status_report: must supply client_order_id or venue_order_id",
6168            );
6169            return Ok(None);
6170        }
6171
6172        let lookup = OrderReportLookup::new(
6173            &self.registry,
6174            &self.dispatch,
6175            cmd.instrument_id,
6176            cmd.client_order_id.as_ref(),
6177            cmd.venue_order_id.as_ref(),
6178        )?;
6179
6180        let report = if let Some(order) =
6181            collect_order(&self.http_client, &self.registry, credential, &lookup).await?
6182        {
6183            let parsed = lookup.parse(
6184                &order,
6185                &self.registry,
6186                self.core.account_id,
6187                self.clock.get_time_ns(),
6188                &self.dispatch,
6189            )?;
6190
6191            if !self
6192                .core
6193                .cache()
6194                .client_order_id(&parsed.0.venue_order_id)
6195                .is_some_and(|cloid| self.dispatch.has_pending_modify(cloid))
6196            {
6197                restore_reconciled_order(
6198                    &self.core,
6199                    &self.dispatch,
6200                    parsed.0.venue_order_id,
6201                    order.client_order_index,
6202                    parsed.0.order_status.is_closed(),
6203                );
6204            }
6205
6206            let report = lookup.finish(&self.dispatch, parsed);
6207            if let Some(context) = self.child_probe_context() {
6208                request_child_recovery(&self.core, &context, &report);
6209            }
6210
6211            order_report_ready(&self.core, &self.dispatch, &report).then_some(report)
6212        } else {
6213            None
6214        };
6215
6216        if let Some(report) = &report {
6217            self.dispatch.seed_accepted_from_report(report);
6218        }
6219
6220        Ok(report)
6221    }
6222
6223    async fn generate_order_status_reports(
6224        &self,
6225        cmd: &GenerateOrderStatusReports,
6226    ) -> anyhow::Result<Vec<OrderStatusReport>> {
6227        let sweep = self.report_client().collect_order_status_reports(cmd).await;
6228        if let Some(context) = self.child_probe_context() {
6229            for (index, report) in &sweep.orders {
6230                if !self
6231                    .core
6232                    .cache()
6233                    .client_order_id(&report.venue_order_id)
6234                    .is_some_and(|cloid| self.dispatch.has_pending_modify(cloid))
6235                {
6236                    restore_reconciled_order(
6237                        &self.core,
6238                        &self.dispatch,
6239                        report.venue_order_id,
6240                        *index,
6241                        report.order_status.is_closed(),
6242                    );
6243                }
6244
6245                request_child_recovery(&self.core, &context, report);
6246            }
6247        }
6248
6249        finish_order_reports(&self.core, &self.dispatch, sweep, cmd)
6250    }
6251
6252    async fn generate_fill_reports(
6253        &self,
6254        cmd: GenerateFillReports,
6255    ) -> anyhow::Result<Vec<FillReport>> {
6256        let reports = self.paginate_fill_reports(&cmd).await?.reports;
6257        Self::log_report_receipt(reports.len(), "FillReport", cmd.log_receipt_level);
6258        Ok(reports)
6259    }
6260
6261    async fn generate_position_status_reports(
6262        &self,
6263        cmd: &GeneratePositionStatusReports,
6264    ) -> anyhow::Result<Vec<PositionStatusReport>> {
6265        let (reports, complete, _) = self.cached_position_reports(cmd)?;
6266        anyhow::ensure!(
6267            complete,
6268            "Lighter position snapshot does not cover the requested instrument scope",
6269        );
6270        Self::log_report_receipt(reports.len(), "PositionStatusReport", cmd.log_receipt_level);
6271        Ok(reports)
6272    }
6273
6274    async fn generate_mass_status(
6275        &self,
6276        lookback_mins: Option<u64>,
6277    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
6278        let ts_init = self.clock.get_time_ns();
6279
6280        // Scope inactive orders at the venue and stop descending trade
6281        // pagination once it crosses this local lookback boundary.
6282        let lookback_start = lookback_mins
6283            .map(DurationNanos::try_from_mins)
6284            .transpose()?
6285            .map(|lookback| ts_init.saturating_sub(lookback));
6286
6287        // open_only = false so the inactive-orders fan-out runs and surfaces
6288        // canceled / rejected / expired / filled orders that the engine
6289        // needs for reconciliation. The active markets set bounds the fan-out
6290        // to markets with known account activity.
6291        let mut order_cmd = GenerateOrderStatusReports::new(
6292            UUID4::new(),
6293            ts_init,
6294            false,
6295            None,
6296            lookback_start,
6297            None,
6298            None,
6299            None,
6300        );
6301        order_cmd.log_receipt_level = LogLevel::Debug;
6302        let fill_cmd = GenerateFillReports::new(
6303            UUID4::new(),
6304            ts_init,
6305            None,
6306            None,
6307            lookback_start,
6308            None,
6309            None,
6310            None,
6311        );
6312        let position_cmd =
6313            GeneratePositionStatusReports::new(UUID4::new(), ts_init, None, None, None, None, None);
6314
6315        // Preserve successful reports if a later market or history page fails.
6316        // Retry the active leg only when the failed request produced nothing.
6317        let mut withheld_instruments = AHashSet::new();
6318        let order_result = self.generate_order_status_reports(&order_cmd).await;
6319        let (mut order_reports, mut reports_complete) = match order_result {
6320            Ok(reports) => (reports, true),
6321            Err(e) => {
6322                withheld_instruments.extend(withheld_order_instruments(&e));
6323                log::warn!(
6324                    "Lighter order report generation failed: {}",
6325                    scrub_auth(&format!("{e:#}")),
6326                );
6327                let partial = partial_order_reports(&e);
6328                let reports = if partial.is_empty() {
6329                    let mut active_cmd = GenerateOrderStatusReports::new(
6330                        UUID4::new(),
6331                        ts_init,
6332                        true,
6333                        None,
6334                        None,
6335                        None,
6336                        None,
6337                        None,
6338                    );
6339                    active_cmd.log_receipt_level = LogLevel::Debug;
6340                    let active_result = self.generate_order_status_reports(&active_cmd).await;
6341                    match active_result {
6342                        Ok(reports) => reports,
6343                        Err(e) => {
6344                            withheld_instruments.extend(withheld_order_instruments(&e));
6345                            log::warn!(
6346                                "Lighter active order report generation incomplete: {}",
6347                                scrub_auth(&format!("{e:#}")),
6348                            );
6349                            partial_order_reports(&e)
6350                        }
6351                    }
6352                } else {
6353                    partial
6354                };
6355                (reports, false)
6356            }
6357        };
6358
6359        let (mut fill_reports, fill_reports_complete, withheld_fills) =
6360            fill_reports_for_mass_status(self.paginate_fill_reports(&fill_cmd).await)
6361                .context("Lighter fill reconciliation failed")?;
6362        reports_complete &= fill_reports_complete;
6363        withheld_instruments.extend(withheld_fills);
6364        Self::log_report_receipt(fill_reports.len(), "FillReport", fill_cmd.log_receipt_level);
6365
6366        let mut reported_orders: AHashSet<VenueOrderId> = order_reports
6367            .iter()
6368            .map(|report| report.venue_order_id)
6369            .collect();
6370        let mut fill_markets: Vec<i64> = fill_reports
6371            .iter()
6372            .filter(|report| !reported_orders.contains(&report.venue_order_id))
6373            .filter_map(|report| self.registry.market_index(&report.instrument_id))
6374            .collect::<AHashSet<_>>()
6375            .into_iter()
6376            .collect();
6377        fill_markets.sort_unstable();
6378
6379        for market_index in fill_markets {
6380            let Some(instrument_id) = self.registry.instrument_id(market_index) else {
6381                reports_complete = false;
6382                continue;
6383            };
6384            let mut order_cmd = GenerateOrderStatusReports::new(
6385                UUID4::new(),
6386                ts_init,
6387                false,
6388                Some(instrument_id),
6389                lookback_start,
6390                None,
6391                None,
6392                None,
6393            );
6394            order_cmd.log_receipt_level = LogLevel::Debug;
6395            let order_result = self.generate_order_status_reports(&order_cmd).await;
6396            reports_complete &= order_result.is_ok();
6397            let reports = match order_result {
6398                Ok(reports) => reports,
6399                Err(e) => {
6400                    withheld_instruments.extend(withheld_order_instruments(&e));
6401                    log::warn!(
6402                        "Lighter order report generation failed: {}",
6403                        scrub_auth(&format!("{e:#}")),
6404                    );
6405                    partial_order_reports(&e)
6406                }
6407            };
6408            reported_orders.extend(reports.iter().map(|report| report.venue_order_id));
6409            order_reports.extend(reports);
6410        }
6411
6412        if fill_reports
6413            .iter()
6414            .any(|report| !reported_orders.contains(&report.venue_order_id))
6415        {
6416            reports_complete = false;
6417        }
6418
6419        for fill_report in &mut fill_reports {
6420            if let Some(client_order_id) = order_reports
6421                .iter()
6422                .find(|order_report| order_report.venue_order_id == fill_report.venue_order_id)
6423                .and_then(|order_report| order_report.client_order_id)
6424            {
6425                fill_report.client_order_id = Some(client_order_id);
6426            }
6427        }
6428
6429        let position_result = self.cached_position_reports(&position_cmd);
6430
6431        let (mut position_reports, mut position_coverage) = match position_result {
6432            Ok((reports, complete, coverage)) => {
6433                reports_complete &= complete;
6434                (reports, coverage)
6435            }
6436            Err(e) => {
6437                reports_complete = false;
6438                log::warn!("Lighter position report generation failed: {e:#}");
6439                (Vec::new(), None)
6440            }
6441        };
6442
6443        position_reports.retain(|report| !withheld_instruments.contains(&report.instrument_id));
6444
6445        if let Some(skipped) = &mut position_coverage {
6446            skipped.extend(
6447                withheld_instruments
6448                    .iter()
6449                    .filter_map(|id| self.registry.market_index(id)),
6450            );
6451        }
6452
6453        Self::log_report_receipt(
6454            position_reports.len(),
6455            "PositionStatusReport",
6456            position_cmd.log_receipt_level,
6457        );
6458
6459        if lookback_start.is_some() {
6460            let touched_instruments: AHashSet<InstrumentId> = order_reports
6461                .iter()
6462                .map(|report| report.instrument_id)
6463                .chain(fill_reports.iter().map(|report| report.instrument_id))
6464                .collect();
6465            let position_instruments: AHashSet<InstrumentId> = position_reports
6466                .iter()
6467                .map(|report| report.instrument_id)
6468                .collect();
6469            let cache = self.core.cache();
6470            let mut touched_instruments: Vec<InstrumentId> =
6471                touched_instruments.into_iter().collect();
6472            touched_instruments.sort_unstable();
6473
6474            for instrument_id in touched_instruments {
6475                let Some(instrument) = cache.instrument(&instrument_id) else {
6476                    reports_complete = false;
6477                    continue;
6478                };
6479
6480                if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
6481                    continue;
6482                }
6483                let Some(market_id) = self.registry.market_index(&instrument_id) else {
6484                    reports_complete = false;
6485                    continue;
6486                };
6487
6488                if !position_coverage
6489                    .as_ref()
6490                    .is_some_and(|skipped| !skipped.contains(&market_id))
6491                {
6492                    reports_complete = false;
6493                    continue;
6494                }
6495
6496                if position_instruments.contains(&instrument_id) {
6497                    continue;
6498                }
6499
6500                position_reports.push(PositionStatusReport::new(
6501                    self.core.account_id,
6502                    instrument_id,
6503                    PositionSide::Flat,
6504                    Quantity::zero(instrument.size_precision()),
6505                    ts_init,
6506                    ts_init,
6507                    Some(UUID4::new()),
6508                    None,
6509                    None,
6510                ));
6511            }
6512        }
6513
6514        let mut mass_status = ExecutionMassStatus::new(
6515            self.core.client_id,
6516            self.core.account_id,
6517            self.core.venue,
6518            ts_init,
6519            None,
6520        );
6521        mass_status.set_report_window(lookback_start, reports_complete);
6522        mass_status.add_order_reports(order_reports);
6523        mass_status.add_fill_reports(fill_reports);
6524        mass_status.add_position_reports(position_reports);
6525
6526        log::debug!(
6527            "Generated Lighter mass status: {} orders, {} fills, {} positions",
6528            mass_status.order_reports().len(),
6529            mass_status.fill_reports().len(),
6530            mass_status.position_reports().len(),
6531        );
6532
6533        Ok(Some(mass_status))
6534    }
6535}
6536
6537// `covers_window` is only meaningful for an account-wide sweep. Retention is per
6538// account, so a market-scoped sweep can serve nothing while older trades for that
6539// market have already been evicted by newer trades in other markets.
6540struct FillSweep {
6541    reports: Vec<FillReport>,
6542    covers_window: bool,
6543    withheld_instruments: AHashSet<InstrumentId>,
6544}
6545
6546#[derive(Debug, thiserror::Error)]
6547#[error("incomplete Lighter order reports: {detail}")]
6548struct IncompleteOrderReports {
6549    reports: Vec<OrderStatusReport>,
6550    withheld_instruments: AHashSet<InstrumentId>,
6551    detail: String,
6552}
6553
6554fn incomplete_order_reports(
6555    reports: Vec<OrderStatusReport>,
6556    detail: impl Into<String>,
6557    withheld_instruments: AHashSet<InstrumentId>,
6558) -> anyhow::Error {
6559    anyhow::Error::new(IncompleteOrderReports {
6560        reports,
6561        withheld_instruments,
6562        detail: detail.into(),
6563    })
6564}
6565
6566fn partial_order_reports(error: &anyhow::Error) -> Vec<OrderStatusReport> {
6567    error
6568        .downcast_ref::<IncompleteOrderReports>()
6569        .map(|incomplete| incomplete.reports.clone())
6570        .unwrap_or_default()
6571}
6572
6573fn withheld_order_instruments(error: &anyhow::Error) -> AHashSet<InstrumentId> {
6574    error
6575        .downcast_ref::<IncompleteOrderReports>()
6576        .map(|incomplete| incomplete.withheld_instruments.clone())
6577        .unwrap_or_default()
6578}
6579
6580fn is_commission_error(error: &anyhow::Error) -> bool {
6581    error
6582        .chain()
6583        .any(|cause| cause.downcast_ref::<LighterCommissionError>().is_some())
6584}
6585
6586fn fill_reports_for_mass_status(
6587    result: anyhow::Result<FillSweep>,
6588) -> anyhow::Result<(Vec<FillReport>, bool, AHashSet<InstrumentId>)> {
6589    match result {
6590        Ok(sweep) => Ok((
6591            sweep.reports,
6592            sweep.covers_window,
6593            sweep.withheld_instruments,
6594        )),
6595        Err(e) if is_commission_error(&e) => Err(e),
6596        Err(e) => {
6597            log::warn!(
6598                "Lighter fill report generation failed: {}",
6599                scrub_auth(&format!("{e:#}")),
6600            );
6601            Ok((Vec::new(), false, AHashSet::new()))
6602        }
6603    }
6604}
6605
6606impl LighterExecutionClient {
6607    fn prepare_oco_cancel_plans(&self, cmd: &CancelOrder) -> Option<Vec<CancelOrderPlan>> {
6608        let cache = self.core.cache();
6609        let order = cache.order(&cmd.client_order_id)?;
6610        if order.contingency_type() != Some(ContingencyType::Oco) || order.is_closed() {
6611            return None;
6612        }
6613
6614        let [peer_id] = order.linked_order_ids()? else {
6615            return None;
6616        };
6617
6618        if *peer_id == order.client_order_id() {
6619            return None;
6620        }
6621
6622        let peer = cache.order(peer_id)?;
6623        if peer.is_closed()
6624            || peer.instrument_id() != order.instrument_id()
6625            || peer.strategy_id() != order.strategy_id()
6626            || peer.account_id() != order.account_id()
6627            || validate_oco_links(&order, &peer, order.parent_order_id()).is_err()
6628        {
6629            return None;
6630        }
6631
6632        let original = self.prepare_cancel_order_plan(cmd).ok()?;
6633
6634        let expected_venue = order.venue_order_id().or_else(|| {
6635            self.dispatch
6636                .lookup_venue_order_id(&order.client_order_id())
6637        });
6638
6639        if original.venue_order_id != expected_venue {
6640            return None;
6641        }
6642
6643        let venue_order_id = peer.venue_order_id().or_else(|| {
6644            self.dispatch
6645                .order_identity(peer_id)
6646                .and_then(|identity| identity.venue_order_id())
6647        });
6648
6649        // Lighter accepts the registered client index before the peer's venue ID arrives.
6650        let venue_index = match venue_order_id {
6651            Some(venue) => venue.as_str().parse().ok()?,
6652            None => {
6653                let identity = self.dispatch.order_identity(peer_id)?;
6654                let original = self.dispatch.order_identity(&order.client_order_id())?;
6655                if !identity.shares_submission(&original) {
6656                    return None;
6657                }
6658
6659                identity.client_order_index
6660            }
6661        };
6662
6663        Some(vec![
6664            original,
6665            CancelOrderPlan {
6666                client_order_id: *peer_id,
6667                strategy_id: peer.strategy_id(),
6668                instrument_id: peer.instrument_id(),
6669                venue_order_id,
6670                market_index: self.registry.market_index(&peer.instrument_id())?,
6671                venue_index,
6672            },
6673        ])
6674    }
6675
6676    fn child_probe_context(&self) -> Option<AckedOrderProbeContext> {
6677        let credential = self.credential.as_ref()?;
6678        self.fanout_dispatch_context(credential)
6679            .ok()
6680            .map(|context| context.probe_context())
6681    }
6682
6683    fn report_client(&self) -> LighterReportClient {
6684        LighterReportClient {
6685            http_client: self.http_client.clone(),
6686            credential: self.credential.clone(),
6687            account_id: self.core.account_id,
6688            registry: Arc::clone(&self.registry),
6689            clock: self.clock,
6690            markets: self.dispatch.active_markets.clone(),
6691            dispatch: self.dispatch.clone(),
6692        }
6693    }
6694
6695    fn report_instruments(
6696        &self,
6697        instrument_id: Option<InstrumentId>,
6698    ) -> AHashMap<InstrumentId, InstrumentAny> {
6699        let cache = self.core.cache();
6700        match instrument_id {
6701            Some(instrument_id) => cache
6702                .instrument(&instrument_id)
6703                .map(|instrument| (instrument_id, instrument.clone()))
6704                .into_iter()
6705                .collect(),
6706            None => cache
6707                .instruments(&self.core.venue, None)
6708                .into_iter()
6709                .map(|instrument| (instrument.id(), instrument.clone()))
6710                .collect(),
6711        }
6712    }
6713
6714    fn cached_position_reports(
6715        &self,
6716        cmd: &GeneratePositionStatusReports,
6717    ) -> anyhow::Result<(Vec<PositionStatusReport>, bool, Option<AHashSet<i64>>)> {
6718        cached_position_reports(&self.core, &self.dispatch, &self.registry, cmd)
6719    }
6720
6721    async fn paginate_fill_reports(&self, cmd: &GenerateFillReports) -> anyhow::Result<FillSweep> {
6722        let client = self.report_client();
6723        let instruments = self.report_instruments(cmd.instrument_id);
6724        let mut markets = BTreeSet::new();
6725        let result = client
6726            .collect_fill_reports(cmd, &instruments, &mut markets)
6727            .await;
6728        let result = finish_fill_reports(&self.core, &self.dispatch, markets, result);
6729
6730        if let Some(context) = self.child_probe_context() {
6731            spawn_child_recoveries(&context);
6732        }
6733
6734        result
6735    }
6736}
6737
6738struct LighterReportClient {
6739    http_client: LighterHttpClient,
6740    credential: Option<Credential>,
6741    account_id: AccountId,
6742    registry: Arc<MarketRegistry>,
6743    clock: &'static AtomicTime,
6744    markets: Arc<dashmap::DashSet<i64>>,
6745    dispatch: WsDispatchState,
6746}
6747
6748#[derive(Default)]
6749struct OrderReportSweep {
6750    orders: Vec<(i64, OrderStatusReport)>,
6751    markets: BTreeSet<i64>,
6752    error: Option<anyhow::Error>,
6753    partial: bool,
6754}
6755
6756impl LighterReportClient {
6757    async fn collect_order_status_reports(
6758        &self,
6759        cmd: &GenerateOrderStatusReports,
6760    ) -> OrderReportSweep {
6761        let mut sweep = OrderReportSweep {
6762            markets: self.markets.iter().map(|market| *market).collect(),
6763            ..Default::default()
6764        };
6765
6766        sweep.error = self.collect_orders(cmd, &mut sweep).await.err();
6767        sweep
6768    }
6769
6770    async fn collect_orders(
6771        &self,
6772        cmd: &GenerateOrderStatusReports,
6773        sweep: &mut OrderReportSweep,
6774    ) -> anyhow::Result<()> {
6775        let Some(credential) = &self.credential else {
6776            log::warn!("Lighter generate_order_status_reports: no credentials");
6777
6778            return Ok(());
6779        };
6780
6781        let auth = build_auth_token_for(credential)
6782            .context("failed to mint Lighter auth token for report fetch")?;
6783        let ts_init = self.clock.get_time_ns();
6784
6785        // Lighter exposes accountActiveOrders only per-market. Mass-status
6786        // requests with no scope iterate over account-active markets rather
6787        // than fanning out to every registered market, since the venue's REST
6788        // rate limit (60 req/min) would make a 180-market fan-out take
6789        // minutes. Account streams seed this set from live order, trade, and
6790        // position frames; if startup reconciliation reaches this path before
6791        // any market is known, one unscoped inactive-order page walk seeds it
6792        // from historical account activity.
6793        if cmd.instrument_id.is_none() && sweep.markets.is_empty() {
6794            collect_active_markets_from_inactive_orders(
6795                &self.http_client,
6796                &mut sweep.markets,
6797                credential,
6798                &auth,
6799                format_between_timestamps(cmd.start, cmd.end, ts_init),
6800            )
6801            .await?;
6802        }
6803
6804        sweep
6805            .markets
6806            .extend(self.markets.iter().map(|market| *market));
6807
6808        let market_indices = match cmd.instrument_id {
6809            Some(id) => match self.registry.market_index(&id) {
6810                Some(idx) => vec![idx],
6811                None => {
6812                    anyhow::bail!("no Lighter market_index for order report instrument {id}",);
6813                }
6814            },
6815            None => sweep.markets.iter().copied().collect::<Vec<_>>(),
6816        };
6817
6818        if market_indices.is_empty() {
6819            log::debug!(
6820                "Lighter generate_order_status_reports: no active markets yet; returning empty",
6821            );
6822
6823            return Ok(());
6824        }
6825
6826        sweep.partial = true;
6827        self.collect_orders_active(market_indices, credential, &auth, ts_init, sweep)
6828            .await?;
6829
6830        // Inactive orders (filled / canceled) are required when the engine
6831        // asks for non-`open_only` reports during a wider reconciliation.
6832        // Pagination is followed because a single market can hold more than
6833        // 200 historical inactive orders for a long-running account. The
6834        // venue-side `between_timestamps` window is set when `cmd.start`
6835        // / `cmd.end` are present so the venue, not the client, scopes the
6836        // pagination: important under the 60 req/min REST quota.
6837        if !cmd.open_only {
6838            self.collect_orders_inactive(cmd, credential, &auth, ts_init, sweep)
6839                .await?;
6840        }
6841
6842        Ok(())
6843    }
6844
6845    async fn collect_orders_active(
6846        &self,
6847        market_indices: Vec<i64>,
6848        credential: &Credential,
6849        auth: &SecretString,
6850        ts_init: UnixNanos,
6851        sweep: &mut OrderReportSweep,
6852    ) -> anyhow::Result<()> {
6853        let mut active_errors = Vec::new();
6854
6855        // Active orders are by definition still open. Returning them
6856        // unconditionally even when `cmd.start` is set: an open order's
6857        // last activity can predate the lookback window without changing
6858        // the fact that the order is currently live and reconciliation
6859        // needs to know about it.
6860        for market_index in market_indices {
6861            let query = Zeroizing::new(LighterAccountActiveOrdersQuery {
6862                authorization: None,
6863                auth: Some(auth.clone()),
6864                account_index: credential.account_index(),
6865                market_id: market_index,
6866            });
6867
6868            let active = match self.http_client.get_account_active_orders(&query).await {
6869                Ok(response) => response,
6870                Err(e) => {
6871                    let detail = format!(
6872                        "failed to fetch Lighter active orders for market_index={market_index}: {}",
6873                        scrub_auth(&format!("{e:#}")),
6874                    );
6875                    log::warn!("{detail}",);
6876                    active_errors.push(detail);
6877                    continue;
6878                }
6879            };
6880
6881            for order in &active.orders {
6882                sweep.markets.insert(order.market_index);
6883
6884                let Some(report) = parse_http_order_to_report(
6885                    order,
6886                    &self.registry,
6887                    self.account_id,
6888                    ts_init,
6889                    &self.dispatch,
6890                ) else {
6891                    let detail = format!(
6892                        "failed to parse Lighter active order {} for market_index={market_index}",
6893                        order.order_id,
6894                    );
6895                    log::warn!("{detail}");
6896                    active_errors.push(detail);
6897                    continue;
6898                };
6899
6900                sweep.orders.push((order.client_order_index, report));
6901            }
6902        }
6903
6904        if !active_errors.is_empty() {
6905            anyhow::bail!(active_errors.join("; "));
6906        }
6907
6908        Ok(())
6909    }
6910
6911    async fn collect_orders_inactive(
6912        &self,
6913        cmd: &GenerateOrderStatusReports,
6914        credential: &Credential,
6915        auth: &SecretString,
6916        ts_init: UnixNanos,
6917        sweep: &mut OrderReportSweep,
6918    ) -> anyhow::Result<()> {
6919        sweep
6920            .markets
6921            .extend(self.markets.iter().map(|market| *market));
6922
6923        let inactive_markets: Vec<i64> = match cmd.instrument_id {
6924            Some(id) => self
6925                .registry
6926                .market_index(&id)
6927                .map(|m| vec![m])
6928                .unwrap_or_default(),
6929            None => sweep.markets.iter().copied().collect::<Vec<_>>(),
6930        };
6931
6932        let between_timestamps = format_between_timestamps(cmd.start, cmd.end, ts_init);
6933
6934        for market_id in inactive_markets {
6935            let mut cursor: Option<String> = None;
6936            let mut seen_cursors = AHashSet::new();
6937            let mut pages = 0_usize;
6938            loop {
6939                pages += 1;
6940                if pages > MAX_RECONCILIATION_PAGES {
6941                    anyhow::bail!(
6942                        "Lighter inactive-order reconciliation exceeded {MAX_RECONCILIATION_PAGES} pages for market_index={market_id}",
6943                    );
6944                }
6945
6946                let query = Zeroizing::new(LighterAccountInactiveOrdersQuery {
6947                    authorization: None,
6948                    auth: Some(auth.clone()),
6949                    account_index: credential.account_index(),
6950                    market_id: Some(market_id),
6951                    ask_filter: None,
6952                    between_timestamps: between_timestamps.clone(),
6953                    cursor: cursor.clone(),
6954                    limit: LIGHTER_REST_PAGE_SIZE,
6955                });
6956
6957                let inactive = self.http_client.get_account_inactive_orders(&query).await.map_err(|e| {
6958                    anyhow::anyhow!(
6959                        "failed to fetch Lighter inactive orders for market_index={market_id}: {}",
6960                        scrub_auth(&format!("{e:#}")),
6961                    )
6962                })?;
6963
6964                for order in &inactive.orders {
6965                    let Some(report) = parse_http_order_to_report(
6966                        order,
6967                        &self.registry,
6968                        self.account_id,
6969                        ts_init,
6970                        &self.dispatch,
6971                    ) else {
6972                        anyhow::bail!(
6973                            "failed to parse Lighter inactive order {} for market_index={market_id}",
6974                            order.order_id,
6975                        );
6976                    };
6977
6978                    if cmd.start.is_some_and(|start| report.ts_last < start)
6979                        || cmd.end.is_some_and(|end| report.ts_last > end)
6980                    {
6981                        continue;
6982                    }
6983
6984                    sweep.markets.insert(order.market_index);
6985                    sweep.orders.push((order.client_order_index, report));
6986                }
6987
6988                let Some(next) = inactive.next_cursor else {
6989                    break;
6990                };
6991
6992                if next.is_empty() {
6993                    break;
6994                }
6995
6996                anyhow::ensure!(
6997                    seen_cursors.insert(next.clone()),
6998                    "Lighter inactive-order reconciliation repeated cursor `{next}` for market_index={market_id}",
6999                );
7000                cursor = Some(next);
7001            }
7002        }
7003
7004        Ok(())
7005    }
7006
7007    async fn collect_fill_reports(
7008        &self,
7009        cmd: &GenerateFillReports,
7010        instruments: &AHashMap<InstrumentId, InstrumentAny>,
7011        markets: &mut BTreeSet<i64>,
7012    ) -> anyhow::Result<FillSweep> {
7013        let Some(credential) = &self.credential else {
7014            log::warn!("Lighter generate_fill_reports: no credentials");
7015            return Ok(FillSweep {
7016                reports: Vec::new(),
7017                covers_window: true,
7018                withheld_instruments: AHashSet::new(),
7019            });
7020        };
7021
7022        collect_fill_reports(
7023            &self.http_client,
7024            &self.registry,
7025            credential,
7026            self.account_id,
7027            self.clock,
7028            cmd,
7029            instruments,
7030            markets,
7031            None,
7032        )
7033        .await
7034    }
7035}
7036
7037#[expect(
7038    clippy::too_many_arguments,
7039    reason = "ordinary and child-scoped recovery share authenticated fill pagination"
7040)]
7041async fn collect_fill_reports(
7042    http_client: &LighterHttpClient,
7043    registry: &Arc<MarketRegistry>,
7044    credential: &Credential,
7045    account_id: AccountId,
7046    clock: &AtomicTime,
7047    cmd: &GenerateFillReports,
7048    instruments: &AHashMap<InstrumentId, InstrumentAny>,
7049    markets: &mut BTreeSet<i64>,
7050    order_index: Option<i64>,
7051) -> anyhow::Result<FillSweep> {
7052    let market_id = match cmd.instrument_id {
7053        Some(instrument_id) => Some(registry.market_index(&instrument_id).ok_or_else(|| {
7054            anyhow::anyhow!("no Lighter market_index for fill instrument {instrument_id}",)
7055        })?),
7056        None => None,
7057    };
7058
7059    let auth = build_auth_token_for(credential)
7060        .context("failed to mint Lighter auth token for fill fetch")?;
7061
7062    let ts_init = clock.get_time_ns();
7063    let mut reports = Vec::new();
7064    let mut cursor: Option<String> = None;
7065    let mut seen_cursors = AHashSet::new();
7066    let mut seen_in_call = AHashSet::new();
7067    let mut pages = 0_usize;
7068    let mut oldest_served: Option<UnixNanos> = None;
7069    let mut covers_window = true;
7070
7071    loop {
7072        pages += 1;
7073        anyhow::ensure!(
7074            pages <= MAX_RECONCILIATION_PAGES,
7075            "Lighter fill reconciliation exceeded {MAX_RECONCILIATION_PAGES} pages",
7076        );
7077
7078        let query = Zeroizing::new(LighterTradesQuery {
7079            authorization: None,
7080            auth: Some(auth.clone()),
7081            market_id,
7082            account_index: Some(credential.account_index()),
7083            order_index,
7084            sort_by: LighterTradeSortBy::Timestamp,
7085            sort_dir: Some(LighterSortDirection::Desc),
7086            cursor: cursor.clone(),
7087            // The venue's `from` parameter is not a timestamp lower bound
7088            // and can omit the newest trades when given an epoch value.
7089            from_timestamp: None,
7090            ask_filter: None,
7091            role: None,
7092            trade_type: None,
7093            limit: LIGHTER_REST_PAGE_SIZE,
7094            aggregate: None,
7095        });
7096
7097        let response = match http_client.get_trades(&query).await {
7098            Ok(response) => response,
7099            Err(e) => {
7100                // `{e:#}` preserves the venue's status/body across the
7101                // outer context wrap; `scrub_auth` redacts any `auth=`
7102                // query value the HTTP layer's error included.
7103                log::warn!(
7104                    "Lighter get_trades failed (market_id={:?}, account_index={}, cursor={:?}): {}",
7105                    query.market_id,
7106                    credential.account_index(),
7107                    cursor,
7108                    scrub_auth(&format!("{e:#}")),
7109                );
7110                return Err(anyhow::Error::new(e).context("failed to fetch Lighter fills"));
7111            }
7112        };
7113
7114        for trade in &response.trades {
7115            let Some(report) = parse_scoped_fill_report(
7116                trade,
7117                registry,
7118                instruments,
7119                credential.account_index(),
7120                account_id,
7121                cmd,
7122                ts_init,
7123            )?
7124            else {
7125                continue;
7126            };
7127
7128            if !seen_in_call.insert(report.trade_id) {
7129                log::debug!(
7130                    "Lighter duplicate trade {} ignored within HTTP fill pagination",
7131                    report.trade_id,
7132                );
7133                continue;
7134            }
7135
7136            markets.insert(trade.market_id);
7137            reports.push(report);
7138        }
7139
7140        let page_oldest = response
7141            .trades
7142            .iter()
7143            .filter_map(|trade| u64::try_from(trade.timestamp).ok())
7144            .map(|timestamp_ms| UnixNanos::from(timestamp_ms.saturating_mul(1_000_000)))
7145            .min();
7146
7147        if let Some(page_oldest) = page_oldest {
7148            oldest_served = Some(oldest_served.map_or(page_oldest, |ts| ts.min(page_oldest)));
7149        }
7150
7151        let reached_start_boundary = cmd
7152            .start
7153            .is_some_and(|start| page_oldest.is_some_and(|oldest| oldest < start));
7154
7155        if reached_start_boundary {
7156            break;
7157        }
7158
7159        match response.next_cursor {
7160            Some(next) if !next.is_empty() => {
7161                anyhow::ensure!(
7162                    seen_cursors.insert(next.clone()),
7163                    "Lighter fill reconciliation repeated cursor `{next}`",
7164                );
7165                cursor = Some(next);
7166            }
7167            _ => {
7168                // The venue retains a bounded number of recent trades per account,
7169                // so an exhausted cursor ends the retained history rather than the
7170                // account's. Only a trade older than `start` proves the requested
7171                // window was served in full; an account-wide sweep that served
7172                // nothing has no history to retain.
7173                if let (Some(start), Some(oldest)) = (cmd.start, oldest_served) {
7174                    covers_window = false;
7175
7176                    log::warn!(
7177                        "Lighter fill reports do not cover {} to {}: trade pagination exhausted before the requested start; the venue `export` endpoint serves full history",
7178                        unix_nanos_to_iso8601(start),
7179                        unix_nanos_to_iso8601(oldest),
7180                    );
7181                }
7182
7183                break;
7184            }
7185        }
7186    }
7187
7188    Ok(FillSweep {
7189        reports,
7190        covers_window,
7191        withheld_instruments: AHashSet::new(),
7192    })
7193}
7194
7195fn parse_scoped_fill_report(
7196    trade: &LighterTrade,
7197    registry: &MarketRegistry,
7198    instruments: &AHashMap<InstrumentId, InstrumentAny>,
7199    account_index: i64,
7200    account_id: AccountId,
7201    cmd: &GenerateFillReports,
7202    ts_init: UnixNanos,
7203) -> anyhow::Result<Option<FillReport>> {
7204    let instrument_id = registry.instrument_id(trade.market_id).with_context(|| {
7205        format!(
7206            "no Lighter instrument registered for fill market_index={}",
7207            trade.market_id
7208        )
7209    })?;
7210
7211    let instrument = instruments
7212        .get(&instrument_id)
7213        .with_context(|| format!("Lighter fill instrument {instrument_id} missing from cache"))?;
7214
7215    let Some(report) = parse_ws_fill_report(trade, account_index, instrument, account_id, ts_init)
7216        .context("failed to parse Lighter fill report")?
7217    else {
7218        return Ok(None);
7219    };
7220
7221    if cmd.start.is_some_and(|start| report.ts_event < start)
7222        || cmd.end.is_some_and(|end| report.ts_event > end)
7223    {
7224        return Ok(None);
7225    }
7226
7227    Ok(Some(report))
7228}
7229
7230fn cached_position_reports(
7231    core: &ExecutionClientCore,
7232    dispatch: &WsDispatchState,
7233    registry: &Arc<MarketRegistry>,
7234    cmd: &GeneratePositionStatusReports,
7235) -> anyhow::Result<(Vec<PositionStatusReport>, bool, Option<AHashSet<i64>>)> {
7236    // Lighter has no REST position source. The latest complete WebSocket
7237    // snapshot is authoritative, while a skipped row keeps the retained
7238    // cache available only as explicitly incomplete mass-status data.
7239    let (mut reports, mut coverage) = dispatch.snapshot_positions_with_coverage();
7240    let pending = execution_instruments_pending(core, dispatch);
7241    reports.retain(|report| !pending.contains(&report.instrument_id));
7242    if let Some(skipped) = &mut coverage {
7243        skipped.extend(pending.iter().filter_map(|id| registry.market_index(id)));
7244    }
7245
7246    let complete = match cmd.instrument_id {
7247        Some(instrument_id) => {
7248            let market_id = registry.market_index(&instrument_id).ok_or_else(|| {
7249                anyhow::anyhow!(
7250                    "no Lighter market_index for position report instrument {instrument_id}",
7251                )
7252            })?;
7253
7254            reports.retain(|report| report.instrument_id == instrument_id);
7255            !pending.contains(&instrument_id)
7256                && coverage
7257                    .as_ref()
7258                    .is_some_and(|skipped| !skipped.contains(&market_id))
7259        }
7260        None => pending.is_empty() && coverage.as_ref().is_some_and(|skipped| skipped.is_empty()),
7261    };
7262
7263    Ok((reports, complete, coverage))
7264}
7265
7266fn execution_instruments_pending(
7267    core: &ExecutionClientCore,
7268    dispatch: &WsDispatchState,
7269) -> AHashSet<InstrumentId> {
7270    let events = dispatch.order_events.lock();
7271    let cache = core.cache();
7272    let mut pending = AHashSet::new();
7273    dispatch.any_order_identity(|cloid, identity| {
7274        if dispatch.has_pending_modify(cloid)
7275            || events.get(cloid).is_some_and(|deferred| {
7276                !deferred.fills.is_empty()
7277                    || deferred
7278                        .order
7279                        .as_ref()
7280                        .is_some_and(|order| order.status.is_terminal())
7281                    || deferred.recovery_epoch.is_some()
7282            })
7283            || identity.publication_pending(cache.order(cloid).as_deref())
7284        {
7285            pending.insert(identity.instrument_id);
7286        }
7287
7288        false
7289    });
7290
7291    pending
7292}
7293
7294fn finish_order_reports(
7295    core: &ExecutionClientCore,
7296    dispatch: &WsDispatchState,
7297    sweep: OrderReportSweep,
7298    cmd: &GenerateOrderStatusReports,
7299) -> anyhow::Result<Vec<OrderStatusReport>> {
7300    for market in sweep.markets {
7301        dispatch.note_active_market(market);
7302    }
7303
7304    let mut withheld = AHashSet::new();
7305    let mut reports = Vec::with_capacity(sweep.orders.len());
7306    for (client_order_index, report) in sweep.orders {
7307        let pending_cloid = core
7308            .cache()
7309            .client_order_id(&report.venue_order_id)
7310            .copied()
7311            .or_else(|| {
7312                dispatch
7313                    .resolve_client_order_index_for_venue(client_order_index, report.venue_order_id)
7314            });
7315
7316        if pending_cloid
7317            .as_ref()
7318            .is_some_and(|cloid| order_shape_pending(core, dispatch, cloid))
7319        {
7320            withheld.insert(report.instrument_id);
7321            continue;
7322        }
7323
7324        restore_reconciled_order(
7325            core,
7326            dispatch,
7327            report.venue_order_id,
7328            client_order_index,
7329            report.order_status.is_closed(),
7330        );
7331        let report = dispatch.translate_order_cloid(report);
7332        let report = dispatch.preserve_pending_order_status(report);
7333        if report
7334            .client_order_id
7335            .as_ref()
7336            .is_some_and(|cloid| order_shape_pending(core, dispatch, cloid))
7337        {
7338            withheld.insert(report.instrument_id);
7339            continue;
7340        }
7341
7342        if !order_report_ready(core, dispatch, &report) {
7343            withheld.insert(report.instrument_id);
7344            continue;
7345        }
7346
7347        dispatch.seed_accepted_from_report(&report);
7348        reports.push(report);
7349    }
7350
7351    if let Some(error) = sweep.error {
7352        return Err(if sweep.partial {
7353            incomplete_order_reports(reports, error.to_string(), withheld)
7354        } else {
7355            error
7356        });
7357    }
7358
7359    if !withheld.is_empty() {
7360        return Err(incomplete_order_reports(
7361            reports,
7362            "Lighter modification confirmation is pending".to_string(),
7363            withheld,
7364        ));
7365    }
7366
7367    LighterExecutionClient::log_report_receipt(
7368        reports.len(),
7369        "OrderStatusReport",
7370        cmd.log_receipt_level,
7371    );
7372    Ok(reports)
7373}
7374
7375fn finish_fill_reports(
7376    core: &ExecutionClientCore,
7377    dispatch: &WsDispatchState,
7378    markets: BTreeSet<i64>,
7379    result: anyhow::Result<FillSweep>,
7380) -> anyhow::Result<FillSweep> {
7381    for market in markets {
7382        dispatch.note_active_market(market);
7383    }
7384
7385    let mut sweep = result?;
7386    let mut reports = Vec::with_capacity(sweep.reports.len());
7387
7388    for mut report in sweep.reports {
7389        defer_reconciled_fill(core, dispatch, &mut report);
7390
7391        // Mass-status reconciliation must surface the original
7392        // Nautilus cloid, not the venue's numeric echo.
7393        let report = dispatch.translate_fill_cloid(report);
7394        if report.client_order_id.as_ref().is_some_and(|cloid| {
7395            order_shape_pending(core, dispatch, cloid)
7396                || (dispatch
7397                    .order_identity(cloid)
7398                    .is_some_and(|identity| identity.child_fill_baseline().is_some())
7399                    && core
7400                        .cache()
7401                        .order(cloid)
7402                        .is_none_or(|order| !order.is_closed()))
7403        }) {
7404            sweep.covers_window = false;
7405            sweep.withheld_instruments.insert(report.instrument_id);
7406            continue;
7407        }
7408
7409        if matches!(
7410            dispatch.mark_trade_reconciled(report.trade_id),
7411            Some(TradeDedupSource::Live)
7412        ) {
7413            log::debug!(
7414                "Lighter trade {} ignored in HTTP fill reports after live delivery",
7415                report.trade_id
7416            );
7417            continue;
7418        }
7419
7420        reports.push(report);
7421    }
7422
7423    sweep.reports = reports;
7424    Ok(sweep)
7425}
7426
7427fn defer_reconciled_fill(
7428    core: &ExecutionClientCore,
7429    dispatch: &WsDispatchState,
7430    report: &mut FillReport,
7431) {
7432    let Some((cloid, identity)) = restore_fill_identity(core, dispatch, report) else {
7433        return;
7434    };
7435
7436    report.client_order_id = Some(cloid);
7437
7438    let Some(fill) = child_fill_from_report(report, &identity, core.trader_id) else {
7439        return;
7440    };
7441
7442    if core
7443        .cache()
7444        .order(&cloid)
7445        .is_some_and(|order| order.is_duplicate_fill(&fill))
7446    {
7447        return;
7448    }
7449
7450    let mut events = dispatch.order_events.lock();
7451    let baseline = identity.child_fill_baseline();
7452    let new_fill = baseline
7453        .as_ref()
7454        .is_none_or(|baseline| !baseline.trade_ids.contains(&fill.trade_id));
7455    if new_fill
7456        && (baseline.is_some()
7457            || dispatch.has_pending_modify(&cloid)
7458            || position_fills_pending(&identity, &events, dispatch))
7459    {
7460        dispatch.defer_fill(events.entry(cloid).or_default(), fill);
7461    }
7462}
7463
7464fn restore_fill_identity(
7465    core: &ExecutionClientCore,
7466    dispatch: &WsDispatchState,
7467    report: &FillReport,
7468) -> Option<(ClientOrderId, OrderIdentity)> {
7469    let cached_order = {
7470        let cache = core.cache();
7471        cache
7472            .client_order_id(&report.venue_order_id)
7473            .and_then(|cloid| cache.order_owned(cloid))
7474            .filter(|order| {
7475                !order.strategy_id().is_external()
7476                    && order.instrument_id() == report.instrument_id
7477                    && order.account_id() == Some(report.account_id)
7478            })
7479    }?;
7480
7481    let cloid = cached_order.client_order_id();
7482    if dispatch.order_identity(&cloid).is_none() {
7483        let index = report
7484            .client_order_id
7485            .and_then(|id| id.as_str().parse::<i64>().ok())
7486            .unwrap_or(0);
7487        dispatch.seed_order_restoration(cached_order);
7488        dispatch.restore_cached_order(report.venue_order_id, index, report.instrument_id);
7489    }
7490
7491    dispatch
7492        .order_identity(&cloid)
7493        .map(|identity| (cloid, identity))
7494}
7495
7496fn order_report_ready(
7497    core: &ExecutionClientCore,
7498    dispatch: &WsDispatchState,
7499    report: &OrderStatusReport,
7500) -> bool {
7501    let Some(cloid) = report.client_order_id else {
7502        return true;
7503    };
7504
7505    if let Some(identity) = dispatch.order_identity(&cloid)
7506        && identity.child_fill_baseline().is_some()
7507        && core
7508            .cache()
7509            .order(&cloid)
7510            .is_none_or(|order| report.filled_qty != order.filled_qty())
7511    {
7512        return false;
7513    }
7514
7515    if !order_shape_pending(core, dispatch, &cloid) {
7516        return true;
7517    }
7518
7519    report.order_status == OrderStatus::PendingUpdate
7520        && core.cache().order(&cloid).is_some_and(|order| {
7521            order.quantity() == report.quantity
7522                && order.price() == report.price
7523                && order.trigger_price() == report.trigger_price
7524                && order.filled_qty() == report.filled_qty
7525        })
7526}
7527
7528fn order_shape_pending(
7529    core: &ExecutionClientCore,
7530    dispatch: &WsDispatchState,
7531    cloid: &ClientOrderId,
7532) -> bool {
7533    let events = dispatch.order_events.lock();
7534    if dispatch.has_pending_modify(cloid) {
7535        return true;
7536    }
7537
7538    let Some(identity) = dispatch.order_identity(cloid) else {
7539        return false;
7540    };
7541
7542    if position_fills_pending(&identity, &events, dispatch) {
7543        return true;
7544    }
7545
7546    let cache = core.cache();
7547
7548    if dispatch.any_order_identity(|id, other| {
7549        other.strategy_id == identity.strategy_id
7550            && other.instrument_id == identity.instrument_id
7551            && other.publication_pending(cache.order(id).as_deref())
7552    }) {
7553        return true;
7554    }
7555
7556    let order = cache.order(cloid);
7557    let pending = identity.publication_pending(order.as_deref());
7558    if !pending && order.as_ref().is_some_and(|order| order.is_closed()) {
7559        dispatch.retire_order_identity(cloid);
7560    }
7561
7562    pending
7563}
7564
7565fn restore_reconciled_order(
7566    core: &ExecutionClientCore,
7567    dispatch: &WsDispatchState,
7568    venue_order_id: VenueOrderId,
7569    client_order_index: i64,
7570    terminal: bool,
7571) {
7572    let cached_order = {
7573        let cache = core.cache();
7574
7575        let Some(cloid) = cache.client_order_id(&venue_order_id).copied() else {
7576            return;
7577        };
7578
7579        let Some(order) = cache.order_owned(&cloid) else {
7580            log::warn!(
7581                "Ignoring reconciled Lighter order missing from cache: cloid={cloid}, venue_order_id={venue_order_id}",
7582            );
7583            return;
7584        };
7585
7586        order
7587    };
7588
7589    if cached_order.instrument_id().venue != core.venue
7590        || cached_order.account_id() != Some(core.account_id)
7591    {
7592        log::warn!(
7593            "Ignoring reconciled Lighter order outside this client: cloid={}, venue_order_id={venue_order_id}",
7594            cached_order.client_order_id(),
7595        );
7596        return;
7597    }
7598
7599    if order_shape_pending(core, dispatch, &cached_order.client_order_id()) {
7600        return;
7601    }
7602
7603    let terminal =
7604        terminal && (cached_order.parent_order_id().is_none() || cached_order.is_closed());
7605
7606    if let Err(e) = dispatch.restore_reconciled_order(
7607        &cached_order,
7608        client_order_index,
7609        venue_order_id,
7610        terminal,
7611    ) {
7612        log::warn!(
7613            "Ignoring conflicting Lighter reconciliation identity: cloid={}, venue_order_id={venue_order_id}, client_order_index={}, error={e}",
7614            cached_order.client_order_id(),
7615            client_order_index,
7616        );
7617    }
7618}
7619
7620fn local_submit_denial_reason(
7621    order: &OrderAny,
7622    instrument: Option<&InstrumentAny>,
7623) -> Option<String> {
7624    if instrument.is_none() {
7625        return Some(
7626            OrderDeniedReason::InstrumentNotFound {
7627                instrument_id: order.instrument_id(),
7628            }
7629            .to_string(),
7630        );
7631    }
7632
7633    if !is_lighter_supported_order_type(order.order_type()) {
7634        return Some(unsupported_lighter_order_type_reason(order.order_type()));
7635    }
7636
7637    if is_lighter_limit_style_order(order.order_type()) && order.price().is_none() {
7638        return Some(
7639            OrderDeniedReason::ValidationFailed {
7640                detail: "Lighter limit-style orders require a limit price".to_string(),
7641            }
7642            .to_string(),
7643        );
7644    }
7645
7646    if order.is_quote_quantity() {
7647        return Some(
7648            OrderDeniedReason::ValidationFailed {
7649                detail:
7650                    "Lighter orders do not support quote_quantity; submit base quantity instead"
7651                        .to_string(),
7652            }
7653            .to_string(),
7654        );
7655    }
7656
7657    if order.display_qty().is_some() {
7658        return Some(
7659            OrderDeniedReason::ValidationFailed {
7660                detail: "Lighter orders do not support display_qty iceberg instructions"
7661                    .to_string(),
7662            }
7663            .to_string(),
7664        );
7665    }
7666
7667    if is_lighter_spot_order(order, instrument) && is_lighter_conditional_order(order.order_type())
7668    {
7669        let denied = OrderDeniedReason::UnsupportedOrderType {
7670            order_type: order.order_type(),
7671        };
7672        return Some(format!(
7673            "{denied}; Lighter spot markets do not support conditional orders",
7674        ));
7675    }
7676
7677    nautilus_to_lighter_tif(
7678        order.order_type(),
7679        order.time_in_force(),
7680        order.is_post_only(),
7681    )
7682    .err()
7683    .map(|e| {
7684        let denied = OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force());
7685        format!("{denied}; {e}")
7686    })
7687}
7688
7689fn unsupported_lighter_order_type_reason(order_type: OrderType) -> String {
7690    let denied = OrderDeniedReason::UnsupportedOrderType { order_type };
7691    format!(
7692        "{denied}; Lighter supports MARKET, LIMIT, STOP_MARKET, STOP_LIMIT, MARKET_IF_TOUCHED, and LIMIT_IF_TOUCHED",
7693    )
7694}
7695
7696fn is_grouped_order(order: &OrderAny) -> bool {
7697    order.contingency_type().is_some() || order.parent_order_id().is_some()
7698}
7699
7700fn grouped_order_indices(orders: &[OrderAny]) -> anyhow::Result<(LighterGroupingType, Vec<usize>)> {
7701    anyhow::ensure!(
7702        (2..=3).contains(&orders.len()),
7703        "Lighter groups require two or three orders"
7704    );
7705    let first = &orders[0];
7706    anyhow::ensure!(
7707        orders
7708            .iter()
7709            .enumerate()
7710            .all(|(index, order)| orders[..index]
7711                .iter()
7712                .all(|other| other.client_order_id() != order.client_order_id())),
7713        "Lighter groups require unique client order IDs"
7714    );
7715    anyhow::ensure!(
7716        orders.iter().all(|order| !order.is_closed()
7717            && order.instrument_id() == first.instrument_id()
7718            && order.strategy_id() == first.strategy_id()
7719            && order.order_list_id() == first.order_list_id()),
7720        "Lighter groups require open orders from one instrument, strategy, and order list"
7721    );
7722    anyhow::ensure!(
7723        !orders
7724            .iter()
7725            .any(|order| order.contingency_type() == Some(ContingencyType::Ouo)),
7726        "Lighter native groups do not support Nautilus OUO quantity updates"
7727    );
7728
7729    let parents: Vec<_> = orders
7730        .iter()
7731        .enumerate()
7732        .filter(|(_, order)| {
7733            order.contingency_type() == Some(ContingencyType::Oto)
7734                && order.parent_order_id().is_none()
7735        })
7736        .map(|(index, _)| index)
7737        .collect();
7738
7739    if parents.is_empty() {
7740        anyhow::ensure!(
7741            orders.len() == 2,
7742            "Lighter OCO requires exactly two siblings"
7743        );
7744        validate_oco_links(&orders[0], &orders[1], None)?;
7745        return Ok((LighterGroupingType::OneCancelsTheOther, vec![0, 1]));
7746    }
7747
7748    anyhow::ensure!(
7749        parents.len() == 1,
7750        "Lighter OTO groups require exactly one parent"
7751    );
7752    let parent_index = parents[0];
7753    let parent = &orders[parent_index];
7754    anyhow::ensure!(
7755        matches!(parent.order_type(), OrderType::Market | OrderType::Limit),
7756        "Lighter OTO parents must be MARKET or LIMIT orders"
7757    );
7758    let children: Vec<_> = orders
7759        .iter()
7760        .enumerate()
7761        .filter(|(index, _)| *index != parent_index)
7762        .collect();
7763    let child_ids: Vec<_> = children
7764        .iter()
7765        .map(|(_, order)| order.client_order_id())
7766        .collect();
7767    let links = parent
7768        .linked_order_ids()
7769        .context("Lighter OTO parent requires linked children")?;
7770    anyhow::ensure!(
7771        links.len() == child_ids.len() && child_ids.iter().all(|id| links.contains(id)),
7772        "Lighter OTO parent links must identify every child exactly once"
7773    );
7774    anyhow::ensure!(
7775        children
7776            .iter()
7777            .all(|(_, order)| order.parent_order_id() == Some(parent.client_order_id())),
7778        "Lighter OTO children must reference their parent"
7779    );
7780
7781    let grouping_type = if children.len() == 1 {
7782        anyhow::ensure!(
7783            children[0].1.contingency_type().is_none()
7784                && children[0].1.linked_order_ids().is_none(),
7785            "Lighter OTO child must not have a sibling contingency"
7786        );
7787        LighterGroupingType::OneTriggersTheOther
7788    } else {
7789        validate_oco_links(children[0].1, children[1].1, Some(parent.client_order_id()))?;
7790        LighterGroupingType::OneTriggersOneCancelsTheOther
7791    };
7792
7793    Ok((
7794        grouping_type,
7795        std::iter::once(parent_index)
7796            .chain(children.iter().map(|(index, _)| *index))
7797            .collect(),
7798    ))
7799}
7800
7801fn validate_oco_links(
7802    first: &OrderAny,
7803    second: &OrderAny,
7804    parent: Option<ClientOrderId>,
7805) -> anyhow::Result<()> {
7806    let orders = [first, second];
7807    for (index, order) in orders.iter().enumerate() {
7808        anyhow::ensure!(
7809            order.contingency_type() == Some(ContingencyType::Oco)
7810                && order.parent_order_id() == parent,
7811            "Lighter OCO siblings must have matching OCO relationships"
7812        );
7813        anyhow::ensure!(
7814            order
7815                .linked_order_ids()
7816                .is_some_and(|links| links == [orders[1 - index].client_order_id()]),
7817            "Lighter OCO siblings must link only to each other"
7818        );
7819    }
7820
7821    Ok(())
7822}
7823
7824fn prepare_group_bodies(
7825    grouping_type: LighterGroupingType,
7826    plans: &mut [CreateOrderPlan],
7827    use_gtd: bool,
7828) -> anyhow::Result<()> {
7829    let has_parent = grouping_type != LighterGroupingType::OneCancelsTheOther;
7830    let parent_quantity = plans[0].order.quantity();
7831    let child_start = usize::from(has_parent);
7832
7833    let default_expiry = plans
7834        .iter()
7835        .find(|plan| {
7836            plan.order_expiry != 0 && !(plan.order.time_in_force() == TimeInForce::Gtd && use_gtd)
7837        })
7838        .map_or(plans[child_start].order_expiry, |plan| plan.order_expiry);
7839
7840    for plan in plans.iter_mut() {
7841        if plan.order_expiry != 0 && !(plan.order.time_in_force() == TimeInForce::Gtd && use_gtd) {
7842            plan.order_expiry = default_expiry;
7843        }
7844    }
7845
7846    let expiry = plans[child_start].order_expiry;
7847    let parent_side = plans[0].order.order_side();
7848    for child in &mut plans[child_start..] {
7849        anyhow::ensure!(
7850            is_lighter_conditional_order(child.order.order_type()) && child.order.is_reduce_only(),
7851            "Lighter group children require reduce-only SL/TP orders"
7852        );
7853        anyhow::ensure!(
7854            child.order.quantity() == parent_quantity,
7855            "Lighter group quantities must match before parent-fill sizing"
7856        );
7857        anyhow::ensure!(
7858            (child.order.order_side() != parent_side) == has_parent,
7859            "Lighter OTO children must oppose their parent; OCO siblings must have the same side"
7860        );
7861        anyhow::ensure!(
7862            child.order_expiry == expiry && expiry != 0,
7863            "Lighter group children require matching nonzero expiries"
7864        );
7865
7866        if has_parent {
7867            child.base_amount = 0;
7868        }
7869    }
7870
7871    anyhow::ensure!(
7872        plans[0].order_expiry == 0 || plans[0].order_expiry == expiry,
7873        "Lighter parent and child expiries must match"
7874    );
7875
7876    if plans.len() - child_start == 2 {
7877        let stop_count = plans[child_start..]
7878            .iter()
7879            .filter(|plan| {
7880                matches!(
7881                    plan.order.order_type(),
7882                    OrderType::StopMarket | OrderType::StopLimit
7883                )
7884            })
7885            .count();
7886
7887        anyhow::ensure!(
7888            stop_count == 1,
7889            "Lighter OCO requires one stop-loss and one take-profit"
7890        );
7891    }
7892
7893    Ok(())
7894}
7895
7896fn is_lighter_spot_order(order: &OrderAny, instrument: Option<&InstrumentAny>) -> bool {
7897    instrument.is_some_and(|instrument| matches!(instrument, InstrumentAny::CurrencyPair(_)))
7898        || product_type_from_instrument_id(&order.instrument_id()) == Some(LighterProductType::Spot)
7899}
7900
7901fn is_lighter_supported_order_type(order_type: OrderType) -> bool {
7902    matches!(
7903        order_type,
7904        OrderType::Market
7905            | OrderType::Limit
7906            | OrderType::StopMarket
7907            | OrderType::StopLimit
7908            | OrderType::MarketIfTouched
7909            | OrderType::LimitIfTouched
7910    )
7911}
7912
7913fn is_lighter_limit_style_order(order_type: OrderType) -> bool {
7914    matches!(
7915        order_type,
7916        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
7917    )
7918}
7919
7920fn is_lighter_conditional_order(order_type: OrderType) -> bool {
7921    matches!(
7922        order_type,
7923        OrderType::StopMarket
7924            | OrderType::StopLimit
7925            | OrderType::MarketIfTouched
7926            | OrderType::LimitIfTouched
7927    )
7928}
7929
7930async fn collect_active_markets_from_inactive_orders(
7931    http_client: &LighterHttpClient,
7932    markets: &mut BTreeSet<i64>,
7933    credential: &Credential,
7934    auth: &SecretString,
7935    between_timestamps: Option<String>,
7936) -> anyhow::Result<()> {
7937    let mut cursor: Option<String> = None;
7938    let mut seen_cursors = AHashSet::new();
7939    let mut orders_seen = 0_usize;
7940    let mut pages = 0_usize;
7941    loop {
7942        pages += 1;
7943        anyhow::ensure!(
7944            pages <= MAX_RECONCILIATION_PAGES,
7945            "Lighter active-market seed exceeded {MAX_RECONCILIATION_PAGES} pages",
7946        );
7947
7948        let query = Zeroizing::new(LighterAccountInactiveOrdersQuery {
7949            authorization: None,
7950            auth: Some(auth.clone()),
7951            account_index: credential.account_index(),
7952            market_id: None,
7953            ask_filter: None,
7954            between_timestamps: between_timestamps.clone(),
7955            cursor: cursor.clone(),
7956            limit: LIGHTER_REST_PAGE_SIZE,
7957        });
7958        let response = http_client
7959            .get_account_inactive_orders(&query)
7960            .await
7961            .context("failed to seed Lighter active markets from inactive orders")?;
7962
7963        for order in &response.orders {
7964            markets.insert(order.market_index);
7965            orders_seen += 1;
7966        }
7967
7968        match response.next_cursor {
7969            Some(next) if !next.is_empty() => {
7970                anyhow::ensure!(
7971                    seen_cursors.insert(next.clone()),
7972                    "Lighter active-market seed repeated cursor `{next}`",
7973                );
7974                cursor = Some(next);
7975            }
7976            _ => break,
7977        }
7978    }
7979
7980    if orders_seen > 0 {
7981        log::debug!("Seeded Lighter active markets from {orders_seen} inactive order report(s)");
7982    }
7983
7984    Ok(())
7985}
7986
7987fn cancel_order_from_cancel_all(
7988    cmd: &CancelAllOrders,
7989    client_order_id: ClientOrderId,
7990    strategy_id: StrategyId,
7991) -> CancelOrder {
7992    CancelOrder {
7993        trader_id: cmd.trader_id,
7994        client_id: cmd.client_id,
7995        strategy_id,
7996        instrument_id: cmd.instrument_id,
7997        client_order_id,
7998        venue_order_id: None,
7999        command_id: cmd.command_id,
8000        ts_init: cmd.ts_init,
8001        params: cmd.params.clone(),
8002        correlation_id: cmd.correlation_id,
8003        causation_id: cmd.causation_id,
8004    }
8005}
8006
8007fn validate_order_amount(
8008    instrument: &InstrumentAny,
8009    quantity: Quantity,
8010    price_ticks: u32,
8011    price_precision: u8,
8012) -> anyhow::Result<()> {
8013    if let Some(min_quantity) = instrument.min_quantity() {
8014        anyhow::ensure!(
8015            quantity >= min_quantity,
8016            "quantity `{quantity}` below Lighter min_base_amount `{min_quantity}` for {}",
8017            instrument.id(),
8018        );
8019    }
8020
8021    if let Some(min_notional) = instrument.min_notional() {
8022        let price = decimal_from_ticks(price_ticks, price_precision);
8023        let notional = quantity.as_decimal() * price;
8024        anyhow::ensure!(
8025            notional >= min_notional.as_decimal(),
8026            "order notional `{notional}` below Lighter min_quote_amount `{}` for {}",
8027            min_notional.as_decimal(),
8028            instrument.id(),
8029        );
8030    }
8031
8032    Ok(())
8033}
8034
8035fn decimal_from_ticks(ticks: u32, decimals: u8) -> Decimal {
8036    Decimal::from(ticks) / Decimal::from(10_i64.pow(u32::from(decimals)))
8037}
8038
8039/// Route a venue `account_orders` payload through the tracked-event path
8040/// when the cloid is known, otherwise fall back to the existing
8041/// [`OrderStatusReport`] flow used for externally-managed orders.
8042fn dispatch_lighter_order(
8043    order: &crate::http::models::LighterOrder,
8044    dispatch: &WsDispatchState,
8045    emitter: &ExecutionEventEmitter,
8046    registry: &Arc<MarketRegistry>,
8047    account_id: AccountId,
8048    trader_id: TraderId,
8049    ts_init: UnixNanos,
8050) {
8051    let mut events = dispatch.order_events.lock();
8052
8053    let instrument_id = match registry.instrument_id(order.market_index) {
8054        Some(id) => id,
8055        None => {
8056            log::debug!(
8057                "Lighter order frame dropped: no instrument for market_index={}",
8058                order.market_index,
8059            );
8060            return;
8061        }
8062    };
8063
8064    dispatch.note_active_market(order.market_index);
8065
8066    let instrument = match LIGHTER_INSTRUMENT_CACHE.get(&instrument_id) {
8067        Some(inst) => inst.value().clone(),
8068        None => {
8069            log::debug!("Lighter order frame dropped: instrument {instrument_id} not in cache",);
8070            return;
8071        }
8072    };
8073
8074    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
8075    if !dispatch.restore_cached_order(venue_order_id, order.client_order_index, instrument_id) {
8076        return;
8077    }
8078
8079    let resolved_cloid =
8080        dispatch.resolve_live_order_cloid(order.client_order_id.as_str(), venue_order_id);
8081
8082    let identity = resolved_cloid.and_then(|cid| {
8083        dispatch
8084            .order_identities
8085            .get(&cid)
8086            .map(|entry| (cid, entry.value().clone()))
8087    });
8088
8089    let Some((cloid, identity)) = identity else {
8090        dispatch_unowned_order(order, &instrument, dispatch, emitter, account_id, ts_init);
8091        return;
8092    };
8093
8094    if identity.instrument_id != instrument_id {
8095        return;
8096    }
8097
8098    if defer_owned_order(order, &identity, cloid, &mut events, dispatch, emitter) {
8099        return;
8100    }
8101
8102    dispatch_owned_order(
8103        order,
8104        &instrument,
8105        &identity,
8106        cloid,
8107        venue_order_id,
8108        dispatch,
8109        emitter,
8110        account_id,
8111        trader_id,
8112        ts_init,
8113    );
8114}
8115
8116fn defer_owned_order(
8117    order: &LighterOrder,
8118    identity: &OrderIdentity,
8119    cloid: ClientOrderId,
8120    events: &mut AHashMap<ClientOrderId, DeferredOrderEvents>,
8121    dispatch: &WsDispatchState,
8122    emitter: &ExecutionEventEmitter,
8123) -> bool {
8124    if dispatch.has_pending_modify(&cloid) {
8125        events.entry(cloid).or_default().store_order(order);
8126        return true;
8127    }
8128
8129    if identity.child_fill_baseline().is_some() {
8130        if identity.has_child_publication() {
8131            return true;
8132        }
8133
8134        let deferred = events.entry(cloid).or_default();
8135
8136        let had_terminal = deferred
8137            .order
8138            .as_ref()
8139            .is_some_and(|prior| prior.status.is_terminal());
8140
8141        deferred.store_order(order);
8142        let terminal = order.status.is_terminal();
8143
8144        if terminal || had_terminal {
8145            publish_child_recovery(cloid, events, dispatch, emitter);
8146            return true;
8147        }
8148    }
8149
8150    if position_fills_pending(identity, events, dispatch) && order.status.is_terminal() {
8151        events.entry(cloid).or_default().store_order(order);
8152        return true;
8153    }
8154
8155    false
8156}
8157
8158fn dispatch_unowned_order(
8159    order: &LighterOrder,
8160    instrument: &InstrumentAny,
8161    dispatch: &WsDispatchState,
8162    emitter: &ExecutionEventEmitter,
8163    account_id: AccountId,
8164    ts_init: UnixNanos,
8165) {
8166    match parse_ws_order_status_report(order, instrument, account_id, ts_init) {
8167        Ok(mut report) => {
8168            report = dispatch.apply_order_contingencies(order, report);
8169            report = dispatch.translate_order_cloid(report);
8170            report = dispatch.preserve_pending_order_status(report);
8171            if let Some(cloid) = &report.client_order_id {
8172                dispatch.venue_id_map.insert(*cloid, report.venue_order_id);
8173            }
8174
8175            if report.order_status.is_closed() {
8176                evict_terminal_mappings(&report, &dispatch.venue_id_map);
8177            }
8178
8179            log::debug!(
8180                "Lighter OrderStatusReport: voi={} status={:?} cloid={:?}",
8181                report.venue_order_id,
8182                report.order_status,
8183                report.client_order_id,
8184            );
8185            emitter.send_order_status_report(report);
8186        }
8187        Err(e) => {
8188            log::error!(
8189                "Failed to parse Lighter order status report: error={e}, order_id={}",
8190                order.order_id,
8191            );
8192        }
8193    }
8194}
8195
8196#[expect(
8197    clippy::too_many_arguments,
8198    reason = "tracked order publication uses the same identity and emitter context as stream dispatch"
8199)]
8200fn dispatch_owned_order(
8201    order: &crate::http::models::LighterOrder,
8202    instrument: &InstrumentAny,
8203    identity: &OrderIdentity,
8204    cloid: ClientOrderId,
8205    venue_order_id: VenueOrderId,
8206    dispatch: &WsDispatchState,
8207    emitter: &ExecutionEventEmitter,
8208    account_id: AccountId,
8209    trader_id: TraderId,
8210    ts_init: UnixNanos,
8211) {
8212    dispatch.venue_id_map.insert(cloid, venue_order_id);
8213
8214    // Pre-compute the parser's Open-frame context: accepted gate,
8215    // triggered gate, and shape diff against the stored snapshot.
8216    // The dispatcher owns the dispatch-state mutation and the parser
8217    // stays pure.
8218    let is_live_status = matches!(
8219        order.status,
8220        crate::common::enums::LighterOrderStatus::Pending
8221            | crate::common::enums::LighterOrderStatus::Open,
8222    );
8223    let pending_action = dispatch.pending_order_action(&cloid);
8224    if is_live_status && pending_action == Some(PendingOrderAction::Cancel) {
8225        log::debug!(
8226            "Deferring Lighter {:?} frame while cancel is pending for {cloid}",
8227            order.status,
8228        );
8229        return;
8230    }
8231
8232    let current_shape = match lighter_order_shape(order, instrument, identity.order_type) {
8233        Ok(shape) => shape,
8234        Err(e) => {
8235            log::error!(
8236                "Failed to compute Lighter order shape: error={e}, voi={venue_order_id}, cloid={cloid}",
8237            );
8238            return;
8239        }
8240    };
8241
8242    let prior_shape = dispatch.snapshot_for(&cloid);
8243
8244    let shape_changed = prior_shape.is_some_and(|previous| {
8245        identity.normalize_shape(previous) != identity.normalize_shape(current_shape.clone())
8246    });
8247
8248    let fresh_trigger = is_order_triggered(order)
8249        && TRIGGERABLE_ORDER_TYPES.contains(&identity.order_type)
8250        && !dispatch.triggered_was_emitted(&cloid);
8251
8252    if pending_action == Some(PendingOrderAction::Modify) && fresh_trigger && !shape_changed {
8253        log::debug!("Deferring Lighter trigger while modify confirmation is pending for {cloid}",);
8254        return;
8255    }
8256
8257    let open_ctx = OpenFrameContext {
8258        accepted_already_emitted: dispatch.accepted_was_emitted(&cloid),
8259        triggered_already_emitted: dispatch.triggered_was_emitted(&cloid),
8260        shape_changed,
8261    };
8262
8263    let event_opt = match parse_lighter_order_event(
8264        order, instrument, identity, cloid, account_id, trader_id, open_ctx, ts_init,
8265    ) {
8266        Ok(event) => event,
8267        Err(e) => {
8268            log::error!(
8269                "Failed to parse Lighter order event: error={e}, voi={venue_order_id}, cloid={cloid}"
8270            );
8271            return;
8272        }
8273    };
8274
8275    dispatch.store_snapshot(cloid, current_shape);
8276
8277    if let Some(event) = event_opt {
8278        dispatch_tracked_order_event(
8279            event,
8280            cloid,
8281            venue_order_id,
8282            identity,
8283            account_id,
8284            trader_id,
8285            emitter,
8286            dispatch,
8287            ts_init,
8288        );
8289    }
8290
8291    if order.status == crate::common::enums::LighterOrderStatus::Filled {
8292        dispatch.clear_pending_order_action(&cloid);
8293        dispatch.retire_order_identity(&cloid);
8294    }
8295}
8296
8297/// Route a venue `account_trades` payload through the tracked-event path
8298/// when the cloid is known, otherwise fall back to the existing
8299/// [`FillReport`] flow. Drops duplicate fill frames keyed by `trade_id`.
8300#[expect(
8301    clippy::too_many_arguments,
8302    reason = "consumption-loop dispatch threads identity and emitter context"
8303)]
8304fn dispatch_lighter_trade(
8305    trade: &crate::http::models::LighterTrade,
8306    dispatch: &WsDispatchState,
8307    emitter: &ExecutionEventEmitter,
8308    registry: &Arc<MarketRegistry>,
8309    account_id: AccountId,
8310    trader_id: TraderId,
8311    account_index: Option<i64>,
8312    ts_init: UnixNanos,
8313) {
8314    let mut events = dispatch.order_events.lock();
8315
8316    let Some(account_index) = account_index else {
8317        log::debug!("Lighter trade frame dropped: no credential / account_index available",);
8318        return;
8319    };
8320
8321    let Some(instrument_id) = registry.instrument_id(trade.market_id) else {
8322        log::debug!(
8323            "Lighter trade frame dropped: no instrument for market_id={}",
8324            trade.market_id,
8325        );
8326        return;
8327    };
8328
8329    dispatch.note_active_market(trade.market_id);
8330
8331    let Some(instrument) = LIGHTER_INSTRUMENT_CACHE
8332        .get(&instrument_id)
8333        .map(|instrument| instrument.value().clone())
8334    else {
8335        log::debug!("Lighter trade frame dropped: instrument {instrument_id} not in cache",);
8336        return;
8337    };
8338
8339    let user_is_bidder = trade.bid_account_id == account_index;
8340    let user_is_asker = trade.ask_account_id == account_index;
8341    if !user_is_bidder && !user_is_asker {
8342        // Defensive: the handler already filters foreign trades, so this
8343        // branch is rare in practice. Drop silently.
8344        return;
8345    }
8346
8347    // Dedupe before dispatch so a duplicate frame on reconnect does not
8348    // double-book on either the tracked or untracked path.
8349    let trade_id = match parse_lighter_trade_id(trade) {
8350        Ok(id) => id,
8351        Err(e) => {
8352            log::error!("Lighter trade has invalid trade_id: {e}");
8353            return;
8354        }
8355    };
8356
8357    let (raw_client_id, client_order_index, venue_order_id) = if user_is_bidder {
8358        (
8359            trade
8360                .bid_client_id_str
8361                .as_deref()
8362                .map_or_else(|| trade.bid_client_id.to_string(), str::to_string),
8363            trade.bid_client_id,
8364            trade.bid_id_str.as_deref().map_or_else(
8365                || VenueOrderId::new(trade.bid_id.to_string()),
8366                VenueOrderId::new,
8367            ),
8368        )
8369    } else {
8370        (
8371            trade
8372                .ask_client_id_str
8373                .as_deref()
8374                .map_or_else(|| trade.ask_client_id.to_string(), str::to_string),
8375            trade.ask_client_id,
8376            trade.ask_id_str.as_deref().map_or_else(
8377                || VenueOrderId::new(trade.ask_id.to_string()),
8378                VenueOrderId::new,
8379            ),
8380        )
8381    };
8382
8383    if !dispatch.restore_cached_order(venue_order_id, client_order_index, instrument_id) {
8384        return;
8385    }
8386
8387    let resolved_cloid = dispatch.resolve_live_trade_cloid(raw_client_id.as_str(), venue_order_id);
8388    let identity = resolved_cloid.and_then(|cid| {
8389        dispatch
8390            .order_identity(&cid)
8391            .map(|identity| (cid, identity))
8392    });
8393
8394    let Some((cloid, identity)) = identity else {
8395        match parse_ws_fill_report(trade, account_index, &instrument, account_id, ts_init) {
8396            Ok(Some(mut report)) => {
8397                report = dispatch.translate_fill_cloid(report);
8398                log::debug!(
8399                    "Lighter FillReport: voi={} qty={} px={} liq={:?} cloid={:?}",
8400                    report.venue_order_id,
8401                    report.last_qty,
8402                    report.last_px,
8403                    report.liquidity_side,
8404                    report.client_order_id,
8405                );
8406
8407                if dispatch.mark_trade_seen(trade_id) {
8408                    emitter.send_fill_report(report);
8409                }
8410            }
8411            Ok(None) => {}
8412            Err(e) => {
8413                log::error!("Failed to parse Lighter fill report: error={e}, trade_id={trade_id}",);
8414            }
8415        }
8416
8417        return;
8418    };
8419
8420    if identity.instrument_id != instrument_id {
8421        return;
8422    }
8423
8424    dispatch_owned_trade(
8425        trade,
8426        &instrument,
8427        &identity,
8428        cloid,
8429        venue_order_id,
8430        &mut events,
8431        dispatch,
8432        emitter,
8433        account_id,
8434        trader_id,
8435        account_index,
8436        trade_id,
8437        ts_init,
8438    );
8439}
8440
8441#[expect(
8442    clippy::too_many_arguments,
8443    reason = "tracked trade publication uses the stream identity and emitter context"
8444)]
8445fn dispatch_owned_trade(
8446    trade: &crate::http::models::LighterTrade,
8447    instrument: &InstrumentAny,
8448    identity: &OrderIdentity,
8449    cloid: ClientOrderId,
8450    venue_order_id: VenueOrderId,
8451    events: &mut AHashMap<ClientOrderId, DeferredOrderEvents>,
8452    dispatch: &WsDispatchState,
8453    emitter: &ExecutionEventEmitter,
8454    account_id: AccountId,
8455    trader_id: TraderId,
8456    account_index: i64,
8457    trade_id: TradeId,
8458    ts_init: UnixNanos,
8459) {
8460    // Synthesize an `OrderAccepted` first if one has not been
8461    // emitted yet: fills can race ahead of the matching `Open`
8462    // order frame.
8463    ensure_accepted_emitted(
8464        cloid,
8465        venue_order_id,
8466        identity,
8467        account_id,
8468        trader_id,
8469        emitter,
8470        dispatch,
8471        ts_init,
8472    );
8473
8474    match parse_lighter_order_filled(
8475        trade,
8476        instrument,
8477        identity,
8478        cloid,
8479        account_id,
8480        trader_id,
8481        account_index,
8482        ts_init,
8483    ) {
8484        Ok(Some(filled)) => {
8485            log::debug!(
8486                "Lighter OrderFilled: voi={} qty={} px={} liq={:?} cloid={cloid}",
8487                filled.venue_order_id,
8488                filled.last_qty,
8489                filled.last_px,
8490                filled.liquidity_side,
8491            );
8492
8493            if let Some(baseline) = identity.child_fill_baseline() {
8494                if baseline.trade_ids.contains(&trade_id) {
8495                    return;
8496                }
8497
8498                dispatch.defer_fill(events.entry(cloid).or_default(), filled);
8499                publish_child_recovery(cloid, events, dispatch, emitter);
8500            } else if dispatch.has_pending_modify(&cloid)
8501                || position_fills_pending(identity, events, dispatch)
8502            {
8503                dispatch.defer_fill(events.entry(cloid).or_default(), filled);
8504            } else if dispatch.mark_trade_seen(trade_id) {
8505                dispatch_lighter_fill(filled, emitter, dispatch);
8506            }
8507        }
8508        Ok(None) => {}
8509        Err(e) => {
8510            log::error!("Failed to parse Lighter typed fill: error={e}, trade_id={trade_id}",);
8511        }
8512    }
8513}
8514
8515fn spawn_child_recoveries(context: &AckedOrderProbeContext) {
8516    let candidates = context
8517        .dispatch
8518        .order_events
8519        .lock()
8520        .iter()
8521        .filter_map(|(cloid, deferred)| (!deferred.fills.is_empty()).then_some(*cloid))
8522        .collect::<Vec<_>>();
8523
8524    for cloid in candidates {
8525        spawn_child_recovery(cloid, context);
8526    }
8527}
8528
8529fn request_child_recovery(
8530    core: &ExecutionClientCore,
8531    context: &AckedOrderProbeContext,
8532    report: &OrderStatusReport,
8533) {
8534    let cache = core.cache();
8535
8536    let Some(cloid) = cache.client_order_id(&report.venue_order_id).copied() else {
8537        return;
8538    };
8539
8540    let Some(order) = cache.order(&cloid) else {
8541        return;
8542    };
8543
8544    if order.parent_order_id().is_some()
8545        && !order.is_closed()
8546        && (report.order_status.is_closed() || report.filled_qty != order.filled_qty())
8547    {
8548        spawn_child_recovery(cloid, context);
8549    }
8550}
8551
8552fn spawn_child_recovery(cloid: ClientOrderId, context: &AckedOrderProbeContext) {
8553    let epoch = context.connection_epoch.load(Ordering::Acquire);
8554    {
8555        let mut events = context.dispatch.order_events.lock();
8556
8557        let Some(identity) = context.dispatch.order_identity(&cloid) else {
8558            return;
8559        };
8560
8561        if identity.child_fill_baseline().is_none()
8562            || identity.has_child_publication()
8563            || context.dispatch.has_pending_modify(&cloid)
8564        {
8565            return;
8566        }
8567
8568        let deferred = events.entry(cloid).or_default();
8569        if deferred.recovery_epoch == Some(epoch) {
8570            return;
8571        }
8572
8573        deferred.recovery_epoch = Some(epoch);
8574    }
8575
8576    let context = context.clone();
8577    let tasks = context.pending_tasks.clone();
8578    let dispatch = context.dispatch.clone();
8579
8580    let future = async move {
8581        for attempt in 0..ACKED_ORDER_PROBE_ATTEMPTS {
8582            if context.connection_epoch.load(Ordering::Acquire) != epoch {
8583                break;
8584            }
8585
8586            let result = tokio::select! {
8587                () = context.cancellation_token.cancelled() => break,
8588                result = recover_child_order(cloid, epoch, &context) => result,
8589            };
8590
8591            match result {
8592                Ok(true) => break,
8593                Ok(false) => {}
8594                Err(e) => log::warn!(
8595                    "Lighter child recovery unavailable for {cloid}: {}",
8596                    scrub_auth(&format!("{e:#}"))
8597                ),
8598            }
8599
8600            if attempt + 1 < ACKED_ORDER_PROBE_ATTEMPTS {
8601                tokio::select! {
8602                    () = context.cancellation_token.cancelled() => break,
8603                    () = tokio::time::sleep(ACKED_ORDER_LOOKUP_DELAY) => {},
8604                }
8605            }
8606        }
8607
8608        let mut events = context.dispatch.order_events.lock();
8609        if let Some(deferred) = events.get_mut(&cloid)
8610            && deferred.recovery_epoch == Some(epoch)
8611        {
8612            deferred.recovery_epoch = None;
8613        }
8614    };
8615
8616    if let Err(e) = tasks.spawn(future) {
8617        if let Some(deferred) = dispatch.order_events.lock().get_mut(&cloid) {
8618            deferred.recovery_epoch = None;
8619        }
8620
8621        log::debug!("Skipping Lighter child recovery during shutdown: {e}");
8622    }
8623}
8624
8625async fn recover_child_order(
8626    cloid: ClientOrderId,
8627    epoch: u64,
8628    context: &AckedOrderProbeContext,
8629) -> anyhow::Result<bool> {
8630    let Some(identity) = context.dispatch.order_identity(&cloid) else {
8631        return Ok(true);
8632    };
8633
8634    if context.dispatch.has_pending_modify(&cloid) || identity.has_child_publication() {
8635        return Ok(true);
8636    }
8637
8638    let Some(venue_order_id) = identity.venue_order_id() else {
8639        return Ok(false);
8640    };
8641
8642    let lookup = OrderReportLookup::new(
8643        &context.registry,
8644        &context.dispatch,
8645        Some(identity.instrument_id),
8646        Some(&cloid),
8647        Some(&venue_order_id),
8648    )?;
8649
8650    let Some(order) = collect_order(
8651        &context.http_client,
8652        &context.registry,
8653        &context.credential,
8654        &lookup,
8655    )
8656    .await?
8657    else {
8658        return Ok(false);
8659    };
8660
8661    anyhow::ensure!(
8662        order.owner_account_index == context.credential.account_index()
8663            && context.registry.instrument_id(order.market_index) == Some(identity.instrument_id)
8664            && identity.matches_venue_order_id(VenueOrderId::new(&order.order_id)),
8665        "Lighter child recovery ownership mismatch"
8666    );
8667    let instrument = LIGHTER_INSTRUMENT_CACHE
8668        .get(&identity.instrument_id)
8669        .context("missing child recovery instrument")?
8670        .value()
8671        .clone();
8672    let report = parse_ws_order_status_report(
8673        &order,
8674        &instrument,
8675        context.account_id,
8676        context.clock.get_time_ns(),
8677    )?;
8678
8679    if !report.order_status.is_closed() {
8680        return Ok(false);
8681    }
8682
8683    let mut instruments = AHashMap::new();
8684    instruments.insert(identity.instrument_id, instrument.clone());
8685
8686    let command = GenerateFillReports::new(
8687        UUID4::new(),
8688        context.clock.get_time_ns(),
8689        Some(identity.instrument_id),
8690        Some(venue_order_id),
8691        None,
8692        None,
8693        None,
8694        None,
8695    );
8696    let sweep = collect_fill_reports(
8697        &context.http_client,
8698        &context.registry,
8699        &context.credential,
8700        context.account_id,
8701        context.clock,
8702        &command,
8703        &instruments,
8704        &mut BTreeSet::new(),
8705        Some(order.order_index),
8706    )
8707    .await?;
8708    let mut events = context.dispatch.order_events.lock();
8709    if context.connection_epoch.load(Ordering::Acquire) != epoch
8710        || context.dispatch.has_pending_modify(&cloid)
8711        || identity.has_child_publication()
8712    {
8713        return Ok(true);
8714    }
8715
8716    let deferred = events.entry(cloid).or_default();
8717    deferred.store_order(&order);
8718
8719    for mut fill in sweep.reports {
8720        fill.client_order_id = Some(cloid);
8721        if fill.venue_order_id != venue_order_id {
8722            continue;
8723        }
8724
8725        if let Some(fill) = child_fill_from_report(&fill, &identity, context.emitter.trader_id()) {
8726            context.dispatch.defer_fill(deferred, fill);
8727        }
8728    }
8729
8730    publish_child_recovery(cloid, &mut events, &context.dispatch, &context.emitter);
8731    Ok(identity.has_child_publication())
8732}
8733
8734fn child_fill_from_report(
8735    report: &FillReport,
8736    identity: &OrderIdentity,
8737    trader_id: TraderId,
8738) -> Option<OrderFilled> {
8739    let instrument = LIGHTER_INSTRUMENT_CACHE.get(&identity.instrument_id)?;
8740    let cloid = report.client_order_id.filter(|_| {
8741        identity.matches_venue_order_id(report.venue_order_id)
8742            && identity.instrument_id == report.instrument_id
8743    })?;
8744
8745    Some(OrderFilled::new(
8746        trader_id,
8747        identity.strategy_id,
8748        identity.instrument_id,
8749        cloid,
8750        report.venue_order_id,
8751        report.account_id,
8752        report.trade_id,
8753        report.order_side,
8754        identity.order_type,
8755        report.last_qty,
8756        report.last_px,
8757        instrument.quote_currency(),
8758        report.liquidity_side,
8759        UUID4::new(),
8760        report.ts_event,
8761        report.ts_init,
8762        false,
8763        None,
8764        Some(report.commission),
8765        None,
8766    ))
8767}
8768
8769fn publish_child_recovery(
8770    cloid: ClientOrderId,
8771    events: &mut AHashMap<ClientOrderId, DeferredOrderEvents>,
8772    dispatch: &WsDispatchState,
8773    emitter: &ExecutionEventEmitter,
8774) {
8775    let Some(identity) = dispatch.order_identity(&cloid) else {
8776        return;
8777    };
8778
8779    if position_modify_pending(&identity, dispatch) {
8780        return;
8781    }
8782
8783    let Some((ids, children)) = collect_child_recoveries(&identity, events, dispatch, emitter)
8784    else {
8785        return;
8786    };
8787
8788    let mut fills = Vec::new();
8789    let mut terminals = Vec::new();
8790
8791    for id in ids {
8792        let Some(mut deferred) = events.remove(&id) else {
8793            continue;
8794        };
8795
8796        fills.extend(deferred.fills.drain(..).map(|fill| {
8797            let sequence = deferred.fill_sequence.remove(&fill.trade_id).unwrap_or(0);
8798            (fill, sequence)
8799        }));
8800
8801        terminals.push((id, deferred));
8802    }
8803
8804    publish_child_updates(&children, &terminals, emitter);
8805
8806    fills.sort_by_key(|(fill, sequence)| (fill.ts_event, *sequence));
8807    for (fill, _) in fills {
8808        if dispatch.mark_trade_seen(fill.trade_id) {
8809            dispatch_lighter_fill(fill, emitter, dispatch);
8810        }
8811    }
8812
8813    for child in children {
8814        let id = child.cloid;
8815        let bundle = ExecutionReport::OrderWithFills(Box::new(child.report.clone()), Vec::new());
8816
8817        if let Err(e) = emitter.try_send_execution_report(bundle) {
8818            log::warn!("Lighter child report publication failed for {id}: {e}");
8819
8820            let Some(index) = terminals.iter().position(|(cid, _)| *cid == id) else {
8821                continue;
8822            };
8823
8824            let (_, deferred) = terminals.swap_remove(index);
8825            events.insert(id, deferred);
8826
8827            continue;
8828        }
8829
8830        child.identity.publish_child(&child.report, child.trade_ids);
8831        dispatch.retire_order_identity(&id);
8832    }
8833
8834    for (id, deferred) in terminals {
8835        if dispatch
8836            .order_identity(&id)
8837            .is_some_and(|identity| identity.child_fill_baseline().is_none())
8838        {
8839            flush_order_terminal(id, deferred, dispatch, emitter);
8840        }
8841    }
8842}
8843
8844struct ChildRecovery {
8845    cloid: ClientOrderId,
8846    identity: OrderIdentity,
8847    report: OrderStatusReport,
8848    trade_ids: Vec<TradeId>,
8849}
8850
8851fn collect_child_recoveries(
8852    identity: &OrderIdentity,
8853    events: &AHashMap<ClientOrderId, DeferredOrderEvents>,
8854    dispatch: &WsDispatchState,
8855    emitter: &ExecutionEventEmitter,
8856) -> Option<(Vec<ClientOrderId>, Vec<ChildRecovery>)> {
8857    let mut children = Vec::new();
8858    let mut ids = Vec::new();
8859
8860    for (id, deferred) in events {
8861        let Some(other) = dispatch.order_identity(id) else {
8862            continue;
8863        };
8864
8865        if other.strategy_id != identity.strategy_id
8866            || other.instrument_id != identity.instrument_id
8867        {
8868            continue;
8869        }
8870
8871        if dispatch.has_pending_modify(id) {
8872            return None;
8873        }
8874
8875        if other.child_fill_baseline().is_none() {
8876            ids.push(*id);
8877            continue;
8878        }
8879
8880        if deferred.fills.is_empty()
8881            && deferred
8882                .order
8883                .as_ref()
8884                .is_none_or(|order| !order.status.is_terminal())
8885        {
8886            continue;
8887        }
8888
8889        let report = child_recovery_report(*id, events, dispatch, emitter)?;
8890        let trade_ids = deferred.fills.iter().map(|fill| fill.trade_id).collect();
8891        children.push(ChildRecovery {
8892            cloid: *id,
8893            identity: other,
8894            report,
8895            trade_ids,
8896        });
8897
8898        ids.push(*id);
8899    }
8900
8901    Some((ids, children))
8902}
8903
8904fn publish_child_updates(
8905    children: &[ChildRecovery],
8906    terminals: &[(ClientOrderId, DeferredOrderEvents)],
8907    emitter: &ExecutionEventEmitter,
8908) {
8909    for child in children {
8910        if child.trade_ids.is_empty() {
8911            continue;
8912        }
8913
8914        let Some(order) = terminals
8915            .iter()
8916            .find(|(cid, _)| *cid == child.cloid)
8917            .and_then(|(_, deferred)| deferred.order.as_ref())
8918        else {
8919            continue;
8920        };
8921
8922        let Some(instrument) = LIGHTER_INSTRUMENT_CACHE.get(&child.identity.instrument_id) else {
8923            continue;
8924        };
8925
8926        if let Ok(updated) = parse_lighter_order_updated(
8927            order,
8928            &instrument,
8929            &child.identity,
8930            child.cloid,
8931            emitter.account_id(),
8932            emitter.trader_id(),
8933            child.report.ts_init,
8934        ) {
8935            emitter.send_order_event(OrderEventAny::Updated(updated));
8936        }
8937    }
8938}
8939
8940fn position_fills_pending(
8941    identity: &OrderIdentity,
8942    events: &AHashMap<ClientOrderId, DeferredOrderEvents>,
8943    dispatch: &WsDispatchState,
8944) -> bool {
8945    position_modify_pending(identity, dispatch)
8946        || events.iter().any(|(id, deferred)| {
8947            !deferred.fills.is_empty()
8948                && dispatch.order_identity(id).is_some_and(|other| {
8949                    other.strategy_id == identity.strategy_id
8950                        && other.instrument_id == identity.instrument_id
8951                })
8952        })
8953}
8954
8955fn position_modify_pending(identity: &OrderIdentity, dispatch: &WsDispatchState) -> bool {
8956    dispatch.order_identities.iter().any(|entry| {
8957        entry.value().strategy_id == identity.strategy_id
8958            && entry.value().instrument_id == identity.instrument_id
8959            && dispatch.has_pending_modify(entry.key())
8960    })
8961}
8962
8963fn child_recovery_report(
8964    cloid: ClientOrderId,
8965    events: &AHashMap<ClientOrderId, DeferredOrderEvents>,
8966    dispatch: &WsDispatchState,
8967    emitter: &ExecutionEventEmitter,
8968) -> Option<OrderStatusReport> {
8969    let identity = dispatch.order_identity(&cloid)?;
8970    if identity.has_child_publication() {
8971        return None;
8972    }
8973
8974    let baseline = identity.child_fill_baseline()?;
8975    let deferred = events.get(&cloid)?;
8976    let order = deferred.order.as_ref()?;
8977    if !identity.matches_venue_order_id(VenueOrderId::new(&order.order_id)) {
8978        return None;
8979    }
8980
8981    let instrument = LIGHTER_INSTRUMENT_CACHE.get(&identity.instrument_id)?;
8982    let report = parse_ws_order_status_report(
8983        order,
8984        &instrument,
8985        emitter.account_id(),
8986        get_atomic_clock_realtime().get_time_ns(),
8987    )
8988    .ok()?;
8989    let report = dispatch.translate_order_cloid(dispatch.apply_order_contingencies(order, report));
8990    if !report.order_status.is_closed() || report.filled_qty > report.quantity {
8991        return None;
8992    }
8993
8994    let mut total = baseline.quantity.as_decimal();
8995    for fill in &deferred.fills {
8996        if baseline.trade_ids.contains(&fill.trade_id) {
8997            continue;
8998        }
8999
9000        if fill.venue_order_id != report.venue_order_id
9001            || fill.instrument_id != report.instrument_id
9002            || fill.account_id != report.account_id
9003            || Some(fill.order_side) != report.order_side
9004            || fill.commission.is_none()
9005        {
9006            return None;
9007        }
9008
9009        total += fill.last_qty.as_decimal();
9010    }
9011
9012    (total == report.filled_qty.as_decimal()).then_some(report)
9013}
9014
9015fn dispatch_lighter_fill(
9016    filled: OrderFilled,
9017    emitter: &ExecutionEventEmitter,
9018    dispatch: &WsDispatchState,
9019) {
9020    ensure_fill_triggered(&filled, emitter, dispatch);
9021    if let Some(identity) = dispatch.order_identity(&filled.client_order_id) {
9022        identity.publish_fill(&filled);
9023    }
9024
9025    emitter.send_order_event(OrderEventAny::Filled(filled));
9026}
9027
9028fn ensure_fill_triggered(
9029    filled: &OrderFilled,
9030    emitter: &ExecutionEventEmitter,
9031    dispatch: &WsDispatchState,
9032) {
9033    if TRIGGERABLE_ORDER_TYPES.contains(&filled.order_type)
9034        && dispatch.mark_triggered_emitted(filled.client_order_id)
9035    {
9036        let triggered = OrderTriggered::new(
9037            filled.trader_id,
9038            filled.strategy_id,
9039            filled.instrument_id,
9040            filled.client_order_id,
9041            UUID4::new(),
9042            filled.ts_event,
9043            filled.ts_init,
9044            false,
9045            Some(filled.venue_order_id),
9046            Some(filled.account_id),
9047        );
9048        emitter.send_order_event(OrderEventAny::Triggered(triggered));
9049    }
9050}
9051
9052/// Send a [`ParsedOrderEvent`] to the engine and update dispatch state for
9053/// the originating cloid. Cleans up [`WsDispatchState::order_identities`]
9054/// on terminal events so subsequent stale frames take the untracked path.
9055#[expect(
9056    clippy::too_many_arguments,
9057    reason = "shared cleanup point across the typed-event variants"
9058)]
9059#[expect(
9060    clippy::needless_pass_by_value,
9061    reason = "event is destructured into typed OrderEventAny variants that consume the payload"
9062)]
9063fn dispatch_tracked_order_event(
9064    event: ParsedOrderEvent,
9065    cloid: ClientOrderId,
9066    venue_order_id: VenueOrderId,
9067    identity: &OrderIdentity,
9068    account_id: AccountId,
9069    trader_id: TraderId,
9070    emitter: &ExecutionEventEmitter,
9071    dispatch: &WsDispatchState,
9072    ts_init: UnixNanos,
9073) {
9074    let is_terminal;
9075
9076    match event {
9077        ParsedOrderEvent::Accepted(e) => {
9078            if !dispatch.claim_accepted_emission(&cloid) {
9079                log::debug!("Skipping duplicate OrderAccepted for {cloid}");
9080                return;
9081            }
9082
9083            is_terminal = false;
9084            emitter.send_order_event(OrderEventAny::Accepted(e));
9085        }
9086        ParsedOrderEvent::Triggered(e) => {
9087            if !dispatch.mark_triggered_emitted(cloid) {
9088                log::debug!("Skipping duplicate OrderTriggered for {cloid}");
9089                return;
9090            }
9091
9092            ensure_accepted_emitted(
9093                cloid,
9094                venue_order_id,
9095                identity,
9096                account_id,
9097                trader_id,
9098                emitter,
9099                dispatch,
9100                ts_init,
9101            );
9102            is_terminal = false;
9103            emitter.send_order_event(OrderEventAny::Triggered(e));
9104        }
9105        ParsedOrderEvent::Updated(e) => {
9106            // Modify-as-restate: the venue echoes the post-modify order as
9107            // `Open`; `accepted_was_emitted` already gated parsing to
9108            // produce `Updated` instead of duplicate `Accepted`. No need
9109            // to re-synthesize the accept here.
9110            dispatch.clear_pending_modify_if_update(&cloid, e.quantity, e.price, e.trigger_price);
9111            is_terminal = false;
9112            identity.publish_update(e.event_id);
9113            emitter.send_order_event(OrderEventAny::Updated(e));
9114        }
9115        ParsedOrderEvent::UpdatedThenTriggered { updated, triggered } => {
9116            if !dispatch.mark_triggered_emitted(cloid) {
9117                log::debug!("Skipping duplicate OrderTriggered for {cloid}");
9118                return;
9119            }
9120
9121            ensure_accepted_emitted(
9122                cloid,
9123                venue_order_id,
9124                identity,
9125                account_id,
9126                trader_id,
9127                emitter,
9128                dispatch,
9129                ts_init,
9130            );
9131            dispatch.clear_pending_modify_if_update(
9132                &cloid,
9133                updated.quantity,
9134                updated.price,
9135                updated.trigger_price,
9136            );
9137            is_terminal = false;
9138            identity.publish_update(updated.event_id);
9139            emitter.send_order_event(OrderEventAny::Updated(updated));
9140            emitter.send_order_event(OrderEventAny::Triggered(triggered));
9141        }
9142        ParsedOrderEvent::Canceled(e) => {
9143            ensure_accepted_emitted(
9144                cloid,
9145                venue_order_id,
9146                identity,
9147                account_id,
9148                trader_id,
9149                emitter,
9150                dispatch,
9151                ts_init,
9152            );
9153            is_terminal = true;
9154            emitter.send_order_event(OrderEventAny::Canceled(e));
9155        }
9156        ParsedOrderEvent::Expired(e) => {
9157            ensure_accepted_emitted(
9158                cloid,
9159                venue_order_id,
9160                identity,
9161                account_id,
9162                trader_id,
9163                emitter,
9164                dispatch,
9165                ts_init,
9166            );
9167            is_terminal = true;
9168            emitter.send_order_event(OrderEventAny::Expired(e));
9169        }
9170        ParsedOrderEvent::Rejected(e) => {
9171            is_terminal = true;
9172            emitter.send_order_event(OrderEventAny::Rejected(e));
9173        }
9174    }
9175
9176    if is_terminal {
9177        dispatch.clear_pending_order_action(&cloid);
9178        dispatch.retire_order_identity(&cloid);
9179    }
9180}
9181
9182/// Synthesize an `OrderAccepted` event if one has not yet been emitted for
9183/// `cloid`. Synthesizes the accepted event the venue does not emit.
9184#[expect(
9185    clippy::too_many_arguments,
9186    reason = "synthesized events need the full identity context to populate the event"
9187)]
9188fn ensure_accepted_emitted(
9189    cloid: ClientOrderId,
9190    venue_order_id: VenueOrderId,
9191    identity: &OrderIdentity,
9192    account_id: AccountId,
9193    trader_id: TraderId,
9194    emitter: &ExecutionEventEmitter,
9195    dispatch: &WsDispatchState,
9196    ts_init: UnixNanos,
9197) {
9198    if !dispatch.claim_accepted_emission(&cloid) {
9199        return;
9200    }
9201
9202    let accepted = OrderAccepted::new(
9203        trader_id,
9204        identity.strategy_id,
9205        identity.instrument_id,
9206        cloid,
9207        venue_order_id,
9208        account_id,
9209        UUID4::new(),
9210        ts_init,
9211        ts_init,
9212        false,
9213    );
9214    emitter.send_order_event(OrderEventAny::Accepted(accepted));
9215}
9216
9217#[cfg(test)]
9218mod tests {
9219    use std::{
9220        cell::RefCell,
9221        rc::Rc,
9222        sync::{Arc, atomic::AtomicUsize},
9223    };
9224
9225    use axum::{
9226        Router,
9227        routing::{get, post},
9228    };
9229    use nautilus_common::{
9230        cache::Cache,
9231        clock::{Clock, VirtualClock},
9232        factories::OrderFactory,
9233        messages::{ExecutionEvent, ExecutionReport as EngineExecutionReport},
9234        testing::wait_until_async,
9235    };
9236    use nautilus_execution::engine::ExecutionEngine;
9237    use nautilus_live::manager::{ExecutionManager, ExecutionManagerConfig};
9238    use nautilus_model::{
9239        accounts::MarginAccount,
9240        data::QuoteTick,
9241        enums::{ContingencyType, LiquiditySide, OrderSide, OrderStatus, TimeInForce},
9242        events::{AccountState, OrderCanceled, OrderEventAny, OrderPendingCancel, OrderTriggered},
9243        identifiers::{
9244            InstrumentId, OrderListId, PositionId, StrategyId, Symbol, TradeId, TraderId,
9245            VenueOrderId,
9246        },
9247        instruments::CryptoPerpetual,
9248        orders::{LimitOrder, OrderList, OrderTestBuilder, stubs::TestOrderEventStubs},
9249        position::Position,
9250        types::{Currency, Money, Price},
9251    };
9252    use rstest::rstest;
9253
9254    use super::*;
9255    use crate::{
9256        common::{
9257            consts::{LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX, LIGHTER_VENUE},
9258            enums::{LighterDeployment, LighterEnvironment, LighterProductType},
9259        },
9260        http::models::{LighterNextNonce, LighterTx},
9261        signing::tx::TX_HASH_BYTES,
9262    };
9263
9264    const TEST_PRIVATE_KEY: &str =
9265        "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
9266    const TEST_ACCOUNT_INDEX: u64 = 12345;
9267    const TEST_ACCOUNT_INDEX_I64: i64 = 12345;
9268    const TEST_API_KEY_INDEX: u8 = 5;
9269    const TEST_NEXT_NONCE: i64 = 42;
9270    const TEST_MARKET_INDEX: i64 = 0;
9271    const TEST_ORDER_NONCE: i64 = 281_474_720_725_346;
9272    const TEST_SUBMISSION_NONCE: i64 = 2_042;
9273
9274    fn handle_send_tx_ack(
9275        dispatch: &WsDispatchState,
9276        account_index: Option<i64>,
9277        code: i64,
9278        tx_hash: Option<&str>,
9279    ) -> Option<PendingSendTx> {
9280        handle_send_tx_ack_for_connection(dispatch, account_index, 0, code, tx_hash)
9281    }
9282
9283    #[expect(
9284        clippy::too_many_arguments,
9285        reason = "test wrapper preserves the established rejection call shape"
9286    )]
9287    fn handle_send_tx_rejection(
9288        dispatch: &WsDispatchState,
9289        emitter: &ExecutionEventEmitter,
9290        account_index: Option<i64>,
9291        now: UnixNanos,
9292        source: SendTxRejectionSource,
9293        code: Option<i64>,
9294        message: &str,
9295        tx_hash: Option<&str>,
9296    ) -> bool {
9297        handle_send_tx_rejection_for_connection(
9298            dispatch,
9299            emitter,
9300            account_index,
9301            0,
9302            now,
9303            source,
9304            code,
9305            message,
9306            tx_hash,
9307        )
9308    }
9309
9310    fn trader_id() -> TraderId {
9311        TraderId::from("TRADER-001")
9312    }
9313
9314    fn client_id() -> ClientId {
9315        ClientId::from("LIGHTER")
9316    }
9317
9318    fn account_id() -> AccountId {
9319        AccountId::from("LIGHTER-001")
9320    }
9321
9322    fn strategy_id() -> StrategyId {
9323        StrategyId::from("S-001")
9324    }
9325
9326    fn test_credential() -> Credential {
9327        Credential::new(TEST_API_KEY_INDEX, TEST_PRIVATE_KEY, TEST_ACCOUNT_INDEX).unwrap()
9328    }
9329
9330    fn test_config() -> LighterExecutionClientConfig {
9331        test_config_with_use_gtd(true)
9332    }
9333
9334    fn test_config_with_use_gtd(use_gtd: bool) -> LighterExecutionClientConfig {
9335        LighterExecutionClientConfig {
9336            account_id: account_id(),
9337            account_index: Some(TEST_ACCOUNT_INDEX),
9338            api_key_index: Some(TEST_API_KEY_INDEX),
9339            private_key: Some(TEST_PRIVATE_KEY.into()),
9340            base_url_http: Some("http://127.0.0.1:1".to_string()),
9341            base_url_ws: Some("ws://127.0.0.1:1/stream".to_string()),
9342            proxy_url: None,
9343            environment: LighterEnvironment::Testnet,
9344            deployment: LighterDeployment::Lighter,
9345            venue: None,
9346            http_timeout_secs: 1,
9347            ws_timeout_secs: 1,
9348            market_order_slippage_bps: 50,
9349            rest_quota_per_min: None,
9350            sendtx_quota_per_min: None,
9351            transport_backend: Default::default(),
9352            use_gtd,
9353        }
9354    }
9355
9356    #[rstest]
9357    fn format_between_timestamps_uses_lighter_seconds_range() {
9358        let start = UnixNanos::from(1_700_000_000_123_456_789);
9359        let end = UnixNanos::from(1_700_003_600_987_654_321);
9360        let now = UnixNanos::from(1_700_007_200_000_000_000);
9361
9362        assert_eq!(
9363            format_between_timestamps(Some(start), Some(end), now),
9364            Some("1700000000-1700003600".to_string()),
9365        );
9366        assert_eq!(
9367            format_between_timestamps(Some(start), None, now),
9368            Some("1700000000-1700007200".to_string()),
9369        );
9370        assert_eq!(
9371            format_between_timestamps(None, Some(end), now),
9372            Some("0-1700003600".to_string()),
9373        );
9374        assert_eq!(format_between_timestamps(None, None, now), None);
9375    }
9376
9377    #[rstest]
9378    #[case::write_timeout(LighterWsError::Transport(SendError::WriteTimeout), "Ambiguous")]
9379    #[case::broken_pipe(
9380        LighterWsError::Transport(SendError::BrokenPipe("writer closed".to_string())),
9381        "Ambiguous",
9382    )]
9383    #[case::handler_result_lost(
9384        LighterWsError::SendTxOutcomeUnknown("result sender dropped".to_string()),
9385        "Ambiguous",
9386    )]
9387    #[case::network(LighterWsError::Network("disconnected".to_string()), "Ambiguous")]
9388    #[case::parse(LighterWsError::Parse("invalid ack".to_string()), "Ambiguous")]
9389    #[case::invalid_input(
9390        LighterWsError::Transport(SendError::InvalidInput("invalid payload".to_string())),
9391        "NotSent",
9392    )]
9393    #[case::buffer_full(LighterWsError::Transport(SendError::BufferFull), "NotSent")]
9394    #[case::closed(LighterWsError::Transport(SendError::Closed), "NotSent")]
9395    #[case::connection_changed(LighterWsError::Transport(SendError::ConnectionChanged), "NotSent")]
9396    #[case::wait_timeout(LighterWsError::Transport(SendError::Timeout), "NotSent")]
9397    #[case::authentication(
9398        LighterWsError::Authentication("invalid token".to_string()),
9399        "NotSent",
9400    )]
9401    #[case::client_unavailable(
9402        LighterWsError::Client("handler unavailable".to_string()),
9403        "NotSent",
9404    )]
9405    fn ws_command_failure_classifies_delivery_evidence(
9406        #[case] error: LighterWsError,
9407        #[case] expected: &str,
9408    ) {
9409        let failure = classify_lighter_ws_command_failure("submit_order", &error);
9410
9411        let actual = match &failure {
9412            CommandFailure::NotSent(_) => "NotSent",
9413            CommandFailure::Ambiguous(_) => "Ambiguous",
9414            CommandFailure::VenueRejected(_) => "VenueRejected",
9415        };
9416
9417        assert_eq!(actual, expected);
9418        assert!(command_failure_reason(&failure).contains("submit_order"));
9419    }
9420
9421    #[rstest]
9422    fn venue_rejection_reason_is_clean_and_bounded() {
9423        let oversized = "x".repeat(STRATEGY_REASON_MAX_CHARS + 100);
9424        let message = format!("<html>\n rejected\0 because   {oversized}</html>");
9425
9426        let failure = venue_rejection_failure(Some(20_001), &message);
9427        let reason = command_failure_reason(&failure);
9428
9429        assert!(matches!(&failure, CommandFailure::VenueRejected(_)));
9430        assert!(!reason.contains('<'));
9431        assert!(!reason.contains('\0'));
9432        assert!(!reason.contains("  "));
9433        assert_eq!(reason.chars().count(), STRATEGY_REASON_MAX_CHARS);
9434        assert!(reason.starts_with("LIGHTER_20001: rejected because "));
9435    }
9436
9437    #[rstest]
9438    fn venue_rejection_reason_uses_stable_fallback_for_empty_markup() {
9439        let failure = venue_rejection_failure(Some(20_001), "<html></html>\n");
9440
9441        assert_eq!(
9442            failure,
9443            CommandFailure::VenueRejected("LIGHTER_20001".to_string()),
9444        );
9445    }
9446
9447    #[rstest]
9448    fn venue_rejection_reason_preserves_comparison_and_removes_format_controls() {
9449        let failure = venue_rejection_failure(
9450            Some(20_001),
9451            "price must be < 100\u{202e}\u{2066}\u{200b}<strong>now</strong>",
9452        );
9453
9454        assert_eq!(
9455            failure,
9456            CommandFailure::VenueRejected("LIGHTER_20001: price must be < 100 now".to_string(),),
9457        );
9458    }
9459
9460    #[rstest]
9461    fn unsupported_order_type_reason_keeps_lighter_capabilities() {
9462        assert_eq!(
9463            unsupported_lighter_order_type_reason(OrderType::MarketToLimit),
9464            "UNSUPPORTED_ORDER_TYPE: MARKET_TO_LIMIT; Lighter supports MARKET, LIMIT, STOP_MARKET, STOP_LIMIT, MARKET_IF_TOUCHED, and LIMIT_IF_TOUCHED",
9465        );
9466    }
9467
9468    #[rstest]
9469    fn mass_status_propagates_commission_error_and_downgrades_other_fill_errors() {
9470        let e = anyhow::Error::new(LighterCommissionError::new("invalid precision"))
9471            .context("failed to parse Lighter fill report");
9472
9473        let commission_error = fill_reports_for_mass_status(Err(e))
9474            .expect_err("commission construction must fail mass status");
9475        let ordinary_error = fill_reports_for_mass_status(Err(anyhow::anyhow!("transport failed")))
9476            .expect("ordinary source errors make mass status incomplete");
9477
9478        let complete = fill_reports_for_mass_status(Ok(FillSweep {
9479            reports: Vec::new(),
9480            covers_window: true,
9481            withheld_instruments: AHashSet::new(),
9482        }))
9483        .expect("complete fill sweep");
9484
9485        assert!(is_commission_error(&commission_error));
9486        assert!(ordinary_error.0.is_empty());
9487        assert!(!ordinary_error.1);
9488        assert!(complete.0.is_empty());
9489        assert!(complete.1);
9490    }
9491
9492    fn create_execution_client() -> (
9493        LighterExecutionClient,
9494        Rc<RefCell<Cache>>,
9495        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9496    ) {
9497        create_execution_client_with_config(test_config())
9498    }
9499
9500    fn create_execution_client_with_config(
9501        config: LighterExecutionClientConfig,
9502    ) -> (
9503        LighterExecutionClient,
9504        Rc<RefCell<Cache>>,
9505        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9506    ) {
9507        let cache = Rc::new(RefCell::new(Cache::default()));
9508        let venue = config.resolved_venue();
9509
9510        let core = ExecutionClientCore::new(
9511            trader_id(),
9512            client_id(),
9513            venue,
9514            OmsType::Netting,
9515            account_id(),
9516            AccountType::Margin,
9517            None,
9518            cache.clone(),
9519        );
9520
9521        let mut client = LighterExecutionClient::new(core, config).unwrap();
9522        client.dispatch.nonce_manager.refresh(
9523            TEST_ACCOUNT_INDEX_I64,
9524            TEST_API_KEY_INDEX,
9525            TEST_NEXT_NONCE,
9526        );
9527
9528        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
9529        client.emitter.set_sender(sender);
9530
9531        (client, cache, receiver)
9532    }
9533
9534    fn register_test_instrument(
9535        client: &LighterExecutionClient,
9536        cache: &Rc<RefCell<Cache>>,
9537    ) -> InstrumentId {
9538        let instrument_id =
9539            client
9540                .registry
9541                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
9542        let instrument = InstrumentAny::CryptoPerpetual(
9543            CryptoPerpetual::builder()
9544                .instrument_id(instrument_id)
9545                .raw_symbol(Symbol::new("ETH-PERP"))
9546                .base_currency(Currency::from("ETH"))
9547                .quote_currency(Currency::from("USDC"))
9548                .settlement_currency(Currency::from("USDC"))
9549                .is_inverse(false)
9550                .price_precision(2)
9551                .size_precision(4)
9552                .price_increment(Price::from("0.01"))
9553                .size_increment(Quantity::from("0.0001"))
9554                .min_notional(Money::from("10.000000 USDC"))
9555                .ts_event(UnixNanos::default())
9556                .ts_init(UnixNanos::default())
9557                .build()
9558                .unwrap(),
9559        );
9560
9561        cache.borrow_mut().add_instrument(instrument).unwrap();
9562
9563        instrument_id
9564    }
9565
9566    fn test_order_factory() -> OrderFactory {
9567        let clock = Rc::new(RefCell::new(VirtualClock::new()));
9568
9569        OrderFactory::new(
9570            trader_id(),
9571            strategy_id(),
9572            Some(0),
9573            Some(0),
9574            clock,
9575            false,
9576            false,
9577        )
9578    }
9579
9580    fn test_limit_order(
9581        factory: &mut OrderFactory,
9582        instrument_id: InstrumentId,
9583        client_order_id: &str,
9584    ) -> OrderAny {
9585        test_limit_order_with(
9586            factory,
9587            instrument_id,
9588            client_order_id,
9589            OrderSide::Buy,
9590            TimeInForce::Gtc,
9591            None,
9592            false,
9593        )
9594    }
9595
9596    fn test_limit_order_with(
9597        factory: &mut OrderFactory,
9598        instrument_id: InstrumentId,
9599        client_order_id: &str,
9600        side: OrderSide,
9601        tif: TimeInForce,
9602        expire_time: Option<UnixNanos>,
9603        reduce_only: bool,
9604    ) -> OrderAny {
9605        factory.limit(
9606            instrument_id,
9607            side,
9608            Quantity::from("0.1000"),
9609            Price::from("2361.31"),
9610            Some(tif),
9611            expire_time,
9612            Some(false),
9613            Some(reduce_only),
9614            None,
9615            None,
9616            None,
9617            None,
9618            None,
9619            None,
9620            None,
9621            Some(ClientOrderId::from(client_order_id)),
9622        )
9623    }
9624
9625    fn cache_order(cache: &Rc<RefCell<Cache>>, order: OrderAny) {
9626        cache
9627            .borrow_mut()
9628            .add_order(order, None, Some(client_id()), false)
9629            .unwrap();
9630    }
9631
9632    fn cache_accepted_order(
9633        cache: &Rc<RefCell<Cache>>,
9634        order: OrderAny,
9635        venue_order_id: VenueOrderId,
9636        client_id: Option<ClientId>,
9637    ) -> (InstrumentId, ClientOrderId) {
9638        let instrument_id = order.instrument_id();
9639        let client_order_id = order.client_order_id();
9640        cache
9641            .borrow_mut()
9642            .add_order(order, None, client_id, false)
9643            .unwrap();
9644
9645        let accepted = OrderEventAny::Accepted(OrderAccepted::new(
9646            trader_id(),
9647            strategy_id(),
9648            instrument_id,
9649            client_order_id,
9650            venue_order_id,
9651            account_id(),
9652            UUID4::new(),
9653            UnixNanos::default(),
9654            UnixNanos::default(),
9655            false,
9656        ));
9657        cache.borrow_mut().update_order(&accepted).unwrap();
9658
9659        (instrument_id, client_order_id)
9660    }
9661
9662    fn cache_pending_cancel_order(
9663        cache: &Rc<RefCell<Cache>>,
9664        order: OrderAny,
9665        venue_order_id: VenueOrderId,
9666    ) {
9667        let (instrument_id, client_order_id) =
9668            cache_accepted_order(cache, order, venue_order_id, Some(client_id()));
9669
9670        let pending_cancel = OrderEventAny::PendingCancel(OrderPendingCancel::new(
9671            trader_id(),
9672            strategy_id(),
9673            instrument_id,
9674            client_order_id,
9675            Some(account_id()),
9676            UUID4::new(),
9677            UnixNanos::default(),
9678            UnixNanos::default(),
9679            false,
9680            Some(venue_order_id),
9681        ));
9682        cache.borrow_mut().update_order(&pending_cancel).unwrap();
9683    }
9684
9685    fn submit_order_list_command(orders: &[OrderAny], order_list_id: &str) -> SubmitOrderList {
9686        let order_list = OrderList::new(
9687            OrderListId::from(order_list_id),
9688            orders[0].instrument_id(),
9689            strategy_id(),
9690            orders.iter().map(|order| order.client_order_id()).collect(),
9691            UnixNanos::default(),
9692        );
9693        let order_inits = orders
9694            .iter()
9695            .map(|order| order.init_event().clone())
9696            .collect();
9697
9698        SubmitOrderList::new(
9699            trader_id(),
9700            Some(client_id()),
9701            strategy_id(),
9702            order_list,
9703            order_inits,
9704            None,
9705            None,
9706            None,
9707            UUID4::new(),
9708            UnixNanos::default(),
9709            None,
9710        )
9711    }
9712
9713    fn test_contingent_limit_order(
9714        instrument_id: InstrumentId,
9715        client_order_id: &str,
9716        order_list_id: &str,
9717        linked_order_id: &str,
9718    ) -> OrderAny {
9719        OrderAny::Limit(LimitOrder::new(
9720            trader_id(),
9721            strategy_id(),
9722            instrument_id,
9723            ClientOrderId::from(client_order_id),
9724            OrderSide::Buy,
9725            Quantity::from("0.1000"),
9726            Price::from("2361.31"),
9727            TimeInForce::Gtc,
9728            None,
9729            false,
9730            false,
9731            false,
9732            None,
9733            None,
9734            None,
9735            Some(ContingencyType::Oco),
9736            Some(OrderListId::from(order_list_id)),
9737            Some(vec![ClientOrderId::from(linked_order_id)]),
9738            None,
9739            None,
9740            None,
9741            None,
9742            None,
9743            UUID4::new(),
9744            UnixNanos::default(),
9745        ))
9746    }
9747
9748    async fn recv_order_event(
9749        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9750    ) -> OrderEventAny {
9751        let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
9752            .await
9753            .expect("timed out waiting for execution event")
9754            .expect("execution event channel closed");
9755
9756        match event {
9757            ExecutionEvent::Order(event) => event,
9758            event => panic!("expected order event, was {event:?}"),
9759        }
9760    }
9761
9762    async fn assert_modify_rejected_reason(
9763        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
9764        reason_part: &str,
9765    ) {
9766        match recv_order_event(rx).await {
9767            OrderEventAny::ModifyRejected(event) => {
9768                assert!(
9769                    event.reason.contains(reason_part),
9770                    "expected modify rejection containing `{reason_part}`, was `{}`",
9771                    event.reason,
9772                );
9773            }
9774            event => panic!("expected modify rejected event, was {event:?}"),
9775        }
9776    }
9777
9778    fn assert_nonce_reusable(dispatch: &WsDispatchState) {
9779        assert_eq!(
9780            dispatch
9781                .nonce_manager
9782                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
9783            Some(TEST_NEXT_NONCE - 1),
9784        );
9785        assert_eq!(
9786            dispatch
9787                .nonce_manager
9788                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
9789                .unwrap(),
9790            TEST_NEXT_NONCE,
9791        );
9792    }
9793
9794    #[tokio::test]
9795    async fn auth_token_rotation_retries_failed_mint() {
9796        let credential = test_credential();
9797        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
9798        let cancellation_token = CancellationToken::new();
9799        let mint_attempts = Arc::new(AtomicUsize::new(0));
9800        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
9801
9802        let outcome = tokio::time::timeout(
9803            Duration::from_secs(3),
9804            refresh_auth_token_until_rotated(
9805                &credential,
9806                &channels,
9807                &cancellation_token,
9808                AuthTokenRefreshBackoff {
9809                    initial_delay: Duration::from_millis(10),
9810                    max_delay: Duration::from_millis(20),
9811                    window: Duration::from_secs(1),
9812                },
9813                {
9814                    let mint_attempts = Arc::clone(&mint_attempts);
9815                    move |_| {
9816                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
9817                        if attempt == 0 {
9818                            Err(anyhow::anyhow!("mint unavailable"))
9819                        } else {
9820                            Ok(format!("token-{attempt}").into())
9821                        }
9822                    }
9823                },
9824                {
9825                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
9826                    move |_channel, _token| {
9827                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
9828                        async move {
9829                            subscribe_attempts.fetch_add(1, Ordering::AcqRel);
9830                            Ok::<(), crate::websocket::error::LighterWsError>(())
9831                        }
9832                    }
9833                },
9834            ),
9835        )
9836        .await
9837        .expect("rotation retry must complete within the test window");
9838
9839        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
9840        assert_eq!(
9841            mint_attempts.load(Ordering::Acquire),
9842            2,
9843            "failed mint must retry before the next refresh interval",
9844        );
9845        assert_eq!(
9846            subscribe_attempts.load(Ordering::Acquire),
9847            channels.len(),
9848            "subscriptions must wait until token mint succeeds",
9849        );
9850    }
9851
9852    #[tokio::test]
9853    async fn auth_token_rotation_retries_failed_resubscribe() {
9854        let credential = test_credential();
9855        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
9856        let cancellation_token = CancellationToken::new();
9857        let mint_attempts = Arc::new(AtomicUsize::new(0));
9858        let subscribe_attempts = Arc::new(AtomicUsize::new(0));
9859
9860        let outcome = tokio::time::timeout(
9861            Duration::from_secs(3),
9862            refresh_auth_token_until_rotated(
9863                &credential,
9864                &channels,
9865                &cancellation_token,
9866                AuthTokenRefreshBackoff {
9867                    initial_delay: Duration::from_millis(10),
9868                    max_delay: Duration::from_millis(20),
9869                    window: Duration::from_secs(1),
9870                },
9871                {
9872                    let mint_attempts = Arc::clone(&mint_attempts);
9873                    move |_| {
9874                        let attempt = mint_attempts.fetch_add(1, Ordering::AcqRel);
9875                        Ok(format!("token-{attempt}").into())
9876                    }
9877                },
9878                {
9879                    let subscribe_attempts = Arc::clone(&subscribe_attempts);
9880                    move |_channel, _token| {
9881                        let subscribe_attempts = Arc::clone(&subscribe_attempts);
9882                        async move {
9883                            let attempt = subscribe_attempts.fetch_add(1, Ordering::AcqRel);
9884                            if attempt == 0 {
9885                                Err(crate::websocket::error::LighterWsError::Client(
9886                                    "handler unavailable".to_string(),
9887                                ))
9888                            } else {
9889                                Ok(())
9890                            }
9891                        }
9892                    }
9893                },
9894            ),
9895        )
9896        .await
9897        .expect("rotation retry must complete within the test window");
9898
9899        assert_eq!(outcome, AuthTokenRefreshOutcome::Rotated);
9900        assert_eq!(
9901            mint_attempts.load(Ordering::Acquire),
9902            2,
9903            "failed resubscribe must trigger a fresh auth-token mint",
9904        );
9905        assert_eq!(
9906            subscribe_attempts.load(Ordering::Acquire),
9907            channels.len() * 2,
9908            "failed resubscribe must retry the private account-channel set",
9909        );
9910    }
9911
9912    #[tokio::test]
9913    async fn auth_token_rotation_exhausts_retry_window() {
9914        let credential = test_credential();
9915        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
9916        let cancellation_token = CancellationToken::new();
9917        let mint_attempts = Arc::new(AtomicUsize::new(0));
9918
9919        let refresh = refresh_auth_token_until_rotated(
9920            &credential,
9921            &channels,
9922            &cancellation_token,
9923            AuthTokenRefreshBackoff {
9924                initial_delay: Duration::from_millis(100),
9925                max_delay: Duration::from_millis(100),
9926                window: Duration::from_secs(1),
9927            },
9928            {
9929                let mint_attempts = Arc::clone(&mint_attempts);
9930                move |_| {
9931                    mint_attempts.fetch_add(1, Ordering::AcqRel);
9932                    Err(anyhow::anyhow!("mint unavailable"))
9933                }
9934            },
9935            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
9936        );
9937        let observe_retry = wait_until_async(
9938            || async { mint_attempts.load(Ordering::Acquire) > 1 },
9939            Duration::from_secs(2),
9940        );
9941
9942        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(3), async {
9943            tokio::join!(refresh, observe_retry)
9944        })
9945        .await
9946        .expect("rotation retry exhaustion must complete within the test window");
9947
9948        assert_eq!(outcome, AuthTokenRefreshOutcome::Exhausted);
9949        assert!(
9950            mint_attempts.load(Ordering::Acquire) > 1,
9951            "persistent mint failure must retry until the window is exhausted",
9952        );
9953    }
9954
9955    #[tokio::test]
9956    async fn auth_token_rotation_cancels_during_retry_backoff() {
9957        let credential = test_credential();
9958        let channels = auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64);
9959        let cancellation_token = CancellationToken::new();
9960        let mint_attempts = Arc::new(AtomicUsize::new(0));
9961
9962        let cancel = cancellation_token.clone();
9963        let cancel_after_first_attempt = wait_until_async(
9964            || async { mint_attempts.load(Ordering::Acquire) > 0 },
9965            Duration::from_secs(2),
9966        );
9967
9968        let refresh = refresh_auth_token_until_rotated(
9969            &credential,
9970            &channels,
9971            &cancellation_token,
9972            AuthTokenRefreshBackoff {
9973                initial_delay: Duration::from_secs(2),
9974                max_delay: Duration::from_secs(2),
9975                window: Duration::from_secs(5),
9976            },
9977            {
9978                let mint_attempts = Arc::clone(&mint_attempts);
9979                move |_| {
9980                    mint_attempts.fetch_add(1, Ordering::AcqRel);
9981                    Err(anyhow::anyhow!("mint unavailable"))
9982                }
9983            },
9984            |_channel, _token| async { Ok::<(), crate::websocket::error::LighterWsError>(()) },
9985        );
9986
9987        let cancel_task = async move {
9988            cancel_after_first_attempt.await;
9989            cancel.cancel();
9990        };
9991
9992        let (outcome, ()) = tokio::time::timeout(Duration::from_secs(6), async {
9993            tokio::join!(refresh, cancel_task)
9994        })
9995        .await
9996        .expect("rotation cancellation must complete within the test window");
9997
9998        assert_eq!(outcome, AuthTokenRefreshOutcome::Cancelled);
9999        assert_eq!(
10000            mint_attempts.load(Ordering::Acquire),
10001            1,
10002            "cancellation during backoff must stop before the next retry",
10003        );
10004    }
10005
10006    #[rstest]
10007    #[case::rotated(AuthTokenRefreshOutcome::Rotated, Some(AUTH_TOKEN_REFRESH_INTERVAL))]
10008    #[case::cancelled(AuthTokenRefreshOutcome::Cancelled, None)]
10009    #[case::exhausted(
10010        AuthTokenRefreshOutcome::Exhausted,
10011        Some(AUTH_TOKEN_REFRESH_RETRY_MAX_DELAY)
10012    )]
10013    fn auth_token_refresh_next_delay_matches_outcome(
10014        #[case] outcome: AuthTokenRefreshOutcome,
10015        #[case] expected: Option<Duration>,
10016    ) {
10017        assert_eq!(auth_token_refresh_next_delay(outcome), expected);
10018    }
10019
10020    #[rstest]
10021    fn auth_token_rotation_channels_match_private_account_streams() {
10022        assert_eq!(
10023            auth_token_rotation_channels(TEST_ACCOUNT_INDEX_I64),
10024            [
10025                LighterWsChannel::AccountAllOrders(TEST_ACCOUNT_INDEX_I64),
10026                LighterWsChannel::AccountAllTrades(TEST_ACCOUNT_INDEX_I64),
10027                LighterWsChannel::AccountAllPositions(TEST_ACCOUNT_INDEX_I64),
10028                LighterWsChannel::AccountAllAssets(TEST_ACCOUNT_INDEX_I64),
10029                LighterWsChannel::UserStats(TEST_ACCOUNT_INDEX_I64),
10030            ],
10031        );
10032    }
10033
10034    #[rstest]
10035    #[case::below_max(
10036        Duration::from_millis(10),
10037        Duration::from_millis(100),
10038        Duration::from_millis(20)
10039    )]
10040    #[case::at_max(
10041        Duration::from_millis(100),
10042        Duration::from_millis(100),
10043        Duration::from_millis(100)
10044    )]
10045    #[case::overflow_clamps(Duration::MAX, Duration::from_secs(300), Duration::from_secs(300))]
10046    fn next_auth_token_refresh_retry_delay_doubles_and_caps(
10047        #[case] current: Duration,
10048        #[case] max: Duration,
10049        #[case] expected: Duration,
10050    ) {
10051        assert_eq!(next_auth_token_refresh_retry_delay(current, max), expected);
10052    }
10053
10054    #[rstest]
10055    #[tokio::test]
10056    async fn auth_token_refresh_is_retained_until_session_shutdown() {
10057        let (mut client, _cache, _rx) = create_execution_client();
10058
10059        client
10060            .spawn_auth_token_refresh(test_credential())
10061            .expect("spawn auth token refresh");
10062        client
10063            .nonce_recovery_inflight
10064            .store(true, Ordering::Release);
10065
10066        assert!(
10067            client
10068                .auth_refresh_handle
10069                .as_ref()
10070                .is_some_and(|handle| !handle.is_finished()),
10071        );
10072
10073        client.begin_session_shutdown();
10074        client
10075            .finish_session_shutdown()
10076            .await
10077            .expect("session shutdown");
10078
10079        assert!(client.auth_refresh_handle.is_none());
10080        assert!(!client.nonce_recovery_inflight.load(Ordering::Acquire));
10081    }
10082
10083    #[rstest]
10084    #[tokio::test]
10085    async fn session_shutdown_rejects_tasks_registered_during_abort() {
10086        struct SpawnOnDrop {
10087            pending_tasks: TaskSpawner,
10088            rejected: Arc<AtomicBool>,
10089            polled: Arc<AtomicBool>,
10090        }
10091
10092        impl Drop for SpawnOnDrop {
10093            fn drop(&mut self) {
10094                let polled = Arc::clone(&self.polled);
10095                let result = self.pending_tasks.spawn(async move {
10096                    polled.store(true, Ordering::Release);
10097                });
10098                self.rejected.store(result.is_err(), Ordering::Release);
10099            }
10100        }
10101
10102        let (mut client, _cache, _rx) = create_execution_client();
10103        let rejected = Arc::new(AtomicBool::new(false));
10104        let polled = Arc::new(AtomicBool::new(false));
10105        let pending_spawner = client.pending_tasks.spawner().expect("task spawner");
10106        let guard = SpawnOnDrop {
10107            pending_tasks: pending_spawner.clone(),
10108            rejected: Arc::clone(&rejected),
10109            polled: Arc::clone(&polled),
10110        };
10111
10112        pending_spawner
10113            .spawn(async move {
10114                let _guard = guard;
10115                std::future::pending::<()>().await;
10116            })
10117            .expect("parent task spawn");
10118
10119        client
10120            .finish_session_shutdown()
10121            .await
10122            .expect("session shutdown");
10123
10124        assert!(client.pending_tasks.is_empty());
10125        assert!(rejected.load(Ordering::Acquire));
10126        assert!(!polled.load(Ordering::Acquire));
10127    }
10128
10129    #[rstest]
10130    #[tokio::test]
10131    async fn session_shutdown_discards_pending_sendtx_before_epoch_reuse() {
10132        struct EnqueueOnDrop {
10133            dispatch: WsDispatchState,
10134            pending: Option<PendingSendTx>,
10135        }
10136
10137        impl Drop for EnqueueOnDrop {
10138            fn drop(&mut self) {
10139                self.dispatch
10140                    .enqueue_pending_sendtx(self.pending.take().expect("pending sendTx"));
10141            }
10142        }
10143
10144        let (mut client, _cache, _rx) = create_execution_client();
10145        let old_epoch = client.ws_client.connection_epoch();
10146        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
10147            batch_id: None,
10148            connection_epoch: old_epoch,
10149            kind: PendingSendTxKind::Other,
10150            submitted_at: UnixNanos::default(),
10151            nonce: TEST_NEXT_NONCE,
10152            api_key_index: TEST_API_KEY_INDEX,
10153            tx_hash: "shutdown-pending".to_string(),
10154        });
10155        let guard = EnqueueOnDrop {
10156            dispatch: client.dispatch.clone(),
10157            pending: Some(PendingSendTx {
10158                batch_id: None,
10159                connection_epoch: old_epoch,
10160                kind: PendingSendTxKind::Other,
10161                submitted_at: UnixNanos::default(),
10162                nonce: TEST_NEXT_NONCE + 1,
10163                api_key_index: TEST_API_KEY_INDEX,
10164                tx_hash: "shutdown-late".to_string(),
10165            }),
10166        };
10167
10168        client
10169            .pending_tasks
10170            .spawn(async move {
10171                let _guard = guard;
10172                std::future::pending::<()>().await;
10173            })
10174            .expect("pending sendTx producer spawn");
10175        client
10176            .ws_stream_handle
10177            .insert(get_runtime().spawn(std::future::pending()));
10178
10179        client.begin_session_shutdown();
10180        client
10181            .finish_session_shutdown()
10182            .await
10183            .expect("session shutdown");
10184
10185        assert_eq!(client.ws_client.connection_epoch(), old_epoch);
10186        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
10187    }
10188
10189    #[rstest]
10190    fn replacing_ws_client_preserves_cached_instruments() {
10191        let (mut client, cache, _rx) = create_execution_client();
10192        let instrument_id = register_test_instrument(&client, &cache);
10193        let instrument = cache
10194            .borrow()
10195            .instrument(&instrument_id)
10196            .expect("instrument")
10197            .clone();
10198        client
10199            .ws_client
10200            .cache_instrument(TEST_MARKET_INDEX, instrument.clone());
10201
10202        let _old_ws_client = client.take_ws_client();
10203
10204        let cached_instruments = client.ws_client.instruments_cache();
10205        assert_eq!(
10206            cached_instruments
10207                .get(&TEST_MARKET_INDEX)
10208                .expect("cached instrument")
10209                .value(),
10210            &instrument,
10211        );
10212    }
10213
10214    #[rstest]
10215    fn tx_send_sequencer_allocates_under_registration_lock() {
10216        let sequencer = TxSendSequencer::new();
10217
10218        let mut lower = sequencer
10219            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10220                assert!(
10221                    sequencer.state.try_lock().is_none(),
10222                    "allocation must be serialized with registration"
10223                );
10224                Ok(TEST_NEXT_NONCE)
10225            })
10226            .unwrap();
10227
10228        let higher = sequencer
10229            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10230                Ok(TEST_NEXT_NONCE + 1)
10231            })
10232            .unwrap();
10233
10234        assert!(!sequencer.ready_to_send(higher.key, higher.nonce));
10235        lower.release();
10236        assert!(sequencer.ready_to_send(higher.key, higher.nonce));
10237    }
10238
10239    #[rstest]
10240    fn tx_send_sequencer_retains_reused_nonce_reservation() {
10241        let (client, _cache, _rx) = create_execution_client();
10242        let context = client
10243            .fanout_dispatch_context(client.credential.as_ref().unwrap())
10244            .unwrap();
10245        let mut original = context.build_tx_context().unwrap();
10246        let nonce = original.context.nonce;
10247        rollback_tx_dispatch(
10248            &client.dispatch,
10249            client.credential.as_ref().unwrap(),
10250            None,
10251            nonce,
10252        );
10253        let mut replacement = context.build_tx_context().unwrap();
10254        let higher = context.build_tx_context().unwrap();
10255        original.send_reservation.release();
10256        assert_eq!(replacement.context.nonce, nonce);
10257        assert_eq!(higher.context.nonce, nonce + 1);
10258        assert!(
10259            !context
10260                .tx_send_sequencer
10261                .ready_to_send(higher.send_reservation.key, higher.context.nonce)
10262        );
10263        replacement.send_reservation.release();
10264        assert!(
10265            context
10266                .tx_send_sequencer
10267                .ready_to_send(higher.send_reservation.key, higher.context.nonce)
10268        );
10269    }
10270
10271    #[tokio::test]
10272    async fn tx_send_sequencer_blocks_higher_nonce_until_lower_batch_releases() {
10273        let sequencer = TxSendSequencer::new();
10274
10275        let mut lower_a = sequencer
10276            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10277                Ok(TEST_NEXT_NONCE)
10278            })
10279            .unwrap();
10280
10281        let mut lower_b = sequencer
10282            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10283                Ok(TEST_NEXT_NONCE + 1)
10284            })
10285            .unwrap();
10286
10287        let mut higher = sequencer
10288            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10289                Ok(TEST_NEXT_NONCE + 2)
10290            })
10291            .unwrap();
10292
10293        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
10294
10295        let higher_task = tokio::spawn(async move {
10296            higher.wait_for_turn().await;
10297            sent_tx.send(higher.nonce).unwrap();
10298            higher.release();
10299        });
10300
10301        assert!(
10302            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
10303                .await
10304                .is_err(),
10305            "higher nonce must wait while the lower batch is pending",
10306        );
10307
10308        {
10309            let lower_reservations = [&lower_a, &lower_b];
10310            tokio::time::timeout(
10311                Duration::from_millis(50),
10312                wait_for_tx_send_reservations(&lower_reservations),
10313            )
10314            .await
10315            .expect("lower batch must already have the send turn");
10316        }
10317
10318        lower_a.release();
10319        lower_b.release();
10320
10321        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
10322            .await
10323            .expect("higher nonce must proceed after lower batch releases")
10324            .expect("send channel must stay open");
10325        higher_task.await.unwrap();
10326
10327        assert_eq!(
10328            sent,
10329            TEST_NEXT_NONCE + 2,
10330            "higher nonce must send after the lower batch releases",
10331        );
10332    }
10333
10334    #[tokio::test]
10335    async fn tx_send_reservation_drop_releases_lower_nonce() {
10336        let sequencer = TxSendSequencer::new();
10337
10338        let lower = sequencer
10339            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10340                Ok(TEST_NEXT_NONCE)
10341            })
10342            .unwrap();
10343
10344        let mut higher = sequencer
10345            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10346                Ok(TEST_NEXT_NONCE + 1)
10347            })
10348            .unwrap();
10349
10350        let (sent_tx, mut sent_rx) = tokio::sync::mpsc::unbounded_channel();
10351
10352        let higher_task = tokio::spawn(async move {
10353            higher.wait_for_turn().await;
10354            sent_tx.send(higher.nonce).unwrap();
10355            higher.release();
10356        });
10357
10358        assert!(
10359            tokio::time::timeout(Duration::from_millis(50), sent_rx.recv())
10360                .await
10361                .is_err(),
10362            "higher nonce must wait while the lower reservation is pending",
10363        );
10364
10365        drop(lower);
10366
10367        let sent = tokio::time::timeout(Duration::from_secs(2), sent_rx.recv())
10368            .await
10369            .expect("higher nonce must proceed after lower reservation drops")
10370            .expect("send channel must stay open");
10371        higher_task.await.unwrap();
10372
10373        assert_eq!(
10374            sent,
10375            TEST_NEXT_NONCE + 1,
10376            "higher nonce must send after the lower reservation drops",
10377        );
10378    }
10379
10380    #[tokio::test]
10381    async fn tx_send_sequencer_keeps_nonce_streams_independent() {
10382        let sequencer = TxSendSequencer::new();
10383
10384        let _other_account = sequencer
10385            .reserve(TEST_ACCOUNT_INDEX_I64 + 1, TEST_API_KEY_INDEX, || {
10386                Ok(TEST_NEXT_NONCE)
10387            })
10388            .unwrap();
10389
10390        let _other_api_key = sequencer
10391            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX + 1, || {
10392                Ok(TEST_NEXT_NONCE)
10393            })
10394            .unwrap();
10395
10396        let mut current = sequencer
10397            .reserve(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, || {
10398                Ok(TEST_NEXT_NONCE + 10)
10399            })
10400            .unwrap();
10401
10402        tokio::time::timeout(Duration::from_millis(50), current.wait_for_turn())
10403            .await
10404            .expect("lower nonces for other keys must not block this key");
10405        current.release();
10406    }
10407
10408    #[rstest]
10409    fn tx_dispatch_guard_rolls_back_nonce_and_cloid_when_armed() {
10410        let dispatch = WsDispatchState::new();
10411        let credential = test_credential();
10412        dispatch
10413            .nonce_manager
10414            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
10415
10416        let cloid = ClientOrderId::from("O-GUARD-ARMED");
10417        let client_order_index = dispatch.derive_client_order_index(&cloid);
10418        dispatch.register_cloid(client_order_index, cloid).unwrap();
10419        let nonce = dispatch
10420            .nonce_manager
10421            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
10422            .unwrap();
10423
10424        {
10425            let _guard = TxDispatchGuard::new(
10426                dispatch.clone(),
10427                &credential,
10428                Some(client_order_index),
10429                nonce,
10430            );
10431        }
10432
10433        assert_nonce_reusable(&dispatch);
10434        assert!(dispatch.cloid_map.get(&client_order_index).is_none());
10435    }
10436
10437    #[rstest]
10438    fn tx_dispatch_guard_rolls_back_nonce_without_cloid_when_armed() {
10439        let dispatch = WsDispatchState::new();
10440        let credential = test_credential();
10441        dispatch
10442            .nonce_manager
10443            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
10444        let nonce = dispatch
10445            .nonce_manager
10446            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
10447            .unwrap();
10448
10449        {
10450            let _guard = TxDispatchGuard::new(dispatch.clone(), &credential, None, nonce);
10451        }
10452
10453        assert_nonce_reusable(&dispatch);
10454        assert!(dispatch.cloid_map.is_empty());
10455    }
10456
10457    #[rstest]
10458    fn tx_dispatch_guard_preserves_nonce_and_cloid_when_disarmed() {
10459        let dispatch = WsDispatchState::new();
10460        let credential = test_credential();
10461        dispatch
10462            .nonce_manager
10463            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, TEST_NEXT_NONCE);
10464
10465        let cloid = ClientOrderId::from("O-GUARD-DISARMED");
10466        let client_order_index = dispatch.derive_client_order_index(&cloid);
10467        dispatch.register_cloid(client_order_index, cloid).unwrap();
10468        let nonce = dispatch
10469            .nonce_manager
10470            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
10471            .unwrap();
10472
10473        {
10474            let mut guard = TxDispatchGuard::new(
10475                dispatch.clone(),
10476                &credential,
10477                Some(client_order_index),
10478                nonce,
10479            );
10480            guard.disarm();
10481        }
10482
10483        assert_eq!(
10484            dispatch
10485                .nonce_manager
10486                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
10487            Some(TEST_NEXT_NONCE),
10488        );
10489        assert_eq!(
10490            dispatch
10491                .cloid_map
10492                .get(&client_order_index)
10493                .map(|entry| *entry.value()),
10494            Some(cloid),
10495        );
10496    }
10497
10498    #[tokio::test]
10499    async fn submit_order_send_failure_emits_submitted_then_rejected_and_rolls_back() {
10500        let (client, cache, mut rx) = create_execution_client();
10501        let instrument_id = register_test_instrument(&client, &cache);
10502        let mut factory = test_order_factory();
10503        let order = test_limit_order(&mut factory, instrument_id, "O-SUBMIT-FAIL");
10504        let client_order_index = client
10505            .dispatch
10506            .derive_client_order_index(&order.client_order_id());
10507        cache_order(&cache, order.clone());
10508
10509        let command = SubmitOrder::from_order(
10510            &order,
10511            trader_id(),
10512            Some(client_id()),
10513            None,
10514            UUID4::new(),
10515            UnixNanos::default(),
10516        );
10517        client.submit_order(command).unwrap();
10518
10519        let submitted = recv_order_event(&mut rx).await;
10520        let rejected = recv_order_event(&mut rx).await;
10521
10522        match submitted {
10523            OrderEventAny::Submitted(event) => {
10524                assert_eq!(event.client_order_id, order.client_order_id());
10525                assert_eq!(event.instrument_id, instrument_id);
10526            }
10527            event => panic!("expected submitted event, was {event:?}"),
10528        }
10529
10530        match rejected {
10531            OrderEventAny::Rejected(event) => {
10532                assert_eq!(event.client_order_id, order.client_order_id());
10533                assert_eq!(event.instrument_id, instrument_id);
10534                assert!(
10535                    event
10536                        .reason
10537                        .contains("Lighter submit_order dispatch failed"),
10538                );
10539                assert!(event.reason.contains("handler unavailable"));
10540            }
10541            event => panic!("expected rejected event, was {event:?}"),
10542        }
10543
10544        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
10545        assert_nonce_reusable(&client.dispatch);
10546        assert_eq!(
10547            client.dispatch.pending_sendtx_len(),
10548            0,
10549            "local-send-failure must remove the pending entry by nonce",
10550        );
10551    }
10552
10553    #[tokio::test]
10554    async fn submit_order_unknown_send_outcome_retains_pending_state() {
10555        let (client, cache, mut rx) = create_execution_client();
10556        let instrument_id = register_test_instrument(&client, &cache);
10557        let mut factory = test_order_factory();
10558        let order = test_limit_order(&mut factory, instrument_id, "O-SUBMIT-UNKNOWN");
10559        let client_order_index = client
10560            .dispatch
10561            .derive_client_order_index(&order.client_order_id());
10562        cache_order(&cache, order.clone());
10563        client.ws_client.drop_next_send_tx_result_for_test().await;
10564
10565        client
10566            .submit_order(SubmitOrder::from_order(
10567                &order,
10568                trader_id(),
10569                Some(client_id()),
10570                None,
10571                UUID4::new(),
10572                UnixNanos::default(),
10573            ))
10574            .unwrap();
10575
10576        assert!(matches!(
10577            recv_order_event(&mut rx).await,
10578            OrderEventAny::Submitted(_)
10579        ));
10580        assert!(
10581            tokio::time::timeout(Duration::from_millis(50), rx.recv())
10582                .await
10583                .is_err(),
10584            "unknown outcome must not emit OrderRejected",
10585        );
10586        assert!(client.dispatch.cloid_map.contains_key(&client_order_index));
10587        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
10588        assert_eq!(
10589            client
10590                .dispatch
10591                .nonce_manager
10592                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
10593            Some(TEST_NEXT_NONCE),
10594        );
10595
10596        let stale = client.dispatch.drain_pending_sendtx(0);
10597        assert_eq!(stale.len(), 1);
10598        assert!(matches!(stale[0].kind, PendingSendTxKind::Create { .. }));
10599        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
10600        assert!(client.dispatch.cloid_map.contains_key(&client_order_index));
10601        assert!(
10602            client
10603                .dispatch
10604                .order_identity(&order.client_order_id())
10605                .is_some(),
10606            "reconnect draining must retain identity for reconciliation",
10607        );
10608        assert_eq!(
10609            client
10610                .dispatch
10611                .nonce_manager
10612                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
10613            Some(TEST_NEXT_NONCE),
10614        );
10615    }
10616
10617    #[tokio::test]
10618    async fn submit_order_cloid_probe_exhaustion_denies_without_overwrite() {
10619        let (client, cache, mut rx) = create_execution_client();
10620        let instrument_id = register_test_instrument(&client, &cache);
10621        let mut factory = test_order_factory();
10622        let order = test_limit_order(&mut factory, instrument_id, "O-PROBE-EXHAUSTED");
10623        let cloid = order.client_order_id();
10624        let mut index = client.dispatch.derive_client_order_index(&cloid);
10625        let mut existing = Vec::new();
10626
10627        for attempt in 0..=crate::websocket::dispatch::CLOID_INDEX_PROBE_LIMIT {
10628            let existing_cloid = ClientOrderId::from(format!("EXISTING-{attempt}"));
10629            client.dispatch.cloid_map.insert(index, existing_cloid);
10630            existing.push((index, existing_cloid));
10631            index = if index == i64::from(i32::MAX) {
10632                0
10633            } else {
10634                index + 1
10635            };
10636        }
10637
10638        cache_order(&cache, order.clone());
10639
10640        client
10641            .submit_order(SubmitOrder::from_order(
10642                &order,
10643                trader_id(),
10644                Some(client_id()),
10645                None,
10646                UUID4::new(),
10647                UnixNanos::default(),
10648            ))
10649            .unwrap();
10650
10651        let event = recv_order_event(&mut rx).await;
10652        assert!(matches!(event, OrderEventAny::Denied(_)));
10653        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
10654        assert!(client.dispatch.order_identity(&cloid).is_none());
10655
10656        for (existing_index, existing_cloid) in existing {
10657            assert_eq!(
10658                client
10659                    .dispatch
10660                    .cloid_map
10661                    .get(&existing_index)
10662                    .map(|entry| *entry.value()),
10663                Some(existing_cloid),
10664            );
10665        }
10666    }
10667
10668    #[tokio::test]
10669    async fn submit_sell_order_send_failure_dispatches_and_rolls_back() {
10670        // Mirror of the buy-side test for OrderSide::Sell; covers the
10671        // `is_ask=true` branch of the CreateOrderTxInfo payload.
10672        let (client, cache, mut rx) = create_execution_client();
10673        let instrument_id = register_test_instrument(&client, &cache);
10674        let mut factory = test_order_factory();
10675        let order = test_limit_order_with(
10676            &mut factory,
10677            instrument_id,
10678            "O-SUBMIT-FAIL-SELL",
10679            OrderSide::Sell,
10680            TimeInForce::Gtc,
10681            None,
10682            false,
10683        );
10684        let client_order_index = client
10685            .dispatch
10686            .derive_client_order_index(&order.client_order_id());
10687        cache_order(&cache, order.clone());
10688
10689        let command = SubmitOrder::from_order(
10690            &order,
10691            trader_id(),
10692            Some(client_id()),
10693            None,
10694            UUID4::new(),
10695            UnixNanos::default(),
10696        );
10697        client.submit_order(command).unwrap();
10698
10699        let _submitted = recv_order_event(&mut rx).await;
10700        let rejected = recv_order_event(&mut rx).await;
10701
10702        match rejected {
10703            OrderEventAny::Rejected(event) => {
10704                assert_eq!(event.client_order_id, order.client_order_id());
10705                assert!(
10706                    event
10707                        .reason
10708                        .contains("Lighter submit_order dispatch failed"),
10709                );
10710            }
10711            event => panic!("expected rejected event, was {event:?}"),
10712        }
10713
10714        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
10715        assert_nonce_reusable(&client.dispatch);
10716    }
10717
10718    #[tokio::test]
10719    async fn submit_gtd_order_with_explicit_expiry_dispatches_and_rolls_back() {
10720        // Covers the GTD branch in `order_expiry_for`: an explicit
10721        // expire_time must propagate as venue millis through the dispatch
10722        // path. Asserts the order reaches the dispatch step (rejected here
10723        // by handler unavailability, not by the adapter validating GTD).
10724        let (client, cache, mut rx) = create_execution_client();
10725        let instrument_id = register_test_instrument(&client, &cache);
10726        let mut factory = test_order_factory();
10727        let expiry = client.clock.get_time_ns() + DurationNanos::from_mins(10);
10728        let order = test_limit_order_with(
10729            &mut factory,
10730            instrument_id,
10731            "O-SUBMIT-FAIL-GTD",
10732            OrderSide::Buy,
10733            TimeInForce::Gtd,
10734            Some(expiry),
10735            false,
10736        );
10737        let client_order_index = client
10738            .dispatch
10739            .derive_client_order_index(&order.client_order_id());
10740        cache_order(&cache, order.clone());
10741
10742        let command = SubmitOrder::from_order(
10743            &order,
10744            trader_id(),
10745            Some(client_id()),
10746            None,
10747            UUID4::new(),
10748            UnixNanos::default(),
10749        );
10750        client.submit_order(command).unwrap();
10751
10752        let _submitted = recv_order_event(&mut rx).await;
10753        let rejected = recv_order_event(&mut rx).await;
10754
10755        match rejected {
10756            OrderEventAny::Rejected(event) => {
10757                assert!(
10758                    event
10759                        .reason
10760                        .contains("Lighter submit_order dispatch failed"),
10761                );
10762            }
10763            event => panic!("expected rejected event, was {event:?}"),
10764        }
10765
10766        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
10767        assert_nonce_reusable(&client.dispatch);
10768    }
10769
10770    #[tokio::test]
10771    async fn submit_gtd_order_with_short_expiry_is_denied_before_nonce_allocation() {
10772        let (client, cache, mut rx) = create_execution_client();
10773        let instrument_id = register_test_instrument(&client, &cache);
10774        let mut factory = test_order_factory();
10775        let expiry = UnixNanos::from(
10776            client
10777                .clock
10778                .get_time_ns()
10779                .as_u64()
10780                .saturating_add(60 * 1_000_000_000),
10781        );
10782        let order = test_limit_order_with(
10783            &mut factory,
10784            instrument_id,
10785            "O-SHORT-GTD",
10786            OrderSide::Buy,
10787            TimeInForce::Gtd,
10788            Some(expiry),
10789            false,
10790        );
10791        cache_order(&cache, order.clone());
10792
10793        let command = SubmitOrder::from_order(
10794            &order,
10795            trader_id(),
10796            Some(client_id()),
10797            None,
10798            UUID4::new(),
10799            UnixNanos::default(),
10800        );
10801        client.submit_order(command).unwrap();
10802
10803        let denied = recv_order_event(&mut rx).await;
10804        match denied {
10805            OrderEventAny::Denied(event) => {
10806                assert!(event.reason.contains("at least 5 minutes"));
10807            }
10808            event => panic!("expected denied event, was {event:?}"),
10809        }
10810
10811        assert_nonce_reusable(&client.dispatch);
10812    }
10813
10814    #[tokio::test]
10815    async fn submit_gtd_order_with_short_expiry_dispatches_when_use_gtd_false() {
10816        // With use_gtd=false the short strategy expire_time must not hit the venue
10817        // lifetime check: the order reaches the dispatch step rather than being
10818        // denied. It is rejected here by handler unavailability, which proves the
10819        // adapter accepted the short expiry and moved on to signing/dispatch.
10820        let (client, cache, mut rx) =
10821            create_execution_client_with_config(test_config_with_use_gtd(false));
10822        let instrument_id = register_test_instrument(&client, &cache);
10823        let mut factory = test_order_factory();
10824        let expiry = UnixNanos::from(
10825            client
10826                .clock
10827                .get_time_ns()
10828                .as_u64()
10829                .saturating_add(60 * 1_000_000_000),
10830        );
10831        let order = test_limit_order_with(
10832            &mut factory,
10833            instrument_id,
10834            "O-SHORT-GTD-MANAGED",
10835            OrderSide::Buy,
10836            TimeInForce::Gtd,
10837            Some(expiry),
10838            false,
10839        );
10840        let client_order_index = client
10841            .dispatch
10842            .derive_client_order_index(&order.client_order_id());
10843        cache_order(&cache, order.clone());
10844
10845        let command = SubmitOrder::from_order(
10846            &order,
10847            trader_id(),
10848            Some(client_id()),
10849            None,
10850            UUID4::new(),
10851            UnixNanos::default(),
10852        );
10853        client.submit_order(command).unwrap();
10854
10855        let _submitted = recv_order_event(&mut rx).await;
10856        let rejected = recv_order_event(&mut rx).await;
10857
10858        match rejected {
10859            OrderEventAny::Rejected(event) => {
10860                assert!(
10861                    event
10862                        .reason
10863                        .contains("Lighter submit_order dispatch failed"),
10864                );
10865            }
10866            event => panic!("expected rejected event, was {event:?}"),
10867        }
10868
10869        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
10870        assert_nonce_reusable(&client.dispatch);
10871    }
10872
10873    #[rstest]
10874    fn prepare_managed_gtd_plan_uses_safe_venue_expiry() {
10875        let (client, cache, _rx) =
10876            create_execution_client_with_config(test_config_with_use_gtd(false));
10877        let instrument_id = register_test_instrument(&client, &cache);
10878        let mut factory = test_order_factory();
10879        let expiry = UnixNanos::from(
10880            client
10881                .clock
10882                .get_time_ns()
10883                .as_u64()
10884                .saturating_add(60 * 1_000_000_000),
10885        );
10886        let order = test_limit_order_with(
10887            &mut factory,
10888            instrument_id,
10889            "O-SHORT-GTD-PLAN",
10890            OrderSide::Buy,
10891            TimeInForce::Gtd,
10892            Some(expiry),
10893            false,
10894        );
10895
10896        let before_ms = (client.clock.get_time_ns().as_u64() / 1_000_000) as i64;
10897        let plan = client
10898            .prepare_create_order_plan(&order, 0)
10899            .expect("managed GTD plan must validate");
10900        let after_ms = (client.clock.get_time_ns().as_u64() / 1_000_000) as i64;
10901
10902        assert!(
10903            (before_ms + crate::websocket::dispatch::ORDER_EXPIRY_DEFAULT_GTC_MS
10904                ..=after_ms + crate::websocket::dispatch::ORDER_EXPIRY_DEFAULT_GTC_MS)
10905                .contains(&plan.order_expiry)
10906        );
10907        assert_eq!(
10908            plan.time_in_force,
10909            crate::common::enums::LighterTimeInForce::GoodTillTime as u8,
10910        );
10911    }
10912
10913    #[tokio::test]
10914    async fn submit_reduce_only_order_dispatches_and_rolls_back() {
10915        // Reduce-only is a venue constraint; this pins the adapter pass-through
10916        let (client, cache, mut rx) = create_execution_client();
10917        let instrument_id = register_test_instrument(&client, &cache);
10918        let mut factory = test_order_factory();
10919        let order = test_limit_order_with(
10920            &mut factory,
10921            instrument_id,
10922            "O-SUBMIT-FAIL-REDUCE",
10923            OrderSide::Sell,
10924            TimeInForce::Gtc,
10925            None,
10926            true,
10927        );
10928        assert!(order.is_reduce_only());
10929        let client_order_index = client
10930            .dispatch
10931            .derive_client_order_index(&order.client_order_id());
10932        cache_order(&cache, order.clone());
10933
10934        let command = SubmitOrder::from_order(
10935            &order,
10936            trader_id(),
10937            Some(client_id()),
10938            None,
10939            UUID4::new(),
10940            UnixNanos::default(),
10941        );
10942        client.submit_order(command).unwrap();
10943
10944        let _submitted = recv_order_event(&mut rx).await;
10945        let rejected = recv_order_event(&mut rx).await;
10946
10947        match rejected {
10948            OrderEventAny::Rejected(event) => {
10949                assert!(
10950                    event
10951                        .reason
10952                        .contains("Lighter submit_order dispatch failed"),
10953                );
10954            }
10955            event => panic!("expected rejected event, was {event:?}"),
10956        }
10957
10958        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
10959        assert_nonce_reusable(&client.dispatch);
10960    }
10961
10962    #[rstest]
10963    #[case::submit(false)]
10964    #[case::modify(true)]
10965    #[tokio::test]
10966    async fn batch_orders_wait_for_nonce_readiness(#[case] modify: bool) {
10967        let (client, cache, mut rx) = create_execution_client();
10968        let instrument_id = register_test_instrument(&client, &cache);
10969        let mut factory = test_order_factory();
10970        let orders: Vec<_> = ["O-READY-A", "O-READY-B"]
10971            .into_iter()
10972            .map(|id| test_limit_order(&mut factory, instrument_id, id))
10973            .collect();
10974
10975        for order in &orders {
10976            cache_order(&cache, order.clone());
10977        }
10978
10979        client.ws_client.drop_next_send_tx_result_for_test().await;
10980        client
10981            .nonce_ready_connection_epoch
10982            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
10983
10984        if modify {
10985            let modifies = orders
10986                .iter()
10987                .enumerate()
10988                .map(|(i, order)| {
10989                    ModifyOrder::new(
10990                        trader_id(),
10991                        Some(client_id()),
10992                        strategy_id(),
10993                        instrument_id,
10994                        order.client_order_id(),
10995                        Some(VenueOrderId::from(format!("{}", 123 + i))),
10996                        Some(Quantity::from("0.2000")),
10997                        Some(Price::from("2362.00")),
10998                        None,
10999                        UUID4::new(),
11000                        UnixNanos::default(),
11001                        None,
11002                        None,
11003                    )
11004                })
11005                .collect();
11006
11007            client
11008                .batch_modify_orders(BatchModifyOrders::new(
11009                    trader_id(),
11010                    Some(client_id()),
11011                    strategy_id(),
11012                    instrument_id,
11013                    modifies,
11014                    UUID4::new(),
11015                    UnixNanos::default(),
11016                    None,
11017                    None,
11018                ))
11019                .unwrap();
11020        } else {
11021            client
11022                .submit_order_list(submit_order_list_command(&orders, "OL-READY"))
11023                .unwrap();
11024        }
11025
11026        assert!(
11027            tokio::time::timeout(Duration::from_millis(100), rx.recv())
11028                .await
11029                .is_err()
11030        );
11031        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11032        assert_eq!(
11033            client
11034                .dispatch
11035                .nonce_manager
11036                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
11037            Some(TEST_NEXT_NONCE - 1)
11038        );
11039        client
11040            .nonce_ready_connection_epoch
11041            .store(0, Ordering::Release);
11042        wait_until_async(
11043            || async { client.dispatch.pending_sendtx_len() == 2 },
11044            Duration::from_secs(2),
11045        )
11046        .await;
11047
11048        let pending = client.dispatch.pending_sendtx.lock().clone();
11049        assert_eq!(pending.len(), 2);
11050        assert_eq!(pending[0].batch_id, pending[1].batch_id);
11051        assert!(
11052            pending[0]
11053                .batch_id
11054                .as_ref()
11055                .unwrap()
11056                .starts_with(TX_BATCH_ID_PREFIX)
11057        );
11058
11059        for (i, (pending, order)) in pending.iter().zip(&orders).enumerate() {
11060            assert_eq!(pending.nonce, TEST_NEXT_NONCE + i as i64);
11061            assert_eq!(pending.api_key_index, TEST_API_KEY_INDEX);
11062            assert_eq!(pending.connection_epoch, 0);
11063
11064            if modify {
11065                assert!(
11066                    matches!(&pending.kind, PendingSendTxKind::Modify { client_order_id, .. } if *client_order_id == order.client_order_id())
11067                );
11068                assert_eq!(
11069                    client
11070                        .dispatch
11071                        .pending_order_action(&order.client_order_id()),
11072                    Some(PendingOrderAction::Modify)
11073                );
11074            } else {
11075                assert!(
11076                    matches!(&pending.kind, PendingSendTxKind::Create { order: pending_order, .. } if pending_order.client_order_id() == order.client_order_id())
11077                );
11078                let event = recv_order_event(&mut rx).await;
11079
11080                let OrderEventAny::Submitted(event) = event else {
11081                    panic!("expected Submitted after nonce readiness")
11082                };
11083
11084                assert_eq!(event.client_order_id, order.client_order_id());
11085            }
11086        }
11087
11088        assert!(
11089            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11090                .await
11091                .is_err()
11092        );
11093        assert_eq!(
11094            client
11095                .dispatch
11096                .nonce_manager
11097                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
11098            Some(TEST_NEXT_NONCE + 1)
11099        );
11100    }
11101
11102    #[tokio::test]
11103    async fn submit_order_list_send_failure_emits_submitted_then_rejected_and_rolls_back() {
11104        let (client, cache, mut rx) = create_execution_client();
11105        let instrument_id = register_test_instrument(&client, &cache);
11106        let mut factory = test_order_factory();
11107        let order_a = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-A");
11108        let order_b = test_limit_order(&mut factory, instrument_id, "O-LIST-FAIL-B");
11109        let index_a = client
11110            .dispatch
11111            .derive_client_order_index(&order_a.client_order_id());
11112        let index_b = client
11113            .dispatch
11114            .derive_client_order_index(&order_b.client_order_id());
11115        cache_order(&cache, order_a.clone());
11116        cache_order(&cache, order_b.clone());
11117
11118        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-FAIL");
11119        client.submit_order_list(command).unwrap();
11120
11121        for expected in [order_a.client_order_id(), order_b.client_order_id()] {
11122            match recv_order_event(&mut rx).await {
11123                OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, expected),
11124                other => panic!("expected Submitted, was {other:?}"),
11125            }
11126        }
11127
11128        for expected in [order_b.client_order_id(), order_a.client_order_id()] {
11129            match recv_order_event(&mut rx).await {
11130                OrderEventAny::Rejected(e) => {
11131                    assert_eq!(e.client_order_id, expected);
11132                    assert!(e.reason.contains("Lighter submit_order dispatch failed"));
11133                }
11134                other => panic!("expected Rejected, was {other:?}"),
11135            }
11136        }
11137
11138        assert!(client.dispatch.cloid_map.get(&index_a).is_none());
11139        assert!(client.dispatch.cloid_map.get(&index_b).is_none());
11140        assert_nonce_reusable(&client.dispatch);
11141        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11142    }
11143
11144    #[rstest]
11145    #[case::spill(16)]
11146    #[case::three(31)]
11147    #[tokio::test]
11148    async fn submit_order_list_chunks_large_lists_on_send_failure(#[case] count: usize) {
11149        let (client, cache, mut rx) = create_execution_client();
11150        let instrument_id = register_test_instrument(&client, &cache);
11151        let mut factory = test_order_factory();
11152        let mut orders = Vec::new();
11153
11154        for i in 0..count {
11155            let order = test_limit_order(&mut factory, instrument_id, &format!("O-LIST-MAX-{i}"));
11156            cache_order(&cache, order.clone());
11157            orders.push(order);
11158        }
11159
11160        let command = submit_order_list_command(&orders, "OL-MAX");
11161        client.submit_order_list(command).unwrap();
11162
11163        for chunk in orders.chunks(LIGHTER_MAX_BATCH_TX) {
11164            for order in chunk {
11165                match recv_order_event(&mut rx).await {
11166                    OrderEventAny::Submitted(e) => {
11167                        assert_eq!(e.client_order_id, order.client_order_id());
11168                    }
11169                    other => panic!("expected Submitted, was {other:?}"),
11170                }
11171            }
11172
11173            for order in chunk.iter().rev() {
11174                match recv_order_event(&mut rx).await {
11175                    OrderEventAny::Rejected(e) => {
11176                        assert_eq!(e.client_order_id, order.client_order_id());
11177                        assert!(e.reason.contains("Lighter submit_order dispatch failed"));
11178                    }
11179                    other => panic!("expected Rejected, was {other:?}"),
11180                }
11181            }
11182        }
11183
11184        assert!(
11185            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11186                .await
11187                .is_err(),
11188            "chunked send failure must not emit extra events",
11189        );
11190
11191        for order in &orders {
11192            let index = client
11193                .dispatch
11194                .derive_client_order_index(&order.client_order_id());
11195            assert!(client.dispatch.cloid_map.get(&index).is_none());
11196        }
11197
11198        assert_nonce_reusable(&client.dispatch);
11199        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11200    }
11201
11202    #[tokio::test]
11203    async fn submit_order_list_denies_unsupported_order_and_dispatches_supported() {
11204        let (client, cache, mut rx) = create_execution_client();
11205        let instrument_id = register_test_instrument(&client, &cache);
11206        let mut factory = test_order_factory();
11207        let valid = test_limit_order(&mut factory, instrument_id, "O-LIST-VALID");
11208        let unsupported = factory.limit(
11209            instrument_id,
11210            OrderSide::Buy,
11211            Quantity::from("0.1000"),
11212            Price::from("2361.31"),
11213            Some(TimeInForce::Gtc),
11214            None,
11215            Some(false),
11216            Some(false),
11217            None,
11218            Some(Quantity::from("0.0500")),
11219            None,
11220            None,
11221            None,
11222            None,
11223            None,
11224            Some(ClientOrderId::from("O-LIST-ICEBERG")),
11225        );
11226        let unsupported_index = client
11227            .dispatch
11228            .derive_client_order_index(&unsupported.client_order_id());
11229        cache_order(&cache, valid.clone());
11230        cache_order(&cache, unsupported.clone());
11231
11232        let command =
11233            submit_order_list_command(&[unsupported.clone(), valid.clone()], "OL-PARTIAL");
11234        client.submit_order_list(command).unwrap();
11235
11236        match recv_order_event(&mut rx).await {
11237            OrderEventAny::Denied(e) => {
11238                assert_eq!(e.client_order_id, unsupported.client_order_id());
11239                assert!(e.reason.contains("display_qty"));
11240            }
11241            other => panic!("expected Denied, was {other:?}"),
11242        }
11243
11244        match recv_order_event(&mut rx).await {
11245            OrderEventAny::Submitted(e) => assert_eq!(e.client_order_id, valid.client_order_id()),
11246            other => panic!("expected Submitted, was {other:?}"),
11247        }
11248
11249        match recv_order_event(&mut rx).await {
11250            OrderEventAny::Rejected(e) => {
11251                assert_eq!(e.client_order_id, valid.client_order_id());
11252                assert!(e.reason.contains("Lighter submit_order dispatch failed"),);
11253            }
11254            other => panic!("expected Rejected, was {other:?}"),
11255        }
11256
11257        assert!(client.dispatch.cloid_map.get(&unsupported_index).is_none());
11258        assert_nonce_reusable(&client.dispatch);
11259    }
11260
11261    #[rstest]
11262    #[case(LighterGroupingType::OneTriggersTheOther)]
11263    #[case(LighterGroupingType::OneCancelsTheOther)]
11264    #[case(LighterGroupingType::OneTriggersOneCancelsTheOther)]
11265    fn prepare_native_group_preserves_relationships_and_sizes_children(
11266        #[case] grouping: LighterGroupingType,
11267    ) {
11268        let (client, cache, _rx) = create_execution_client();
11269        let instrument = register_test_instrument(&client, &cache);
11270        let mut orders = test_native_group(instrument, grouping);
11271        orders.reverse();
11272        let plan = client.prepare_create_group_plan(&orders, 50).unwrap();
11273        let context = client
11274            .fanout_dispatch_context(client.credential.as_ref().unwrap())
11275            .unwrap();
11276        let signed = context.sign_create_group(&plan).unwrap();
11277        let json: serde_json::Value = serde_json::from_str(signed.tx_info.get()).unwrap();
11278        let bodies = json["Orders"].as_array().unwrap();
11279        let has_parent = grouping != LighterGroupingType::OneCancelsTheOther;
11280        assert_eq!(plan.grouping_type, grouping);
11281        assert_eq!(signed.tx_type, LighterTxType::CreateGroupedOrders as u8);
11282        assert_eq!(bodies.len(), orders.len());
11283        assert_eq!(bodies[0]["BaseAmount"], 1_000);
11284
11285        for (index, (body, leg)) in bodies.iter().zip(&plan.orders).enumerate() {
11286            assert_eq!(body["MarketIndex"], TEST_MARKET_INDEX);
11287            assert_eq!(
11288                body["ClientOrderIndex"],
11289                client
11290                    .dispatch
11291                    .client_order_index(&leg.order.client_order_id())
11292                    .unwrap()
11293            );
11294            assert_eq!(body["Price"], leg.price);
11295            assert_eq!(body["Type"], leg.order_type);
11296            assert_eq!(body["IsAsk"], u8::from(index > 0 || !has_parent));
11297            assert_eq!(body["ReduceOnly"], u8::from(index > 0 || !has_parent));
11298            assert_eq!(body["TimeInForce"], leg.time_in_force);
11299            assert_eq!(body["TriggerPrice"], leg.trigger_price);
11300            assert_eq!(body["OrderExpiry"], leg.order_expiry);
11301            assert_eq!(
11302                body["BaseAmount"],
11303                if has_parent && index > 0 { 0 } else { 1_000 }
11304            );
11305            assert_eq!(leg.order.quantity(), Quantity::from("0.1000"));
11306        }
11307
11308        assert_eq!(signed.pending.kind.creates().count(), orders.len());
11309        assert_eq!(
11310            client
11311                .dispatch
11312                .nonce_manager
11313                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
11314            Some(TEST_NEXT_NONCE)
11315        );
11316    }
11317
11318    #[tokio::test]
11319    async fn submit_native_group_send_failure_rejects_every_leg_and_releases_one_nonce() {
11320        let (client, cache, mut rx) = create_execution_client();
11321        let instrument = register_test_instrument(&client, &cache);
11322        let orders = test_native_group(
11323            instrument,
11324            LighterGroupingType::OneTriggersOneCancelsTheOther,
11325        );
11326
11327        for order in &orders {
11328            cache_order(&cache, order.clone());
11329        }
11330
11331        client
11332            .submit_order_list(submit_order_list_command(&orders, "OL-GROUP"))
11333            .unwrap();
11334
11335        for order in &orders {
11336            match recv_order_event(&mut rx).await {
11337                OrderEventAny::Submitted(event) => {
11338                    assert_eq!(event.client_order_id, order.client_order_id());
11339                }
11340                event => panic!("expected submitted event, was {event:?}"),
11341            }
11342        }
11343
11344        for order in &orders {
11345            match recv_order_event(&mut rx).await {
11346                OrderEventAny::Rejected(event) => {
11347                    assert_eq!(event.client_order_id, order.client_order_id());
11348                    assert!(
11349                        event
11350                            .reason
11351                            .contains("Lighter submit_order dispatch failed")
11352                    );
11353                }
11354                event => panic!("expected rejected event, was {event:?}"),
11355            }
11356
11357            assert!(
11358                client
11359                    .dispatch
11360                    .order_identity(&order.client_order_id())
11361                    .is_none()
11362            );
11363        }
11364
11365        assert_eq!(client.dispatch.cloid_map.len(), 0);
11366        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11367        assert_nonce_reusable(&client.dispatch);
11368    }
11369
11370    #[tokio::test]
11371    async fn native_group_denies_default_bracket_without_dispatch() {
11372        let (client, cache, mut rx) = create_execution_client();
11373        let instrument = register_test_instrument(&client, &cache);
11374        let orders = test_order_factory()
11375            .bracket()
11376            .instrument_id(instrument)
11377            .order_side(OrderSide::Buy)
11378            .quantity(Quantity::from("0.1000"))
11379            .entry_order_type(OrderType::Limit)
11380            .entry_price(Price::from("2300.00"))
11381            .tp_price(Price::from("2400.00"))
11382            .sl_trigger_price(Price::from("2200.00"))
11383            .call();
11384
11385        for order in &orders {
11386            cache_order(&cache, order.clone());
11387        }
11388
11389        client
11390            .submit_order_list(submit_order_list_command(
11391                &orders,
11392                orders[0].order_list_id().unwrap().as_str(),
11393            ))
11394            .unwrap();
11395
11396        for order in &orders {
11397            match recv_order_event(&mut rx).await {
11398                OrderEventAny::Denied(event) => {
11399                    assert_eq!(event.client_order_id, order.client_order_id());
11400                    assert_eq!(
11401                        event.reason.as_str(),
11402                        "UNSUPPORTED_ORDER_LIST: Lighter grouped submit failed: Lighter native groups do not support Nautilus OUO quantity updates"
11403                    );
11404                }
11405                event => panic!("expected denied event, was {event:?}"),
11406            }
11407        }
11408
11409        assert_eq!(client.dispatch.cloid_map.len(), 0);
11410        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11411        assert_nonce_reusable(&client.dispatch);
11412    }
11413
11414    #[rstest]
11415    #[case(true)]
11416    #[case(false)]
11417    fn native_group_preserves_explicit_gtd_expiries(#[case] matching: bool) {
11418        let (client, cache, _rx) = create_execution_client();
11419        let instrument = register_test_instrument(&client, &cache);
11420        let mut orders = test_native_group(instrument, LighterGroupingType::OneCancelsTheOther);
11421        let expiry = client.clock.get_time_ns() + DurationNanos::from_mins(10);
11422
11423        for (index, order) in orders.iter_mut().enumerate() {
11424            *order = OrderTestBuilder::new(order.order_type())
11425                .trader_id(order.trader_id())
11426                .strategy_id(order.strategy_id())
11427                .instrument_id(instrument)
11428                .client_order_id(order.client_order_id())
11429                .side(order.order_side())
11430                .quantity(order.quantity())
11431                .price(order.price().unwrap())
11432                .trigger_price(order.trigger_price().unwrap())
11433                .reduce_only(true)
11434                .order_list_id(order.order_list_id().unwrap())
11435                .contingency_type(ContingencyType::Oco)
11436                .linked_order_ids(order.linked_order_ids().unwrap().to_vec())
11437                .time_in_force(TimeInForce::Gtd)
11438                .expire_time(UnixNanos::from(
11439                    expiry.as_u64() + u64::from(!matching) * index as u64 * 1_000_000_000,
11440                ))
11441                .build();
11442        }
11443
11444        let result = client.prepare_create_group_plan(&orders, 50);
11445        if matching {
11446            let plan = result.unwrap();
11447            assert_eq!(
11448                plan.orders
11449                    .iter()
11450                    .map(|leg| leg.order_expiry)
11451                    .collect::<Vec<_>>(),
11452                vec![expiry.as_u64() as i64 / 1_000_000; 2]
11453            );
11454        } else {
11455            assert_eq!(
11456                result.err().unwrap().to_string(),
11457                "Lighter group children require matching nonzero expiries"
11458            );
11459        }
11460
11461        assert_eq!(client.dispatch.cloid_map.len(), 0);
11462    }
11463
11464    #[rstest]
11465    fn native_group_denies_quantity_mismatch_without_registering_orders() {
11466        let (client, cache, _rx) = create_execution_client();
11467        let instrument = register_test_instrument(&client, &cache);
11468        let mut orders = test_native_group(
11469            instrument,
11470            LighterGroupingType::OneTriggersOneCancelsTheOther,
11471        );
11472        orders[1].set_quantity(Quantity::from("0.2000"));
11473        let error = client.prepare_create_group_plan(&orders, 50).err().unwrap();
11474        assert_eq!(
11475            error.to_string(),
11476            "Lighter group quantities must match before parent-fill sizing"
11477        );
11478        assert_eq!(client.dispatch.cloid_map.len(), 0);
11479    }
11480
11481    #[rstest]
11482    fn native_group_denies_duplicate_client_ids_without_registering_orders() {
11483        let (client, cache, _rx) = create_execution_client();
11484        let instrument = register_test_instrument(&client, &cache);
11485        let mut orders = test_native_group(
11486            instrument,
11487            LighterGroupingType::OneTriggersOneCancelsTheOther,
11488        );
11489        orders[1] = orders[2].clone();
11490        let error = client.prepare_create_group_plan(&orders, 50).err().unwrap();
11491        assert_eq!(
11492            error.to_string(),
11493            "Lighter groups require unique client order IDs"
11494        );
11495        assert_eq!(client.dispatch.cloid_map.len(), 0);
11496    }
11497
11498    #[rstest]
11499    fn native_group_reports_keep_client_links_and_held_quantities() {
11500        let (client, cache, _rx) = create_execution_client();
11501        let instrument = register_test_instrument(&client, &cache);
11502        let orders = test_native_group(
11503            instrument,
11504            LighterGroupingType::OneTriggersOneCancelsTheOther,
11505        );
11506        let plan = client.prepare_create_group_plan(&orders, 50).unwrap();
11507        let context = client
11508            .fanout_dispatch_context(client.credential.as_ref().unwrap())
11509            .unwrap();
11510        let signed = context.sign_create_group(&plan).unwrap();
11511        context.enqueue_batch_tx(&signed);
11512        let venues = [
11513            VenueOrderId::from("281477001"),
11514            VenueOrderId::from("281477002"),
11515            VenueOrderId::from("281477003"),
11516        ];
11517        let indices: Vec<_> = signed
11518            .pending
11519            .kind
11520            .creates()
11521            .map(|(_, index)| index)
11522            .collect();
11523
11524        for (index, order) in orders.iter().enumerate() {
11525            assert!(client.dispatch.observe_create_submission(
11526                &order.client_order_id(),
11527                indices[index],
11528                signed.pending.nonce,
11529                venues[index]
11530            ));
11531        }
11532
11533        let mut parent = reconciliation_raw_order(indices[0], venues[0], LighterOrderStatus::Open);
11534        parent.to_trigger_order_id_0 = venues[1].to_string();
11535        parent.to_trigger_order_id_1 = venues[2].to_string();
11536        let parent_report = parse_http_order_to_report(
11537            &parent,
11538            &client.registry,
11539            account_id(),
11540            UnixNanos::from(7),
11541            &client.dispatch,
11542        )
11543        .unwrap();
11544        let parent_report = client.dispatch.translate_order_cloid(parent_report);
11545        let mut child =
11546            reconciliation_raw_order(indices[1], venues[1], LighterOrderStatus::Pending);
11547        child.order_type = LighterOrderKind::StopLossLimit;
11548        child.parent_order_id = venues[0].to_string();
11549        child.to_cancel_order_id_0 = venues[2].to_string();
11550        child.initial_base_amount = Decimal::ZERO;
11551        child.filled_base_amount = Decimal::ZERO;
11552        child.trigger_price = Decimal::from(2200);
11553        let child_report = parse_http_order_to_report(
11554            &child,
11555            &client.registry,
11556            account_id(),
11557            UnixNanos::from(8),
11558            &client.dispatch,
11559        )
11560        .unwrap();
11561        let child_report = client.dispatch.translate_order_cloid(child_report);
11562        client
11563            .dispatch
11564            .retire_order_identity(&orders[0].client_order_id());
11565        child.parent_order_id = "0".to_string();
11566        child.to_cancel_order_id_0 = "0".to_string();
11567        child.status = LighterOrderStatus::Open;
11568        child.trigger_status = LighterTriggerStatus::Na;
11569        child.trigger_price = Decimal::ZERO;
11570        child.initial_base_amount = Decimal::from_str("0.0400").unwrap();
11571        let activated = parse_http_order_to_report(
11572            &child,
11573            &client.registry,
11574            account_id(),
11575            UnixNanos::from(9),
11576            &client.dispatch,
11577        )
11578        .unwrap();
11579        let activated = client.dispatch.translate_order_cloid(activated);
11580        assert_eq!(
11581            parent_report.client_order_id,
11582            Some(orders[0].client_order_id())
11583        );
11584        assert_eq!(parent_report.contingency_type, Some(ContingencyType::Oto));
11585        assert_eq!(
11586            parent_report.linked_order_ids,
11587            Some(vec![
11588                orders[1].client_order_id(),
11589                orders[2].client_order_id()
11590            ])
11591        );
11592        assert_eq!(parent_report.parent_order_id, None);
11593        assert_eq!(
11594            child_report.client_order_id,
11595            Some(orders[1].client_order_id())
11596        );
11597        assert_eq!(child_report.contingency_type, Some(ContingencyType::Oco));
11598        assert_eq!(
11599            child_report.linked_order_ids,
11600            Some(vec![orders[2].client_order_id()])
11601        );
11602        assert_eq!(
11603            child_report.parent_order_id,
11604            Some(orders[0].client_order_id())
11605        );
11606        assert_eq!(child_report.quantity, Quantity::from("0.1000"));
11607        assert_eq!(activated.order_type, OrderType::StopLimit);
11608        assert_eq!(activated.order_status, OrderStatus::Triggered);
11609        assert_eq!(activated.trigger_price, Some(Price::from("2200.00")));
11610        assert_eq!(activated.quantity, Quantity::from("0.0400"));
11611        assert_eq!(activated.parent_order_id, Some(orders[0].client_order_id()));
11612        assert_eq!(
11613            activated.linked_order_ids,
11614            Some(vec![orders[2].client_order_id()])
11615        );
11616    }
11617
11618    #[rstest]
11619    fn native_group_restores_held_child_before_first_stream_frame() {
11620        let (client, cache, mut rx) = create_execution_client();
11621        let instrument = register_test_instrument(&client, &cache);
11622        let orders = test_native_group(
11623            instrument,
11624            LighterGroupingType::OneTriggersOneCancelsTheOther,
11625        );
11626        let child = orders[1].clone();
11627        let venue = VenueOrderId::from("281477004");
11628        cache_accepted_order(&cache, child.clone(), venue, Some(client_id()));
11629        let cached = cache
11630            .borrow()
11631            .order_owned(&child.client_order_id())
11632            .unwrap();
11633        client.dispatch.seed_order_restorations(vec![cached]);
11634        let (_, index) = forced_probed_index(child.client_order_id());
11635        let mut raw = reconciliation_raw_order(index, venue, LighterOrderStatus::Pending);
11636        raw.order_type = LighterOrderKind::StopLossLimit;
11637        raw.initial_base_amount = Decimal::ZERO;
11638        raw.filled_base_amount = Decimal::ZERO;
11639        raw.is_ask = true;
11640        raw.side = Some(LighterOrderSide::Sell);
11641        raw.reduce_only = true;
11642        raw.price = Decimal::from(2190);
11643        raw.trigger_price = Decimal::from(2200);
11644        dispatch_lighter_order(
11645            &raw,
11646            &client.dispatch,
11647            &client.emitter,
11648            &client.registry,
11649            account_id(),
11650            trader_id(),
11651            UnixNanos::from(1),
11652        );
11653        let events = drain_events(&mut rx);
11654        let report = parse_http_order_to_report(
11655            &raw,
11656            &client.registry,
11657            account_id(),
11658            UnixNanos::from(2),
11659            &client.dispatch,
11660        )
11661        .unwrap();
11662        let report = client.dispatch.translate_order_cloid(report);
11663        assert!(
11664            events.is_empty(),
11665            "restored pending frame must be a tracked replay: {events:?}"
11666        );
11667        assert_eq!(
11668            cache
11669                .borrow()
11670                .order(&child.client_order_id())
11671                .unwrap()
11672                .quantity(),
11673            Quantity::from("0.1000")
11674        );
11675        assert_eq!(
11676            client.dispatch.client_order_index(&child.client_order_id()),
11677            Some(index)
11678        );
11679        assert_eq!(report.client_order_id, Some(child.client_order_id()));
11680        assert_eq!(report.quantity, Quantity::from("0.1000"));
11681        assert_eq!(report.parent_order_id, Some(orders[0].client_order_id()));
11682        assert_eq!(
11683            report.linked_order_ids,
11684            Some(vec![orders[2].client_order_id()])
11685        );
11686        assert_eq!(report.contingency_type, Some(ContingencyType::Oco));
11687        assert_eq!(report.order_type, OrderType::StopLimit);
11688        assert_eq!(report.trigger_price, Some(Price::from("2200.00")));
11689        assert_eq!(report.price, Some(Price::from("2190.00")));
11690        assert_eq!(report.order_side, Some(OrderSide::Sell));
11691        assert_eq!(report.order_status, OrderStatus::Accepted);
11692        assert!(report.reduce_only);
11693    }
11694
11695    #[rstest]
11696    #[case(true)]
11697    #[case(false)]
11698    fn native_oco_cancel_targets_peer_with_venue_or_shared_submission_index(
11699        #[case] acknowledged: bool,
11700    ) {
11701        let (client, cache, _rx) = create_execution_client();
11702        let instrument = register_test_instrument(&client, &cache);
11703        let orders = test_native_group(
11704            instrument,
11705            LighterGroupingType::OneTriggersOneCancelsTheOther,
11706        );
11707        let group = client.prepare_create_group_plan(&orders, 50).unwrap();
11708        let context = client
11709            .fanout_dispatch_context(client.credential.as_ref().unwrap())
11710            .unwrap();
11711        let signed = context.sign_create_group(&group).unwrap();
11712        client
11713            .dispatch
11714            .enqueue_pending_sendtx(signed.pending.clone());
11715        let first = VenueOrderId::from("281477101");
11716        let second = VenueOrderId::from("281477102");
11717        cache_accepted_order(&cache, orders[1].clone(), first, Some(client_id()));
11718        let first_index = client
11719            .dispatch
11720            .client_order_index(&orders[1].client_order_id())
11721            .unwrap();
11722        let second_index = client
11723            .dispatch
11724            .client_order_index(&orders[2].client_order_id())
11725            .unwrap();
11726        assert!(client.dispatch.observe_create_submission(
11727            &orders[1].client_order_id(),
11728            first_index,
11729            signed.pending.nonce,
11730            first
11731        ));
11732
11733        if acknowledged {
11734            cache_accepted_order(&cache, orders[2].clone(), second, Some(client_id()));
11735            assert!(client.dispatch.observe_create_submission(
11736                &orders[2].client_order_id(),
11737                second_index,
11738                signed.pending.nonce,
11739                second
11740            ));
11741        } else {
11742            cache_order(&cache, orders[2].clone());
11743            cache
11744                .borrow_mut()
11745                .update_order(&TestOrderEventStubs::submitted(&orders[2], account_id()))
11746                .unwrap();
11747        }
11748
11749        let command = CancelOrder::new(
11750            trader_id(),
11751            Some(client_id()),
11752            strategy_id(),
11753            instrument,
11754            orders[1].client_order_id(),
11755            None,
11756            UUID4::new(),
11757            UnixNanos::default(),
11758            None,
11759            None,
11760        );
11761        let plans = client.prepare_oco_cancel_plans(&command).unwrap();
11762        assert_eq!(plans.len(), 2);
11763
11764        for (index, plan) in plans.iter().enumerate() {
11765            assert_eq!(plan.client_order_id, orders[index + 1].client_order_id());
11766            assert_eq!(plan.strategy_id, strategy_id());
11767            assert_eq!(plan.instrument_id, instrument);
11768            assert_eq!(plan.market_index, TEST_MARKET_INDEX);
11769        }
11770
11771        assert_eq!(plans[0].venue_order_id, Some(first));
11772        assert_eq!(plans[0].venue_index, 281_477_101);
11773        assert_eq!(plans[1].venue_order_id, acknowledged.then_some(second));
11774        assert_eq!(
11775            plans[1].venue_index,
11776            if acknowledged {
11777                281_477_102
11778            } else {
11779                second_index
11780            }
11781        );
11782        let mut override_venue = command.clone();
11783        override_venue.venue_order_id = Some(VenueOrderId::from("281477103"));
11784        assert!(client.prepare_oco_cancel_plans(&override_venue).is_none());
11785
11786        if !acknowledged {
11787            client
11788                .dispatch
11789                .mark_order_submission(&orders[2].client_order_id(), signed.pending.nonce + 1);
11790            assert!(client.prepare_oco_cancel_plans(&command).is_none());
11791        }
11792    }
11793
11794    #[rstest]
11795    fn native_group_denies_held_child_modification() {
11796        let (client, cache, _rx) = create_execution_client();
11797        let instrument = register_test_instrument(&client, &cache);
11798        let orders = test_native_group(instrument, LighterGroupingType::OneTriggersTheOther);
11799        let child = orders[1].clone();
11800        let venue = VenueOrderId::from("281477004");
11801        cache_accepted_order(&cache, child.clone(), venue, Some(client_id()));
11802        client.dispatch.store_snapshot(
11803            child.client_order_id(),
11804            OrderShapeSnapshot {
11805                quantity: Quantity::from("0.0000"),
11806                price: child.price(),
11807                trigger_price: child.trigger_price(),
11808            },
11809        );
11810
11811        let command = ModifyOrder::new(
11812            trader_id(),
11813            Some(client_id()),
11814            strategy_id(),
11815            instrument,
11816            child.client_order_id(),
11817            Some(venue),
11818            None,
11819            Some(Price::from("2180.00")),
11820            None,
11821            UUID4::new(),
11822            UnixNanos::default(),
11823            None,
11824            None,
11825        );
11826        let error = client.prepare_modify_order_plan(&command).err().unwrap();
11827        assert_eq!(
11828            error.to_string(),
11829            "Lighter cannot modify an OTO child while it waits for parent execution"
11830        );
11831    }
11832
11833    #[rstest]
11834    fn native_group_modifies_triggered_child_without_rearming_trigger() {
11835        let (client, cache, _rx) = create_execution_client();
11836        let instrument = register_test_instrument(&client, &cache);
11837        let orders = test_native_group(
11838            instrument,
11839            LighterGroupingType::OneTriggersOneCancelsTheOther,
11840        );
11841        let child = orders[2].clone();
11842        let venue = VenueOrderId::from("281477004");
11843        cache_accepted_order(&cache, child.clone(), venue, Some(client_id()));
11844        client
11845            .dispatch
11846            .mark_triggered_emitted(child.client_order_id());
11847
11848        let command = ModifyOrder::new(
11849            trader_id(),
11850            Some(client_id()),
11851            strategy_id(),
11852            instrument,
11853            child.client_order_id(),
11854            Some(venue),
11855            None,
11856            Some(Price::from("2380.00")),
11857            None,
11858            UUID4::new(),
11859            UnixNanos::default(),
11860            None,
11861            None,
11862        );
11863        let plan = client.prepare_modify_order_plan(&command).unwrap();
11864        let mut rearm = command;
11865        rearm.trigger_price = Some(Price::from("2450.00"));
11866        let error = client.prepare_modify_order_plan(&rearm).err().unwrap();
11867        assert_eq!(plan.trigger_price, 0);
11868        assert_eq!(plan.price, 238_000);
11869        assert_eq!(plan.base_amount, 1_000);
11870        assert_eq!(
11871            error.to_string(),
11872            "Lighter cannot modify a trigger price after the order has triggered"
11873        );
11874    }
11875
11876    fn test_native_group(
11877        instrument_id: InstrumentId,
11878        grouping: LighterGroupingType,
11879    ) -> Vec<OrderAny> {
11880        let has_parent = grouping != LighterGroupingType::OneCancelsTheOther;
11881        let ids = [
11882            ClientOrderId::from("O-GROUP-ENTRY"),
11883            ClientOrderId::from("O-GROUP-SL"),
11884            ClientOrderId::from("O-GROUP-TP"),
11885        ];
11886        let mut orders = Vec::new();
11887        if has_parent {
11888            orders.push(
11889                OrderTestBuilder::new(OrderType::Limit)
11890                    .trader_id(trader_id())
11891                    .strategy_id(strategy_id())
11892                    .instrument_id(instrument_id)
11893                    .client_order_id(ids[0])
11894                    .side(OrderSide::Buy)
11895                    .quantity(Quantity::from("0.1000"))
11896                    .price(Price::from("2300.00"))
11897                    .order_list_id(OrderListId::from("OL-GROUP"))
11898                    .contingency_type(ContingencyType::Oto)
11899                    .linked_order_ids(if grouping == LighterGroupingType::OneTriggersTheOther {
11900                        vec![ids[1]]
11901                    } else {
11902                        vec![ids[1], ids[2]]
11903                    })
11904                    .build(),
11905            );
11906        }
11907
11908        let count = if grouping == LighterGroupingType::OneTriggersTheOther {
11909            1
11910        } else {
11911            2
11912        };
11913
11914        let children = [
11915            (
11916                OrderType::StopLimit,
11917                Price::from("2190.00"),
11918                Price::from("2200.00"),
11919            ),
11920            (
11921                OrderType::LimitIfTouched,
11922                Price::from("2390.00"),
11923                Price::from("2400.00"),
11924            ),
11925        ];
11926
11927        for (index, (kind, price, trigger)) in children.into_iter().take(count).enumerate() {
11928            let mut builder = OrderTestBuilder::new(kind);
11929            builder
11930                .trader_id(trader_id())
11931                .strategy_id(strategy_id())
11932                .instrument_id(instrument_id)
11933                .client_order_id(ids[index + 1])
11934                .side(OrderSide::Sell)
11935                .quantity(Quantity::from("0.1000"))
11936                .price(price)
11937                .trigger_price(trigger)
11938                .reduce_only(true)
11939                .order_list_id(OrderListId::from("OL-GROUP"));
11940
11941            if has_parent {
11942                builder.parent_order_id(ids[0]);
11943            }
11944
11945            if count == 2 {
11946                builder
11947                    .contingency_type(ContingencyType::Oco)
11948                    .linked_order_ids(vec![ids[2 - index]]);
11949            }
11950
11951            orders.push(builder.build());
11952        }
11953
11954        orders
11955    }
11956
11957    #[tokio::test]
11958    async fn submit_order_list_plain_limit_oco_denies_all_without_dispatch() {
11959        let (client, cache, mut rx) = create_execution_client();
11960        let instrument_id = register_test_instrument(&client, &cache);
11961        let order_a_id = "O-LIST-OCO-A";
11962        let order_b_id = "O-LIST-OCO-B";
11963        let order_a = test_contingent_limit_order(instrument_id, order_a_id, "OL-OCO", order_b_id);
11964        let order_b = test_contingent_limit_order(instrument_id, order_b_id, "OL-OCO", order_a_id);
11965        cache_order(&cache, order_a.clone());
11966        cache_order(&cache, order_b.clone());
11967
11968        let command = submit_order_list_command(&[order_a.clone(), order_b.clone()], "OL-OCO");
11969        client.submit_order_list(command).unwrap();
11970
11971        for order in [&order_a, &order_b] {
11972            match recv_order_event(&mut rx).await {
11973                OrderEventAny::Denied(e) => {
11974                    assert_eq!(e.client_order_id, order.client_order_id());
11975                    assert!(e.reason.contains("reduce-only SL/TP orders"));
11976                }
11977                other => panic!("expected Denied, was {other:?}"),
11978            }
11979        }
11980
11981        assert!(
11982            tokio::time::timeout(Duration::from_millis(50), rx.recv())
11983                .await
11984                .is_err(),
11985            "grouped list denial must not emit extra events",
11986        );
11987        assert_nonce_reusable(&client.dispatch);
11988        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
11989    }
11990
11991    #[tokio::test]
11992    async fn cancel_order_send_failure_emits_cancel_rejected_and_rolls_back() {
11993        let (client, cache, mut rx) = create_execution_client();
11994        let instrument_id = register_test_instrument(&client, &cache);
11995        let mut factory = test_order_factory();
11996        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-FAIL");
11997        let client_order_id = order.client_order_id();
11998        let venue_order_id = VenueOrderId::from("123");
11999        cache_pending_cancel_order(&cache, order, venue_order_id);
12000
12001        let command = CancelOrder::new(
12002            trader_id(),
12003            Some(client_id()),
12004            strategy_id(),
12005            instrument_id,
12006            client_order_id,
12007            Some(venue_order_id),
12008            UUID4::new(),
12009            UnixNanos::default(),
12010            None,
12011            None,
12012        );
12013        client.cancel_order(command).unwrap();
12014
12015        let rejected = recv_order_event(&mut rx).await;
12016
12017        match rejected {
12018            OrderEventAny::CancelRejected(event) => {
12019                assert_eq!(event.client_order_id, client_order_id);
12020                assert_eq!(event.instrument_id, instrument_id);
12021                assert_eq!(event.venue_order_id, Some(venue_order_id));
12022                assert!(
12023                    event
12024                        .reason
12025                        .contains("Lighter cancel_order dispatch failed"),
12026                );
12027                assert!(event.reason.contains("handler unavailable"));
12028            }
12029            event => panic!("expected cancel rejected event, was {event:?}"),
12030        }
12031
12032        assert_nonce_reusable(&client.dispatch);
12033        assert_eq!(
12034            client.dispatch.pending_sendtx_len(),
12035            0,
12036            "local-send-failure must remove the pending cancel entry",
12037        );
12038        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
12039    }
12040
12041    #[tokio::test]
12042    async fn cancel_order_unknown_send_outcome_retains_pending_state() {
12043        let (client, cache, mut rx) = create_execution_client();
12044        let instrument_id = register_test_instrument(&client, &cache);
12045        let mut factory = test_order_factory();
12046        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-UNKNOWN");
12047        let client_order_id = order.client_order_id();
12048        let venue_order_id = VenueOrderId::from("123");
12049        cache_pending_cancel_order(&cache, order, venue_order_id);
12050        client.ws_client.drop_next_send_tx_result_for_test().await;
12051
12052        client
12053            .cancel_order(CancelOrder::new(
12054                trader_id(),
12055                Some(client_id()),
12056                strategy_id(),
12057                instrument_id,
12058                client_order_id,
12059                Some(venue_order_id),
12060                UUID4::new(),
12061                UnixNanos::default(),
12062                None,
12063                None,
12064            ))
12065            .unwrap();
12066
12067        assert!(
12068            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12069                .await
12070                .is_err(),
12071            "unknown outcome must not emit OrderCancelRejected",
12072        );
12073        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
12074        assert_eq!(
12075            client.dispatch.pending_order_action(&client_order_id),
12076            Some(PendingOrderAction::Cancel),
12077        );
12078        assert_eq!(
12079            client
12080                .dispatch
12081                .nonce_manager
12082                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12083            Some(TEST_NEXT_NONCE),
12084        );
12085    }
12086
12087    #[tokio::test]
12088    async fn cancel_order_prepare_failure_emits_cancel_rejected_without_dispatch() {
12089        let (client, cache, mut rx) = create_execution_client();
12090        let instrument_id = register_test_instrument(&client, &cache);
12091        let mut factory = test_order_factory();
12092        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NO-VOI");
12093        let client_order_id = order.client_order_id();
12094        cache_pending_cancel_order(&cache, order, VenueOrderId::from("123"));
12095
12096        let command = CancelOrder::new(
12097            trader_id(),
12098            Some(client_id()),
12099            strategy_id(),
12100            instrument_id,
12101            client_order_id,
12102            None,
12103            UUID4::new(),
12104            UnixNanos::default(),
12105            None,
12106            None,
12107        );
12108        client.cancel_order(command).unwrap();
12109
12110        let rejected = recv_order_event(&mut rx).await;
12111        match rejected {
12112            OrderEventAny::CancelRejected(event) => {
12113                assert_eq!(event.client_order_id, client_order_id);
12114                assert_eq!(event.instrument_id, instrument_id);
12115                assert_eq!(event.venue_order_id, None);
12116                assert!(event.reason.contains("Lighter cancel_order failed"));
12117                assert!(event.reason.contains("venue order_id not yet known"));
12118            }
12119            event => panic!("expected cancel rejected event, was {event:?}"),
12120        }
12121
12122        assert_nonce_reusable(&client.dispatch);
12123        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12124        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
12125        assert!(
12126            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12127                .await
12128                .is_err(),
12129            "prepare failure must emit exactly one cancel rejection",
12130        );
12131    }
12132
12133    #[tokio::test]
12134    async fn cancel_order_prepare_failure_emits_cancel_rejected_when_order_uncached() {
12135        let (client, cache, mut rx) = create_execution_client();
12136        let instrument_id = register_test_instrument(&client, &cache);
12137        let client_order_id = ClientOrderId::from("O-CANCEL-NO-CACHE");
12138        let venue_order_id = VenueOrderId::from("123");
12139
12140        let command = CancelOrder::new(
12141            trader_id(),
12142            Some(client_id()),
12143            strategy_id(),
12144            instrument_id,
12145            client_order_id,
12146            Some(venue_order_id),
12147            UUID4::new(),
12148            UnixNanos::default(),
12149            None,
12150            None,
12151        );
12152        client.cancel_order(command).unwrap();
12153
12154        let rejected = recv_order_event(&mut rx).await;
12155        match rejected {
12156            OrderEventAny::CancelRejected(event) => {
12157                assert_eq!(event.client_order_id, client_order_id);
12158                assert_eq!(event.instrument_id, instrument_id);
12159                assert_eq!(event.venue_order_id, Some(venue_order_id));
12160                assert!(event.reason.contains("Lighter cancel_order failed"));
12161                assert!(event.reason.contains("order not found in cache"));
12162            }
12163            event => panic!("expected cancel rejected event, was {event:?}"),
12164        }
12165
12166        assert_nonce_reusable(&client.dispatch);
12167        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12168        assert!(
12169            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12170                .await
12171                .is_err(),
12172            "prepare failure must emit exactly one cancel rejection",
12173        );
12174    }
12175
12176    #[rstest]
12177    #[case::buy(Some(OrderSide::Buy), false, vec![0, 2])]
12178    #[case::sell(Some(OrderSide::Sell), false, vec![1, 3])]
12179    #[case::unsided(None, false, vec![0, 1, 2, 3])]
12180    #[case::empty_buy(Some(OrderSide::Buy), true, vec![])]
12181    #[case::empty_sell(Some(OrderSide::Sell), true, vec![])]
12182    #[case::empty_unsided(None, true, vec![])]
12183    #[tokio::test]
12184    async fn cancel_all_orders_filters_orders_and_preserves_owners(
12185        #[case] order_side: Option<OrderSide>,
12186        #[case] empty: bool,
12187        #[case] expected_indices: Vec<usize>,
12188    ) {
12189        let (client, cache, mut rx) = create_execution_client();
12190        let instrument_id = register_test_instrument(&client, &cache);
12191        let other_instrument_id = InstrumentId::from("BTC-PERP.LIGHTER");
12192        let selected_account = account_id();
12193        let other_account = AccountId::from("LIGHTER-002");
12194        let mut orders = Vec::new();
12195
12196        for (index, (instrument, owner, side, open, account)) in [
12197            (
12198                instrument_id,
12199                "S-001",
12200                OrderSide::Buy,
12201                true,
12202                selected_account,
12203            ),
12204            (
12205                instrument_id,
12206                "S-001",
12207                OrderSide::Sell,
12208                true,
12209                selected_account,
12210            ),
12211            (
12212                instrument_id,
12213                "S-002",
12214                OrderSide::Buy,
12215                true,
12216                selected_account,
12217            ),
12218            (
12219                instrument_id,
12220                "S-002",
12221                OrderSide::Sell,
12222                true,
12223                selected_account,
12224            ),
12225            (
12226                other_instrument_id,
12227                "S-001",
12228                OrderSide::Buy,
12229                true,
12230                selected_account,
12231            ),
12232            (
12233                other_instrument_id,
12234                "S-002",
12235                OrderSide::Sell,
12236                true,
12237                selected_account,
12238            ),
12239            (
12240                instrument_id,
12241                "S-001",
12242                OrderSide::Buy,
12243                false,
12244                selected_account,
12245            ),
12246            (
12247                instrument_id,
12248                "S-002",
12249                OrderSide::Sell,
12250                false,
12251                selected_account,
12252            ),
12253            (instrument_id, "S-001", OrderSide::Buy, true, other_account),
12254            (instrument_id, "S-002", OrderSide::Sell, true, other_account),
12255        ]
12256        .into_iter()
12257        .enumerate()
12258        {
12259            if empty
12260                && account == selected_account
12261                && instrument == instrument_id
12262                && open
12263                && order_side.is_none_or(|filter| filter == side)
12264            {
12265                continue;
12266            }
12267
12268            let order = OrderTestBuilder::new(OrderType::Limit)
12269                .trader_id(trader_id())
12270                .strategy_id(StrategyId::from(owner))
12271                .instrument_id(instrument)
12272                .client_order_id(ClientOrderId::from(format!("O-CANCEL-ALL-{index}")))
12273                .side(side)
12274                .quantity(Quantity::from("0.1000"))
12275                .price(Price::from("2361.31"))
12276                .build();
12277            let venue_order_id = VenueOrderId::from(format!("{}", 123 + index));
12278            let accepted = TestOrderEventStubs::accepted(&order, account, venue_order_id);
12279            cache_order(&cache, order.clone());
12280            cache.borrow_mut().update_order(&accepted).unwrap();
12281            client
12282                .dispatch
12283                .venue_id_map
12284                .insert(order.client_order_id(), venue_order_id);
12285
12286            let event = if open {
12287                OrderEventAny::PendingCancel(OrderPendingCancel::new(
12288                    order.trader_id(),
12289                    order.strategy_id(),
12290                    instrument,
12291                    order.client_order_id(),
12292                    Some(account),
12293                    UUID4::new(),
12294                    UnixNanos::default(),
12295                    UnixNanos::default(),
12296                    false,
12297                    Some(venue_order_id),
12298                ))
12299            } else {
12300                TestOrderEventStubs::canceled(&order, account, Some(venue_order_id))
12301            };
12302
12303            cache.borrow_mut().update_order(&event).unwrap();
12304            orders.push((order, venue_order_id));
12305        }
12306
12307        client
12308            .cancel_all_orders(CancelAllOrders::new(
12309                trader_id(),
12310                Some(client_id()),
12311                strategy_id(),
12312                instrument_id,
12313                order_side,
12314                UUID4::new(),
12315                UnixNanos::default(),
12316                None,
12317                None,
12318            ))
12319            .unwrap();
12320
12321        // The disconnected transport rejects signed cancels, exposing selection
12322        // and event ownership through the existing dispatch failure path.
12323        let mut actual = Vec::new();
12324
12325        for _ in &expected_indices {
12326            match recv_order_event(&mut rx).await {
12327                OrderEventAny::CancelRejected(event) => {
12328                    assert!(
12329                        event
12330                            .reason
12331                            .contains("Lighter cancel_order dispatch failed")
12332                    );
12333                    assert!(event.reason.contains("handler unavailable"));
12334                    assert_eq!(event.trader_id, trader_id());
12335                    assert_eq!(event.account_id, Some(account_id()));
12336                    actual.push((
12337                        event.client_order_id,
12338                        event.strategy_id,
12339                        event.instrument_id,
12340                        event.venue_order_id,
12341                    ));
12342                }
12343                event => panic!("expected cancel rejected event, was {event:?}"),
12344            }
12345        }
12346
12347        let mut expected: Vec<_> = expected_indices
12348            .iter()
12349            .map(|&index| {
12350                let (order, venue_order_id) = &orders[index];
12351                (
12352                    order.client_order_id(),
12353                    order.strategy_id(),
12354                    order.instrument_id(),
12355                    Some(*venue_order_id),
12356                )
12357            })
12358            .collect();
12359
12360        actual.sort();
12361        expected.sort();
12362
12363        assert_eq!(actual, expected);
12364        assert!(
12365            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12366                .await
12367                .is_err()
12368        );
12369        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12370
12371        for (order, _) in orders {
12372            assert_eq!(
12373                client
12374                    .dispatch
12375                    .pending_order_action(&order.client_order_id()),
12376                None
12377            );
12378        }
12379
12380        if empty {
12381            assert_nonce_reusable(&client.dispatch);
12382        }
12383    }
12384
12385    #[tokio::test]
12386    async fn cancel_all_orders_prepare_failure_suppresses_cancel_rejected_for_open_order() {
12387        let (client, cache, mut rx) = create_execution_client();
12388        let instrument_id = register_test_instrument(&client, &cache);
12389        let mut factory = test_order_factory();
12390        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-ALL-NO-VOI");
12391        cache_accepted_order(&cache, order, VenueOrderId::from("123"), Some(client_id()));
12392
12393        let command = CancelAllOrders::new(
12394            trader_id(),
12395            Some(client_id()),
12396            strategy_id(),
12397            instrument_id,
12398            Some(OrderSide::Buy),
12399            UUID4::new(),
12400            UnixNanos::default(),
12401            None,
12402            None,
12403        );
12404        client.cancel_all_orders(command).unwrap();
12405
12406        assert_nonce_reusable(&client.dispatch);
12407        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12408        assert!(
12409            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12410                .await
12411                .is_err(),
12412            "cancel-all prepare failure must not emit an invalid cancel rejection",
12413        );
12414    }
12415
12416    #[tokio::test]
12417    async fn cancel_order_nonce_prepare_failure_emits_cancel_rejected() {
12418        let mut config = test_config();
12419        config.base_url_http = Some(spawn_next_nonce_server(100).await);
12420        let (client, cache, mut rx) = create_execution_client_with_config(config);
12421        let instrument_id = register_test_instrument(&client, &cache);
12422        let mut factory = test_order_factory();
12423        let order = test_limit_order(&mut factory, instrument_id, "O-CANCEL-NONCE-FAIL");
12424        let client_order_id = order.client_order_id();
12425        let venue_order_id = VenueOrderId::from("123");
12426        cache_pending_cancel_order(&cache, order, venue_order_id);
12427
12428        let window = i64::from(client.dispatch.nonce_manager.skip_window());
12429        for _ in 0..window {
12430            client
12431                .dispatch
12432                .nonce_manager
12433                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12434                .unwrap();
12435        }
12436
12437        let command = CancelOrder::new(
12438            trader_id(),
12439            Some(client_id()),
12440            strategy_id(),
12441            instrument_id,
12442            client_order_id,
12443            Some(venue_order_id),
12444            UUID4::new(),
12445            UnixNanos::default(),
12446            None,
12447            None,
12448        );
12449        client.cancel_order(command).unwrap();
12450
12451        let rejected = recv_order_event(&mut rx).await;
12452        match rejected {
12453            OrderEventAny::CancelRejected(event) => {
12454                assert_eq!(event.client_order_id, client_order_id);
12455                assert_eq!(event.venue_order_id, Some(venue_order_id));
12456                assert!(event.reason.contains("failed to allocate Lighter nonce"),);
12457                assert!(event.reason.contains("skip-window exhausted"));
12458            }
12459            event => panic!("expected cancel rejected event, was {event:?}"),
12460        }
12461
12462        wait_for_spawned_tasks(&client).await;
12463        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12464        assert_eq!(
12465            client
12466                .dispatch
12467                .nonce_manager
12468                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12469                .unwrap(),
12470            100,
12471        );
12472    }
12473
12474    #[tokio::test]
12475    async fn batch_cancel_releases_split_reservations_before_waiting_for_ack() {
12476        let (client, cache, _rx) = create_execution_client();
12477        let instrument_id = register_test_instrument(&client, &cache);
12478        client.ws_client.drop_next_send_tx_result_for_test().await;
12479        let context = client
12480            .fanout_dispatch_context(client.credential.as_ref().unwrap())
12481            .unwrap();
12482
12483        let plan = CancelOrderPlan {
12484            client_order_id: ClientOrderId::from("O-SPLIT-CANCEL"),
12485            strategy_id: strategy_id(),
12486            instrument_id,
12487            venue_order_id: Some(VenueOrderId::from("123")),
12488            market_index: TEST_MARKET_INDEX,
12489            venue_index: 123,
12490        };
12491
12492        let first = context.sign_cancel_order(&plan).unwrap();
12493        let interleaved = context.build_tx_context().unwrap();
12494        let second = context.sign_cancel_order(&plan).unwrap();
12495        let first_nonce = first.nonce;
12496        let second_nonce = second.nonce;
12497        drop(interleaved);
12498
12499        let task = tokio::spawn(async move {
12500            context
12501                .send_batch(vec![
12502                    PreparedBatchTx::from((first, false)),
12503                    PreparedBatchTx::from((second, false)),
12504                ])
12505                .await;
12506        });
12507
12508        let dispatched = tokio::time::timeout(Duration::from_secs(2), async {
12509            while client.dispatch.pending_sendtx_len() == 0 {
12510                tokio::task::yield_now().await;
12511            }
12512        })
12513        .await;
12514
12515        let released = tokio::time::timeout(
12516            Duration::from_millis(250),
12517            client.nonce_submission_gate.write(),
12518        )
12519        .await
12520        .is_ok();
12521        let pending = client.dispatch.pending_sendtx.lock().clone();
12522        task.abort();
12523        let _ = task.await;
12524
12525        assert!(dispatched.is_ok());
12526        assert!(
12527            released,
12528            "unacknowledged group must not hold later nonce reservations"
12529        );
12530        assert_eq!(second_nonce, first_nonce + 2);
12531        assert_eq!(pending.len(), 1);
12532        assert_eq!(pending[0].nonce, first_nonce);
12533    }
12534
12535    #[rstest]
12536    #[case::not_sent(false)]
12537    #[case::ambiguous(true)]
12538    #[tokio::test]
12539    async fn batch_cancel_orders_send_failure_preserves_delivery_semantics(
12540        #[case] ambiguous: bool,
12541    ) {
12542        let (client, cache, mut rx) = create_execution_client();
12543        let instrument_id = register_test_instrument(&client, &cache);
12544        let mut factory = test_order_factory();
12545        let cancels = ["O-BATCH-CANCEL-A", "O-BATCH-CANCEL-B"]
12546            .iter()
12547            .enumerate()
12548            .map(|(i, id)| {
12549                let order = test_limit_order(&mut factory, instrument_id, id);
12550                let client_order_id = order.client_order_id();
12551                let venue_order_id = VenueOrderId::from(format!("{}", 123 + i).as_str());
12552                cache_pending_cancel_order(&cache, order, venue_order_id);
12553
12554                CancelOrder::new(
12555                    trader_id(),
12556                    Some(client_id()),
12557                    strategy_id(),
12558                    instrument_id,
12559                    client_order_id,
12560                    Some(venue_order_id),
12561                    UUID4::new(),
12562                    UnixNanos::default(),
12563                    None,
12564                    None,
12565                )
12566            })
12567            .collect::<Vec<_>>();
12568
12569        let command = BatchCancelOrders::new(
12570            trader_id(),
12571            Some(client_id()),
12572            strategy_id(),
12573            instrument_id,
12574            cancels.clone(),
12575            UUID4::new(),
12576            UnixNanos::default(),
12577            None,
12578            None,
12579        );
12580
12581        if ambiguous {
12582            client.ws_client.drop_next_send_tx_result_for_test().await;
12583        }
12584
12585        client.batch_cancel_orders(command).unwrap();
12586
12587        if ambiguous {
12588            assert!(
12589                tokio::time::timeout(Duration::from_millis(100), rx.recv())
12590                    .await
12591                    .is_err()
12592            );
12593            let pending = client.dispatch.pending_sendtx.lock().clone();
12594            assert_eq!(pending.len(), 2);
12595            assert_eq!(pending[0].batch_id, pending[1].batch_id);
12596            assert!(
12597                pending[0]
12598                    .batch_id
12599                    .as_ref()
12600                    .unwrap()
12601                    .starts_with("tx-batch:")
12602            );
12603            assert_eq!(pending[0].nonce, TEST_NEXT_NONCE);
12604            assert_eq!(pending[1].nonce, TEST_NEXT_NONCE + 1);
12605
12606            for cancel in cancels {
12607                assert_eq!(
12608                    client
12609                        .dispatch
12610                        .pending_order_action(&cancel.client_order_id),
12611                    Some(PendingOrderAction::Cancel)
12612                );
12613            }
12614
12615            assert_eq!(
12616                client
12617                    .dispatch
12618                    .nonce_manager
12619                    .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12620                Some(TEST_NEXT_NONCE + 1)
12621            );
12622            return;
12623        }
12624
12625        let first = recv_order_event(&mut rx).await;
12626        let second = recv_order_event(&mut rx).await;
12627        let rejected_ids = [first, second].map(|event| match event {
12628            OrderEventAny::CancelRejected(e) => {
12629                assert!(e.reason.contains("Lighter cancel_order dispatch failed"),);
12630                e.client_order_id
12631            }
12632            other => panic!("expected CancelRejected, was {other:?}"),
12633        });
12634
12635        for cancel in cancels {
12636            assert!(rejected_ids.contains(&cancel.client_order_id));
12637            assert_eq!(
12638                client
12639                    .dispatch
12640                    .pending_order_action(&cancel.client_order_id),
12641                None,
12642            );
12643        }
12644        assert_nonce_reusable(&client.dispatch);
12645        assert_eq!(
12646            client.dispatch.pending_sendtx_len(),
12647            0,
12648            "local-send-failure must remove batch cancel pending entries",
12649        );
12650    }
12651
12652    #[rstest]
12653    #[case::spill(16)]
12654    #[case::three(31)]
12655    #[tokio::test]
12656    async fn batch_cancel_orders_chunks_large_commands_on_send_failure(#[case] count: usize) {
12657        let (client, cache, mut rx) = create_execution_client();
12658        let instrument_id = register_test_instrument(&client, &cache);
12659        let mut factory = test_order_factory();
12660
12661        let cancels = (0..count)
12662            .map(|i| {
12663                let order = test_limit_order(
12664                    &mut factory,
12665                    instrument_id,
12666                    &format!("O-BATCH-CANCEL-MAX-{i}"),
12667                );
12668                let id = order.client_order_id();
12669                let venue = VenueOrderId::from(format!("{}", 1_000 + i));
12670                cache_pending_cancel_order(&cache, order, venue);
12671
12672                CancelOrder::new(
12673                    trader_id(),
12674                    Some(client_id()),
12675                    strategy_id(),
12676                    instrument_id,
12677                    id,
12678                    Some(venue),
12679                    UUID4::new(),
12680                    UnixNanos::default(),
12681                    None,
12682                    None,
12683                )
12684            })
12685            .collect::<Vec<_>>();
12686
12687        let command = BatchCancelOrders::new(
12688            trader_id(),
12689            Some(client_id()),
12690            strategy_id(),
12691            instrument_id,
12692            cancels.clone(),
12693            UUID4::new(),
12694            UnixNanos::default(),
12695            None,
12696            None,
12697        );
12698        client.batch_cancel_orders(command).unwrap();
12699
12700        let mut rejected = Vec::new();
12701
12702        for _ in &cancels {
12703            match recv_order_event(&mut rx).await {
12704                OrderEventAny::CancelRejected(e) => {
12705                    assert!(e.reason.contains("Lighter cancel_order dispatch failed"));
12706                    rejected.push((e.client_order_id, e.venue_order_id));
12707                }
12708                other => panic!("expected CancelRejected, was {other:?}"),
12709            }
12710        }
12711
12712        let mut expected: Vec<_> = cancels
12713            .iter()
12714            .map(|cancel| (cancel.client_order_id, cancel.venue_order_id))
12715            .collect();
12716        expected.sort();
12717        rejected.sort();
12718        assert_eq!(rejected, expected);
12719
12720        for cancel in &cancels {
12721            assert_eq!(
12722                client
12723                    .dispatch
12724                    .pending_order_action(&cancel.client_order_id),
12725                None
12726            );
12727            assert_eq!(
12728                client
12729                    .dispatch
12730                    .pending_cancel_nonce(&cancel.client_order_id),
12731                None
12732            );
12733        }
12734
12735        assert!(
12736            tokio::time::timeout(Duration::from_millis(50), rx.recv())
12737                .await
12738                .is_err(),
12739        );
12740        assert_nonce_reusable(&client.dispatch);
12741        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12742    }
12743
12744    #[rstest]
12745    #[case::success(200, "batch-a", 0, vec!["hash-1", "hash-2"], 2, false)]
12746    #[case::partial_ack(200, "batch-a", 0, vec!["hash-2"], 1, false)]
12747    #[case::missing_hashes(200, "batch-a", 0, vec![], 0, false)]
12748    #[case::wrong_hashes(200, "batch-a", 0, vec!["unknown"], 0, false)]
12749    #[case::wrong_id(200, "unknown", 0, vec!["hash-1", "hash-2"], 0, false)]
12750    #[case::stale(200, "batch-a", 1, vec!["hash-1", "hash-2"], 0, false)]
12751    #[case::invalid_nonce(21104, "batch-a", 0, vec![], 0, true)]
12752    #[case::rejected(21727, "batch-a", 0, vec![], 0, false)]
12753    #[tokio::test]
12754    async fn batch_cancel_response_correlates_members_and_preserves_nonces(
12755        #[case] code: i64,
12756        #[case] id: &str,
12757        #[case] epoch: u64,
12758        #[case] hashes: Vec<&str>,
12759        #[case] acked_count: usize,
12760        #[case] resync_expected: bool,
12761    ) {
12762        let (client, cache, mut rx) = create_execution_client();
12763        let instrument_id = register_test_instrument(&client, &cache);
12764        for i in 0..3 {
12765            let nonce = client
12766                .dispatch
12767                .nonce_manager
12768                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
12769                .unwrap();
12770            let client_order_id = ClientOrderId::from(format!("O-BATCH-{i}"));
12771            client
12772                .dispatch
12773                .set_pending_order_action(client_order_id, PendingOrderAction::Cancel);
12774            client
12775                .dispatch
12776                .set_pending_cancel_nonce(&client_order_id, 0, nonce, true);
12777            client.dispatch.enqueue_pending_sendtx(PendingSendTx {
12778                batch_id: Some(if i == 0 { "batch-b" } else { "batch-a" }.to_string()),
12779                connection_epoch: 0,
12780                kind: PendingSendTxKind::Cancel {
12781                    emit_cancel_rejected: true,
12782                    strategy_id: strategy_id(),
12783                    instrument_id,
12784                    client_order_id,
12785                    venue_order_id: Some(VenueOrderId::from(format!("{}", 100 + i))),
12786                },
12787                submitted_at: UnixNanos::default(),
12788                nonce,
12789                api_key_index: TEST_API_KEY_INDEX,
12790                tx_hash: format!("hash-{i}"),
12791            });
12792        }
12793
12794        assert!(client.dispatch.pop_pending_sendtx_if_only(0).is_none());
12795        let hashes: Vec<_> = hashes.into_iter().map(str::to_string).collect();
12796        let (acked, resync) = handle_send_tx_batch_result(
12797            &client.dispatch,
12798            &client.emitter,
12799            Some(TEST_ACCOUNT_INDEX_I64),
12800            epoch,
12801            UnixNanos::default(),
12802            id,
12803            code,
12804            "batch failure",
12805            &hashes,
12806        );
12807        assert_eq!(resync, resync_expected);
12808        assert_eq!(acked.len(), acked_count);
12809        let rejected = code != 200;
12810        assert_eq!(
12811            client.dispatch.pending_sendtx_len(),
12812            if rejected { 1 } else { 3 - acked_count }
12813        );
12814
12815        if rejected {
12816            for i in [2, 1] {
12817                match recv_order_event(&mut rx).await {
12818                    OrderEventAny::CancelRejected(event) => {
12819                        assert_eq!(
12820                            event.client_order_id,
12821                            ClientOrderId::from(format!("O-BATCH-{i}"))
12822                        );
12823                        assert_eq!(event.strategy_id, strategy_id());
12824                        assert_eq!(event.instrument_id, instrument_id);
12825                        assert_eq!(
12826                            event.venue_order_id,
12827                            Some(VenueOrderId::from(format!("{}", 100 + i)))
12828                        );
12829                    }
12830                    event => panic!("expected rejection, was {event:?}"),
12831                }
12832
12833                assert_eq!(
12834                    client
12835                        .dispatch
12836                        .pending_order_action(&ClientOrderId::from(format!("O-BATCH-{i}"))),
12837                    None
12838                );
12839            }
12840
12841            assert_eq!(
12842                client
12843                    .dispatch
12844                    .nonce_manager
12845                    .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12846                Some(TEST_NEXT_NONCE)
12847            );
12848        } else {
12849            for pending in &acked {
12850                assert!(hashes.contains(&pending.tx_hash));
12851            }
12852
12853            assert_eq!(
12854                client
12855                    .dispatch
12856                    .nonce_manager
12857                    .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
12858                Some(TEST_NEXT_NONCE + 2)
12859            );
12860        }
12861
12862        assert_eq!(
12863            client
12864                .dispatch
12865                .pending_order_action(&ClientOrderId::from("O-BATCH-0")),
12866            Some(PendingOrderAction::Cancel)
12867        );
12868        assert!(
12869            tokio::time::timeout(Duration::from_millis(20), rx.recv())
12870                .await
12871                .is_err()
12872        );
12873        let remaining = client.dispatch.drain_pending_sendtx(0);
12874        assert_eq!(remaining.len(), if rejected { 1 } else { 3 - acked_count });
12875        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
12876    }
12877
12878    #[rstest]
12879    fn cancel_order_from_cancel_all_preserves_tracing_ids() {
12880        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
12881        let client_order_id = ClientOrderId::from("O-CANCEL-ALL-CHILD");
12882        let command_id = UUID4::new();
12883        let correlation_id = UUID4::new();
12884        let causation_id = UUID4::new();
12885        let ts_init = UnixNanos::default();
12886        let mut cmd = CancelAllOrders::new(
12887            trader_id(),
12888            Some(client_id()),
12889            strategy_id(),
12890            instrument_id,
12891            Some(OrderSide::Buy),
12892            command_id,
12893            ts_init,
12894            None,
12895            Some(correlation_id),
12896        );
12897        cmd.causation_id = Some(causation_id);
12898
12899        let owner = StrategyId::from("S-002");
12900        let order_cmd = cancel_order_from_cancel_all(&cmd, client_order_id, owner);
12901
12902        assert_eq!(order_cmd.trader_id, trader_id());
12903        assert_eq!(order_cmd.client_id, Some(client_id()));
12904        assert_eq!(order_cmd.strategy_id, owner);
12905        assert_eq!(order_cmd.instrument_id, instrument_id);
12906        assert_eq!(order_cmd.client_order_id, client_order_id);
12907        assert_eq!(order_cmd.venue_order_id, None);
12908        assert_eq!(order_cmd.command_id, command_id);
12909        assert_eq!(order_cmd.ts_init, ts_init);
12910        assert_eq!(order_cmd.params, None);
12911        assert_eq!(order_cmd.correlation_id, Some(correlation_id));
12912        assert_eq!(order_cmd.causation_id, Some(causation_id));
12913    }
12914
12915    #[rstest]
12916    #[case::not_sent(false)]
12917    #[case::ambiguous(true)]
12918    #[tokio::test]
12919    async fn batch_modify_orders_send_failure_preserves_delivery_semantics(
12920        #[case] ambiguous: bool,
12921    ) {
12922        let (client, cache, mut rx) = create_execution_client();
12923        let instrument_id = register_test_instrument(&client, &cache);
12924        let mut factory = test_order_factory();
12925
12926        let modifies: Vec<_> = (0..2)
12927            .map(|i| {
12928                let order =
12929                    test_limit_order(&mut factory, instrument_id, &format!("O-BATCH-MODIFY-{i}"));
12930                let id = order.client_order_id();
12931                cache_order(&cache, order);
12932
12933                ModifyOrder::new(
12934                    trader_id(),
12935                    Some(client_id()),
12936                    strategy_id(),
12937                    instrument_id,
12938                    id,
12939                    Some(VenueOrderId::from(format!("{}", 123 + i))),
12940                    Some(Quantity::from("0.2000")),
12941                    Some(Price::from("2362.00")),
12942                    None,
12943                    UUID4::new(),
12944                    UnixNanos::default(),
12945                    None,
12946                    None,
12947                )
12948            })
12949            .collect();
12950
12951        if ambiguous {
12952            client.ws_client.drop_next_send_tx_result_for_test().await;
12953        }
12954
12955        client
12956            .batch_modify_orders(BatchModifyOrders::new(
12957                trader_id(),
12958                Some(client_id()),
12959                strategy_id(),
12960                instrument_id,
12961                modifies.clone(),
12962                UUID4::new(),
12963                UnixNanos::default(),
12964                None,
12965                None,
12966            ))
12967            .unwrap();
12968
12969        if ambiguous {
12970            wait_until_async(
12971                || async { client.dispatch.pending_sendtx_len() == 2 },
12972                Duration::from_secs(2),
12973            )
12974            .await;
12975            assert!(
12976                tokio::time::timeout(Duration::from_millis(100), rx.recv())
12977                    .await
12978                    .is_err()
12979            );
12980            let pending = client.dispatch.pending_sendtx.lock().clone();
12981            assert_eq!(pending.len(), 2);
12982            assert_eq!(pending[0].batch_id, pending[1].batch_id);
12983            assert!(
12984                pending[0]
12985                    .batch_id
12986                    .as_ref()
12987                    .unwrap()
12988                    .starts_with(TX_BATCH_ID_PREFIX)
12989            );
12990            assert_eq!(pending[0].nonce, TEST_NEXT_NONCE);
12991            assert_eq!(pending[1].nonce, TEST_NEXT_NONCE + 1);
12992
12993            for modify in &modifies {
12994                assert_eq!(
12995                    client
12996                        .dispatch
12997                        .pending_order_action(&modify.client_order_id),
12998                    Some(PendingOrderAction::Modify)
12999                );
13000            }
13001        } else {
13002            let mut ids = Vec::new();
13003
13004            for _ in 0..2 {
13005                match recv_order_event(&mut rx).await {
13006                    OrderEventAny::ModifyRejected(e) => {
13007                        assert!(e.reason.contains("Lighter modify_order dispatch failed"));
13008                        assert_eq!(e.instrument_id, instrument_id);
13009                        assert_eq!(e.strategy_id, strategy_id());
13010                        ids.push((e.client_order_id, e.venue_order_id));
13011                    }
13012                    other => panic!("expected ModifyRejected, was {other:?}"),
13013                }
13014            }
13015
13016            ids.sort();
13017            let mut expected: Vec<_> = modifies
13018                .iter()
13019                .map(|modify| (modify.client_order_id, modify.venue_order_id))
13020                .collect();
13021            expected.sort();
13022            assert_eq!(ids, expected);
13023            assert_eq!(client.dispatch.pending_sendtx_len(), 0);
13024
13025            for modify in &modifies {
13026                assert_eq!(
13027                    client
13028                        .dispatch
13029                        .pending_order_action(&modify.client_order_id),
13030                    None
13031                );
13032            }
13033
13034            assert_nonce_reusable(&client.dispatch);
13035        }
13036    }
13037
13038    #[tokio::test]
13039    async fn batch_modify_orders_rejects_invalid_child_and_dispatches_supported() {
13040        let (client, cache, mut rx) = create_execution_client();
13041        let instrument_id = register_test_instrument(&client, &cache);
13042        let mut factory = test_order_factory();
13043        let mut modifies = Vec::new();
13044
13045        for (id, venue_id) in [("O-MODIFY-INVALID", "invalid"), ("O-MODIFY-VALID", "456")] {
13046            let order = test_limit_order(&mut factory, instrument_id, id);
13047            let client_order_id = order.client_order_id();
13048            cache_order(&cache, order);
13049            modifies.push(ModifyOrder::new(
13050                trader_id(),
13051                Some(client_id()),
13052                strategy_id(),
13053                instrument_id,
13054                client_order_id,
13055                Some(VenueOrderId::from(venue_id)),
13056                Some(Quantity::from("0.2000")),
13057                Some(Price::from("2362.00")),
13058                None,
13059                UUID4::new(),
13060                UnixNanos::default(),
13061                None,
13062                None,
13063            ));
13064        }
13065
13066        client.ws_client.drop_next_send_tx_result_for_test().await;
13067        client
13068            .batch_modify_orders(BatchModifyOrders::new(
13069                trader_id(),
13070                Some(client_id()),
13071                strategy_id(),
13072                instrument_id,
13073                modifies.clone(),
13074                UUID4::new(),
13075                UnixNanos::default(),
13076                None,
13077                None,
13078            ))
13079            .unwrap();
13080        let event = recv_order_event(&mut rx).await;
13081
13082        let OrderEventAny::ModifyRejected(event) = event else {
13083            panic!("expected invalid-child rejection")
13084        };
13085
13086        assert_eq!(event.client_order_id, modifies[0].client_order_id);
13087        assert_eq!(event.venue_order_id, modifies[0].venue_order_id);
13088        assert!(event.reason.contains("invalid"));
13089        wait_until_async(
13090            || async { client.dispatch.pending_sendtx_len() == 1 },
13091            Duration::from_secs(2),
13092        )
13093        .await;
13094        let pending = client.dispatch.pending_sendtx.lock().clone();
13095        assert_eq!(pending.len(), 1);
13096        assert_eq!(pending[0].nonce, TEST_NEXT_NONCE);
13097        assert!(
13098            matches!(&pending[0].kind, PendingSendTxKind::Modify { client_order_id, .. } if *client_order_id == modifies[1].client_order_id)
13099        );
13100        assert_eq!(
13101            client
13102                .dispatch
13103                .pending_order_action(&modifies[0].client_order_id),
13104            None
13105        );
13106        assert_eq!(
13107            client
13108                .dispatch
13109                .pending_order_action(&modifies[1].client_order_id),
13110            Some(PendingOrderAction::Modify)
13111        );
13112        assert!(rx.try_recv().is_err());
13113    }
13114
13115    #[rstest]
13116    #[case::empty(0)]
13117    #[case::full(15)]
13118    #[case::spill(16)]
13119    #[case::three(31)]
13120    #[tokio::test]
13121    async fn batch_modify_orders_chunks_large_commands_on_send_failure(#[case] count: usize) {
13122        let (client, cache, mut rx) = create_execution_client();
13123        let instrument_id = register_test_instrument(&client, &cache);
13124        let mut factory = test_order_factory();
13125
13126        let modifies: Vec<_> = (0..count)
13127            .map(|i| {
13128                let order =
13129                    test_limit_order(&mut factory, instrument_id, &format!("O-BATCH-SIZE-{i}"));
13130                let id = order.client_order_id();
13131                cache_order(&cache, order);
13132
13133                ModifyOrder::new(
13134                    trader_id(),
13135                    Some(client_id()),
13136                    strategy_id(),
13137                    instrument_id,
13138                    id,
13139                    Some(VenueOrderId::from(format!("{}", 123 + i))),
13140                    Some(Quantity::from("0.2000")),
13141                    Some(Price::from("2362.00")),
13142                    None,
13143                    UUID4::new(),
13144                    UnixNanos::default(),
13145                    None,
13146                    None,
13147                )
13148            })
13149            .collect();
13150
13151        client
13152            .batch_modify_orders(BatchModifyOrders::new(
13153                trader_id(),
13154                Some(client_id()),
13155                strategy_id(),
13156                instrument_id,
13157                modifies.clone(),
13158                UUID4::new(),
13159                UnixNanos::default(),
13160                None,
13161                None,
13162            ))
13163            .unwrap();
13164
13165        let mut rejected = Vec::new();
13166
13167        for _ in &modifies {
13168            match recv_order_event(&mut rx).await {
13169                OrderEventAny::ModifyRejected(e) => {
13170                    assert!(e.reason.contains("Lighter modify_order dispatch failed"));
13171                    rejected.push((e.client_order_id, e.venue_order_id));
13172                }
13173                other => panic!("expected ModifyRejected, was {other:?}"),
13174            }
13175        }
13176
13177        let mut expected: Vec<_> = modifies
13178            .iter()
13179            .map(|modify| (modify.client_order_id, modify.venue_order_id))
13180            .collect();
13181        expected.sort();
13182        rejected.sort();
13183        assert_eq!(rejected, expected);
13184
13185        for modify in &modifies {
13186            assert_eq!(
13187                client
13188                    .dispatch
13189                    .pending_order_action(&modify.client_order_id),
13190                None
13191            );
13192            assert_eq!(
13193                client
13194                    .dispatch
13195                    .pending_cancel_nonce(&modify.client_order_id),
13196                None
13197            );
13198        }
13199
13200        assert!(
13201            tokio::time::timeout(Duration::from_millis(50), rx.recv())
13202                .await
13203                .is_err(),
13204        );
13205        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
13206        assert_nonce_reusable(&client.dispatch);
13207    }
13208
13209    #[tokio::test]
13210    async fn modify_order_send_failure_emits_modify_rejected_and_rolls_back() {
13211        let (client, cache, mut rx) = create_execution_client();
13212        let instrument_id = register_test_instrument(&client, &cache);
13213        let mut factory = test_order_factory();
13214        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-FAIL");
13215        let client_order_id = order.client_order_id();
13216        let venue_order_id = VenueOrderId::from("123");
13217        cache_order(&cache, order);
13218
13219        let command = ModifyOrder::new(
13220            trader_id(),
13221            Some(client_id()),
13222            strategy_id(),
13223            instrument_id,
13224            client_order_id,
13225            Some(venue_order_id),
13226            Some(Quantity::from("0.2000")),
13227            Some(Price::from("2362.00")),
13228            None,
13229            UUID4::new(),
13230            UnixNanos::default(),
13231            None,
13232            None,
13233        );
13234        client.modify_order(command).unwrap();
13235
13236        let rejected = recv_order_event(&mut rx).await;
13237
13238        match rejected {
13239            OrderEventAny::ModifyRejected(event) => {
13240                assert_eq!(event.client_order_id, client_order_id);
13241                assert_eq!(event.instrument_id, instrument_id);
13242                assert_eq!(event.venue_order_id, Some(venue_order_id));
13243                assert!(
13244                    event
13245                        .reason
13246                        .contains("Lighter modify_order dispatch failed"),
13247                );
13248                assert!(event.reason.contains("handler unavailable"));
13249            }
13250            event => panic!("expected modify rejected event, was {event:?}"),
13251        }
13252
13253        assert_nonce_reusable(&client.dispatch);
13254        assert_eq!(
13255            client.dispatch.pending_sendtx_len(),
13256            0,
13257            "local-send-failure must remove the pending modify entry",
13258        );
13259        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
13260    }
13261
13262    #[tokio::test]
13263    async fn modify_order_unknown_send_outcome_retains_pending_state() {
13264        let (client, cache, mut rx) = create_execution_client();
13265        let instrument_id = register_test_instrument(&client, &cache);
13266        let mut factory = test_order_factory();
13267        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-UNKNOWN");
13268        let client_order_id = order.client_order_id();
13269        let venue_order_id = VenueOrderId::from("123");
13270        cache_order(&cache, order);
13271        client.ws_client.drop_next_send_tx_result_for_test().await;
13272
13273        client
13274            .modify_order(ModifyOrder::new(
13275                trader_id(),
13276                Some(client_id()),
13277                strategy_id(),
13278                instrument_id,
13279                client_order_id,
13280                Some(venue_order_id),
13281                Some(Quantity::from("0.2000")),
13282                Some(Price::from("2362.00")),
13283                None,
13284                UUID4::new(),
13285                UnixNanos::default(),
13286                None,
13287                None,
13288            ))
13289            .unwrap();
13290
13291        assert!(
13292            tokio::time::timeout(Duration::from_millis(50), rx.recv())
13293                .await
13294                .is_err(),
13295            "unknown outcome must not emit OrderModifyRejected",
13296        );
13297        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
13298        assert_eq!(
13299            client.dispatch.pending_order_action(&client_order_id),
13300            Some(PendingOrderAction::Modify),
13301        );
13302        assert_eq!(
13303            client
13304                .dispatch
13305                .nonce_manager
13306                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
13307            Some(TEST_NEXT_NONCE),
13308        );
13309    }
13310
13311    #[tokio::test]
13312    async fn modify_order_prepare_failure_emits_modify_rejected_without_dispatch() {
13313        let (client, cache, mut rx) = create_execution_client();
13314        let instrument_id = register_test_instrument(&client, &cache);
13315        let client_order_id = ClientOrderId::from("O-MODIFY-NO-CACHE");
13316        let venue_order_id = VenueOrderId::from("123");
13317
13318        let command = ModifyOrder::new(
13319            trader_id(),
13320            Some(client_id()),
13321            strategy_id(),
13322            instrument_id,
13323            client_order_id,
13324            Some(venue_order_id),
13325            Some(Quantity::from("0.2000")),
13326            Some(Price::from("2362.00")),
13327            None,
13328            UUID4::new(),
13329            UnixNanos::default(),
13330            None,
13331            None,
13332        );
13333        client.modify_order(command).unwrap();
13334
13335        let rejected = recv_order_event(&mut rx).await;
13336        match rejected {
13337            OrderEventAny::ModifyRejected(event) => {
13338                assert_eq!(event.client_order_id, client_order_id);
13339                assert_eq!(event.instrument_id, instrument_id);
13340                assert_eq!(event.venue_order_id, Some(venue_order_id));
13341                assert!(event.reason.contains("Lighter modify_order failed"));
13342                assert!(event.reason.contains("order not found in cache"));
13343            }
13344            event => panic!("expected modify rejected event, was {event:?}"),
13345        }
13346
13347        assert_nonce_reusable(&client.dispatch);
13348        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
13349        assert_eq!(client.dispatch.pending_order_action(&client_order_id), None);
13350        assert!(
13351            tokio::time::timeout(Duration::from_millis(50), rx.recv())
13352                .await
13353                .is_err(),
13354            "prepare failure must emit exactly one modify rejection",
13355        );
13356    }
13357
13358    #[tokio::test]
13359    async fn modify_order_prepare_failure_emits_modify_rejected_when_instrument_uncached() {
13360        let (client, cache, mut rx) = create_execution_client();
13361        let instrument_id =
13362            client
13363                .registry
13364                .insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
13365        let mut factory = test_order_factory();
13366        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NO-INSTRUMENT");
13367        let client_order_id = order.client_order_id();
13368        let venue_order_id = VenueOrderId::from("123");
13369        cache_order(&cache, order);
13370
13371        let command = ModifyOrder::new(
13372            trader_id(),
13373            Some(client_id()),
13374            strategy_id(),
13375            instrument_id,
13376            client_order_id,
13377            Some(venue_order_id),
13378            Some(Quantity::from("0.2000")),
13379            Some(Price::from("2362.00")),
13380            None,
13381            UUID4::new(),
13382            UnixNanos::default(),
13383            None,
13384            None,
13385        );
13386        client.modify_order(command).unwrap();
13387
13388        let rejected = recv_order_event(&mut rx).await;
13389        match rejected {
13390            OrderEventAny::ModifyRejected(event) => {
13391                assert_eq!(event.client_order_id, client_order_id);
13392                assert_eq!(event.instrument_id, instrument_id);
13393                assert_eq!(event.venue_order_id, Some(venue_order_id));
13394                assert!(event.reason.contains("Lighter modify_order failed"));
13395                assert!(event.reason.contains("instrument not found in cache"));
13396            }
13397            event => panic!("expected modify rejected event, was {event:?}"),
13398        }
13399
13400        assert_nonce_reusable(&client.dispatch);
13401        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
13402        assert!(
13403            tokio::time::timeout(Duration::from_millis(50), rx.recv())
13404                .await
13405                .is_err(),
13406            "prepare failure must emit exactly one modify rejection",
13407        );
13408    }
13409
13410    #[tokio::test]
13411    async fn modify_stop_market_derives_price_from_trigger_without_explicit_price() {
13412        // A trigger-only STOP_MARKET carries no limit price; modifying its trigger
13413        // must derive the wire cap from the trigger rather than trip the price
13414        // guard. Prepare succeeds, so the only failure here is the test harness's
13415        // missing WS send handler.
13416        let (client, cache, mut rx) = create_execution_client();
13417        let instrument_id = register_test_instrument(&client, &cache);
13418        let mut factory = test_order_factory();
13419        let order = factory.stop_market(
13420            instrument_id,
13421            OrderSide::Sell,
13422            Quantity::from("0.1000"),
13423            Price::from("2300.00"), // trigger
13424            None,
13425            Some(TimeInForce::Gtc),
13426            None,
13427            Some(false),
13428            Some(false),
13429            None,
13430            None,
13431            None,
13432            None,
13433            None,
13434            None,
13435            Some(ClientOrderId::from("O-MODIFY-STOP-MARKET")),
13436        );
13437        let client_order_id = order.client_order_id();
13438        let venue_order_id = VenueOrderId::from("123");
13439        cache_order(&cache, order);
13440
13441        let command = ModifyOrder::new(
13442            trader_id(),
13443            Some(client_id()),
13444            strategy_id(),
13445            instrument_id,
13446            client_order_id,
13447            Some(venue_order_id),
13448            None,
13449            None,
13450            Some(Price::from("2310.00")),
13451            UUID4::new(),
13452            UnixNanos::default(),
13453            None,
13454            None,
13455        );
13456        client.modify_order(command).unwrap();
13457
13458        let rejected = recv_order_event(&mut rx).await;
13459        match rejected {
13460            OrderEventAny::ModifyRejected(event) => {
13461                assert!(
13462                    !event.reason.contains("requires a price"),
13463                    "trigger-only stop modify must not trip the price guard, was: {}",
13464                    event.reason,
13465                );
13466                assert!(
13467                    event.reason.contains("dispatch failed"),
13468                    "expected send-stage failure after a successful prepare, was: {}",
13469                    event.reason,
13470                );
13471            }
13472            event => panic!("expected modify rejected event, was {event:?}"),
13473        }
13474        assert_nonce_reusable(&client.dispatch);
13475    }
13476
13477    #[tokio::test]
13478    async fn modify_order_rejects_sub_tick_quantity_before_nonce_allocation() {
13479        let (client, cache, mut rx) = create_execution_client();
13480        let instrument_id = register_test_instrument(&client, &cache);
13481        let mut factory = test_order_factory();
13482        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-SUB-TICK");
13483        let client_order_id = order.client_order_id();
13484        cache_order(&cache, order);
13485
13486        client
13487            .modify_order(ModifyOrder::new(
13488                trader_id(),
13489                Some(client_id()),
13490                strategy_id(),
13491                instrument_id,
13492                client_order_id,
13493                Some(VenueOrderId::from("123")),
13494                Some(Quantity::from("0.00001")),
13495                Some(Price::from("2362.00")),
13496                None,
13497                UUID4::new(),
13498                UnixNanos::default(),
13499                None,
13500                None,
13501            ))
13502            .unwrap();
13503
13504        assert_modify_rejected_reason(&mut rx, "rounds to 0 ticks").await;
13505        assert_nonce_reusable(&client.dispatch);
13506    }
13507
13508    #[tokio::test]
13509    async fn modify_order_rejects_below_min_notional_before_nonce_allocation() {
13510        let (client, cache, mut rx) = create_execution_client();
13511        let instrument_id = register_test_instrument(&client, &cache);
13512        let mut factory = test_order_factory();
13513        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-MIN-NOTIONAL");
13514        let client_order_id = order.client_order_id();
13515        cache_order(&cache, order);
13516
13517        client
13518            .modify_order(ModifyOrder::new(
13519                trader_id(),
13520                Some(client_id()),
13521                strategy_id(),
13522                instrument_id,
13523                client_order_id,
13524                Some(VenueOrderId::from("123")),
13525                Some(Quantity::from("0.0001")),
13526                Some(Price::from("2362.00")),
13527                None,
13528                UUID4::new(),
13529                UnixNanos::default(),
13530                None,
13531                None,
13532            ))
13533            .unwrap();
13534
13535        assert_modify_rejected_reason(&mut rx, "below Lighter min_quote_amount").await;
13536        assert_nonce_reusable(&client.dispatch);
13537    }
13538
13539    #[tokio::test]
13540    async fn modify_conditional_order_rejects_sub_tick_trigger_before_nonce_allocation() {
13541        let (client, cache, mut rx) = create_execution_client();
13542        let instrument_id = register_test_instrument(&client, &cache);
13543        let mut factory = test_order_factory();
13544        let order = factory.stop_market(
13545            instrument_id,
13546            OrderSide::Sell,
13547            Quantity::from("0.1000"),
13548            Price::from("2300.00"),
13549            None,
13550            Some(TimeInForce::Gtc),
13551            None,
13552            Some(false),
13553            Some(false),
13554            None,
13555            None,
13556            None,
13557            None,
13558            None,
13559            None,
13560            Some(ClientOrderId::from("O-MODIFY-SUB-TICK-TRIGGER")),
13561        );
13562        let client_order_id = order.client_order_id();
13563        cache_order(&cache, order);
13564
13565        client
13566            .modify_order(ModifyOrder::new(
13567                trader_id(),
13568                Some(client_id()),
13569                strategy_id(),
13570                instrument_id,
13571                client_order_id,
13572                Some(VenueOrderId::from("123")),
13573                None,
13574                None,
13575                Some(Price::from("0.001")),
13576                UUID4::new(),
13577                UnixNanos::default(),
13578                None,
13579                None,
13580            ))
13581            .unwrap();
13582
13583        assert_modify_rejected_reason(&mut rx, "rounds to 0 ticks").await;
13584        assert_nonce_reusable(&client.dispatch);
13585    }
13586
13587    #[tokio::test]
13588    async fn modify_order_nonce_prepare_failure_emits_modify_rejected() {
13589        let mut config = test_config();
13590        config.base_url_http = Some(spawn_next_nonce_server(101).await);
13591        let (client, cache, mut rx) = create_execution_client_with_config(config);
13592        let instrument_id = register_test_instrument(&client, &cache);
13593        let mut factory = test_order_factory();
13594        let order = test_limit_order(&mut factory, instrument_id, "O-MODIFY-NONCE-FAIL");
13595        let client_order_id = order.client_order_id();
13596        let venue_order_id = VenueOrderId::from("123");
13597        cache_order(&cache, order);
13598
13599        let window = i64::from(client.dispatch.nonce_manager.skip_window());
13600        for _ in 0..window {
13601            client
13602                .dispatch
13603                .nonce_manager
13604                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
13605                .unwrap();
13606        }
13607
13608        let command = ModifyOrder::new(
13609            trader_id(),
13610            Some(client_id()),
13611            strategy_id(),
13612            instrument_id,
13613            client_order_id,
13614            Some(venue_order_id),
13615            Some(Quantity::from("0.2000")),
13616            Some(Price::from("2362.00")),
13617            None,
13618            UUID4::new(),
13619            UnixNanos::default(),
13620            None,
13621            None,
13622        );
13623        client.modify_order(command).unwrap();
13624
13625        let rejected = recv_order_event(&mut rx).await;
13626        match rejected {
13627            OrderEventAny::ModifyRejected(event) => {
13628                assert_eq!(event.client_order_id, client_order_id);
13629                assert_eq!(event.venue_order_id, Some(venue_order_id));
13630                assert!(event.reason.contains("failed to allocate Lighter nonce"),);
13631                assert!(event.reason.contains("skip-window exhausted"));
13632            }
13633            event => panic!("expected modify rejected event, was {event:?}"),
13634        }
13635
13636        wait_for_spawned_tasks(&client).await;
13637        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
13638        assert_eq!(
13639            client
13640                .dispatch
13641                .nonce_manager
13642                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
13643                .unwrap(),
13644            101,
13645        );
13646    }
13647
13648    #[tokio::test]
13649    async fn update_leverage_requires_credentials() {
13650        let (mut client, _cache, _rx) = create_execution_client();
13651        client.credential = None;
13652        let instrument_id = InstrumentId::from("ETH-PERP.LIGHTER");
13653
13654        let err = client
13655            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
13656            .unwrap_err();
13657
13658        assert!(
13659            err.to_string()
13660                .contains("cannot update leverage without credentials"),
13661        );
13662    }
13663
13664    #[tokio::test]
13665    async fn update_leverage_requires_registered_instrument() {
13666        let (client, _cache, _rx) = create_execution_client();
13667        let unknown = InstrumentId::from("DOGE-PERP.LIGHTER");
13668
13669        let err = client
13670            .update_leverage(unknown, 500, LighterPositionMarginMode::Isolated)
13671            .unwrap_err();
13672
13673        assert!(
13674            err.to_string()
13675                .contains("no Lighter market_index registered")
13676        );
13677        // Pin that nonce was not burned on the rejected path: instrument
13678        // lookup must happen before `build_tx_context` allocates a nonce.
13679        assert_nonce_reusable(&client.dispatch);
13680    }
13681
13682    #[tokio::test]
13683    async fn update_leverage_dispatches_and_rolls_back_on_send_failure() {
13684        let (client, cache, _rx) = create_execution_client();
13685        let instrument_id = register_test_instrument(&client, &cache);
13686
13687        client
13688            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
13689            .unwrap();
13690
13691        wait_for_spawned_tasks(&client).await;
13692        assert_nonce_reusable(&client.dispatch);
13693    }
13694
13695    #[tokio::test]
13696    async fn update_leverage_unknown_send_outcome_retains_pending_nonce() {
13697        let (client, cache, _rx) = create_execution_client();
13698        let instrument_id = register_test_instrument(&client, &cache);
13699        client.ws_client.drop_next_send_tx_result_for_test().await;
13700
13701        client
13702            .update_leverage(instrument_id, 500, LighterPositionMarginMode::Isolated)
13703            .unwrap();
13704
13705        wait_for_spawned_tasks(&client).await;
13706        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
13707        assert_eq!(
13708            client
13709                .dispatch
13710                .nonce_manager
13711                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
13712            Some(TEST_NEXT_NONCE),
13713        );
13714    }
13715
13716    #[tokio::test]
13717    async fn update_leverage_rejects_zero_margin_fraction() {
13718        let (client, cache, _rx) = create_execution_client();
13719        let instrument_id = register_test_instrument(&client, &cache);
13720
13721        let err = client
13722            .update_leverage(instrument_id, 0, LighterPositionMarginMode::Cross)
13723            .unwrap_err();
13724        assert!(err.to_string().contains("must be in 1..=10_000"));
13725    }
13726
13727    #[tokio::test]
13728    async fn update_leverage_rejects_above_margin_fraction_tick() {
13729        let (client, cache, _rx) = create_execution_client();
13730        let instrument_id = register_test_instrument(&client, &cache);
13731
13732        let err = client
13733            .update_leverage(instrument_id, 10_001, LighterPositionMarginMode::Cross)
13734            .unwrap_err();
13735        assert!(err.to_string().contains("must be in 1..=10_000"));
13736    }
13737
13738    #[tokio::test]
13739    async fn update_leverage_accepts_minimum_margin_fraction() {
13740        // Pin the inclusive lower bound of the venue's `MarginFractionTick`
13741        // range. An exclusive `(1.., ...)` range check would fail this case.
13742        let (client, cache, _rx) = create_execution_client();
13743        let instrument_id = register_test_instrument(&client, &cache);
13744
13745        client
13746            .update_leverage(instrument_id, 1, LighterPositionMarginMode::Cross)
13747            .unwrap();
13748
13749        wait_for_spawned_tasks(&client).await;
13750        assert_nonce_reusable(&client.dispatch);
13751    }
13752
13753    #[tokio::test]
13754    async fn update_leverage_accepts_maximum_margin_fraction() {
13755        // Pin the inclusive upper bound of the venue's `MarginFractionTick`
13756        // range. An exclusive `(..10_000)` range check would fail this case.
13757        let (client, cache, _rx) = create_execution_client();
13758        let instrument_id = register_test_instrument(&client, &cache);
13759
13760        client
13761            .update_leverage(instrument_id, 10_000, LighterPositionMarginMode::Isolated)
13762            .unwrap();
13763
13764        wait_for_spawned_tasks(&client).await;
13765        assert_nonce_reusable(&client.dispatch);
13766    }
13767
13768    async fn wait_for_spawned_tasks(client: &LighterExecutionClient) {
13769        wait_until_async(
13770            || async { client.pending_tasks_all_finished() },
13771            Duration::from_secs(2),
13772        )
13773        .await;
13774    }
13775
13776    fn mark_all_streams_ready(client: &LighterExecutionClient) {
13777        let ready = &client.dispatch.account_streams_ready;
13778        ready.mark_orders();
13779        ready.mark_trades();
13780        ready.mark_positions();
13781        ready.mark_assets();
13782        ready.mark_user_stats();
13783    }
13784
13785    #[tokio::test]
13786    async fn await_account_streams_ready_times_out_when_no_frame_arrives() {
13787        // Drives the timeout branch `connect()` uses to tear the WS down
13788        // when at least one account stream has not delivered a first frame.
13789        let (client, _cache, _rx) = create_execution_client();
13790
13791        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
13792
13793        assert!(
13794            err.to_string().contains("Timeout")
13795                && err.to_string().contains("Lighter account streams"),
13796            "unexpected error message, was {err}",
13797        );
13798    }
13799
13800    #[tokio::test]
13801    async fn await_account_streams_ready_returns_when_all_streams_marked() {
13802        let (client, _cache, _rx) = create_execution_client();
13803        mark_all_streams_ready(&client);
13804
13805        client.await_account_streams_ready(0.05).await.unwrap();
13806    }
13807
13808    #[tokio::test]
13809    async fn await_account_streams_ready_returns_when_streams_arrive_mid_wait() {
13810        // Pins that the Notify-based wait wakes promptly when frames land
13811        // after the wait has started.
13812        let (client, _cache, _rx) = create_execution_client();
13813        let ready = Arc::clone(&client.dispatch.account_streams_ready);
13814
13815        let wait = client.await_account_streams_ready(1.0);
13816
13817        let seed = async move {
13818            tokio::time::sleep(Duration::from_millis(20)).await;
13819            ready.mark_orders();
13820            ready.mark_trades();
13821            ready.mark_positions();
13822            ready.mark_assets();
13823            ready.mark_user_stats();
13824        };
13825
13826        let (result, ()) = tokio::join!(wait, seed);
13827        result.unwrap();
13828    }
13829
13830    #[tokio::test]
13831    async fn await_account_streams_ready_times_out_with_partial_marks() {
13832        // Three out of four streams marked must still time out: strict
13833        // await means every account stream has to deliver before connect
13834        // unblocks.
13835        let (client, _cache, _rx) = create_execution_client();
13836        let ready = &client.dispatch.account_streams_ready;
13837        ready.mark_orders();
13838        ready.mark_trades();
13839        ready.mark_positions();
13840
13841        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
13842        assert!(
13843            err.to_string().contains("assets"),
13844            "pending list should call out the missing stream, was {err}",
13845        );
13846    }
13847
13848    #[tokio::test]
13849    async fn await_account_streams_ready_after_reset_requires_new_marks() {
13850        // Pins the connect-retry contract: marks from a prior session
13851        // must not satisfy a fresh await once `reset()` has cleared the
13852        // gate. A regression that drops the reset() call from connect()
13853        // would let a retried session return immediately with stale flags.
13854        let (client, _cache, _rx) = create_execution_client();
13855        mark_all_streams_ready(&client);
13856        client.await_account_streams_ready(0.05).await.unwrap();
13857
13858        client.dispatch.account_streams_ready.reset();
13859
13860        let err = client.await_account_streams_ready(0.05).await.unwrap_err();
13861        let msg = err.to_string();
13862        assert!(msg.contains("orders"), "pending list missing orders: {msg}");
13863        assert!(msg.contains("trades"), "pending list missing trades: {msg}");
13864        assert!(
13865            msg.contains("positions"),
13866            "pending list missing positions: {msg}",
13867        );
13868        assert!(msg.contains("assets"), "pending list missing assets: {msg}");
13869        assert!(
13870            msg.contains("user_stats"),
13871            "pending list missing user_stats: {msg}",
13872        );
13873    }
13874
13875    fn test_market_order(
13876        factory: &mut OrderFactory,
13877        instrument_id: InstrumentId,
13878        client_order_id: &str,
13879        side: OrderSide,
13880    ) -> OrderAny {
13881        factory.market(
13882            instrument_id,
13883            side,
13884            Quantity::from("0.1000"),
13885            Some(TimeInForce::Ioc),
13886            Some(false),
13887            Some(false),
13888            None,
13889            None,
13890            None,
13891            Some(ClientOrderId::from(client_order_id)),
13892        )
13893    }
13894
13895    fn add_test_quote(
13896        cache: &Rc<RefCell<Cache>>,
13897        instrument_id: InstrumentId,
13898        bid: &str,
13899        ask: &str,
13900    ) {
13901        let quote = QuoteTick::new(
13902            instrument_id,
13903            Price::from(bid),
13904            Price::from(ask),
13905            Quantity::from("1.0000"),
13906            Quantity::from("1.0000"),
13907            UnixNanos::default(),
13908            UnixNanos::default(),
13909        );
13910        cache.borrow_mut().add_quote(quote).unwrap();
13911    }
13912
13913    #[tokio::test]
13914    async fn submit_market_order_without_cached_quote_emits_denied() {
13915        let (client, cache, mut rx) = create_execution_client();
13916        let instrument_id = register_test_instrument(&client, &cache);
13917        let mut factory = test_order_factory();
13918        let order = test_market_order(
13919            &mut factory,
13920            instrument_id,
13921            "O-MARKET-NO-QUOTE",
13922            OrderSide::Buy,
13923        );
13924        cache_order(&cache, order.clone());
13925
13926        let command = SubmitOrder::from_order(
13927            &order,
13928            trader_id(),
13929            Some(client_id()),
13930            None,
13931            UUID4::new(),
13932            UnixNanos::default(),
13933        );
13934        // submit_order returns Err but also emits OrderDenied; consume both.
13935        let _ = client.submit_order(command);
13936
13937        let event = recv_order_event(&mut rx).await;
13938        match event {
13939            OrderEventAny::Denied(event) => {
13940                assert!(
13941                    event.reason.contains("no cached quote"),
13942                    "expected no-cached-quote in reason, was {:?}",
13943                    event.reason,
13944                );
13945            }
13946            event => panic!("expected denied event, was {event:?}"),
13947        }
13948
13949        assert_nonce_reusable(&client.dispatch);
13950    }
13951
13952    #[tokio::test]
13953    async fn submit_market_buy_with_quote_uses_ask_widened_by_slippage() {
13954        let (client, cache, mut rx) = create_execution_client();
13955        let instrument_id = register_test_instrument(&client, &cache);
13956        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
13957
13958        let mut factory = test_order_factory();
13959        let order = test_market_order(
13960            &mut factory,
13961            instrument_id,
13962            "O-MARKET-QUOTED-BUY",
13963            OrderSide::Buy,
13964        );
13965        cache_order(&cache, order.clone());
13966
13967        let command = SubmitOrder::from_order(
13968            &order,
13969            trader_id(),
13970            Some(client_id()),
13971            None,
13972            UUID4::new(),
13973            UnixNanos::default(),
13974        );
13975        let _ = client.submit_order(command);
13976
13977        let submitted = recv_order_event(&mut rx).await;
13978        assert!(
13979            matches!(submitted, OrderEventAny::Submitted(_)),
13980            "expected submitted, was {submitted:?}",
13981        );
13982        let rejected = recv_order_event(&mut rx).await;
13983        match rejected {
13984            OrderEventAny::Rejected(event) => {
13985                assert!(
13986                    event
13987                        .reason
13988                        .contains("Lighter submit_order dispatch failed"),
13989                );
13990            }
13991            event => panic!("expected rejected event, was {event:?}"),
13992        }
13993
13994        assert_nonce_reusable(&client.dispatch);
13995    }
13996
13997    #[tokio::test]
13998    async fn submit_order_with_sub_tick_quantity_emits_denied() {
13999        let (client, cache, mut rx) = create_execution_client();
14000        let instrument_id = register_test_instrument(&client, &cache);
14001        let mut factory = test_order_factory();
14002        // ETH-PERP size_precision=4; quantity 0.00001 truncates to 0 ticks.
14003        let order = factory.limit(
14004            instrument_id,
14005            OrderSide::Buy,
14006            Quantity::from("0.00001"),
14007            Price::from("2361.31"),
14008            Some(TimeInForce::Gtc),
14009            None,
14010            Some(false),
14011            Some(false),
14012            None,
14013            None,
14014            None,
14015            None,
14016            None,
14017            None,
14018            None,
14019            Some(ClientOrderId::from("O-SUB-TICK-QTY")),
14020        );
14021        cache_order(&cache, order.clone());
14022
14023        let command = SubmitOrder::from_order(
14024            &order,
14025            trader_id(),
14026            Some(client_id()),
14027            None,
14028            UUID4::new(),
14029            UnixNanos::default(),
14030        );
14031        let _ = client.submit_order(command);
14032
14033        let event = recv_order_event(&mut rx).await;
14034        match event {
14035            OrderEventAny::Denied(event) => {
14036                assert!(
14037                    event.reason.contains("rounds to 0 ticks"),
14038                    "expected rounds-to-0 in reason, was {:?}",
14039                    event.reason,
14040                );
14041            }
14042            event => panic!("expected denied event, was {event:?}"),
14043        }
14044
14045        assert_nonce_reusable(&client.dispatch);
14046    }
14047
14048    #[tokio::test]
14049    async fn submit_order_below_min_notional_emits_denied() {
14050        let (client, cache, mut rx) = create_execution_client();
14051        let instrument_id = register_test_instrument(&client, &cache);
14052        let mut factory = test_order_factory();
14053        let order = factory.limit(
14054            instrument_id,
14055            OrderSide::Buy,
14056            Quantity::from("0.0010"),
14057            Price::from("2361.31"),
14058            Some(TimeInForce::Gtc),
14059            None,
14060            Some(false),
14061            Some(false),
14062            None,
14063            None,
14064            None,
14065            None,
14066            None,
14067            None,
14068            None,
14069            Some(ClientOrderId::from("O-BELOW-MIN-NOTIONAL")),
14070        );
14071        cache_order(&cache, order.clone());
14072
14073        let command = SubmitOrder::from_order(
14074            &order,
14075            trader_id(),
14076            Some(client_id()),
14077            None,
14078            UUID4::new(),
14079            UnixNanos::default(),
14080        );
14081        let _ = client.submit_order(command);
14082
14083        let event = recv_order_event(&mut rx).await;
14084        match event {
14085            OrderEventAny::Denied(event) => {
14086                assert!(
14087                    event.reason.contains("min_quote_amount"),
14088                    "expected min_quote_amount in reason, was {:?}",
14089                    event.reason,
14090                );
14091            }
14092            event => panic!("expected denied event, was {event:?}"),
14093        }
14094
14095        assert_nonce_reusable(&client.dispatch);
14096    }
14097
14098    #[tokio::test]
14099    async fn submit_stop_market_with_sub_tick_trigger_emits_denied() {
14100        let (client, cache, mut rx) = create_execution_client();
14101        let instrument_id = register_test_instrument(&client, &cache);
14102        let mut factory = test_order_factory();
14103        // ETH-PERP price_precision=2; trigger 0.001 truncates to 0 ticks.
14104        let order = factory.stop_market(
14105            instrument_id,
14106            OrderSide::Buy,
14107            Quantity::from("0.1000"),
14108            Price::from("0.001"),
14109            None,
14110            Some(TimeInForce::Gtc),
14111            None,
14112            Some(false),
14113            Some(false),
14114            None,
14115            None,
14116            None,
14117            None,
14118            None,
14119            None,
14120            Some(ClientOrderId::from("O-STOP-SUB-TICK")),
14121        );
14122        cache_order(&cache, order.clone());
14123
14124        let command = SubmitOrder::from_order(
14125            &order,
14126            trader_id(),
14127            Some(client_id()),
14128            None,
14129            UUID4::new(),
14130            UnixNanos::default(),
14131        );
14132        let _ = client.submit_order(command);
14133
14134        let event = recv_order_event(&mut rx).await;
14135        match event {
14136            OrderEventAny::Denied(event) => {
14137                assert!(
14138                    event.reason.contains("rounds to 0 ticks"),
14139                    "expected rounds-to-0 in reason, was {:?}",
14140                    event.reason,
14141                );
14142            }
14143            event => panic!("expected denied event, was {event:?}"),
14144        }
14145
14146        assert_nonce_reusable(&client.dispatch);
14147    }
14148
14149    #[tokio::test]
14150    async fn submit_stop_market_dispatches_using_trigger_widened_by_slippage() {
14151        let (client, cache, mut rx) = create_execution_client();
14152        let instrument_id = register_test_instrument(&client, &cache);
14153        let mut factory = test_order_factory();
14154        let order = factory.stop_market(
14155            instrument_id,
14156            OrderSide::Sell,
14157            Quantity::from("0.1000"),
14158            Price::from("2300.00"), // trigger
14159            None,
14160            Some(TimeInForce::Gtc),
14161            None,
14162            Some(false),
14163            Some(false),
14164            None,
14165            None,
14166            None,
14167            None,
14168            None,
14169            None,
14170            Some(ClientOrderId::from("O-STOP-MARKET")),
14171        );
14172        cache_order(&cache, order.clone());
14173
14174        let command = SubmitOrder::from_order(
14175            &order,
14176            trader_id(),
14177            Some(client_id()),
14178            None,
14179            UUID4::new(),
14180            UnixNanos::default(),
14181        );
14182        let _ = client.submit_order(command);
14183
14184        let submitted = recv_order_event(&mut rx).await;
14185        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
14186        let rejected = recv_order_event(&mut rx).await;
14187        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
14188        assert_nonce_reusable(&client.dispatch);
14189    }
14190
14191    #[tokio::test]
14192    async fn submit_market_order_respects_per_order_slippage_override() {
14193        // 0-bps override on a valid ask exercises the params path without
14194        // adding any widening.
14195        let (client, cache, mut rx) = create_execution_client();
14196        let instrument_id = register_test_instrument(&client, &cache);
14197        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
14198
14199        let mut factory = test_order_factory();
14200        let order = test_market_order(
14201            &mut factory,
14202            instrument_id,
14203            "O-MARKET-ZERO-SLIP",
14204            OrderSide::Buy,
14205        );
14206        cache_order(&cache, order.clone());
14207
14208        let params: Params =
14209            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 0})).unwrap();
14210        let mut command = SubmitOrder::from_order(
14211            &order,
14212            trader_id(),
14213            Some(client_id()),
14214            None,
14215            UUID4::new(),
14216            UnixNanos::default(),
14217        );
14218        command.params = Some(params);
14219        let _ = client.submit_order(command);
14220
14221        let submitted = recv_order_event(&mut rx).await;
14222        assert!(matches!(submitted, OrderEventAny::Submitted(_)));
14223        let rejected = recv_order_event(&mut rx).await;
14224        assert!(matches!(rejected, OrderEventAny::Rejected(_)));
14225        assert_nonce_reusable(&client.dispatch);
14226    }
14227
14228    #[tokio::test]
14229    async fn resolve_slippage_bps_prefers_params_over_config_default() {
14230        let (client, _cache, _rx) = create_execution_client();
14231        assert_eq!(client.resolve_slippage_bps(None), 50);
14232
14233        let override_params: Params =
14234            serde_json::from_value(serde_json::json!({"market_order_slippage_bps": 100})).unwrap();
14235        assert_eq!(client.resolve_slippage_bps(Some(&override_params)), 100);
14236
14237        let unrelated_params: Params =
14238            serde_json::from_value(serde_json::json!({"other_key": 999})).unwrap();
14239        assert_eq!(client.resolve_slippage_bps(Some(&unrelated_params)), 50);
14240    }
14241
14242    #[tokio::test]
14243    async fn submit_market_sell_with_quote_uses_bid_widened_by_slippage() {
14244        let (client, cache, mut rx) = create_execution_client();
14245        let instrument_id = register_test_instrument(&client, &cache);
14246        add_test_quote(&cache, instrument_id, "2360.00", "2361.00");
14247
14248        let mut factory = test_order_factory();
14249        let order = test_market_order(
14250            &mut factory,
14251            instrument_id,
14252            "O-MARKET-QUOTED-SELL",
14253            OrderSide::Sell,
14254        );
14255        cache_order(&cache, order.clone());
14256
14257        let command = SubmitOrder::from_order(
14258            &order,
14259            trader_id(),
14260            Some(client_id()),
14261            None,
14262            UUID4::new(),
14263            UnixNanos::default(),
14264        );
14265        let _ = client.submit_order(command);
14266
14267        let submitted = recv_order_event(&mut rx).await;
14268        assert!(
14269            matches!(submitted, OrderEventAny::Submitted(_)),
14270            "expected submitted, was {submitted:?}",
14271        );
14272        let rejected = recv_order_event(&mut rx).await;
14273        match rejected {
14274            OrderEventAny::Rejected(event) => {
14275                assert!(
14276                    event
14277                        .reason
14278                        .contains("Lighter submit_order dispatch failed"),
14279                );
14280            }
14281            event => panic!("expected rejected event, was {event:?}"),
14282        }
14283
14284        assert_nonce_reusable(&client.dispatch);
14285    }
14286
14287    #[rstest]
14288    fn integrator_attributes_tag_mainnet_orders() {
14289        assert_eq!(
14290            integrator_attributes(Some(LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX)),
14291            L2TxAttributes {
14292                integrator_account_index: LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX,
14293                ..Default::default()
14294            },
14295        );
14296    }
14297
14298    #[rstest]
14299    fn integrator_attributes_leave_testnet_orders_unattributed() {
14300        assert_eq!(integrator_attributes(None), L2TxAttributes::default());
14301    }
14302
14303    #[rstest]
14304    #[case::lighter_mainnet(
14305        LighterDeployment::Lighter,
14306        LighterEnvironment::Mainnet,
14307        Some(LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX)
14308    )]
14309    #[case::lighter_testnet(LighterDeployment::Lighter, LighterEnvironment::Testnet, None)]
14310    #[case::robinhood_mainnet(LighterDeployment::Robinhood, LighterEnvironment::Mainnet, None)]
14311    #[case::robinhood_testnet(LighterDeployment::Robinhood, LighterEnvironment::Testnet, None)]
14312    fn integrator_account_index_follows_deployment(
14313        #[case] deployment: LighterDeployment,
14314        #[case] environment: LighterEnvironment,
14315        #[case] expected: Option<u64>,
14316    ) {
14317        let mut config = test_config();
14318        config.deployment = deployment;
14319        config.environment = environment;
14320        config.venue = Some(*LIGHTER_VENUE);
14321        let (client, _cache, _rx) = create_execution_client_with_config(config);
14322
14323        assert_eq!(client.integrator_account_index(), expected);
14324    }
14325
14326    #[rstest]
14327    fn robinhood_orders_keep_default_l2_attributes() {
14328        let mut config = test_config();
14329        config.deployment = LighterDeployment::Robinhood;
14330        config.environment = LighterEnvironment::Mainnet;
14331
14332        assert_eq!(
14333            integrator_attributes(deployment::integrator_account_index(
14334                config.deployment,
14335                config.environment,
14336            )),
14337            L2TxAttributes::default(),
14338        );
14339    }
14340
14341    use std::str::FromStr;
14342
14343    use nautilus_live::ExecutionEventEmitter;
14344    use rust_decimal::Decimal;
14345
14346    use crate::{
14347        common::enums::{
14348            LighterOrderKind, LighterOrderSide, LighterOrderStatus, LighterOrderTimeInForce,
14349            LighterTradeType, LighterTriggerStatus,
14350        },
14351        http::models::{LighterOrder, LighterTrade},
14352    };
14353
14354    fn dispatcher_emitter() -> (
14355        ExecutionEventEmitter,
14356        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
14357    ) {
14358        let mut emitter = ExecutionEventEmitter::new(
14359            get_atomic_clock_realtime(),
14360            trader_id(),
14361            account_id(),
14362            AccountType::Margin,
14363            None,
14364        );
14365        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
14366        emitter.set_sender(sender);
14367        (emitter, receiver)
14368    }
14369
14370    /// Test rig that owns a `WsDispatchState`, a process-global instrument
14371    /// cache entry, a `MarketRegistry`, and an emitter wired to a receiver.
14372    /// Used by every dispatcher test to keep the call-site short.
14373    struct DispatcherRig {
14374        dispatch: WsDispatchState,
14375        registry: Arc<MarketRegistry>,
14376        emitter: ExecutionEventEmitter,
14377        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
14378        instrument_id: InstrumentId,
14379        cloid: ClientOrderId,
14380    }
14381
14382    fn dispatcher_rig(cloid_suffix: &str) -> DispatcherRig {
14383        let registry = Arc::new(MarketRegistry::new());
14384        // All dispatcher tests share the same instrument (ETH-PERP) so
14385        // `LIGHTER_INSTRUMENT_CACHE` only ever holds one entry; per-test
14386        // isolation comes from the per-rig `WsDispatchState` and the
14387        // unique cloid built from `cloid_suffix`.
14388        let instrument_id = registry.insert(TEST_MARKET_INDEX, "ETH", LighterProductType::Perp);
14389        let instrument = InstrumentAny::CryptoPerpetual(
14390            CryptoPerpetual::builder()
14391                .instrument_id(instrument_id)
14392                .raw_symbol(Symbol::new("ETH-PERP"))
14393                .base_currency(Currency::from("ETH"))
14394                .quote_currency(Currency::from("USDC"))
14395                .settlement_currency(Currency::from("USDC"))
14396                .is_inverse(false)
14397                .price_precision(2)
14398                .size_precision(4)
14399                .price_increment(Price::from("0.01"))
14400                .size_increment(Quantity::from("0.0001"))
14401                .min_notional(Money::from("10.000000 USDC"))
14402                .ts_event(UnixNanos::default())
14403                .ts_init(UnixNanos::default())
14404                .build()
14405                .unwrap(),
14406        );
14407        LIGHTER_INSTRUMENT_CACHE.insert(instrument_id, instrument);
14408        let (emitter, rx) = dispatcher_emitter();
14409
14410        DispatcherRig {
14411            dispatch: WsDispatchState::new(),
14412            registry,
14413            emitter,
14414            rx,
14415            instrument_id,
14416            cloid: ClientOrderId::new(format!("CLOID-{cloid_suffix}")),
14417        }
14418    }
14419
14420    fn register_identity(rig: &DispatcherRig) {
14421        register_identity_for(rig, OrderType::Limit);
14422    }
14423
14424    fn register_identity_for(rig: &DispatcherRig, order_type: OrderType) {
14425        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
14426        let client_order_index = rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
14427        rig.dispatch.register_order_identity(
14428            rig.cloid,
14429            OrderIdentity::new(
14430                rig.instrument_id,
14431                strategy_id(),
14432                OrderSide::Buy,
14433                order_type,
14434                client_order_index,
14435            ),
14436        );
14437        rig.dispatch
14438            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
14439    }
14440
14441    fn register_unowned_identity(rig: &DispatcherRig) {
14442        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
14443        let client_order_index = rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
14444        rig.dispatch.register_order_identity(
14445            rig.cloid,
14446            OrderIdentity::new(
14447                rig.instrument_id,
14448                strategy_id(),
14449                OrderSide::Buy,
14450                OrderType::Limit,
14451                client_order_index,
14452            ),
14453        );
14454    }
14455
14456    fn dispatcher_test_order(rig: &DispatcherRig, status: LighterOrderStatus) -> LighterOrder {
14457        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
14458        rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
14459
14460        LighterOrder {
14461            order_index: 281_476_929_510_110,
14462            client_order_index: derived,
14463            order_id: "281476929510110".to_string(),
14464            client_order_id: derived.to_string(),
14465            market_index: TEST_MARKET_INDEX,
14466            owner_account_index: TEST_ACCOUNT_INDEX_I64,
14467            initial_base_amount: Decimal::from_str("0.0050").unwrap(),
14468            price: Decimal::from_str("2352.74").unwrap(),
14469            nonce: TEST_ORDER_NONCE,
14470            remaining_base_amount: Decimal::from_str("0.0050").unwrap(),
14471            is_ask: false,
14472            base_size: 50,
14473            base_price: 235_274,
14474            filled_base_amount: Decimal::ZERO,
14475            filled_quote_amount: Decimal::ZERO,
14476            side: Some(LighterOrderSide::Buy),
14477            order_type: LighterOrderKind::Limit,
14478            time_in_force: LighterOrderTimeInForce::GoodTillTime,
14479            reduce_only: false,
14480            trigger_price: Decimal::ZERO,
14481            order_expiry: 1_780_360_584_479,
14482            status,
14483            trigger_status: LighterTriggerStatus::Na,
14484            trigger_time: 0,
14485            parent_order_index: 0,
14486            parent_order_id: "0".to_string(),
14487            to_trigger_order_id_0: "0".to_string(),
14488            to_trigger_order_id_1: "0".to_string(),
14489            to_cancel_order_id_0: "0".to_string(),
14490            integrator_fee_collector_index: "0".to_string(),
14491            integrator_taker_fee: Decimal::ZERO,
14492            integrator_maker_fee: Decimal::ZERO,
14493            block_height: 227_535_532,
14494            timestamp: 1_777_941_383_576,
14495            created_at: 1_777_941_383_576,
14496            updated_at: 1_777_941_383_900,
14497            transaction_time: 1_777_941_383_576_735,
14498        }
14499    }
14500
14501    fn dispatcher_test_trade(rig: &DispatcherRig, user_is_bidder: bool) -> LighterTrade {
14502        let derived = rig.dispatch.derive_client_order_index(&rig.cloid);
14503        rig.dispatch.register_cloid(derived, rig.cloid).unwrap();
14504
14505        LighterTrade {
14506            trade_id: 19_209_006_902,
14507            trade_id_str: Some("19209006902".to_string()),
14508            tx_hash: "000000128b1ee814".to_string(),
14509            trade_type: LighterTradeType::Trade,
14510            market_id: TEST_MARKET_INDEX,
14511            size: Decimal::from_str("0.1336").unwrap(),
14512            price: Decimal::from_str("2352.73").unwrap(),
14513            usd_amount: Decimal::from_str("314.324728").unwrap(),
14514            ask_id: 281_476_929_510_102,
14515            ask_id_str: Some("281476929510102".to_string()),
14516            bid_id: 562_947_905_631_053,
14517            bid_id_str: Some("562947905631053".to_string()),
14518            ask_client_id: if user_is_bidder { 0 } else { derived },
14519            ask_client_id_str: Some(if user_is_bidder {
14520                "0".to_string()
14521            } else {
14522                derived.to_string()
14523            }),
14524            bid_client_id: if user_is_bidder { derived } else { 0 },
14525            bid_client_id_str: Some(if user_is_bidder {
14526                derived.to_string()
14527            } else {
14528                "0".to_string()
14529            }),
14530            ask_account_id: if user_is_bidder {
14531                91_249
14532            } else {
14533                TEST_ACCOUNT_INDEX_I64
14534            },
14535            bid_account_id: if user_is_bidder {
14536                TEST_ACCOUNT_INDEX_I64
14537            } else {
14538                91_249
14539            },
14540            is_maker_ask: false,
14541            block_height: 227_535_535,
14542            timestamp: 1_777_941_384_181,
14543            taker_fee: Some(196),
14544            taker_position_size_before: None,
14545            taker_entry_quote_before: None,
14546            taker_initial_margin_fraction_before: None,
14547            taker_position_sign_changed: None,
14548            maker_fee: Some(28),
14549            maker_position_size_before: None,
14550            maker_entry_quote_before: None,
14551            maker_initial_margin_fraction_before: None,
14552            maker_position_sign_changed: None,
14553            transaction_time: 1_777_941_384_181_586,
14554            ask_account_pnl: None,
14555            bid_account_pnl: None,
14556        }
14557    }
14558
14559    /// Drain all pending events from the rig's receiver. Useful when a
14560    /// test wants to assert what landed without timing-sensitive
14561    /// `recv_order_event` waits.
14562    fn drain_events(
14563        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
14564    ) -> Vec<ExecutionEvent> {
14565        let mut events = Vec::new();
14566        while let Ok(event) = rx.try_recv() {
14567            events.push(event);
14568        }
14569
14570        events
14571    }
14572
14573    #[rstest]
14574    fn dispatch_lighter_order_tracked_emits_accepted_then_silent_repeat() {
14575        let mut rig = dispatcher_rig("1");
14576        register_identity(&rig);
14577        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
14578
14579        dispatch_lighter_order(
14580            &order,
14581            &rig.dispatch,
14582            &rig.emitter,
14583            &rig.registry,
14584            account_id(),
14585            trader_id(),
14586            UnixNanos::from(1),
14587        );
14588        dispatch_lighter_order(
14589            &order,
14590            &rig.dispatch,
14591            &rig.emitter,
14592            &rig.registry,
14593            account_id(),
14594            trader_id(),
14595            UnixNanos::from(2),
14596        );
14597
14598        let events = drain_events(&mut rig.rx);
14599        assert_eq!(
14600            events.len(),
14601            1,
14602            "exactly one event expected, was {events:?}",
14603        );
14604
14605        match &events[0] {
14606            ExecutionEvent::Order(OrderEventAny::Accepted(e)) => {
14607                assert_eq!(e.client_order_id, rig.cloid);
14608                assert_eq!(e.venue_order_id.to_string(), "281476929510110");
14609            }
14610            other => panic!("expected Accepted, was {other:?}"),
14611        }
14612
14613        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
14614        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_some());
14615    }
14616
14617    #[rstest]
14618    fn dispatch_lighter_order_tracked_emits_updated_on_shape_change() {
14619        let mut rig = dispatcher_rig("2");
14620        register_identity(&rig);
14621        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
14622
14623        dispatch_lighter_order(
14624            &order,
14625            &rig.dispatch,
14626            &rig.emitter,
14627            &rig.registry,
14628            account_id(),
14629            trader_id(),
14630            UnixNanos::from(1),
14631        );
14632        assert_eq!(drain_events(&mut rig.rx).len(), 1);
14633
14634        let mut modified = order;
14635        modified.price = Decimal::from_str("2400.00").unwrap();
14636
14637        dispatch_lighter_order(
14638            &modified,
14639            &rig.dispatch,
14640            &rig.emitter,
14641            &rig.registry,
14642            account_id(),
14643            trader_id(),
14644            UnixNanos::from(2),
14645        );
14646
14647        let events = drain_events(&mut rig.rx);
14648        assert_eq!(
14649            events.len(),
14650            1,
14651            "expected one Updated event, was {events:?}",
14652        );
14653
14654        match &events[0] {
14655            ExecutionEvent::Order(OrderEventAny::Updated(e)) => {
14656                assert_eq!(e.client_order_id, rig.cloid);
14657                assert_eq!(e.price, Some(Price::from("2400.00")));
14658            }
14659            other => panic!("expected Updated, was {other:?}"),
14660        }
14661
14662        let snapshot = rig.dispatch.snapshot_for(&rig.cloid).expect("snapshot");
14663        assert_eq!(snapshot.price, Some(Price::from("2400.00")));
14664    }
14665
14666    #[rstest]
14667    #[case(LighterOrderStatus::Pending)]
14668    #[case(LighterOrderStatus::Open)]
14669    fn dispatch_lighter_order_defers_live_frame_while_cancel_pending(
14670        #[case] status: LighterOrderStatus,
14671    ) {
14672        let mut rig = dispatcher_rig(match status {
14673            LighterOrderStatus::Pending => "24-PENDING",
14674            LighterOrderStatus::Open => "24-OPEN",
14675            other => panic!("unexpected case {other:?}"),
14676        });
14677
14678        register_identity(&rig);
14679        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
14680
14681        dispatch_lighter_order(
14682            &order,
14683            &rig.dispatch,
14684            &rig.emitter,
14685            &rig.registry,
14686            account_id(),
14687            trader_id(),
14688            UnixNanos::from(1),
14689        );
14690        assert_eq!(drain_events(&mut rig.rx).len(), 1);
14691
14692        rig.dispatch
14693            .set_pending_order_action(rig.cloid, PendingOrderAction::Cancel);
14694        let mut late = order;
14695        late.status = status;
14696        late.initial_base_amount = Decimal::from_str("0.0075").unwrap();
14697        late.price = Decimal::from_str("2400.00").unwrap();
14698
14699        dispatch_lighter_order(
14700            &late,
14701            &rig.dispatch,
14702            &rig.emitter,
14703            &rig.registry,
14704            account_id(),
14705            trader_id(),
14706            UnixNanos::from(2),
14707        );
14708
14709        let events = drain_events(&mut rig.rx);
14710        assert_eq!(events.len(), 0, "late frame must be silent: {events:?}");
14711        assert_eq!(
14712            rig.dispatch.pending_order_action(&rig.cloid),
14713            Some(PendingOrderAction::Cancel),
14714        );
14715        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
14716        assert!(!rig.dispatch.triggered_was_emitted(&rig.cloid));
14717        assert_eq!(
14718            rig.dispatch.lookup_venue_order_id(&rig.cloid),
14719            Some(VenueOrderId::new("281476929510110")),
14720        );
14721        assert!(rig.dispatch.order_identities.contains_key(&rig.cloid));
14722        assert_eq!(
14723            rig.dispatch.snapshot_for(&rig.cloid),
14724            Some(crate::websocket::dispatch::OrderShapeSnapshot {
14725                quantity: Quantity::from("0.0050"),
14726                price: Some(Price::from("2352.74")),
14727                trigger_price: None,
14728            }),
14729        );
14730    }
14731
14732    #[rstest]
14733    #[case::standalone(false)]
14734    #[case::native_child(true)]
14735    fn dispatch_lighter_order_emits_updated_then_triggered_for_one_frame(
14736        #[case] native_child: bool,
14737    ) {
14738        let mut rig = dispatcher_rig("25");
14739
14740        if native_child {
14741            let child = OrderTestBuilder::new(OrderType::StopLimit)
14742                .trader_id(trader_id())
14743                .strategy_id(strategy_id())
14744                .instrument_id(rig.instrument_id)
14745                .client_order_id(rig.cloid)
14746                .side(OrderSide::Buy)
14747                .quantity(Quantity::from("0.0050"))
14748                .price(Price::from("2352.74"))
14749                .trigger_price(Price::from("2300.00"))
14750                .parent_order_id(ClientOrderId::new("TRIGGER-PARENT"))
14751                .build();
14752            rig.dispatch.register_create_identity(&child).unwrap();
14753            rig.dispatch
14754                .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
14755        } else {
14756            register_identity_for(&rig, OrderType::StopLimit);
14757        }
14758
14759        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
14760        order.order_type = LighterOrderKind::StopLossLimit;
14761        order.trigger_price = Decimal::from_str("2300.00").unwrap();
14762
14763        dispatch_lighter_order(
14764            &order,
14765            &rig.dispatch,
14766            &rig.emitter,
14767            &rig.registry,
14768            account_id(),
14769            trader_id(),
14770            UnixNanos::from(1),
14771        );
14772        let accepted = drain_events(&mut rig.rx);
14773        assert_eq!(accepted.len(), 1, "expected one acceptance: {accepted:?}");
14774        assert!(matches!(
14775            accepted[0],
14776            ExecutionEvent::Order(OrderEventAny::Accepted(_)),
14777        ));
14778
14779        let command = ModifyOrder::new(
14780            trader_id(),
14781            Some(client_id()),
14782            strategy_id(),
14783            rig.instrument_id,
14784            rig.cloid,
14785            Some(VenueOrderId::new("281476929510110")),
14786            Some(Quantity::from("0.0075")),
14787            Some(Price::from("2400.00")),
14788            Some(Price::from("2390.00")),
14789            UUID4::new(),
14790            UnixNanos::from(2),
14791            None,
14792            None,
14793        );
14794        rig.dispatch.set_pending_modify_action(&command);
14795        rig.dispatch.set_pending_modify_shape(
14796            &command,
14797            OrderShapeSnapshot {
14798                quantity: command.quantity.unwrap(),
14799                price: command.price,
14800                trigger_price: command.trigger_price,
14801            },
14802        );
14803
14804        rig.dispatch
14805            .set_pending_modify_nonce(&rig.cloid, command.command_id, 0, 29);
14806        let mut triggered = order;
14807        triggered.status = LighterOrderStatus::Open;
14808        triggered.trigger_status = LighterTriggerStatus::Ready;
14809        triggered.initial_base_amount = Decimal::from_str("0.0075").unwrap();
14810        triggered.price = Decimal::from_str("2400.00").unwrap();
14811        triggered.trigger_price = Decimal::from_str("2390.00").unwrap();
14812
14813        dispatch_lighter_order(
14814            &triggered,
14815            &rig.dispatch,
14816            &rig.emitter,
14817            &rig.registry,
14818            account_id(),
14819            trader_id(),
14820            UnixNanos::from(2),
14821        );
14822
14823        assert!(drain_events(&mut rig.rx).is_empty());
14824        let (client, _, _) = create_execution_client();
14825        let mut context = acked_probe_context(&client);
14826        context.dispatch = rig.dispatch.clone();
14827        context.registry = rig.registry.clone();
14828        context.emitter = rig.emitter.clone();
14829
14830        let pending = PendingSendTx {
14831            batch_id: None,
14832            connection_epoch: 0,
14833            kind: PendingSendTxKind::Modify {
14834                strategy_id: strategy_id(),
14835                instrument_id: rig.instrument_id,
14836                client_order_id: rig.cloid,
14837                venue_order_id: command.venue_order_id,
14838            },
14839            submitted_at: UnixNanos::from(2),
14840            nonce: 29,
14841            api_key_index: TEST_API_KEY_INDEX,
14842            tx_hash: "confirmed-trigger".to_string(),
14843        };
14844
14845        confirm_modify_order(&pending, &context, &triggered).unwrap();
14846        let events = drain_events(&mut rig.rx);
14847        assert_eq!(
14848            events.len(),
14849            2,
14850            "expected Updated then Triggered: {events:?}",
14851        );
14852
14853        match &events[0] {
14854            ExecutionEvent::Order(OrderEventAny::Updated(event)) => {
14855                assert_eq!(event.trader_id, trader_id());
14856                assert_eq!(event.strategy_id, strategy_id());
14857                assert_eq!(event.instrument_id, rig.instrument_id);
14858                assert_eq!(event.client_order_id, rig.cloid);
14859                assert_eq!(
14860                    event.venue_order_id,
14861                    Some(VenueOrderId::new("281476929510110")),
14862                );
14863                assert_eq!(event.account_id, Some(account_id()));
14864                assert_eq!(event.quantity, Quantity::from("0.0075"));
14865                assert_eq!(event.price, Some(Price::from("2400.00")));
14866                assert_eq!(event.trigger_price, Some(Price::from("2390.00")));
14867            }
14868            other => panic!("first event should be Updated, was {other:?}"),
14869        }
14870
14871        match &events[1] {
14872            ExecutionEvent::Order(OrderEventAny::Triggered(event)) => {
14873                assert_eq!(event.trader_id, trader_id());
14874                assert_eq!(event.strategy_id, strategy_id());
14875                assert_eq!(event.instrument_id, rig.instrument_id);
14876                assert_eq!(event.client_order_id, rig.cloid);
14877                assert_eq!(
14878                    event.venue_order_id,
14879                    Some(VenueOrderId::new("281476929510110")),
14880                );
14881                assert_eq!(event.account_id, Some(account_id()));
14882            }
14883            other => panic!("second event should be Triggered, was {other:?}"),
14884        }
14885
14886        assert_eq!(rig.dispatch.pending_order_action(&rig.cloid), None);
14887        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
14888        assert!(rig.dispatch.triggered_was_emitted(&rig.cloid));
14889        assert_eq!(
14890            rig.dispatch.snapshot_for(&rig.cloid),
14891            Some(crate::websocket::dispatch::OrderShapeSnapshot {
14892                quantity: Quantity::from("0.0075"),
14893                price: Some(Price::from("2400.00")),
14894                trigger_price: Some(Price::from("2390.00")),
14895            }),
14896        );
14897
14898        dispatch_lighter_order(
14899            &triggered,
14900            &rig.dispatch,
14901            &rig.emitter,
14902            &rig.registry,
14903            account_id(),
14904            trader_id(),
14905            UnixNanos::from(3),
14906        );
14907        assert!(
14908            drain_events(&mut rig.rx).is_empty(),
14909            "replayed frame must be silent",
14910        );
14911    }
14912
14913    #[rstest]
14914    #[case(LighterTriggerStatus::Ready)]
14915    #[case(LighterTriggerStatus::Na)]
14916    fn dispatch_lighter_order_defers_trigger_until_modify_resolves(
14917        #[case] trigger_status: LighterTriggerStatus,
14918    ) {
14919        let mut rig = dispatcher_rig("26");
14920        register_identity_for(&rig, OrderType::StopLimit);
14921        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
14922        order.order_type = LighterOrderKind::StopLossLimit;
14923        order.trigger_price = Decimal::from_str("2300.00").unwrap();
14924
14925        dispatch_lighter_order(
14926            &order,
14927            &rig.dispatch,
14928            &rig.emitter,
14929            &rig.registry,
14930            account_id(),
14931            trader_id(),
14932            UnixNanos::from(1),
14933        );
14934        assert_eq!(drain_events(&mut rig.rx).len(), 1);
14935
14936        let command = ModifyOrder::new(
14937            trader_id(),
14938            Some(client_id()),
14939            strategy_id(),
14940            rig.instrument_id,
14941            rig.cloid,
14942            Some(VenueOrderId::new("281476929510110")),
14943            Some(Quantity::from("0.0075")),
14944            Some(Price::from("2400.00")),
14945            Some(Price::from("2390.00")),
14946            UUID4::new(),
14947            UnixNanos::from(2),
14948            None,
14949            None,
14950        );
14951        rig.dispatch.set_pending_modify_action(&command);
14952        rig.dispatch.set_pending_modify_shape(
14953            &command,
14954            OrderShapeSnapshot {
14955                quantity: command.quantity.unwrap(),
14956                price: command.price,
14957                trigger_price: command.trigger_price,
14958            },
14959        );
14960
14961        order.status = LighterOrderStatus::Open;
14962        order.trigger_status = trigger_status;
14963        dispatch_lighter_order(
14964            &order,
14965            &rig.dispatch,
14966            &rig.emitter,
14967            &rig.registry,
14968            account_id(),
14969            trader_id(),
14970            UnixNanos::from(2),
14971        );
14972
14973        assert!(drain_events(&mut rig.rx).is_empty());
14974        assert_eq!(
14975            rig.dispatch.pending_order_action(&rig.cloid),
14976            Some(PendingOrderAction::Modify),
14977        );
14978        assert!(!rig.dispatch.triggered_was_emitted(&rig.cloid));
14979
14980        assert!(
14981            rig.dispatch
14982                .clear_pending_modify_if_command(&rig.cloid, command.command_id)
14983        );
14984        dispatch_lighter_order(
14985            &order,
14986            &rig.dispatch,
14987            &rig.emitter,
14988            &rig.registry,
14989            account_id(),
14990            trader_id(),
14991            UnixNanos::from(3),
14992        );
14993
14994        let events = drain_events(&mut rig.rx);
14995        assert_eq!(events.len(), 1, "expected deferred trigger: {events:?}");
14996
14997        match &events[0] {
14998            ExecutionEvent::Order(OrderEventAny::Triggered(event)) => {
14999                assert_eq!(event.client_order_id, rig.cloid);
15000                assert_eq!(
15001                    event.venue_order_id,
15002                    Some(VenueOrderId::new("281476929510110")),
15003                );
15004                assert_eq!(event.account_id, Some(account_id()));
15005            }
15006            other => panic!("expected Triggered, was {other:?}"),
15007        }
15008
15009        assert!(rig.dispatch.triggered_was_emitted(&rig.cloid));
15010    }
15011
15012    #[rstest]
15013    fn dispatch_lighter_order_pending_emits_accepted_then_updated() {
15014        let mut rig = dispatcher_rig("23");
15015        register_identity(&rig);
15016        let order = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
15017
15018        dispatch_lighter_order(
15019            &order,
15020            &rig.dispatch,
15021            &rig.emitter,
15022            &rig.registry,
15023            account_id(),
15024            trader_id(),
15025            UnixNanos::from(1),
15026        );
15027        let accepted = drain_events(&mut rig.rx);
15028
15029        let mut modified = order;
15030        modified.price = Decimal::from_str("2400.00").unwrap();
15031        dispatch_lighter_order(
15032            &modified,
15033            &rig.dispatch,
15034            &rig.emitter,
15035            &rig.registry,
15036            account_id(),
15037            trader_id(),
15038            UnixNanos::from(2),
15039        );
15040        let updated = drain_events(&mut rig.rx);
15041
15042        assert_eq!(
15043            accepted.len(),
15044            1,
15045            "expected one Accepted event: {accepted:?}"
15046        );
15047        assert!(matches!(
15048            accepted[0],
15049            ExecutionEvent::Order(OrderEventAny::Accepted(_))
15050        ));
15051        assert_eq!(updated.len(), 1, "expected one Updated event: {updated:?}");
15052        assert!(matches!(
15053            updated[0],
15054            ExecutionEvent::Order(OrderEventAny::Updated(_))
15055        ));
15056        assert_eq!(
15057            rig.dispatch
15058                .snapshot_for(&rig.cloid)
15059                .expect("snapshot")
15060                .price,
15061            Some(Price::from("2400.00")),
15062        );
15063    }
15064
15065    #[rstest]
15066    fn dispatch_lighter_order_untracked_emits_report() {
15067        let mut rig = dispatcher_rig("3");
15068        // No identity registered: this is an external order.
15069        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
15070        order.client_order_id = "external-1".to_string();
15071        order.client_order_index = 0;
15072
15073        dispatch_lighter_order(
15074            &order,
15075            &rig.dispatch,
15076            &rig.emitter,
15077            &rig.registry,
15078            account_id(),
15079            trader_id(),
15080            UnixNanos::from(1),
15081        );
15082
15083        let events = drain_events(&mut rig.rx);
15084        assert_eq!(events.len(), 1);
15085
15086        match &events[0] {
15087            ExecutionEvent::Report(report) => match report {
15088                EngineExecutionReport::Order(r) => {
15089                    assert_eq!(r.venue_order_id.to_string(), "281476929510110");
15090                }
15091                other => panic!("expected order report, was {other:?}"),
15092            },
15093            other => panic!("expected report, was {other:?}"),
15094        }
15095
15096        assert!(
15097            !rig.dispatch
15098                .accepted_was_emitted(&ClientOrderId::new("external-1"))
15099        );
15100    }
15101
15102    #[rstest]
15103    fn same_index_external_order_waits_for_create_submission_ownership() {
15104        let mut rig = dispatcher_rig("EPOCH-ORDER");
15105        register_unowned_identity(&rig);
15106        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
15107        let venue_order_id = VenueOrderId::new(order.order_id.as_str());
15108
15109        dispatch_lighter_order(
15110            &order,
15111            &rig.dispatch,
15112            &rig.emitter,
15113            &rig.registry,
15114            account_id(),
15115            trader_id(),
15116            UnixNanos::from(1),
15117        );
15118
15119        let external = drain_events(&mut rig.rx);
15120        assert_eq!(external.len(), 1);
15121
15122        match &external[0] {
15123            ExecutionEvent::Report(EngineExecutionReport::Order(report)) => {
15124                assert_eq!(report.venue_order_id, venue_order_id);
15125                assert_eq!(
15126                    report.client_order_id,
15127                    Some(ClientOrderId::new(venue_order_id.as_str())),
15128                );
15129            }
15130            other => panic!("expected one external order report, was {other:?}"),
15131        }
15132
15133        assert!(!rig.dispatch.accepted_was_emitted(&rig.cloid));
15134        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
15135
15136        rig.dispatch
15137            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
15138        dispatch_lighter_order(
15139            &order,
15140            &rig.dispatch,
15141            &rig.emitter,
15142            &rig.registry,
15143            account_id(),
15144            trader_id(),
15145            UnixNanos::from(2),
15146        );
15147
15148        let replayed_external = drain_events(&mut rig.rx);
15149        assert_eq!(replayed_external.len(), 1);
15150
15151        match &replayed_external[0] {
15152            ExecutionEvent::Report(EngineExecutionReport::Order(report)) => {
15153                assert_eq!(report.venue_order_id, venue_order_id);
15154                assert_eq!(
15155                    report.client_order_id,
15156                    Some(ClientOrderId::new(venue_order_id.as_str())),
15157                );
15158            }
15159            other => panic!("expected replayed external order report, was {other:?}"),
15160        }
15161
15162        assert!(!rig.dispatch.accepted_was_emitted(&rig.cloid));
15163        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
15164
15165        let mut local_order = order.clone();
15166        local_order.order_id = "281476929510111".to_string();
15167        let local_venue_order_id = VenueOrderId::new(local_order.order_id.as_str());
15168        dispatch_lighter_order(
15169            &local_order,
15170            &rig.dispatch,
15171            &rig.emitter,
15172            &rig.registry,
15173            account_id(),
15174            trader_id(),
15175            UnixNanos::from(3),
15176        );
15177
15178        let local = drain_events(&mut rig.rx);
15179        assert_eq!(local.len(), 1);
15180
15181        match &local[0] {
15182            ExecutionEvent::Order(OrderEventAny::Accepted(event)) => {
15183                assert_eq!(event.client_order_id, rig.cloid);
15184                assert_eq!(event.venue_order_id, local_venue_order_id);
15185                assert_eq!(event.account_id, account_id());
15186            }
15187            other => panic!("expected one local OrderAccepted, was {other:?}"),
15188        }
15189
15190        assert_eq!(
15191            rig.dispatch
15192                .venue_id_map
15193                .get(&rig.cloid)
15194                .map(|entry| *entry),
15195            Some(local_venue_order_id),
15196        );
15197        assert_eq!(
15198            rig.dispatch
15199                .resolve_live_order_cloid(order.client_order_id.as_str(), local_venue_order_id,),
15200            Some(rig.cloid),
15201            "a bound identity remains stable across replay epochs",
15202        );
15203    }
15204
15205    #[rstest]
15206    #[case::order_first(false)]
15207    #[case::trade_first(true)]
15208    fn quantity_amend_immediate_fill_orders_update_before_fill(#[case] trade_first: bool) {
15209        let mut rig = dispatcher_rig("AMEND-FILL");
15210        let mut cached = OrderTestBuilder::new(OrderType::Limit)
15211            .trader_id(trader_id())
15212            .strategy_id(strategy_id())
15213            .instrument_id(rig.instrument_id)
15214            .client_order_id(rig.cloid)
15215            .side(OrderSide::Buy)
15216            .quantity(Quantity::from("0.0050"))
15217            .price(Price::from("2352.74"))
15218            .build();
15219        rig.dispatch.register_create_identity(&cached).unwrap();
15220        rig.dispatch
15221            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
15222        let mut frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
15223        dispatch_lighter_order(
15224            &frame,
15225            &rig.dispatch,
15226            &rig.emitter,
15227            &rig.registry,
15228            account_id(),
15229            trader_id(),
15230            UnixNanos::from(1),
15231        );
15232
15233        for event in drain_events(&mut rig.rx) {
15234            let ExecutionEvent::Order(event) = event else {
15235                panic!("expected order event");
15236            };
15237
15238            cached.apply(event).unwrap();
15239        }
15240
15241        let command = ModifyOrder::new(
15242            trader_id(),
15243            Some(client_id()),
15244            strategy_id(),
15245            rig.instrument_id,
15246            rig.cloid,
15247            cached.venue_order_id(),
15248            Some(Quantity::from("0.0100")),
15249            Some(Price::from("2400.00")),
15250            None,
15251            UUID4::new(),
15252            UnixNanos::from(2),
15253            None,
15254            None,
15255        );
15256        rig.dispatch.set_pending_modify_action(&command);
15257        rig.dispatch.set_pending_modify_shape(
15258            &command,
15259            OrderShapeSnapshot {
15260                quantity: Quantity::from("0.0100"),
15261                price: Some(Price::from("2400.00")),
15262                trigger_price: None,
15263            },
15264        );
15265
15266        rig.dispatch
15267            .set_pending_modify_nonce(&rig.cloid, command.command_id, 0, 19);
15268
15269        let pending = PendingSendTx {
15270            batch_id: None,
15271            connection_epoch: 0,
15272            kind: PendingSendTxKind::Modify {
15273                strategy_id: strategy_id(),
15274                instrument_id: rig.instrument_id,
15275                client_order_id: rig.cloid,
15276                venue_order_id: cached.venue_order_id(),
15277            },
15278            submitted_at: UnixNanos::from(2),
15279            nonce: 19,
15280            api_key_index: TEST_API_KEY_INDEX,
15281            tx_hash: "confirmed-amend".to_string(),
15282        };
15283
15284        let mut trade = dispatcher_test_trade(&rig, true);
15285        trade.bid_id = frame.order_index;
15286        trade.bid_id_str = Some(frame.order_id.clone());
15287        trade.size = Decimal::from_str("0.0100").unwrap();
15288        trade.usd_amount = trade.size * trade.price;
15289
15290        if trade_first {
15291            dispatch_lighter_trade(
15292                &trade,
15293                &rig.dispatch,
15294                &rig.emitter,
15295                &rig.registry,
15296                account_id(),
15297                trader_id(),
15298                Some(TEST_ACCOUNT_INDEX_I64),
15299                UnixNanos::from(3),
15300            );
15301            assert!(
15302                drain_events(&mut rig.rx).is_empty(),
15303                "unconfirmed amend fill must wait"
15304            );
15305            assert!(
15306                !rig.dispatch
15307                    .seen_trade_ids
15308                    .contains(&parse_lighter_trade_id(&trade).unwrap())
15309            );
15310        }
15311
15312        frame.status = LighterOrderStatus::Filled;
15313        frame.initial_base_amount = trade.size;
15314        frame.remaining_base_amount = Decimal::ZERO;
15315        frame.filled_base_amount = trade.size;
15316        frame.price = Decimal::from_str("2400.00").unwrap();
15317        dispatch_lighter_order(
15318            &frame,
15319            &rig.dispatch,
15320            &rig.emitter,
15321            &rig.registry,
15322            account_id(),
15323            trader_id(),
15324            UnixNanos::from(4),
15325        );
15326
15327        if !trade_first {
15328            dispatch_lighter_trade(
15329                &trade,
15330                &rig.dispatch,
15331                &rig.emitter,
15332                &rig.registry,
15333                account_id(),
15334                trader_id(),
15335                Some(TEST_ACCOUNT_INDEX_I64),
15336                UnixNanos::from(5),
15337            );
15338        }
15339
15340        assert!(
15341            drain_events(&mut rig.rx).is_empty(),
15342            "order echo must wait for transaction confirmation"
15343        );
15344        let (client, _, _) = create_execution_client();
15345        let mut context = acked_probe_context(&client);
15346        context.dispatch = rig.dispatch.clone();
15347        context.registry = rig.registry.clone();
15348        context.emitter = rig.emitter.clone();
15349        confirm_modify_order(&pending, &context, &frame).unwrap();
15350        let events = drain_events(&mut rig.rx);
15351        assert_eq!(events.len(), 2, "expected update then fill, was {events:?}");
15352
15353        let ExecutionEvent::Order(OrderEventAny::Updated(update)) = &events[0] else {
15354            panic!("expected update before fill, was {:?}", events[0]);
15355        };
15356
15357        assert_eq!(update.client_order_id, rig.cloid);
15358        assert_eq!(update.quantity, Quantity::from("0.0100"));
15359        assert_eq!(update.price, Some(Price::from("2400.00")));
15360        assert_eq!(update.trigger_price, None);
15361
15362        let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = &events[1] else {
15363            panic!("expected real fill, was {:?}", events[1]);
15364        };
15365
15366        assert_eq!(fill.trade_id, parse_lighter_trade_id(&trade).unwrap());
15367        assert_eq!(fill.last_qty, Quantity::from("0.0100"));
15368        assert_eq!(fill.last_px, Price::from("2352.73"));
15369
15370        for event in events {
15371            let ExecutionEvent::Order(event) = event else {
15372                panic!("expected order event");
15373            };
15374
15375            cached.apply(event).unwrap();
15376        }
15377
15378        assert_eq!(cached.quantity(), Quantity::from("0.0100"));
15379        assert_eq!(cached.filled_qty(), Quantity::from("0.0100"));
15380        assert_eq!(cached.price(), Some(Price::from("2400.00")));
15381        assert_eq!(cached.status(), OrderStatus::Filled);
15382        dispatch_lighter_order(
15383            &frame,
15384            &rig.dispatch,
15385            &rig.emitter,
15386            &rig.registry,
15387            account_id(),
15388            trader_id(),
15389            UnixNanos::from(6),
15390        );
15391        dispatch_lighter_trade(
15392            &trade,
15393            &rig.dispatch,
15394            &rig.emitter,
15395            &rig.registry,
15396            account_id(),
15397            trader_id(),
15398            Some(TEST_ACCOUNT_INDEX_I64),
15399            UnixNanos::from(7),
15400        );
15401        assert!(
15402            drain_events(&mut rig.rx)
15403                .iter()
15404                .all(|event| matches!(event, ExecutionEvent::Report(_))),
15405            "terminal replay must not emit duplicate order events"
15406        );
15407    }
15408
15409    #[rstest]
15410    #[case(OrderType::StopMarket)]
15411    #[case(OrderType::StopLimit)]
15412    #[case(OrderType::MarketIfTouched)]
15413    #[case(OrderType::LimitIfTouched)]
15414    fn conditional_fill_synthesizes_trigger_once_across_retirement(#[case] kind: OrderType) {
15415        let mut rig = dispatcher_rig("CONDITIONAL-FILL");
15416        register_identity_for(&rig, kind);
15417        let trade = dispatcher_test_trade(&rig, true);
15418        dispatch_lighter_trade(
15419            &trade,
15420            &rig.dispatch,
15421            &rig.emitter,
15422            &rig.registry,
15423            account_id(),
15424            trader_id(),
15425            Some(TEST_ACCOUNT_INDEX_I64),
15426            UnixNanos::from(1),
15427        );
15428        let first = drain_events(&mut rig.rx);
15429        let triggerable = TRIGGERABLE_ORDER_TYPES.contains(&kind);
15430        let mut builder = OrderTestBuilder::new(kind);
15431        builder
15432            .trader_id(trader_id())
15433            .strategy_id(strategy_id())
15434            .instrument_id(rig.instrument_id)
15435            .client_order_id(rig.cloid)
15436            .side(OrderSide::Buy)
15437            .quantity(Quantity::from("0.2672"))
15438            .trigger_price(Price::from("2300.00"));
15439
15440        if LIMIT_ORDER_TYPES.contains(&kind) {
15441            builder.price(Price::from("2352.74"));
15442        }
15443
15444        let mut order = builder.build();
15445
15446        for event in &first {
15447            let ExecutionEvent::Order(event) = event else {
15448                panic!("expected order event");
15449            };
15450
15451            order.apply(event.clone()).unwrap();
15452        }
15453
15454        rig.dispatch.retire_order_identity(&rig.cloid);
15455        let mut subsequent = trade;
15456        subsequent.trade_id += 1;
15457        subsequent.trade_id_str = Some(subsequent.trade_id.to_string());
15458        dispatch_lighter_trade(
15459            &subsequent,
15460            &rig.dispatch,
15461            &rig.emitter,
15462            &rig.registry,
15463            account_id(),
15464            trader_id(),
15465            Some(TEST_ACCOUNT_INDEX_I64),
15466            UnixNanos::from(2),
15467        );
15468        let next = drain_events(&mut rig.rx);
15469        assert_eq!(first.len(), 2 + usize::from(triggerable));
15470        assert!(
15471            matches!(&first[0], ExecutionEvent::Order(OrderEventAny::Accepted(event)) if event.client_order_id == rig.cloid)
15472        );
15473
15474        if triggerable {
15475            assert!(
15476                matches!(&first[1], ExecutionEvent::Order(OrderEventAny::Triggered(event)) if event.client_order_id == rig.cloid)
15477            );
15478        }
15479
15480        assert!(
15481            matches!(first.last().unwrap(), ExecutionEvent::Order(OrderEventAny::Filled(event)) if event.client_order_id == rig.cloid && event.order_type == kind && event.last_qty == Quantity::from("0.1336"))
15482        );
15483        assert_eq!(next.len(), 1);
15484        assert!(
15485            matches!(&next[0], ExecutionEvent::Order(OrderEventAny::Filled(event)) if event.client_order_id == rig.cloid && event.trade_id.as_str() == subsequent.trade_id.to_string())
15486        );
15487
15488        let ExecutionEvent::Order(event) = &next[0] else {
15489            panic!("expected trailing fill");
15490        };
15491
15492        order.apply(event.clone()).unwrap();
15493        assert_eq!(order.status(), OrderStatus::Filled);
15494        assert_eq!(order.filled_qty(), Quantity::from("0.2672"));
15495        assert_eq!(order.is_triggered(), Some(triggerable));
15496        assert_eq!(rig.dispatch.triggered_was_emitted(&rig.cloid), triggerable);
15497    }
15498
15499    #[rstest]
15500    #[case(OrderType::Limit)]
15501    #[case(OrderType::StopLimit)]
15502    #[case(OrderType::StopMarket)]
15503    fn cached_order_restores_before_first_fill(
15504        #[case] kind: OrderType,
15505        #[values(false, true)] retired: bool,
15506    ) {
15507        let mut rig = dispatcher_rig("CACHED-FIRST-FILL");
15508        let trade = dispatcher_test_trade(&rig, true);
15509        rig.dispatch.forget_cloid(trade.bid_client_id);
15510        let venue = VenueOrderId::new(trade.bid_id_str.as_deref().unwrap());
15511        let mut builder = OrderTestBuilder::new(kind);
15512        builder
15513            .trader_id(trader_id())
15514            .strategy_id(strategy_id())
15515            .instrument_id(rig.instrument_id)
15516            .client_order_id(rig.cloid)
15517            .side(OrderSide::Buy)
15518            .quantity(Quantity::from("0.1336"));
15519
15520        if LIMIT_ORDER_TYPES.contains(&kind) {
15521            builder.price(Price::from("2352.74"));
15522        }
15523
15524        if STOP_ORDER_TYPES.contains(&kind) {
15525            builder.trigger_price(Price::from("2300.00"));
15526        }
15527
15528        let mut order = builder.build();
15529        order
15530            .apply(OrderEventAny::Accepted(OrderAccepted::new(
15531                trader_id(),
15532                strategy_id(),
15533                rig.instrument_id,
15534                rig.cloid,
15535                venue,
15536                account_id(),
15537                UUID4::new(),
15538                UnixNanos::from(1),
15539                UnixNanos::from(1),
15540                false,
15541            )))
15542            .unwrap();
15543        rig.dispatch.seed_order_restorations(vec![order.clone()]);
15544        if retired {
15545            rig.dispatch
15546                .restore_reconciled_order(&order, trade.bid_client_id, venue, true)
15547                .unwrap();
15548        }
15549
15550        dispatch_lighter_trade(
15551            &trade,
15552            &rig.dispatch,
15553            &rig.emitter,
15554            &rig.registry,
15555            account_id(),
15556            trader_id(),
15557            Some(TEST_ACCOUNT_INDEX_I64),
15558            UnixNanos::from(2),
15559        );
15560        let events = drain_events(&mut rig.rx);
15561        let triggerable = TRIGGERABLE_ORDER_TYPES.contains(&kind);
15562        assert_eq!(events.len(), 1 + usize::from(triggerable));
15563
15564        for event in events {
15565            let ExecutionEvent::Order(event) = event else {
15566                panic!("cached fill must remain tracked");
15567            };
15568
15569            assert_eq!(event.client_order_id(), rig.cloid);
15570            order.apply(event).unwrap();
15571        }
15572
15573        assert_eq!(order.status(), OrderStatus::Filled);
15574        assert_eq!(order.filled_qty(), Quantity::from("0.1336"));
15575        assert_eq!(order.venue_order_id(), Some(venue));
15576        assert_eq!(
15577            rig.dispatch.client_order_index(&rig.cloid),
15578            Some(trade.bid_client_id)
15579        );
15580    }
15581
15582    #[rstest]
15583    fn dispatch_lighter_trade_tracked_synthesizes_accepted_before_filled() {
15584        // Fill-before-open: the trade arrives before the matching Open
15585        // frame. The dispatcher must synthesize `OrderAccepted` first so
15586        // the engine sees the lifecycle in order.
15587        let mut rig = dispatcher_rig("4");
15588        register_identity(&rig);
15589
15590        let trade = dispatcher_test_trade(&rig, true);
15591
15592        dispatch_lighter_trade(
15593            &trade,
15594            &rig.dispatch,
15595            &rig.emitter,
15596            &rig.registry,
15597            account_id(),
15598            trader_id(),
15599            Some(TEST_ACCOUNT_INDEX_I64),
15600            UnixNanos::from(1),
15601        );
15602
15603        let events = drain_events(&mut rig.rx);
15604        assert_eq!(
15605            events.len(),
15606            2,
15607            "expected Accepted then Filled, was {events:?}",
15608        );
15609
15610        match &events[0] {
15611            ExecutionEvent::Order(OrderEventAny::Accepted(_)) => {}
15612            other => panic!("first event should be Accepted, was {other:?}"),
15613        }
15614
15615        match &events[1] {
15616            ExecutionEvent::Order(OrderEventAny::Filled(e)) => {
15617                assert_eq!(e.client_order_id, rig.cloid);
15618                assert_eq!(e.last_qty, Quantity::from("0.1336"));
15619                assert_eq!(e.last_px, Price::from("2352.73"));
15620            }
15621            other => panic!("second event should be Filled, was {other:?}"),
15622        }
15623
15624        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
15625    }
15626
15627    #[rstest]
15628    fn dispatch_lighter_trade_dedupes_repeated_trade_ids() {
15629        let mut rig = dispatcher_rig("5");
15630        register_identity(&rig);
15631        let trade = dispatcher_test_trade(&rig, true);
15632
15633        for _ in 0..3 {
15634            dispatch_lighter_trade(
15635                &trade,
15636                &rig.dispatch,
15637                &rig.emitter,
15638                &rig.registry,
15639                account_id(),
15640                trader_id(),
15641                Some(TEST_ACCOUNT_INDEX_I64),
15642                UnixNanos::from(1),
15643            );
15644        }
15645
15646        let events = drain_events(&mut rig.rx);
15647        // First call: Accepted + Filled. Subsequent calls deduped by trade_id.
15648        assert_eq!(
15649            events.len(),
15650            2,
15651            "expected dedup after first dispatch, was {events:?}"
15652        );
15653    }
15654
15655    #[rstest]
15656    #[case::tracked(true)]
15657    #[case::external(false)]
15658    fn malformed_duplicate_preserves_delivered_fill(#[case] tracked: bool) {
15659        let mut rig = dispatcher_rig("DUPLICATE-MALFORMED");
15660        if tracked {
15661            register_identity(&rig);
15662        }
15663
15664        let original = dispatcher_test_trade(&rig, true);
15665        let mut malformed = original.clone();
15666        malformed.timestamp = -1;
15667        for trade in [&original, &malformed, &original] {
15668            dispatch_lighter_trade(
15669                trade,
15670                &rig.dispatch,
15671                &rig.emitter,
15672                &rig.registry,
15673                account_id(),
15674                trader_id(),
15675                Some(TEST_ACCOUNT_INDEX_I64),
15676                UnixNanos::from(1),
15677            );
15678        }
15679
15680        let events = drain_events(&mut rig.rx);
15681        assert_eq!(events.len(), if tracked { 2 } else { 1 });
15682
15683        let fills = events
15684            .iter()
15685            .filter(|event| {
15686                matches!(
15687                    event,
15688                    ExecutionEvent::Order(OrderEventAny::Filled(_)) | ExecutionEvent::Report(_)
15689                )
15690            })
15691            .count();
15692
15693        assert_eq!(fills, 1);
15694        assert!(
15695            rig.dispatch
15696                .seen_trade_ids
15697                .contains(&parse_lighter_trade_id(&original).unwrap())
15698        );
15699    }
15700
15701    #[rstest]
15702    #[case::confirmed(true, false)]
15703    #[case::confirmed_stale_http(true, true)]
15704    #[case::rejected(false, false)]
15705    fn cancel_overlap_preserves_amendment_and_real_fill(
15706        #[case] confirmed: bool,
15707        #[case] stale_http: bool,
15708    ) {
15709        let mut rig = dispatcher_rig("AMEND-CANCEL-FILL");
15710        let mut cached = OrderTestBuilder::new(OrderType::Limit)
15711            .trader_id(trader_id())
15712            .strategy_id(strategy_id())
15713            .instrument_id(rig.instrument_id)
15714            .client_order_id(rig.cloid)
15715            .side(OrderSide::Buy)
15716            .quantity(Quantity::from("0.0050"))
15717            .price(Price::from("2352.74"))
15718            .build();
15719        rig.dispatch.register_create_identity(&cached).unwrap();
15720        rig.dispatch
15721            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
15722        let mut raw = dispatcher_test_order(&rig, LighterOrderStatus::Open);
15723        dispatch_lighter_order(
15724            &raw,
15725            &rig.dispatch,
15726            &rig.emitter,
15727            &rig.registry,
15728            account_id(),
15729            trader_id(),
15730            UnixNanos::from(1),
15731        );
15732
15733        for event in drain_events(&mut rig.rx) {
15734            let ExecutionEvent::Order(event) = event else {
15735                panic!("expected acceptance");
15736            };
15737
15738            cached.apply(event).unwrap();
15739        }
15740
15741        let command = ModifyOrder::new(
15742            trader_id(),
15743            Some(client_id()),
15744            strategy_id(),
15745            rig.instrument_id,
15746            rig.cloid,
15747            cached.venue_order_id(),
15748            Some(Quantity::from("0.0100")),
15749            Some(Price::from("2400.00")),
15750            None,
15751            UUID4::new(),
15752            UnixNanos::from(2),
15753            None,
15754            None,
15755        );
15756        assert!(rig.dispatch.set_pending_modify_action(&command));
15757        rig.dispatch.set_pending_modify_shape(
15758            &command,
15759            OrderShapeSnapshot {
15760                quantity: command.quantity.unwrap(),
15761                price: command.price,
15762                trigger_price: None,
15763            },
15764        );
15765
15766        rig.dispatch
15767            .set_pending_modify_nonce(&rig.cloid, command.command_id, 0, 19);
15768        rig.dispatch
15769            .set_pending_order_action(rig.cloid, PendingOrderAction::Cancel);
15770        rig.dispatch
15771            .set_pending_cancel_nonce(&rig.cloid, 0, 20, true);
15772        assert_eq!(rig.dispatch.pending_modify_nonce(&rig.cloid), Some((0, 19)));
15773        assert_eq!(rig.dispatch.pending_cancel_nonce(&rig.cloid), Some((0, 20)));
15774        let mut trade = dispatcher_test_trade(&rig, true);
15775        trade.bid_id = raw.order_index;
15776        trade.bid_id_str = Some(raw.order_id.clone());
15777        trade.size = Decimal::from_str("0.0030").unwrap();
15778        trade.usd_amount = trade.size * trade.price;
15779        dispatch_lighter_trade(
15780            &trade,
15781            &rig.dispatch,
15782            &rig.emitter,
15783            &rig.registry,
15784            account_id(),
15785            trader_id(),
15786            Some(TEST_ACCOUNT_INDEX_I64),
15787            UnixNanos::from(3),
15788        );
15789        raw.status = LighterOrderStatus::Canceled;
15790        raw.filled_base_amount = trade.size;
15791        if confirmed {
15792            raw.initial_base_amount = Decimal::from_str("0.0100").unwrap();
15793            raw.price = Decimal::from_str("2400.00").unwrap();
15794        }
15795
15796        raw.remaining_base_amount = raw.initial_base_amount - trade.size;
15797        dispatch_lighter_order(
15798            &raw,
15799            &rig.dispatch,
15800            &rig.emitter,
15801            &rig.registry,
15802            account_id(),
15803            trader_id(),
15804            UnixNanos::from(4),
15805        );
15806        assert!(drain_events(&mut rig.rx).is_empty());
15807        assert!(
15808            !rig.dispatch
15809                .seen_trade_ids
15810                .contains(&parse_lighter_trade_id(&trade).unwrap())
15811        );
15812
15813        let pending = PendingSendTx {
15814            batch_id: None,
15815            connection_epoch: 0,
15816            kind: PendingSendTxKind::Modify {
15817                strategy_id: strategy_id(),
15818                instrument_id: rig.instrument_id,
15819                client_order_id: rig.cloid,
15820                venue_order_id: cached.venue_order_id(),
15821            },
15822            submitted_at: UnixNanos::from(2),
15823            nonce: 19,
15824            api_key_index: TEST_API_KEY_INDEX,
15825            tx_hash: "amend-cancel".to_string(),
15826        };
15827
15828        if confirmed {
15829            let (client, _, _) = create_execution_client();
15830            let mut context = acked_probe_context(&client);
15831            context.dispatch = rig.dispatch.clone();
15832            context.registry = rig.registry.clone();
15833            context.emitter = rig.emitter.clone();
15834            let mut http = raw;
15835            if stale_http {
15836                http.status = LighterOrderStatus::Open;
15837            }
15838
15839            confirm_modify_order(&pending, &context, &http).unwrap();
15840        } else {
15841            reject_modify_order(
15842                &rig.dispatch,
15843                &rig.emitter,
15844                &pending,
15845                "amendment failed",
15846                UnixNanos::from(5),
15847            );
15848        }
15849
15850        let events = drain_events(&mut rig.rx);
15851        assert_eq!(events.len(), 3);
15852
15853        match &events[0] {
15854            ExecutionEvent::Order(OrderEventAny::Updated(update)) if confirmed => {
15855                assert_eq!(update.client_order_id, rig.cloid);
15856                assert_eq!(update.quantity, Quantity::from("0.0100"));
15857                assert_eq!(update.price, Some(Price::from("2400.00")));
15858            }
15859            ExecutionEvent::Order(OrderEventAny::ModifyRejected(rejected)) if !confirmed => {
15860                assert_eq!(rejected.client_order_id, rig.cloid);
15861                assert_eq!(rejected.reason.as_str(), "amendment failed");
15862            }
15863            other => panic!("expected amendment resolution before fill, was {other:?}"),
15864        }
15865
15866        let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = &events[1] else {
15867            panic!("expected held real fill");
15868        };
15869
15870        assert_eq!(fill.client_order_id, rig.cloid);
15871        assert_eq!(fill.trade_id, parse_lighter_trade_id(&trade).unwrap());
15872        assert_eq!(fill.last_qty, Quantity::from("0.0030"));
15873        assert_eq!(fill.last_px, Price::from("2352.73"));
15874
15875        let ExecutionEvent::Order(OrderEventAny::Canceled(cancel)) = &events[2] else {
15876            panic!("expected cancellation after fill");
15877        };
15878
15879        assert_eq!(cancel.client_order_id, rig.cloid);
15880        assert_eq!(cancel.venue_order_id, cached.venue_order_id());
15881
15882        for event in events {
15883            let ExecutionEvent::Order(event) = event else {
15884                panic!("expected order event");
15885            };
15886
15887            cached.apply(event).unwrap();
15888        }
15889
15890        assert_eq!(cached.status(), OrderStatus::Canceled);
15891        assert_eq!(
15892            cached.quantity(),
15893            Quantity::from(if confirmed { "0.0100" } else { "0.0050" })
15894        );
15895        assert_eq!(cached.filled_qty(), Quantity::from("0.0030"));
15896        assert_eq!(
15897            cached.leaves_qty(),
15898            Quantity::from(if confirmed { "0.0070" } else { "0.0020" })
15899        );
15900        assert_eq!(rig.dispatch.pending_modify_nonce(&rig.cloid), None);
15901        assert_eq!(rig.dispatch.pending_cancel_nonce(&rig.cloid), None);
15902        assert!(
15903            rig.dispatch
15904                .seen_trade_ids
15905                .contains(&parse_lighter_trade_id(&trade).unwrap())
15906        );
15907    }
15908
15909    #[rstest]
15910    fn overlapping_modify_rejection_preserves_first_submission() {
15911        let (client, cache, mut rx) = create_execution_client();
15912        let instrument_id = register_test_instrument(&client, &cache);
15913        let cloid = ClientOrderId::from("OVERLAPPING-MODIFY");
15914        let venue = VenueOrderId::from("789");
15915        enqueue_modify(&client, instrument_id, cloid, venue, 23);
15916
15917        let command = ModifyOrder::new(
15918            trader_id(),
15919            Some(client_id()),
15920            strategy_id(),
15921            instrument_id,
15922            cloid,
15923            Some(venue),
15924            Some(Quantity::from("0.3000")),
15925            Some(Price::from("2363.00")),
15926            None,
15927            UUID4::new(),
15928            UnixNanos::from(2),
15929            None,
15930            None,
15931        );
15932        assert!(!client.admit_modify_order(&command));
15933        let events = drain_events(&mut rx);
15934        assert_eq!(events.len(), 1);
15935
15936        let ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) = &events[0] else {
15937            panic!("expected overlap rejection");
15938        };
15939
15940        assert_eq!(event.trader_id, trader_id());
15941        assert_eq!(event.strategy_id, strategy_id());
15942        assert_eq!(event.instrument_id, instrument_id);
15943        assert_eq!(event.client_order_id, cloid);
15944        assert_eq!(event.venue_order_id, Some(venue));
15945        assert_eq!(event.account_id, Some(account_id()));
15946        assert_eq!(
15947            event.reason.as_str(),
15948            "Lighter permits one unresolved modification per order; wait for its update or rejection"
15949        );
15950        assert!(!event.reconciliation);
15951        assert_eq!(client.dispatch.pending_modify_nonce(&cloid), Some((0, 23)));
15952        assert_eq!(
15953            client.dispatch.pending_modify_shape(&cloid),
15954            Some(OrderShapeSnapshot {
15955                quantity: Quantity::from("0.2000"),
15956                price: Some(Price::from("2362.00")),
15957                trigger_price: None
15958            })
15959        );
15960        assert_eq!(client.dispatch.pending_sendtx_len(), 1);
15961    }
15962
15963    #[rstest]
15964    fn recovery_waits_for_confirmed_update_in_cache() {
15965        let (client, cache, mut rx) = create_execution_client();
15966        let instrument_id = register_test_instrument(&client, &cache);
15967        let rig = dispatcher_rig("HTTP-AMEND");
15968        let cloid = rig.cloid;
15969        let mut raw = dispatcher_test_order(&rig, LighterOrderStatus::Filled);
15970        let venue = VenueOrderId::new(&raw.order_id);
15971        let order = OrderTestBuilder::new(OrderType::Limit)
15972            .trader_id(trader_id())
15973            .strategy_id(strategy_id())
15974            .instrument_id(instrument_id)
15975            .client_order_id(cloid)
15976            .side(OrderSide::Buy)
15977            .quantity(Quantity::from("0.0050"))
15978            .price(Price::from("2352.74"))
15979            .build();
15980        cache_accepted_order(&cache, order, venue, Some(client_id()));
15981        let cached = cache.borrow().order_owned(&cloid).unwrap();
15982        client
15983            .dispatch
15984            .restore_reconciled_order(&cached, raw.client_order_index, venue, false)
15985            .unwrap();
15986        enqueue_modify(&client, instrument_id, cloid, venue, 23);
15987        let pending = client.dispatch.pop_pending_sendtx_head().unwrap();
15988        raw.initial_base_amount = Decimal::from_str("0.2000").unwrap();
15989        raw.filled_base_amount = raw.initial_base_amount;
15990        raw.remaining_base_amount = Decimal::ZERO;
15991        raw.price = Decimal::from_str("2362.00").unwrap();
15992        let instrument = LIGHTER_INSTRUMENT_CACHE.get(&instrument_id).unwrap();
15993        let report =
15994            parse_ws_order_status_report(&raw, &instrument, account_id(), UnixNanos::from(3))
15995                .unwrap()
15996                .with_client_order_id(cloid);
15997        drop(instrument);
15998
15999        let command = GenerateOrderStatusReports::new(
16000            UUID4::new(),
16001            UnixNanos::default(),
16002            false,
16003            Some(instrument_id),
16004            None,
16005            None,
16006            None,
16007            None,
16008        );
16009
16010        let result = finish_order_reports(
16011            &client.core,
16012            &client.dispatch,
16013            OrderReportSweep {
16014                orders: vec![(raw.client_order_index, report)],
16015                ..Default::default()
16016            },
16017            &command,
16018        );
16019
16020        assert_eq!(
16021            result.unwrap_err().to_string(),
16022            "incomplete Lighter order reports: Lighter modification confirmation is pending"
16023        );
16024        assert_eq!(client.dispatch.pending_modify_nonce(&cloid), Some((0, 23)));
16025        assert!(client.dispatch.order_identities.contains_key(&cloid));
16026        let fill = reconciliation_fill_report(
16027            instrument_id,
16028            raw.client_order_index,
16029            venue,
16030            "HTTP-AMEND-TRADE",
16031        );
16032
16033        let withheld = finish_fill_reports(
16034            &client.core,
16035            &client.dispatch,
16036            BTreeSet::new(),
16037            Ok(FillSweep {
16038                reports: vec![fill.clone()],
16039                covers_window: true,
16040                withheld_instruments: AHashSet::new(),
16041            }),
16042        )
16043        .unwrap();
16044
16045        assert!(withheld.reports.is_empty());
16046        assert!(!withheld.covers_window);
16047        assert!(!client.dispatch.seen_trade_ids.contains(&fill.trade_id));
16048        confirm_modify_order(&pending, &acked_probe_context(&client), &raw).unwrap();
16049        let events = drain_events(&mut rx);
16050        assert_eq!(events.len(), 2);
16051        let mut events = events.into_iter();
16052
16053        let ExecutionEvent::Order(update @ OrderEventAny::Updated(_)) = events.next().unwrap()
16054        else {
16055            panic!("expected confirmed update before real fill");
16056        };
16057
16058        let ExecutionEvent::Order(delivered_fill @ OrderEventAny::Filled(_)) =
16059            events.next().unwrap()
16060        else {
16061            panic!("expected buffered real fill");
16062        };
16063
16064        let OrderEventAny::Filled(actual) = &delivered_fill else {
16065            unreachable!()
16066        };
16067
16068        assert_eq!(actual.trade_id, fill.trade_id);
16069        assert_eq!(actual.client_order_id, cloid);
16070        assert_eq!(actual.venue_order_id, fill.venue_order_id);
16071        assert_eq!(actual.last_qty, fill.last_qty);
16072        assert_eq!(actual.last_px, fill.last_px);
16073        assert_eq!(actual.commission, Some(fill.commission));
16074        assert_eq!(actual.liquidity_side, fill.liquidity_side);
16075        assert_eq!(actual.ts_event, fill.ts_event);
16076        assert!(!actual.reconciliation);
16077
16078        let still_withheld = finish_fill_reports(
16079            &client.core,
16080            &client.dispatch,
16081            BTreeSet::new(),
16082            Ok(FillSweep {
16083                reports: vec![fill.clone()],
16084                covers_window: true,
16085                withheld_instruments: AHashSet::new(),
16086            }),
16087        )
16088        .unwrap();
16089
16090        assert!(still_withheld.reports.is_empty());
16091        assert!(!still_withheld.covers_window);
16092        assert!(client.dispatch.seen_trade_ids.contains(&fill.trade_id));
16093        cache.borrow_mut().update_order(&update).unwrap();
16094        assert!(order_shape_pending(&client.core, &client.dispatch, &cloid));
16095        cache.borrow_mut().update_order(&delivered_fill).unwrap();
16096
16097        let delivered = finish_fill_reports(
16098            &client.core,
16099            &client.dispatch,
16100            BTreeSet::new(),
16101            Ok(FillSweep {
16102                reports: vec![fill.clone()],
16103                covers_window: true,
16104                withheld_instruments: AHashSet::new(),
16105            }),
16106        )
16107        .unwrap();
16108
16109        assert!(delivered.reports.is_empty());
16110        assert!(delivered.covers_window);
16111        assert_eq!(
16112            client.dispatch.mark_trade_reconciled(fill.trade_id),
16113            Some(TradeDedupSource::Live)
16114        );
16115    }
16116
16117    #[rstest]
16118    fn dispatch_lighter_trade_parse_failure_rolls_back_dedup() {
16119        // A parse failure must not consume the dedup slot, else the fill is lost on replay
16120        let mut rig = dispatcher_rig("21");
16121        register_identity(&rig);
16122        let mut trade = dispatcher_test_trade(&rig, true);
16123        trade.timestamp = -1;
16124        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
16125
16126        dispatch_lighter_trade(
16127            &trade,
16128            &rig.dispatch,
16129            &rig.emitter,
16130            &rig.registry,
16131            account_id(),
16132            trader_id(),
16133            Some(TEST_ACCOUNT_INDEX_I64),
16134            UnixNanos::from(1),
16135        );
16136
16137        let events = drain_events(&mut rig.rx);
16138        assert!(
16139            !events
16140                .iter()
16141                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
16142            "malformed trade must not emit a fill, was {events:?}",
16143        );
16144        assert!(
16145            rig.dispatch.mark_trade_seen(trade_id),
16146            "parse-failed trade must be re-markable (dedup rolled back)",
16147        );
16148    }
16149
16150    #[rstest]
16151    fn dispatch_lighter_trade_untracked_parse_failure_rolls_back_dedup() {
16152        // Untracked path (no registered identity -> FillReport): dedup rollback must still hold
16153        let mut rig = dispatcher_rig("22");
16154        let mut trade = dispatcher_test_trade(&rig, true);
16155        trade.timestamp = -1;
16156        let trade_id = parse_lighter_trade_id(&trade).expect("trade id parses");
16157
16158        dispatch_lighter_trade(
16159            &trade,
16160            &rig.dispatch,
16161            &rig.emitter,
16162            &rig.registry,
16163            account_id(),
16164            trader_id(),
16165            Some(TEST_ACCOUNT_INDEX_I64),
16166            UnixNanos::from(1),
16167        );
16168
16169        let events = drain_events(&mut rig.rx);
16170        assert!(
16171            events.is_empty(),
16172            "untracked malformed trade must emit nothing, was {events:?}",
16173        );
16174        assert!(
16175            rig.dispatch.mark_trade_seen(trade_id),
16176            "untracked parse-failed trade must be re-markable (dedup rolled back)",
16177        );
16178    }
16179
16180    #[rstest]
16181    fn dispatch_tracked_order_event_terminal_cancel_retires_identity_and_removes_snapshot() {
16182        let mut rig = dispatcher_rig("6");
16183        register_identity(&rig);
16184        rig.dispatch.mark_accepted_emitted(rig.cloid);
16185        rig.dispatch.store_snapshot(
16186            rig.cloid,
16187            crate::websocket::dispatch::OrderShapeSnapshot {
16188                quantity: Quantity::from("0.0050"),
16189                price: Some(Price::from("2352.74")),
16190                trigger_price: None,
16191            },
16192        );
16193        let mut order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
16194        order.filled_base_amount = Decimal::ZERO;
16195        let mut trade = dispatcher_test_trade(&rig, true);
16196        trade.bid_id = order.order_index;
16197        trade.bid_id_str = Some(order.order_id.clone());
16198        dispatch_lighter_order(
16199            &order,
16200            &rig.dispatch,
16201            &rig.emitter,
16202            &rig.registry,
16203            account_id(),
16204            trader_id(),
16205            UnixNanos::from(1),
16206        );
16207
16208        let events = drain_events(&mut rig.rx);
16209        let canceled = events
16210            .iter()
16211            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Canceled(_))))
16212            .expect("expected a Canceled event");
16213        if let ExecutionEvent::Order(OrderEventAny::Canceled(e)) = canceled {
16214            assert_eq!(e.client_order_id, rig.cloid);
16215        }
16216        assert!(!rig.dispatch.order_identities.contains_key(&rig.cloid));
16217        assert!(rig.dispatch.order_identity(&rig.cloid).is_some());
16218        assert!(rig.dispatch.snapshot_for(&rig.cloid).is_none());
16219        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
16220
16221        dispatch_lighter_trade(
16222            &trade,
16223            &rig.dispatch,
16224            &rig.emitter,
16225            &rig.registry,
16226            account_id(),
16227            trader_id(),
16228            Some(TEST_ACCOUNT_INDEX_I64),
16229            UnixNanos::from(2),
16230        );
16231        let trailing = drain_events(&mut rig.rx);
16232        assert_eq!(
16233            trailing.len(),
16234            1,
16235            "expected one trailing fill: {trailing:?}"
16236        );
16237
16238        match &trailing[0] {
16239            ExecutionEvent::Order(OrderEventAny::Filled(event)) => {
16240                assert_eq!(event.client_order_id, rig.cloid);
16241            }
16242            other => panic!("expected typed trailing OrderFilled, was {other:?}"),
16243        }
16244    }
16245
16246    #[rstest]
16247    fn dispatch_tracked_cancel_after_report_seed_skips_synthesized_accept() {
16248        let mut rig = dispatcher_rig("10");
16249        register_identity(&rig);
16250
16251        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
16252        let instrument = LIGHTER_INSTRUMENT_CACHE
16253            .get(&rig.instrument_id)
16254            .expect("instrument cached");
16255        let report = parse_ws_order_status_report(
16256            &report_order,
16257            instrument.value(),
16258            account_id(),
16259            UnixNanos::from(1),
16260        )
16261        .map(|report| report.with_client_order_id(rig.cloid))
16262        .expect("report parses");
16263
16264        assert_eq!(report.order_status, OrderStatus::Accepted);
16265        rig.dispatch.seed_accepted_from_report(&report);
16266
16267        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
16268        cancel_order.filled_base_amount = Decimal::ZERO;
16269        dispatch_lighter_order(
16270            &cancel_order,
16271            &rig.dispatch,
16272            &rig.emitter,
16273            &rig.registry,
16274            account_id(),
16275            trader_id(),
16276            UnixNanos::from(2),
16277        );
16278
16279        let events = drain_events(&mut rig.rx);
16280        assert_eq!(
16281            events.len(),
16282            1,
16283            "report-seeded cancel should emit only Canceled, was {events:?}",
16284        );
16285        assert!(
16286            !events
16287                .iter()
16288                .any(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_)))),
16289            "typed cancel must not synthesize a second Accepted",
16290        );
16291
16292        match &events[0] {
16293            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
16294                assert_eq!(e.client_order_id, rig.cloid);
16295                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
16296            }
16297            other => panic!("expected Canceled, was {other:?}"),
16298        }
16299    }
16300
16301    #[rstest]
16302    fn dispatch_tracked_cancel_after_submitted_report_seed_skips_synthesized_accept() {
16303        let mut rig = dispatcher_rig("11");
16304        register_identity(&rig);
16305
16306        let report_order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
16307        let instrument = LIGHTER_INSTRUMENT_CACHE
16308            .get(&rig.instrument_id)
16309            .expect("instrument cached");
16310        let mut report = parse_ws_order_status_report(
16311            &report_order,
16312            instrument.value(),
16313            account_id(),
16314            UnixNanos::from(1),
16315        )
16316        .map(|report| report.with_client_order_id(rig.cloid))
16317        .expect("report parses");
16318        report.order_status = OrderStatus::Submitted;
16319
16320        rig.dispatch.seed_accepted_from_report(&report);
16321        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
16322
16323        let mut cancel_order = dispatcher_test_order(&rig, LighterOrderStatus::Canceled);
16324        cancel_order.filled_base_amount = Decimal::ZERO;
16325
16326        dispatch_lighter_order(
16327            &cancel_order,
16328            &rig.dispatch,
16329            &rig.emitter,
16330            &rig.registry,
16331            account_id(),
16332            trader_id(),
16333            UnixNanos::from(2),
16334        );
16335
16336        let events = drain_events(&mut rig.rx);
16337        assert_eq!(
16338            events.len(),
16339            1,
16340            "Cancel after Submitted report should emit only Canceled, was {events:?}",
16341        );
16342
16343        match &events[0] {
16344            ExecutionEvent::Order(OrderEventAny::Canceled(e)) => {
16345                assert_eq!(e.client_order_id, rig.cloid);
16346                assert_eq!(e.venue_order_id, Some(VenueOrderId::new("281476929510110")));
16347            }
16348            other => panic!("expected Canceled, was {other:?}"),
16349        }
16350    }
16351
16352    #[rstest]
16353    fn dispatch_tracked_order_event_accept_dedup_is_idempotent() {
16354        let mut rig = dispatcher_rig("7");
16355        register_identity(&rig);
16356        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
16357
16358        // First dispatch emits Accepted. Second dispatch must be silent
16359        // (no shape change) and not re-emit Accepted.
16360        dispatch_lighter_order(
16361            &order,
16362            &rig.dispatch,
16363            &rig.emitter,
16364            &rig.registry,
16365            account_id(),
16366            trader_id(),
16367            UnixNanos::from(1),
16368        );
16369        dispatch_lighter_order(
16370            &order,
16371            &rig.dispatch,
16372            &rig.emitter,
16373            &rig.registry,
16374            account_id(),
16375            trader_id(),
16376            UnixNanos::from(2),
16377        );
16378
16379        let events = drain_events(&mut rig.rx);
16380        let accepted_count = events
16381            .iter()
16382            .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Accepted(_))))
16383            .count();
16384        assert_eq!(accepted_count, 1, "Accepted must be emitted exactly once");
16385    }
16386
16387    #[rstest]
16388    fn dispatch_lighter_order_drops_when_instrument_uncached() {
16389        // Construct a rig but use a market_index the registry does not know.
16390        let registry = Arc::new(MarketRegistry::new());
16391        let (emitter, mut rx) = dispatcher_emitter();
16392        let dispatch = WsDispatchState::new();
16393        let cloid = ClientOrderId::new("CLOID-MISSING");
16394        dispatch.register_order_identity(
16395            cloid,
16396            OrderIdentity::new(
16397                InstrumentId::from("MISSING-PERP.LIGHTER"),
16398                strategy_id(),
16399                OrderSide::Buy,
16400                OrderType::Limit,
16401                1,
16402            ),
16403        );
16404        let mut order = LighterOrder {
16405            order_index: 1,
16406            client_order_index: 1,
16407            order_id: "1".to_string(),
16408            client_order_id: "1".to_string(),
16409            market_index: 999, // not in registry
16410            owner_account_index: TEST_ACCOUNT_INDEX_I64,
16411            initial_base_amount: Decimal::ZERO,
16412            price: Decimal::ZERO,
16413            nonce: 0,
16414            remaining_base_amount: Decimal::ZERO,
16415            is_ask: false,
16416            base_size: 0,
16417            base_price: 0,
16418            filled_base_amount: Decimal::ZERO,
16419            filled_quote_amount: Decimal::ZERO,
16420            side: Some(LighterOrderSide::Buy),
16421            order_type: LighterOrderKind::Limit,
16422            time_in_force: LighterOrderTimeInForce::GoodTillTime,
16423            reduce_only: false,
16424            trigger_price: Decimal::ZERO,
16425            order_expiry: 0,
16426            status: LighterOrderStatus::Open,
16427            trigger_status: LighterTriggerStatus::Na,
16428            trigger_time: 0,
16429            parent_order_index: 0,
16430            parent_order_id: "0".to_string(),
16431            to_trigger_order_id_0: "0".to_string(),
16432            to_trigger_order_id_1: "0".to_string(),
16433            to_cancel_order_id_0: "0".to_string(),
16434            integrator_fee_collector_index: "0".to_string(),
16435            integrator_taker_fee: Decimal::ZERO,
16436            integrator_maker_fee: Decimal::ZERO,
16437            block_height: 0,
16438            timestamp: 0,
16439            created_at: 0,
16440            updated_at: 0,
16441            transaction_time: 0,
16442        };
16443        order.client_order_id = "1".to_string();
16444
16445        dispatch_lighter_order(
16446            &order,
16447            &dispatch,
16448            &emitter,
16449            &registry,
16450            account_id(),
16451            trader_id(),
16452            UnixNanos::from(1),
16453        );
16454
16455        let events = drain_events(&mut rx);
16456        assert!(
16457            events.is_empty(),
16458            "no event for uncached instrument, was {events:?}"
16459        );
16460        assert!(dispatch.order_identities.contains_key(&cloid));
16461    }
16462
16463    #[rstest]
16464    fn dispatch_lighter_trade_filters_non_account_trades_defensively() {
16465        let mut rig = dispatcher_rig("8");
16466        // Trade involves accounts 91249 and 91250, but the supplied
16467        // account_index is TEST_ACCOUNT_INDEX_I64 (12345). The handler is
16468        // the first defensive filter; this verifies the dispatcher path
16469        // also drops foreign trades cleanly.
16470        let mut trade = dispatcher_test_trade(&rig, true);
16471        trade.bid_account_id = 91_249;
16472        trade.ask_account_id = 91_250;
16473
16474        dispatch_lighter_trade(
16475            &trade,
16476            &rig.dispatch,
16477            &rig.emitter,
16478            &rig.registry,
16479            account_id(),
16480            trader_id(),
16481            Some(TEST_ACCOUNT_INDEX_I64),
16482            UnixNanos::from(1),
16483        );
16484
16485        let events = drain_events(&mut rig.rx);
16486        assert!(
16487            events.is_empty(),
16488            "foreign trade must produce no event, was {events:?}"
16489        );
16490        assert!(
16491            !rig.dispatch
16492                .seen_trade_ids
16493                .contains(&TradeId::new("19209006902"),)
16494        );
16495    }
16496
16497    #[rstest]
16498    fn register_cloid_in_submit_path_uses_probed_index_on_collision() {
16499        // Forcing a collision at the derived index for a fresh cloid must
16500        // result in `register_cloid` returning a different (probed) index;
16501        // the submit path uses this returned value as the venue-side
16502        // client_order_index, so it must be the probed one.
16503        let dispatch = WsDispatchState::new();
16504        let cloid = ClientOrderId::new("PROBE-CLOID");
16505        let derived = dispatch.derive_client_order_index(&cloid);
16506
16507        let intruder = ClientOrderId::new("INTRUDER");
16508        dispatch.cloid_map.insert(derived, intruder);
16509
16510        let chosen = dispatch.register_cloid(derived, cloid).unwrap();
16511
16512        assert_ne!(chosen, derived);
16513        assert_eq!(
16514            dispatch.cloid_map.get(&derived).map(|e| *e.value()),
16515            Some(intruder),
16516        );
16517        assert_eq!(
16518            dispatch.cloid_map.get(&chosen).map(|e| *e.value()),
16519            Some(cloid),
16520        );
16521    }
16522
16523    #[rstest]
16524    fn reconciliation_restores_active_collision_probed_identity() {
16525        let (client, cache, _rx) = create_execution_client();
16526        let instrument_id = register_test_instrument(&client, &cache);
16527        let mut factory = test_order_factory();
16528        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-ACTIVE");
16529        let cloid = order.client_order_id();
16530        let venue_order_id = VenueOrderId::from("281476929510110");
16531        cache_accepted_order(&cache, order, venue_order_id, None);
16532        let (base_index, client_order_index) = forced_probed_index(cloid);
16533        let raw =
16534            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
16535
16536        restore_reconciled_order(
16537            &client.core,
16538            &client.dispatch,
16539            VenueOrderId::new(raw.order_id.as_str()),
16540            raw.client_order_index,
16541            false,
16542        );
16543        let report = parse_http_order_to_report(
16544            &raw,
16545            &client.registry,
16546            account_id(),
16547            UnixNanos::from(1),
16548            &client.dispatch,
16549        )
16550        .unwrap();
16551        let translated = client.dispatch.translate_order_cloid(report);
16552        let replacement = ClientOrderId::from("O-RECON-ACTIVE-REPLACEMENT");
16553        let replacement_index = client
16554            .dispatch
16555            .register_cloid(client_order_index, replacement)
16556            .unwrap();
16557        let wrong_venue_order_id = VenueOrderId::from("281476929510111");
16558        let wrong_raw = reconciliation_raw_order(
16559            client_order_index,
16560            wrong_venue_order_id,
16561            LighterOrderStatus::Open,
16562        );
16563        let wrong_report = parse_http_order_to_report(
16564            &wrong_raw,
16565            &client.registry,
16566            account_id(),
16567            UnixNanos::from(2),
16568            &client.dispatch,
16569        )
16570        .unwrap();
16571        let wrong_translated = client.dispatch.translate_order_cloid(wrong_report);
16572
16573        assert_ne!(client_order_index, base_index);
16574        assert_eq!(
16575            client.dispatch.client_order_index(&cloid),
16576            Some(client_order_index),
16577        );
16578        assert_eq!(
16579            client
16580                .dispatch
16581                .resolve_client_order_index(client_order_index),
16582            Some(cloid),
16583        );
16584        assert_eq!(
16585            client.dispatch.lookup_venue_order_id(&cloid),
16586            Some(venue_order_id),
16587        );
16588        assert!(client.dispatch.order_identities.contains_key(&cloid));
16589        assert!(client.dispatch.accepted_was_emitted(&cloid));
16590        assert_eq!(translated.client_order_id, Some(cloid));
16591        assert_eq!(translated.venue_order_id, venue_order_id);
16592        assert_ne!(replacement_index, client_order_index);
16593        assert_eq!(
16594            client
16595                .dispatch
16596                .resolve_client_order_index(replacement_index),
16597            Some(replacement),
16598        );
16599        assert_eq!(
16600            wrong_translated.client_order_id,
16601            Some(ClientOrderId::new(wrong_venue_order_id.as_str())),
16602        );
16603        assert_eq!(wrong_translated.venue_order_id, wrong_venue_order_id);
16604    }
16605
16606    #[rstest]
16607    fn reconciliation_retires_stale_active_cache_from_terminal_report() {
16608        let (client, cache, _rx) = create_execution_client();
16609        let instrument_id = register_test_instrument(&client, &cache);
16610        let mut factory = test_order_factory();
16611        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-FILLED");
16612        let cloid = order.client_order_id();
16613        let venue_order_id = VenueOrderId::from("281476929510119");
16614        cache_accepted_order(&cache, order, venue_order_id, None);
16615        let (_, client_order_index) = forced_probed_index(cloid);
16616        let raw = reconciliation_raw_order(
16617            client_order_index,
16618            venue_order_id,
16619            LighterOrderStatus::Filled,
16620        );
16621
16622        restore_reconciled_order(
16623            &client.core,
16624            &client.dispatch,
16625            VenueOrderId::new(raw.order_id.as_str()),
16626            raw.client_order_index,
16627            true,
16628        );
16629        let fill = client
16630            .dispatch
16631            .translate_fill_cloid(reconciliation_fill_report(
16632                instrument_id,
16633                client_order_index,
16634                venue_order_id,
16635                "19209006919",
16636            ));
16637
16638        assert!(!client.dispatch.cloid_map.contains_key(&client_order_index));
16639        assert!(!client.dispatch.order_identities.contains_key(&cloid));
16640        assert_eq!(
16641            client.dispatch.client_order_index(&cloid),
16642            Some(client_order_index),
16643        );
16644        assert_eq!(fill.client_order_id, Some(cloid));
16645        assert_eq!(fill.venue_order_id, venue_order_id);
16646    }
16647
16648    #[rstest]
16649    fn reconciliation_restores_reused_retired_index_for_fill_translation() {
16650        let (client, cache, _rx) = create_execution_client();
16651        let instrument_id = register_test_instrument(&client, &cache);
16652        let mut factory = test_order_factory();
16653        let first_order = test_limit_order(&mut factory, instrument_id, "O-RECON-RETIRED-A");
16654        let second_order = test_limit_order(&mut factory, instrument_id, "O-RECON-RETIRED-B");
16655        let first_cloid = first_order.client_order_id();
16656        let second_cloid = second_order.client_order_id();
16657        let first_venue_order_id = VenueOrderId::from("281476929510120");
16658        let second_venue_order_id = VenueOrderId::from("281476929510121");
16659        cache_canceled_order(&cache, first_order, first_venue_order_id);
16660        cache_canceled_order(&cache, second_order, second_venue_order_id);
16661        let (_, client_order_index) = forced_probed_index(first_cloid);
16662        let first_raw = reconciliation_raw_order(
16663            client_order_index,
16664            first_venue_order_id,
16665            LighterOrderStatus::Canceled,
16666        );
16667        let second_raw = reconciliation_raw_order(
16668            client_order_index,
16669            second_venue_order_id,
16670            LighterOrderStatus::Canceled,
16671        );
16672
16673        restore_reconciled_order(
16674            &client.core,
16675            &client.dispatch,
16676            VenueOrderId::new(first_raw.order_id.as_str()),
16677            first_raw.client_order_index,
16678            true,
16679        );
16680        restore_reconciled_order(
16681            &client.core,
16682            &client.dispatch,
16683            VenueOrderId::new(second_raw.order_id.as_str()),
16684            second_raw.client_order_index,
16685            true,
16686        );
16687        let first_fill = client
16688            .dispatch
16689            .translate_fill_cloid(reconciliation_fill_report(
16690                instrument_id,
16691                client_order_index,
16692                first_venue_order_id,
16693                "19209006920",
16694            ));
16695        let second_fill = client
16696            .dispatch
16697            .translate_fill_cloid(reconciliation_fill_report(
16698                instrument_id,
16699                client_order_index,
16700                second_venue_order_id,
16701                "19209006921",
16702            ));
16703        let replacement = ClientOrderId::from("O-RECON-RETIRED-REPLACEMENT");
16704        let replacement_index = client
16705            .dispatch
16706            .register_cloid(client_order_index, replacement)
16707            .unwrap();
16708
16709        assert!(!client.dispatch.cloid_map.contains_key(&client_order_index));
16710        assert!(!client.dispatch.order_identities.contains_key(&first_cloid));
16711        assert!(!client.dispatch.order_identities.contains_key(&second_cloid));
16712        assert_eq!(
16713            client.dispatch.client_order_index(&first_cloid),
16714            Some(client_order_index),
16715        );
16716        assert_eq!(
16717            client.dispatch.client_order_index(&second_cloid),
16718            Some(client_order_index),
16719        );
16720        assert_eq!(
16721            client
16722                .dispatch
16723                .resolve_client_order_index(client_order_index),
16724            None,
16725        );
16726        assert_eq!(first_fill.client_order_id, Some(first_cloid));
16727        assert_eq!(first_fill.venue_order_id, first_venue_order_id);
16728        assert_eq!(second_fill.client_order_id, Some(second_cloid));
16729        assert_eq!(second_fill.venue_order_id, second_venue_order_id);
16730        assert_ne!(replacement_index, client_order_index);
16731        assert_eq!(
16732            client
16733                .dispatch
16734                .resolve_client_order_index(replacement_index),
16735            Some(replacement),
16736        );
16737    }
16738
16739    #[rstest]
16740    fn reconciliation_restores_triggered_marker() {
16741        let (client, cache, _rx) = create_execution_client();
16742        let instrument_id = register_test_instrument(&client, &cache);
16743        let mut factory = test_order_factory();
16744        let order = factory.stop_limit(
16745            instrument_id,
16746            OrderSide::Sell,
16747            Quantity::from("0.1000"),
16748            Price::from("2290.00"),
16749            Price::from("2300.00"),
16750            None,
16751            Some(TimeInForce::Gtc),
16752            None,
16753            Some(false),
16754            Some(false),
16755            Some(false),
16756            None,
16757            None,
16758            None,
16759            None,
16760            None,
16761            None,
16762            Some(ClientOrderId::from("O-RECON-TRIGGERED")),
16763        );
16764        let cloid = order.client_order_id();
16765        let venue_order_id = VenueOrderId::from("281476929510124");
16766        cache_accepted_order(&cache, order, venue_order_id, None);
16767        let triggered = OrderEventAny::Triggered(OrderTriggered::new(
16768            trader_id(),
16769            strategy_id(),
16770            instrument_id,
16771            cloid,
16772            UUID4::new(),
16773            UnixNanos::from(1),
16774            UnixNanos::from(2),
16775            false,
16776            Some(venue_order_id),
16777            Some(account_id()),
16778        ));
16779        cache.borrow_mut().update_order(&triggered).unwrap();
16780        let (_, client_order_index) = forced_probed_index(cloid);
16781        let raw =
16782            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
16783
16784        restore_reconciled_order(
16785            &client.core,
16786            &client.dispatch,
16787            VenueOrderId::new(raw.order_id.as_str()),
16788            raw.client_order_index,
16789            false,
16790        );
16791
16792        assert!(client.dispatch.order_identities.contains_key(&cloid));
16793        assert!(client.dispatch.accepted_was_emitted(&cloid));
16794        assert!(client.dispatch.triggered_was_emitted(&cloid));
16795    }
16796
16797    #[rstest]
16798    fn reconciliation_routes_reused_index_by_exact_venue_order_id() {
16799        let (client, cache, _rx) = create_execution_client();
16800        let instrument_id = register_test_instrument(&client, &cache);
16801        let mut factory = test_order_factory();
16802        let active_order = test_limit_order(&mut factory, instrument_id, "O-RECON-LIVE-ACTIVE");
16803        let retired_order = test_limit_order(&mut factory, instrument_id, "O-RECON-LIVE-RETIRED");
16804        let active_cloid = active_order.client_order_id();
16805        let retired_cloid = retired_order.client_order_id();
16806        let active_venue_order_id = VenueOrderId::from("281476929510125");
16807        let retired_venue_order_id = VenueOrderId::from("281476929510126");
16808        cache_accepted_order(
16809            &cache,
16810            active_order,
16811            active_venue_order_id,
16812            Some(client_id()),
16813        );
16814        cache_canceled_order(&cache, retired_order, retired_venue_order_id);
16815        let (_, client_order_index) = forced_probed_index(active_cloid);
16816        let active_raw = reconciliation_raw_order(
16817            client_order_index,
16818            active_venue_order_id,
16819            LighterOrderStatus::Open,
16820        );
16821        let retired_raw = reconciliation_raw_order(
16822            client_order_index,
16823            retired_venue_order_id,
16824            LighterOrderStatus::Canceled,
16825        );
16826        let raw_client_id = client_order_index.to_string();
16827
16828        restore_reconciled_order(
16829            &client.core,
16830            &client.dispatch,
16831            VenueOrderId::new(active_raw.order_id.as_str()),
16832            active_raw.client_order_index,
16833            false,
16834        );
16835        restore_reconciled_order(
16836            &client.core,
16837            &client.dispatch,
16838            VenueOrderId::new(retired_raw.order_id.as_str()),
16839            retired_raw.client_order_index,
16840            true,
16841        );
16842
16843        assert_eq!(
16844            client
16845                .dispatch
16846                .resolve_live_order_cloid(&raw_client_id, active_venue_order_id,),
16847            Some(active_cloid),
16848        );
16849        assert_eq!(
16850            client
16851                .dispatch
16852                .resolve_live_order_cloid(&raw_client_id, retired_venue_order_id,),
16853            Some(retired_cloid),
16854        );
16855        assert_eq!(
16856            client
16857                .dispatch
16858                .resolve_live_trade_cloid(&raw_client_id, active_venue_order_id),
16859            Some(active_cloid),
16860        );
16861        assert_eq!(
16862            client
16863                .dispatch
16864                .resolve_live_trade_cloid(&raw_client_id, retired_venue_order_id),
16865            Some(retired_cloid),
16866        );
16867    }
16868
16869    #[rstest]
16870    fn reconciliation_does_not_restore_from_numeric_client_index_alone() {
16871        let (client, cache, _rx) = create_execution_client();
16872        let instrument_id = register_test_instrument(&client, &cache);
16873        let mut factory = test_order_factory();
16874        let order = test_limit_order(&mut factory, instrument_id, "O-RECON-NUMERIC-ONLY");
16875        let cloid = order.client_order_id();
16876        let client_order_index = client.dispatch.derive_client_order_index(&cloid);
16877        let venue_order_id = VenueOrderId::from("281476929510130");
16878        cache_order(&cache, order);
16879        let raw =
16880            reconciliation_raw_order(client_order_index, venue_order_id, LighterOrderStatus::Open);
16881
16882        restore_reconciled_order(
16883            &client.core,
16884            &client.dispatch,
16885            VenueOrderId::new(raw.order_id.as_str()),
16886            raw.client_order_index,
16887            false,
16888        );
16889        let report = parse_http_order_to_report(
16890            &raw,
16891            &client.registry,
16892            account_id(),
16893            UnixNanos::from(1),
16894            &client.dispatch,
16895        )
16896        .unwrap();
16897        let translated = client.dispatch.translate_order_cloid(report);
16898
16899        assert_eq!(client.dispatch.client_order_index(&cloid), None);
16900        assert_eq!(
16901            client
16902                .dispatch
16903                .resolve_client_order_index(client_order_index),
16904            None,
16905        );
16906        assert!(!client.dispatch.order_identities.contains_key(&cloid));
16907        assert_eq!(
16908            translated.client_order_id,
16909            Some(ClientOrderId::new(venue_order_id.as_str())),
16910        );
16911        assert_eq!(translated.venue_order_id, venue_order_id);
16912    }
16913
16914    fn forced_probed_index(cloid: ClientOrderId) -> (i64, i64) {
16915        let source = WsDispatchState::new();
16916        let base_index = source.derive_client_order_index(&cloid);
16917        source
16918            .cloid_map
16919            .insert(base_index, ClientOrderId::from("O-FORCED-COLLISION"));
16920        let client_order_index = source.register_cloid(base_index, cloid).unwrap();
16921        (base_index, client_order_index)
16922    }
16923
16924    fn reconciliation_raw_order(
16925        client_order_index: i64,
16926        venue_order_id: VenueOrderId,
16927        status: LighterOrderStatus,
16928    ) -> LighterOrder {
16929        let rig = dispatcher_rig("RECONCILIATION-RAW");
16930        let mut raw = dispatcher_test_order(&rig, status);
16931        raw.order_index = venue_order_id.as_str().parse().unwrap();
16932        raw.order_id = venue_order_id.to_string();
16933        raw.client_order_index = client_order_index;
16934        raw.client_order_id = client_order_index.to_string();
16935        raw
16936    }
16937
16938    fn cache_canceled_order(
16939        cache: &Rc<RefCell<Cache>>,
16940        order: OrderAny,
16941        venue_order_id: VenueOrderId,
16942    ) {
16943        let (instrument_id, client_order_id) =
16944            cache_accepted_order(cache, order, venue_order_id, Some(client_id()));
16945        let canceled = OrderEventAny::Canceled(OrderCanceled::new(
16946            trader_id(),
16947            strategy_id(),
16948            instrument_id,
16949            client_order_id,
16950            UUID4::new(),
16951            UnixNanos::from(1),
16952            UnixNanos::from(2),
16953            false,
16954            Some(venue_order_id),
16955            Some(account_id()),
16956            None,
16957        ));
16958        cache.borrow_mut().update_order(&canceled).unwrap();
16959    }
16960
16961    fn reconciliation_fill_report(
16962        instrument_id: InstrumentId,
16963        client_order_index: i64,
16964        venue_order_id: VenueOrderId,
16965        trade_id: &str,
16966    ) -> FillReport {
16967        FillReport::new(
16968            account_id(),
16969            instrument_id,
16970            venue_order_id,
16971            TradeId::new(trade_id),
16972            OrderSide::Buy,
16973            Quantity::from("0.1336"),
16974            Price::from("2352.73"),
16975            Money::from("0.000196 USDC"),
16976            LiquiditySide::Taker,
16977            Some(ClientOrderId::new(client_order_index.to_string())),
16978            None,
16979            UnixNanos::from(1),
16980            UnixNanos::from(2),
16981            Some(UUID4::new()),
16982        )
16983    }
16984
16985    #[rstest]
16986    fn dispatch_lighter_order_seeds_snapshot_after_synthesized_accept() {
16987        // After a synthesized `OrderAccepted` (fill-before-open), the
16988        // next `Open` frame must seed the shape snapshot even when the
16989        // parser returns None. Without the seed, shape_changed stays
16990        // permanently false and a later modify is lost.
16991        let mut rig = dispatcher_rig("9");
16992        register_identity(&rig);
16993
16994        let order = dispatcher_test_order(&rig, LighterOrderStatus::Open);
16995        let mut trade = dispatcher_test_trade(&rig, true);
16996        trade.bid_id = order.order_index;
16997        trade.bid_id_str = Some(order.order_id.clone());
16998        dispatch_lighter_trade(
16999            &trade,
17000            &rig.dispatch,
17001            &rig.emitter,
17002            &rig.registry,
17003            account_id(),
17004            trader_id(),
17005            Some(TEST_ACCOUNT_INDEX_I64),
17006            UnixNanos::from(1),
17007        );
17008        assert_eq!(drain_events(&mut rig.rx).len(), 2);
17009        assert!(rig.dispatch.accepted_was_emitted(&rig.cloid));
17010        assert!(
17011            rig.dispatch.snapshot_for(&rig.cloid).is_none(),
17012            "synthesized Accept has no snapshot until the Open frame seeds one",
17013        );
17014
17015        // Open frame lands later (matches venue ordering). Parser
17016        // returns None (already accepted, shape unchanged) but the
17017        // dispatcher must still seed the snapshot baseline.
17018        dispatch_lighter_order(
17019            &order,
17020            &rig.dispatch,
17021            &rig.emitter,
17022            &rig.registry,
17023            account_id(),
17024            trader_id(),
17025            UnixNanos::from(2),
17026        );
17027        assert!(
17028            rig.dispatch.snapshot_for(&rig.cloid).is_some(),
17029            "Open frame after synthesized accept must seed the snapshot",
17030        );
17031
17032        // A real modify must now fire Updated.
17033        let mut modified = order;
17034        modified.price = Decimal::from_str("2400.00").unwrap();
17035        dispatch_lighter_order(
17036            &modified,
17037            &rig.dispatch,
17038            &rig.emitter,
17039            &rig.registry,
17040            account_id(),
17041            trader_id(),
17042            UnixNanos::from(3),
17043        );
17044        let events = drain_events(&mut rig.rx);
17045        let updated = events
17046            .iter()
17047            .find(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Updated(_))));
17048        assert!(
17049            updated.is_some(),
17050            "real modify must produce Updated, events={events:?}"
17051        );
17052    }
17053
17054    fn enqueue_create(client: &LighterExecutionClient, order: &OrderAny, nonce: i64) -> i64 {
17055        let client_order_index = client
17056            .dispatch
17057            .derive_client_order_index(&order.client_order_id());
17058        client
17059            .dispatch
17060            .register_cloid(client_order_index, order.client_order_id())
17061            .unwrap();
17062        client.dispatch.register_order_identity(
17063            order.client_order_id(),
17064            OrderIdentity::new(
17065                order.instrument_id(),
17066                order.strategy_id(),
17067                order.order_side(),
17068                order.order_type(),
17069                client_order_index,
17070            ),
17071        );
17072        let now = UnixNanos::from(1_000_000_000);
17073        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17074            batch_id: None,
17075            connection_epoch: 0,
17076            kind: PendingSendTxKind::Create {
17077                order: Box::new(order.clone()),
17078                client_order_index,
17079            },
17080            submitted_at: now,
17081            nonce,
17082            api_key_index: TEST_API_KEY_INDEX,
17083            tx_hash: format!("hash{nonce:02x}"),
17084        });
17085        client.dispatch.nonce_manager.refresh(
17086            TEST_ACCOUNT_INDEX_I64,
17087            TEST_API_KEY_INDEX,
17088            nonce + 1,
17089        );
17090        let _ = client
17091            .dispatch
17092            .nonce_manager
17093            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX);
17094        client_order_index
17095    }
17096
17097    fn enqueue_other(client: &LighterExecutionClient, nonce: i64) {
17098        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17099            batch_id: None,
17100            connection_epoch: 0,
17101            kind: PendingSendTxKind::Other,
17102            submitted_at: UnixNanos::from(1_000_000_000),
17103            nonce,
17104            api_key_index: TEST_API_KEY_INDEX,
17105            tx_hash: format!("hash{nonce:02x}"),
17106        });
17107    }
17108
17109    fn enqueue_cancel(
17110        client: &LighterExecutionClient,
17111        instrument_id: InstrumentId,
17112        client_order_id: ClientOrderId,
17113        venue_order_id: VenueOrderId,
17114        nonce: i64,
17115    ) {
17116        client
17117            .dispatch
17118            .set_pending_order_action(client_order_id, PendingOrderAction::Cancel);
17119        client
17120            .dispatch
17121            .set_pending_cancel_nonce(&client_order_id, 0, nonce, true);
17122        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17123            batch_id: None,
17124            connection_epoch: 0,
17125            kind: PendingSendTxKind::Cancel {
17126                emit_cancel_rejected: true,
17127                strategy_id: strategy_id(),
17128                instrument_id,
17129                client_order_id,
17130                venue_order_id: Some(venue_order_id),
17131            },
17132            submitted_at: UnixNanos::from(1_000_000_000),
17133            nonce,
17134            api_key_index: TEST_API_KEY_INDEX,
17135            tx_hash: format!("hash{nonce:02x}"),
17136        });
17137    }
17138
17139    fn enqueue_modify(
17140        client: &LighterExecutionClient,
17141        instrument_id: InstrumentId,
17142        client_order_id: ClientOrderId,
17143        venue_order_id: VenueOrderId,
17144        nonce: i64,
17145    ) {
17146        let command = ModifyOrder::new(
17147            trader_id(),
17148            Some(client_id()),
17149            strategy_id(),
17150            instrument_id,
17151            client_order_id,
17152            Some(venue_order_id),
17153            Some(Quantity::from("0.2000")),
17154            Some(Price::from("2362.00")),
17155            None,
17156            UUID4::new(),
17157            UnixNanos::default(),
17158            None,
17159            None,
17160        );
17161        client.dispatch.set_pending_modify_action(&command);
17162        client.dispatch.set_pending_modify_shape(
17163            &command,
17164            OrderShapeSnapshot {
17165                quantity: command.quantity.unwrap(),
17166                price: command.price,
17167                trigger_price: command.trigger_price,
17168            },
17169        );
17170
17171        client
17172            .dispatch
17173            .set_pending_modify_nonce(&client_order_id, command.command_id, 0, nonce);
17174        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17175            batch_id: None,
17176            connection_epoch: 0,
17177            kind: PendingSendTxKind::Modify {
17178                strategy_id: strategy_id(),
17179                instrument_id,
17180                client_order_id,
17181                venue_order_id: Some(venue_order_id),
17182            },
17183            submitted_at: UnixNanos::from(1_000_000_000),
17184            nonce,
17185            api_key_index: TEST_API_KEY_INDEX,
17186            tx_hash: format!("hash{nonce:02x}"),
17187        });
17188    }
17189
17190    fn acked_probe_context(client: &LighterExecutionClient) -> AckedOrderProbeContext {
17191        AckedOrderProbeContext {
17192            http_client: client.http_client.clone(),
17193            registry: Arc::clone(&client.registry),
17194            credential: test_credential(),
17195            dispatch: client.dispatch.clone(),
17196            account_id: client.core.account_id,
17197            clock: client.clock,
17198            emitter: client.emitter.clone(),
17199            connection_epoch: client.ws_client.connection_epoch_atomic(),
17200            cancellation_token: client.cancellation_token.clone(),
17201            pending_tasks: client.pending_tasks.spawner().unwrap(),
17202        }
17203    }
17204
17205    fn failed_modify_tx(pending: &PendingSendTx) -> LighterTx {
17206        LighterTx {
17207            code: 200,
17208            message: None,
17209            hash: pending.tx_hash.clone(),
17210            tx_type: LighterTxType::ModifyOrder as u8,
17211            info: String::new(),
17212            event_info: serde_json::json!({"ae":"invalid order price"}).to_string(),
17213            status: LighterTxStatus::Failed,
17214            account_index: TEST_ACCOUNT_INDEX_I64,
17215            nonce: pending.nonce,
17216            api_key_index: pending.api_key_index,
17217        }
17218    }
17219
17220    #[rstest]
17221    #[case::hash("hash")]
17222    #[case::type_mismatch("type")]
17223    #[case::account("account")]
17224    #[case::key("key")]
17225    #[case::nonce("nonce")]
17226    #[tokio::test]
17227    async fn acknowledged_modify_requires_exact_transaction_identity(#[case] mismatch: &str) {
17228        let (client, cache, mut rx) = create_execution_client();
17229        let instrument = register_test_instrument(&client, &cache);
17230        let id = ClientOrderId::from("MODIFY-IDENTITY");
17231        enqueue_modify(&client, instrument, id, VenueOrderId::from("789"), 23);
17232        let pending = client.dispatch.pop_pending_sendtx_head().unwrap();
17233        let mut tx = failed_modify_tx(&pending);
17234        match mismatch {
17235            "hash" => tx.hash = "different".to_string(),
17236            "type" => tx.tx_type = LighterTxType::CancelOrder as u8,
17237            "account" => tx.account_index += 1,
17238            "key" => tx.api_key_index += 1,
17239            "nonce" => tx.nonce += 1,
17240            _ => unreachable!(),
17241        }
17242
17243        let result = resolve_acked_action(&pending, &acked_probe_context(&client), tx).await;
17244        assert_eq!(
17245            result.unwrap_err().to_string(),
17246            "Lighter transaction lookup did not match acknowledged action identity"
17247        );
17248        assert_eq!(client.dispatch.pending_modify_nonce(&id), Some((0, 23)));
17249        assert_eq!(
17250            client.dispatch.pending_order_action(&id),
17251            Some(PendingOrderAction::Modify)
17252        );
17253        assert!(rx.try_recv().is_err());
17254    }
17255
17256    #[rstest]
17257    #[case::failed_status(LighterTxStatus::Failed, "", true)]
17258    #[case::malformed_failed_status(LighterTxStatus::Failed, "invalid json", true)]
17259    #[case::application_error(LighterTxStatus::Executed, "application", true)]
17260    #[case::pending(LighterTxStatus::Pending, "", false)]
17261    #[case::pending_final(LighterTxStatus::PendingFinal, "", false)]
17262    #[tokio::test]
17263    async fn acknowledged_modify_outcomes_consume_only_matching_action(
17264        #[case] status: LighterTxStatus,
17265        #[case] event_info: &str,
17266        #[case] failed: bool,
17267    ) {
17268        let (client, cache, mut rx) = create_execution_client();
17269        let instrument = register_test_instrument(&client, &cache);
17270        let id = ClientOrderId::from("MODIFY-OUTCOME");
17271        let venue = VenueOrderId::from("789");
17272        let nonce = client
17273            .dispatch
17274            .nonce_manager
17275            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
17276            .unwrap();
17277        enqueue_modify(&client, instrument, id, venue, nonce);
17278        let pending = client.dispatch.pop_pending_sendtx_head().unwrap();
17279        let context = acked_probe_context(&client);
17280        let mut tx = failed_modify_tx(&pending);
17281        tx.status = status;
17282
17283        tx.event_info = if event_info == "application" {
17284            serde_json::json!({"ae":"invalid order price"}).to_string()
17285        } else {
17286            event_info.to_string()
17287        };
17288
17289        let resolved = resolve_acked_action(&pending, &context, tx.clone())
17290            .await
17291            .unwrap();
17292        assert_eq!(resolved, failed);
17293
17294        if failed {
17295            let OrderEventAny::ModifyRejected(event) = recv_order_event(&mut rx).await else {
17296                panic!("expected ModifyRejected")
17297            };
17298
17299            assert_eq!(event.client_order_id, id);
17300            assert_eq!(event.venue_order_id, Some(venue));
17301            assert_eq!(event.instrument_id, instrument);
17302            assert_eq!(event.strategy_id, strategy_id());
17303            assert_eq!(
17304                event.reason.as_str(),
17305                if event_info == "application" {
17306                    "Lighter modify transaction failed: invalid order price"
17307                } else {
17308                    "Lighter modify transaction failed: transaction failed without an application error"
17309                }
17310            );
17311            assert_eq!(client.dispatch.pending_modify_nonce(&id), None);
17312            assert_eq!(client.dispatch.pending_order_action(&id), None);
17313            assert!(resolve_acked_action(&pending, &context, tx).await.unwrap());
17314        } else {
17315            assert_eq!(client.dispatch.pending_modify_nonce(&id), Some((0, nonce)));
17316        }
17317
17318        assert!(rx.try_recv().is_err());
17319        assert_eq!(
17320            client
17321                .dispatch
17322                .nonce_manager
17323                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
17324                .unwrap(),
17325            nonce + 1
17326        );
17327    }
17328
17329    #[rstest]
17330    #[case::newer_nonce(false)]
17331    #[case::reconnected(true)]
17332    #[tokio::test]
17333    async fn acknowledged_modify_does_not_consume_newer_submission(#[case] reconnected: bool) {
17334        let (client, cache, mut rx) = create_execution_client();
17335        let instrument = register_test_instrument(&client, &cache);
17336        let id = ClientOrderId::from("MODIFY-NEWER");
17337        let venue = VenueOrderId::from("789");
17338        enqueue_modify(&client, instrument, id, venue, 23);
17339        let pending = client.dispatch.pop_pending_sendtx_head().unwrap();
17340        let context = acked_probe_context(&client);
17341        client.dispatch.clear_pending_modify_if_nonce(&id, 0, 23);
17342
17343        if reconnected {
17344            context.connection_epoch.store(1, Ordering::Release);
17345            enqueue_modify(&client, instrument, id, venue, 24);
17346        } else {
17347            enqueue_modify(&client, instrument, id, venue, 24);
17348        }
17349
17350        assert!(
17351            resolve_acked_action(&pending, &context, failed_modify_tx(&pending))
17352                .await
17353                .unwrap()
17354        );
17355        assert_eq!(client.dispatch.pending_modify_nonce(&id), Some((0, 24)));
17356        assert!(rx.try_recv().is_err());
17357    }
17358
17359    #[rstest]
17360    fn queued_modify_cannot_bind_a_newer_command() {
17361        let (client, cache, _rx) = create_execution_client();
17362        let instrument = register_test_instrument(&client, &cache);
17363        let id = ClientOrderId::from("MODIFY-QUEUED");
17364
17365        let mut cmd = ModifyOrder::new(
17366            trader_id(),
17367            Some(client_id()),
17368            strategy_id(),
17369            instrument,
17370            id,
17371            Some(VenueOrderId::from("789")),
17372            Some(Quantity::from("0.2000")),
17373            Some(Price::from("2362.00")),
17374            None,
17375            UUID4::new(),
17376            UnixNanos::default(),
17377            None,
17378            None,
17379        );
17380        let old_command = cmd.command_id;
17381        let old_cmd = cmd.clone();
17382        client.dispatch.set_pending_modify_action(&cmd);
17383        assert!(
17384            client
17385                .dispatch
17386                .clear_pending_modify_if_command(&id, old_command)
17387        );
17388        cmd.command_id = UUID4::new();
17389        cmd.price = Some(Price::from("2363.00"));
17390        client.dispatch.set_pending_modify_action(&cmd);
17391        client.dispatch.set_pending_modify_shape(
17392            &cmd,
17393            OrderShapeSnapshot {
17394                quantity: cmd.quantity.unwrap(),
17395                price: cmd.price,
17396                trigger_price: cmd.trigger_price,
17397            },
17398        );
17399
17400        client.dispatch.set_pending_modify_shape(
17401            &old_cmd,
17402            OrderShapeSnapshot {
17403                quantity: old_cmd.quantity.unwrap(),
17404                price: old_cmd.price,
17405                trigger_price: old_cmd.trigger_price,
17406            },
17407        );
17408
17409        client
17410            .dispatch
17411            .set_pending_modify_nonce(&id, old_command, 0, 23);
17412        assert_eq!(client.dispatch.pending_modify_nonce(&id), None);
17413        assert!(
17414            !client
17415                .dispatch
17416                .clear_pending_modify_if_command(&id, old_command)
17417        );
17418        assert_eq!(
17419            client.dispatch.pending_order_action(&id),
17420            Some(PendingOrderAction::Modify)
17421        );
17422        client
17423            .dispatch
17424            .set_pending_modify_nonce(&id, cmd.command_id, 0, 24);
17425        client.dispatch.clear_pending_modify_if_update(
17426            &id,
17427            Quantity::from("0.2000"),
17428            Some(Price::from("2362.00")),
17429            None,
17430        );
17431        assert_eq!(client.dispatch.pending_modify_nonce(&id), Some((0, 24)));
17432        client.dispatch.clear_pending_modify_if_update(
17433            &id,
17434            Quantity::from("0.2000"),
17435            Some(Price::from("2363.00")),
17436            None,
17437        );
17438        assert_eq!(client.dispatch.pending_order_action(&id), None);
17439    }
17440
17441    #[tokio::test]
17442    async fn handle_send_tx_ack_removes_hash_matched_entry() {
17443        let (client, cache, mut rx) = create_execution_client();
17444        let instrument_id = register_test_instrument(&client, &cache);
17445        let mut factory = test_order_factory();
17446        let order_a = test_limit_order(&mut factory, instrument_id, "ACK-A");
17447        let order_b = test_limit_order(&mut factory, instrument_id, "ACK-B");
17448        enqueue_create(&client, &order_a, 10);
17449        enqueue_create(&client, &order_b, 11);
17450
17451        // Out-of-order ack: B's hash must remove B even though A is at head.
17452        let acked = handle_send_tx_ack(
17453            &client.dispatch,
17454            Some(TEST_ACCOUNT_INDEX_I64),
17455            200,
17456            Some("hash0b"),
17457        );
17458
17459        let acked = acked.expect("create ack");
17460        assert!(matches!(acked.kind, PendingSendTxKind::Create { .. }));
17461        assert!(matches!(
17462            AckedOrderProbe::from_pending(&acked),
17463            Some(AckedOrderProbe::Create {
17464                nonce: 11,
17465                connection_epoch: 0,
17466                ..
17467            }),
17468        ));
17469        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "only B pops");
17470        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
17471        match head.kind {
17472            PendingSendTxKind::Create { order, .. } => {
17473                assert_eq!(order.client_order_id(), order_a.client_order_id());
17474            }
17475            _ => panic!("expected Create kind"),
17476        }
17477
17478        assert!(
17479            tokio::time::timeout(Duration::from_millis(50), rx.recv())
17480                .await
17481                .is_err(),
17482            "ack must not emit an event",
17483        );
17484    }
17485
17486    #[rstest]
17487    #[case::executed(LighterTxStatus::Executed, false)]
17488    #[case::pending_final(LighterTxStatus::PendingFinal, true)]
17489    #[tokio::test]
17490    async fn acknowledged_create_probe_retains_unqueryable_order(
17491        #[case] status: LighterTxStatus,
17492        #[case] pending: bool,
17493    ) {
17494        let (client, cache, mut rx) = create_execution_client();
17495        let instrument_id = register_test_instrument(&client, &cache);
17496        let mut factory = test_order_factory();
17497        let order = test_limit_order(&mut factory, instrument_id, "PROBE-UNQUERYABLE");
17498        let index = enqueue_create(&client, &order, TEST_SUBMISSION_NONCE);
17499        let submission = client.dispatch.pop_pending_sendtx_head().unwrap();
17500
17501        let tx = LighterTx {
17502            code: 200,
17503            message: None,
17504            hash: submission.tx_hash.clone(),
17505            tx_type: LighterTxType::CreateOrder as u8,
17506            info: serde_json::json!({"ClientOrderIndex": index}).to_string(),
17507            event_info: serde_json::json!({"ae": ""}).to_string(),
17508            status,
17509            account_index: TEST_ACCOUNT_INDEX_I64,
17510            nonce: TEST_SUBMISSION_NONCE,
17511            api_key_index: TEST_API_KEY_INDEX,
17512        };
17513
17514        let (http_client, server) = spawn_create_probe_server(&tx).await;
17515        let mut context = acked_probe_context(&client);
17516        context.http_client = http_client;
17517        let cloid = order.client_order_id();
17518        assert!(
17519            client
17520                .dispatch
17521                .create_submission_is_pending(&cloid, index, TEST_SUBMISSION_NONCE)
17522        );
17523
17524        probe_acked_order(
17525            &AckedOrderProbe::from_pending(&submission).unwrap(),
17526            &context,
17527        )
17528        .await
17529        .unwrap();
17530        server.abort();
17531        let _ = server.await;
17532
17533        let identity = client.dispatch.order_identity(&cloid).unwrap();
17534        assert_eq!(
17535            client
17536                .dispatch
17537                .create_submission_is_pending(&cloid, index, TEST_SUBMISSION_NONCE),
17538            pending
17539        );
17540        assert_eq!(identity.instrument_id, instrument_id);
17541        assert_eq!(identity.strategy_id, strategy_id());
17542        assert_eq!(identity.order_side, order.order_side());
17543        assert_eq!(identity.order_type, order.order_type());
17544        assert_eq!(identity.client_order_index, index);
17545        assert_eq!(identity.venue_order_id(), None);
17546        assert!(!client.dispatch.accepted_was_emitted(&cloid));
17547        assert!(!client.dispatch.venue_id_map.contains_key(&cloid));
17548        assert_eq!(
17549            client.dispatch.cloid_map.get(&index).map(|id| *id),
17550            Some(cloid)
17551        );
17552        assert!(drain_events(&mut rx).is_empty());
17553    }
17554
17555    #[rstest]
17556    #[case::hash("hash")]
17557    #[case::type_mismatch("type")]
17558    #[case::account("account")]
17559    #[case::key("key")]
17560    #[case::nonce("nonce")]
17561    #[case::index("index")]
17562    #[tokio::test]
17563    async fn acknowledged_create_probe_requires_exact_http_transaction_identity(
17564        #[case] mismatch: &str,
17565    ) {
17566        let (client, cache, mut rx) = create_execution_client();
17567        let instrument_id = register_test_instrument(&client, &cache);
17568        let mut factory = test_order_factory();
17569        let order = test_limit_order(&mut factory, instrument_id, "PROBE-IDENTITY");
17570        let index = enqueue_create(&client, &order, TEST_SUBMISSION_NONCE);
17571        let submission = client.dispatch.pop_pending_sendtx_head().unwrap();
17572
17573        let mut tx = LighterTx {
17574            code: 200,
17575            message: None,
17576            hash: submission.tx_hash.clone(),
17577            tx_type: LighterTxType::CreateOrder as u8,
17578            info: serde_json::json!({"ClientOrderIndex": index}).to_string(),
17579            event_info: serde_json::json!({"ae": "foreign transaction failed"}).to_string(),
17580            status: LighterTxStatus::Failed,
17581            account_index: TEST_ACCOUNT_INDEX_I64,
17582            nonce: TEST_SUBMISSION_NONCE,
17583            api_key_index: TEST_API_KEY_INDEX,
17584        };
17585
17586        match mismatch {
17587            "hash" => tx.hash = "foreign".to_string(),
17588            "type" => tx.tx_type = LighterTxType::CancelOrder as u8,
17589            "account" => tx.account_index += 1,
17590            "key" => tx.api_key_index += 1,
17591            "nonce" => tx.nonce += 1,
17592            "index" => tx.info = serde_json::json!({"ClientOrderIndex": index + 1}).to_string(),
17593            _ => unreachable!(),
17594        }
17595
17596        let (http_client, server) = spawn_create_probe_server(&tx).await;
17597        let mut context = acked_probe_context(&client);
17598        context.http_client = http_client;
17599        let error = probe_acked_order(
17600            &AckedOrderProbe::from_pending(&submission).unwrap(),
17601            &context,
17602        )
17603        .await
17604        .unwrap_err();
17605        server.abort();
17606        let _ = server.await;
17607
17608        let expected = if mismatch == "index" {
17609            format!(
17610                "Lighter transaction lookup returned client_order_index {} for acknowledged create {index}",
17611                index + 1
17612            )
17613        } else {
17614            "Lighter transaction lookup did not match acknowledged create identity".to_string()
17615        };
17616
17617        let cloid = order.client_order_id();
17618        assert_eq!(error.to_string(), expected);
17619        assert!(
17620            client
17621                .dispatch
17622                .create_submission_is_pending(&cloid, index, TEST_SUBMISSION_NONCE)
17623        );
17624        assert_eq!(
17625            client
17626                .dispatch
17627                .order_identity(&cloid)
17628                .unwrap()
17629                .client_order_index,
17630            index
17631        );
17632        assert_eq!(
17633            client.dispatch.cloid_map.get(&index).map(|id| *id),
17634            Some(cloid)
17635        );
17636        assert!(!client.dispatch.accepted_was_emitted(&cloid));
17637        assert!(!client.dispatch.venue_id_map.contains_key(&cloid));
17638        assert!(drain_events(&mut rx).is_empty());
17639    }
17640
17641    async fn spawn_create_probe_server(
17642        tx: &LighterTx,
17643    ) -> (LighterHttpClient, tokio::task::JoinHandle<()>) {
17644        let body = serde_json::to_string(tx).unwrap();
17645
17646        let app = Router::new()
17647            .route(
17648                "/api/v1/accountActiveOrders",
17649                get(|| async { r#"{"code":200,"orders":[]}"# }),
17650            )
17651            .route(
17652                "/api/v1/tx",
17653                get(move || {
17654                    let body = body.clone();
17655                    async move { body }
17656                }),
17657            );
17658
17659        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
17660        let addr = listener.local_addr().unwrap();
17661        let server = tokio::spawn(async move {
17662            axum::serve(listener, app).await.unwrap();
17663        });
17664
17665        let client = LighterHttpClient::new(
17666            LighterEnvironment::Testnet,
17667            Some(format!("http://{addr}")),
17668            1,
17669            None,
17670        )
17671        .unwrap();
17672        (client, server)
17673    }
17674
17675    #[rstest]
17676    fn acknowledged_create_tx_validation_requires_exact_identity() {
17677        let tx = LighterTx {
17678            code: 200,
17679            message: None,
17680            hash: "ABCDEF".to_string(),
17681            tx_type: LighterTxType::CreateOrder as u8,
17682            info: serde_json::json!({"ClientOrderIndex": 42}).to_string(),
17683            event_info: serde_json::json!({"ae": ""}).to_string(),
17684            status: LighterTxStatus::Failed,
17685            account_index: TEST_ACCOUNT_INDEX_I64,
17686            nonce: 10,
17687            api_key_index: TEST_API_KEY_INDEX,
17688        };
17689        assert!(
17690            validate_acked_create_tx(
17691                &tx,
17692                TEST_ACCOUNT_INDEX_I64,
17693                TEST_API_KEY_INDEX,
17694                42,
17695                10,
17696                "0xabcdef",
17697            )
17698            .is_ok(),
17699        );
17700
17701        let mismatches = [
17702            LighterTx {
17703                hash: "different".to_string(),
17704                ..tx.clone()
17705            },
17706            LighterTx {
17707                tx_type: LighterTxType::CancelOrder as u8,
17708                ..tx.clone()
17709            },
17710            LighterTx {
17711                account_index: TEST_ACCOUNT_INDEX_I64 + 1,
17712                ..tx.clone()
17713            },
17714            LighterTx {
17715                nonce: 11,
17716                ..tx.clone()
17717            },
17718            LighterTx {
17719                api_key_index: TEST_API_KEY_INDEX + 1,
17720                ..tx.clone()
17721            },
17722        ];
17723
17724        for mismatch in mismatches {
17725            let error = validate_acked_create_tx(
17726                &mismatch,
17727                TEST_ACCOUNT_INDEX_I64,
17728                TEST_API_KEY_INDEX,
17729                42,
17730                10,
17731                "abcdef",
17732            )
17733            .expect_err("mismatched transaction identity must fail");
17734            assert_eq!(
17735                error.to_string(),
17736                "Lighter transaction lookup did not match acknowledged create identity",
17737            );
17738        }
17739
17740        let wrong_index = LighterTx {
17741            info: serde_json::json!({"ClientOrderIndex": 43}).to_string(),
17742            ..tx
17743        };
17744
17745        let error = validate_acked_create_tx(
17746            &wrong_index,
17747            TEST_ACCOUNT_INDEX_I64,
17748            TEST_API_KEY_INDEX,
17749            42,
17750            10,
17751            "abcdef",
17752        )
17753        .expect_err("mismatched client order index must fail");
17754        assert_eq!(
17755            error.to_string(),
17756            "Lighter transaction lookup returned client_order_index 43 for acknowledged create 42",
17757        );
17758    }
17759
17760    #[rstest]
17761    #[case(41, true)]
17762    #[case(42, true)]
17763    #[case(43, true)]
17764    #[case(44, false)]
17765    fn acknowledged_group_tx_validation_requires_membership(
17766        #[case] client_order_index: i64,
17767        #[case] expected: bool,
17768    ) {
17769        let tx = LighterTx {
17770            code: 200,
17771            message: None,
17772            hash: "ABCDEF".to_string(),
17773            tx_type: LighterTxType::CreateGroupedOrders as u8,
17774            info: serde_json::json!({"Orders": [
17775                {"ClientOrderIndex": 41}, {"ClientOrderIndex": 42}, {"ClientOrderIndex": 43}
17776            ]})
17777            .to_string(),
17778            event_info: serde_json::json!({"ae": ""}).to_string(),
17779            status: LighterTxStatus::Executed,
17780            account_index: TEST_ACCOUNT_INDEX_I64,
17781            nonce: 10,
17782            api_key_index: TEST_API_KEY_INDEX,
17783        };
17784
17785        let result = validate_acked_create_tx(
17786            &tx,
17787            TEST_ACCOUNT_INDEX_I64,
17788            TEST_API_KEY_INDEX,
17789            client_order_index,
17790            10,
17791            "0xabcdef",
17792        );
17793        assert_eq!(result.is_ok(), expected);
17794
17795        if !expected {
17796            assert_eq!(
17797                result.unwrap_err().to_string(),
17798                "Lighter transaction lookup did not include acknowledged client_order_index 44"
17799            );
17800        }
17801    }
17802
17803    #[tokio::test]
17804    async fn send_tx_responses_only_attribute_within_their_connection_epoch() {
17805        let (client, _cache, mut rx) = create_execution_client();
17806        for (connection_epoch, nonce) in [(4, 20), (5, 21)] {
17807            client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17808                batch_id: None,
17809                connection_epoch,
17810                kind: PendingSendTxKind::Other,
17811                submitted_at: UnixNanos::from(1_000_000_000 + nonce as u64),
17812                nonce,
17813                api_key_index: TEST_API_KEY_INDEX,
17814                tx_hash: "same-hash".to_string(),
17815            });
17816        }
17817
17818        let acked =
17819            handle_send_tx_ack_for_connection(&client.dispatch, None, 5, 200, Some("same-hash"))
17820                .expect("replacement epoch response should match");
17821        assert_eq!(acked.connection_epoch, 5);
17822        assert_eq!(acked.nonce, 21);
17823        {
17824            let queue = client.dispatch.pending_sendtx.lock();
17825            assert_eq!(queue.len(), 1);
17826            assert_eq!(queue[0].connection_epoch, 4);
17827            assert_eq!(queue[0].nonce, 20);
17828        }
17829
17830        assert!(!handle_send_tx_rejection_for_connection(
17831            &client.dispatch,
17832            &client.emitter,
17833            None,
17834            4,
17835            UnixNanos::from(1_000_000_100),
17836            SendTxRejectionSource::Ack,
17837            Some(21702),
17838            "invalid price",
17839            Some("same-hash"),
17840        ));
17841        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
17842        assert!(
17843            tokio::time::timeout(Duration::from_millis(50), rx.recv())
17844                .await
17845                .is_err(),
17846            "non-order responses must not emit order events",
17847        );
17848    }
17849
17850    #[tokio::test]
17851    async fn handle_send_tx_ack_unmatched_hash_pops_nothing() {
17852        let (client, cache, mut rx) = create_execution_client();
17853        let instrument_id = register_test_instrument(&client, &cache);
17854        let mut factory = test_order_factory();
17855        let order = test_limit_order(&mut factory, instrument_id, "ACK-UNMATCHED");
17856        enqueue_create(&client, &order, 10);
17857
17858        let acked = handle_send_tx_ack(
17859            &client.dispatch,
17860            Some(TEST_ACCOUNT_INDEX_I64),
17861            200,
17862            Some("0xabc"),
17863        );
17864
17865        assert!(acked.is_none());
17866        assert_eq!(
17867            client.dispatch.pending_sendtx_len(),
17868            1,
17869            "an echoed hash with no matching entry must not pop the head",
17870        );
17871        assert!(
17872            tokio::time::timeout(Duration::from_millis(50), rx.recv())
17873                .await
17874                .is_err(),
17875            "unmatched ack must not emit an event",
17876        );
17877    }
17878
17879    #[tokio::test]
17880    async fn handle_send_tx_ack_hashless_with_multiple_pending_pops_nothing() {
17881        let (client, _cache, _rx) = create_execution_client();
17882        enqueue_other(&client, 10);
17883        enqueue_other(&client, 11);
17884
17885        let acked = handle_send_tx_ack(&client.dispatch, Some(TEST_ACCOUNT_INDEX_I64), 200, None);
17886
17887        assert!(acked.is_none());
17888        assert_eq!(
17889            client.dispatch.pending_sendtx_len(),
17890            2,
17891            "a hashless ack is ambiguous when multiple transactions are pending",
17892        );
17893    }
17894
17895    #[tokio::test]
17896    async fn prepared_create_tx_hash_round_trips_through_ack() {
17897        // The hash prepare threads into the queue must be the lowercase hex
17898        // form a venue ack echoes, or every live ack goes unattributed.
17899        let (client, cache, _rx) = create_execution_client();
17900        let instrument_id = register_test_instrument(&client, &cache);
17901        let mut factory = test_order_factory();
17902        let order = test_limit_order(&mut factory, instrument_id, "ACK-REAL-HASH");
17903
17904        let credential = test_credential();
17905        let plan = client
17906            .prepare_create_order_plan(&order, 0)
17907            .expect("prepare must validate");
17908        let prepared = client
17909            .fanout_dispatch_context(&credential)
17910            .expect("task admission")
17911            .sign_create_order(&plan)
17912            .expect("prepare must sign");
17913
17914        assert_eq!(prepared.tx_hash.len(), TX_HASH_BYTES * 2);
17915        assert!(
17916            prepared
17917                .tx_hash
17918                .chars()
17919                .all(|c| matches!(c, '0'..='9' | 'a'..='f')),
17920            "tx_hash must be lowercase hex, was `{}`",
17921            prepared.tx_hash,
17922        );
17923
17924        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
17925            batch_id: None,
17926            connection_epoch: 0,
17927            kind: PendingSendTxKind::Create {
17928                order: Box::new(order),
17929                client_order_index: prepared.client_order_index,
17930            },
17931            submitted_at: UnixNanos::from(1_000_000_000),
17932            nonce: prepared.nonce,
17933            api_key_index: prepared.api_key_index,
17934            tx_hash: prepared.tx_hash.clone(),
17935        });
17936
17937        let acked = handle_send_tx_ack(
17938            &client.dispatch,
17939            Some(TEST_ACCOUNT_INDEX_I64),
17940            200,
17941            Some(&prepared.tx_hash),
17942        );
17943
17944        assert!(acked.is_some());
17945        assert_eq!(
17946            client.dispatch.pending_sendtx_len(),
17947            0,
17948            "venue echo of the signed hash must match the enqueued entry",
17949        );
17950    }
17951
17952    #[tokio::test]
17953    async fn handle_send_tx_ack_returns_cancel_for_noop_probe() {
17954        let (client, cache, mut rx) = create_execution_client();
17955        let instrument_id = register_test_instrument(&client, &cache);
17956        let client_order_id = ClientOrderId::from("ACK-CANCEL-NOOP");
17957        let venue_order_id = VenueOrderId::from("123");
17958        enqueue_cancel(&client, instrument_id, client_order_id, venue_order_id, 12);
17959
17960        let acked = handle_send_tx_ack(
17961            &client.dispatch,
17962            Some(TEST_ACCOUNT_INDEX_I64),
17963            200,
17964            Some("hash0c"),
17965        )
17966        .expect("acked cancel pending entry");
17967        let probe = AckedOrderProbe::from_pending(&acked).expect("cancel should schedule probe");
17968
17969        match probe {
17970            AckedOrderProbe::Cancel {
17971                instrument_id: actual_instrument_id,
17972                client_order_id: actual_client_order_id,
17973                venue_order_id: actual_venue_order_id,
17974            } => {
17975                assert_eq!(actual_instrument_id, instrument_id);
17976                assert_eq!(actual_client_order_id, client_order_id);
17977                assert_eq!(actual_venue_order_id, Some(venue_order_id));
17978            }
17979            other => panic!("expected cancel probe, was {other:?}"),
17980        }
17981        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
17982        assert!(
17983            tokio::time::timeout(Duration::from_millis(50), rx.recv())
17984                .await
17985                .is_err(),
17986            "ack itself must not emit before the no-op probe runs",
17987        );
17988    }
17989
17990    #[rstest]
17991    #[tokio::test]
17992    async fn acknowledged_order_probe_is_retained_until_session_shutdown() {
17993        let (mut client, cache, _rx) = create_execution_client();
17994        let instrument_id = register_test_instrument(&client, &cache);
17995        let pending = PendingSendTx {
17996            batch_id: None,
17997            connection_epoch: 0,
17998            kind: PendingSendTxKind::Cancel {
17999                emit_cancel_rejected: true,
18000                strategy_id: strategy_id(),
18001                instrument_id,
18002                client_order_id: ClientOrderId::from("ACK-RETAINED"),
18003                venue_order_id: Some(VenueOrderId::from("123")),
18004            },
18005            submitted_at: UnixNanos::from(1_000_000_000),
18006            nonce: 12,
18007            api_key_index: TEST_API_KEY_INDEX,
18008            tx_hash: "hash0c".to_string(),
18009        };
18010        let context = AckedOrderProbeContext {
18011            http_client: client.http_client.clone(),
18012            registry: Arc::clone(&client.registry),
18013            credential: test_credential(),
18014            dispatch: client.dispatch.clone(),
18015            account_id: client.core.account_id,
18016            clock: client.clock,
18017            emitter: client.emitter.clone(),
18018            connection_epoch: client.ws_client.connection_epoch_atomic(),
18019            cancellation_token: client.cancellation_token.clone(),
18020            pending_tasks: client.pending_tasks.spawner().expect("task spawner"),
18021        };
18022
18023        spawn_acked_order_probe(&pending, context);
18024
18025        assert_eq!(client.pending_tasks.len(), 1);
18026
18027        client.begin_session_shutdown();
18028        client
18029            .finish_session_shutdown()
18030            .await
18031            .expect("session shutdown");
18032
18033        assert!(client.pending_tasks.is_empty());
18034    }
18035
18036    #[tokio::test]
18037    async fn handle_send_tx_ack_retains_modify_for_transaction_probe() {
18038        let (client, cache, mut rx) = create_execution_client();
18039        let instrument_id = register_test_instrument(&client, &cache);
18040        let client_order_id = ClientOrderId::from("ACK-MODIFY-TX");
18041        let venue_order_id = VenueOrderId::from("456");
18042        enqueue_modify(&client, instrument_id, client_order_id, venue_order_id, 13);
18043
18044        let acked = handle_send_tx_ack(
18045            &client.dispatch,
18046            Some(TEST_ACCOUNT_INDEX_I64),
18047            200,
18048            Some("hash0d"),
18049        )
18050        .expect("acked modify pending entry");
18051        assert!(AckedOrderProbe::from_pending(&acked).is_none());
18052
18053        match acked.kind {
18054            PendingSendTxKind::Modify {
18055                instrument_id: actual_instrument_id,
18056                client_order_id: actual_client_order_id,
18057                venue_order_id: actual_venue_order_id,
18058                ..
18059            } => {
18060                assert_eq!(actual_instrument_id, instrument_id);
18061                assert_eq!(actual_client_order_id, client_order_id);
18062                assert_eq!(actual_venue_order_id, Some(venue_order_id));
18063            }
18064            other => panic!("expected acknowledged modify, was {other:?}"),
18065        }
18066
18067        assert_eq!(acked.connection_epoch, 0);
18068        assert_eq!(acked.nonce, 13);
18069        assert_eq!(acked.api_key_index, TEST_API_KEY_INDEX);
18070        assert_eq!(acked.tx_hash, "hash0d");
18071        assert_eq!(
18072            client.dispatch.pending_modify_nonce(&client_order_id),
18073            Some((0, 13))
18074        );
18075        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
18076        assert!(
18077            tokio::time::timeout(Duration::from_millis(50), rx.recv())
18078                .await
18079                .is_err(),
18080            "ack itself must not emit before the transaction probe runs",
18081        );
18082    }
18083
18084    #[tokio::test]
18085    async fn ack_noop_probe_missing_cancel_leaves_outcome_unresolved() {
18086        let (client, cache, mut rx) = create_execution_client();
18087        let instrument_id = register_test_instrument(&client, &cache);
18088        let client_order_id = ClientOrderId::from("ACK-CANCEL-MISSING");
18089        let venue_order_id = VenueOrderId::from("123");
18090
18091        let probe = AckedOrderProbe::Cancel {
18092            instrument_id,
18093            client_order_id,
18094            venue_order_id: Some(venue_order_id),
18095        };
18096
18097        warn_if_acked_order_missing(&probe, false);
18098        assert!(
18099            tokio::time::timeout(Duration::from_millis(50), rx.recv())
18100                .await
18101                .is_err(),
18102            "one missing lookup must not become CancelRejected",
18103        );
18104    }
18105
18106    #[tokio::test]
18107    async fn ack_noop_probe_found_order_skips_rejection() {
18108        let (client, cache, mut rx) = create_execution_client();
18109        let instrument_id = register_test_instrument(&client, &cache);
18110
18111        let probe = AckedOrderProbe::Cancel {
18112            instrument_id,
18113            client_order_id: ClientOrderId::from("ACK-CANCEL-FOUND"),
18114            venue_order_id: Some(VenueOrderId::from("123")),
18115        };
18116
18117        warn_if_acked_order_missing(&probe, true);
18118        assert!(
18119            tokio::time::timeout(Duration::from_millis(50), rx.recv())
18120                .await
18121                .is_err(),
18122            "found acked order must not emit a no-op rejection",
18123        );
18124    }
18125
18126    #[tokio::test]
18127    async fn send_tx_acks_recover_skip_window_past_window_size() {
18128        let (client, _cache, _rx) = create_execution_client();
18129        let window = i64::from(client.dispatch.nonce_manager.skip_window());
18130        for _ in 0..window {
18131            let nonce = client
18132                .dispatch
18133                .nonce_manager
18134                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18135                .unwrap();
18136            enqueue_other(&client, nonce);
18137        }
18138
18139        assert!(
18140            client
18141                .dispatch
18142                .nonce_manager
18143                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18144                .is_err(),
18145            "window must trip after {window} unacked txs",
18146        );
18147
18148        // Each venue ack reopens one slot, carrying issuance past 2x the
18149        // window without a refresh.
18150        for i in 0..window {
18151            let acked = handle_send_tx_ack(
18152                &client.dispatch,
18153                Some(TEST_ACCOUNT_INDEX_I64),
18154                200,
18155                Some(&format!("hash{:02x}", TEST_NEXT_NONCE + i)),
18156            );
18157            assert!(matches!(
18158                acked.map(|pending| pending.kind),
18159                Some(PendingSendTxKind::Other),
18160            ));
18161            let nonce = client
18162                .dispatch
18163                .nonce_manager
18164                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18165                .unwrap();
18166            assert_eq!(nonce, TEST_NEXT_NONCE + window + i);
18167            enqueue_other(&client, nonce);
18168        }
18169    }
18170
18171    #[tokio::test]
18172    async fn handle_send_tx_rejection_rolls_back_latest_nonce() {
18173        let (client, cache, mut rx) = create_execution_client();
18174        let instrument_id = register_test_instrument(&client, &cache);
18175        let mut factory = test_order_factory();
18176        let order = test_limit_order(&mut factory, instrument_id, "REJECT-LATEST");
18177
18178        let nonce = client
18179            .dispatch
18180            .nonce_manager
18181            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18182            .unwrap();
18183        let client_order_index = client.dispatch.register_create_identity(&order).unwrap();
18184        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
18185            batch_id: None,
18186            connection_epoch: 0,
18187            kind: PendingSendTxKind::Create {
18188                order: Box::new(order.clone()),
18189                client_order_index,
18190            },
18191            submitted_at: UnixNanos::from(1_000_000_000),
18192            nonce,
18193            api_key_index: TEST_API_KEY_INDEX,
18194            tx_hash: format!("hash{nonce:02x}"),
18195        });
18196
18197        handle_send_tx_rejection(
18198            &client.dispatch,
18199            &client.emitter,
18200            Some(TEST_ACCOUNT_INDEX_I64),
18201            UnixNanos::from(1_000_000_000),
18202            SendTxRejectionSource::Ack,
18203            Some(21702),
18204            "invalid price",
18205            None,
18206        );
18207
18208        let event = recv_order_event(&mut rx).await;
18209        assert!(matches!(event, OrderEventAny::Rejected(_)));
18210        assert_nonce_reusable(&client.dispatch);
18211    }
18212
18213    #[tokio::test]
18214    async fn handle_send_tx_rejection_hashless_with_multiple_pending_is_unattributed() {
18215        let (client, cache, mut rx) = create_execution_client();
18216        let instrument_id = register_test_instrument(&client, &cache);
18217        let mut factory = test_order_factory();
18218        let order_a = test_limit_order(&mut factory, instrument_id, "REJECT-AMBIGUOUS-A");
18219        let order_b = test_limit_order(&mut factory, instrument_id, "REJECT-AMBIGUOUS-B");
18220        enqueue_create(&client, &order_a, 10);
18221        enqueue_create(&client, &order_b, 11);
18222
18223        handle_send_tx_rejection(
18224            &client.dispatch,
18225            &client.emitter,
18226            Some(TEST_ACCOUNT_INDEX_I64),
18227            UnixNanos::from(1_000_000_000),
18228            SendTxRejectionSource::Ack,
18229            Some(21702),
18230            "invalid price",
18231            None,
18232        );
18233
18234        assert_eq!(client.dispatch.pending_sendtx_len(), 2);
18235        assert!(
18236            tokio::time::timeout(Duration::from_millis(50), rx.recv())
18237                .await
18238                .is_err(),
18239            "an ambiguous hashless rejection must not reject either order",
18240        );
18241    }
18242
18243    #[tokio::test]
18244    async fn handle_send_tx_rejection_with_newer_issuance_skips_rollback() {
18245        let (client, cache, mut rx) = create_execution_client();
18246        let instrument_id = register_test_instrument(&client, &cache);
18247        let mut factory = test_order_factory();
18248        let order = test_limit_order(&mut factory, instrument_id, "REJECT-STALE");
18249
18250        let rejected_nonce = client
18251            .dispatch
18252            .nonce_manager
18253            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18254            .unwrap();
18255        let newer_nonce = client
18256            .dispatch
18257            .nonce_manager
18258            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18259            .unwrap();
18260        let client_order_index = client.dispatch.register_create_identity(&order).unwrap();
18261        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
18262            batch_id: None,
18263            connection_epoch: 0,
18264            kind: PendingSendTxKind::Create {
18265                order: Box::new(order.clone()),
18266                client_order_index,
18267            },
18268            submitted_at: UnixNanos::from(1_000_000_000),
18269            nonce: rejected_nonce,
18270            api_key_index: TEST_API_KEY_INDEX,
18271            tx_hash: format!("hash{rejected_nonce:02x}"),
18272        });
18273
18274        handle_send_tx_rejection(
18275            &client.dispatch,
18276            &client.emitter,
18277            Some(TEST_ACCOUNT_INDEX_I64),
18278            UnixNanos::from(1_000_000_000),
18279            SendTxRejectionSource::Ack,
18280            Some(21702),
18281            "invalid price",
18282            None,
18283        );
18284
18285        let event = recv_order_event(&mut rx).await;
18286        assert!(matches!(event, OrderEventAny::Rejected(_)));
18287        // The newer nonce is signed into an in-flight tx: the failed nonce
18288        // must not be freed for reissue.
18289        assert_eq!(
18290            client
18291                .dispatch
18292                .nonce_manager
18293                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18294            Some(newer_nonce),
18295        );
18296        assert_eq!(
18297            client
18298                .dispatch
18299                .nonce_manager
18300                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18301                .unwrap(),
18302            newer_nonce + 1,
18303        );
18304    }
18305
18306    async fn spawn_next_nonce_server(nonce: i64) -> String {
18307        let body = serde_json::to_string(&LighterNextNonce {
18308            code: 200,
18309            message: None,
18310            nonce,
18311        })
18312        .unwrap();
18313
18314        let app = Router::new().route(
18315            "/api/v1/nextNonce",
18316            get(move || {
18317                let body = body.clone();
18318                async move { body }
18319            }),
18320        );
18321
18322        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
18323        let addr = listener.local_addr().unwrap();
18324        tokio::spawn(async move {
18325            axum::serve(listener, app).await.unwrap();
18326        });
18327
18328        format!("http://{addr}")
18329    }
18330
18331    async fn spawn_counting_next_nonce_server(nonce: i64) -> (String, Arc<AtomicUsize>) {
18332        let requests = Arc::new(AtomicUsize::new(0));
18333        let requests_for_route = Arc::clone(&requests);
18334
18335        let body = serde_json::to_string(&LighterNextNonce {
18336            code: 200,
18337            message: None,
18338            nonce,
18339        })
18340        .unwrap();
18341
18342        let app = Router::new().route(
18343            "/api/v1/nextNonce",
18344            get(move || {
18345                requests_for_route.fetch_add(1, Ordering::SeqCst);
18346                let body = body.clone();
18347                async move { body }
18348            }),
18349        );
18350
18351        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
18352        let addr = listener.local_addr().unwrap();
18353        tokio::spawn(async move {
18354            axum::serve(listener, app).await.unwrap();
18355        });
18356
18357        (format!("http://{addr}"), requests)
18358    }
18359
18360    #[tokio::test]
18361    async fn hard_nonce_refresh_waits_for_submission_and_replaces_its_baseline() {
18362        let (base_url_http, requests) = spawn_counting_next_nonce_server(100).await;
18363        let mut config = test_config();
18364        config.base_url_http = Some(base_url_http);
18365        let (client, _cache, _rx) = create_execution_client_with_config(config);
18366        let credential = test_credential();
18367        client
18368            .dispatch
18369            .nonce_manager
18370            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, 40);
18371
18372        let reserved = client.build_tx_context(&credential).unwrap();
18373        assert_eq!(reserved.context.nonce, 40);
18374        let mut refresh = Box::pin(client.refresh_nonce());
18375        assert!(
18376            tokio::time::timeout(Duration::from_millis(50), refresh.as_mut())
18377                .await
18378                .is_err(),
18379            "hard refresh must wait while a submission retains its nonce",
18380        );
18381        assert_eq!(requests.load(Ordering::SeqCst), 0);
18382        assert_eq!(
18383            client.nonce_ready_connection_epoch.load(Ordering::Acquire),
18384            NONCE_CONNECTION_EPOCH_UNAVAILABLE,
18385        );
18386        assert_eq!(
18387            client
18388                .dispatch
18389                .nonce_manager
18390                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18391            Some(40),
18392        );
18393
18394        drop(reserved);
18395        refresh.await.unwrap();
18396
18397        assert_eq!(requests.load(Ordering::SeqCst), 1);
18398        assert_eq!(
18399            client.nonce_ready_connection_epoch.load(Ordering::Acquire),
18400            NONCE_CONNECTION_EPOCH_UNAVAILABLE,
18401        );
18402        client
18403            .nonce_ready_connection_epoch
18404            .store(client.ws_client.connection_epoch(), Ordering::Release);
18405        let next = client.build_tx_context(&credential).unwrap();
18406        assert_eq!(next.context.nonce, 100);
18407        assert_eq!(
18408            client
18409                .dispatch
18410                .nonce_manager
18411                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18412            Some(100),
18413        );
18414    }
18415
18416    #[tokio::test]
18417    async fn nonce_refresh_retry_reopens_submission_with_replacement_baseline() {
18418        let (base_url_http, requests) = spawn_counting_next_nonce_server(100).await;
18419        let mut config = test_config();
18420        config.base_url_http = Some(base_url_http);
18421        let (client, _cache, _rx) = create_execution_client_with_config(config);
18422        let credential = test_credential();
18423        let connection_epoch = client.ws_client.connection_epoch();
18424        client
18425            .dispatch
18426            .nonce_manager
18427            .refresh(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX, 40);
18428        client
18429            .nonce_ready_connection_epoch
18430            .store(NONCE_CONNECTION_EPOCH_UNAVAILABLE, Ordering::Release);
18431
18432        NonceRefreshRetry {
18433            http_client: client.http_client.clone(),
18434            dispatch: client.dispatch.clone(),
18435            credential: Some(credential.clone()),
18436            submission_gate: Arc::clone(&client.nonce_submission_gate),
18437            ready_connection_epoch: Arc::clone(&client.nonce_ready_connection_epoch),
18438            ws_client: client.ws_client.clone(),
18439            cancellation_token: client.cancellation_token.clone(),
18440            pending_tasks: client.pending_tasks.spawner().expect("task spawner"),
18441        }
18442        .spawn(connection_epoch);
18443
18444        assert_eq!(client.pending_tasks.len(), 1);
18445
18446        let err = client
18447            .build_tx_context(&credential)
18448            .expect_err("submission must stay closed until retry succeeds");
18449        assert_eq!(
18450            err.to_string(),
18451            format!("Lighter nonce refresh is pending for connection epoch {connection_epoch}"),
18452        );
18453        wait_until_async(
18454            || async {
18455                client.nonce_ready_connection_epoch.load(Ordering::Acquire) == connection_epoch
18456            },
18457            Duration::from_secs(3),
18458        )
18459        .await;
18460
18461        let reserved = client.build_tx_context(&credential).unwrap();
18462        assert_eq!(requests.load(Ordering::SeqCst), 1);
18463        assert_eq!(reserved.context.nonce, 100);
18464        assert_eq!(
18465            client
18466                .dispatch
18467                .nonce_manager
18468                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18469            Some(99),
18470        );
18471        assert_eq!(
18472            client
18473                .dispatch
18474                .nonce_manager
18475                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18476            Some(100),
18477        );
18478        assert!(client.pending_tasks.all_finished());
18479    }
18480
18481    async fn spawn_integrator_approval_rejection_server() -> String {
18482        let app = Router::new()
18483            .route(
18484                "/api/v1/getMakerOnlyApiKeys",
18485                get(|| async { r#"{"code":200,"api_key_indexes":[]}"# }),
18486            )
18487            .route(
18488                "/api/v1/sendTx",
18489                post(|| async { r#"{"code":21702,"message":"invalid approval"}"# }),
18490            );
18491        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
18492        let addr = listener.local_addr().unwrap();
18493        tokio::spawn(async move {
18494            axum::serve(listener, app).await.unwrap();
18495        });
18496
18497        format!("http://{addr}")
18498    }
18499
18500    #[tokio::test]
18501    async fn integrator_approval_api_rejection_releases_reservation_and_nonce() {
18502        let mut config = test_config();
18503        config.environment = LighterEnvironment::Mainnet;
18504        config.base_url_http = Some(spawn_integrator_approval_rejection_server().await);
18505        let (client, _cache, _rx) = create_execution_client_with_config(config);
18506
18507        let err = client.submit_integrator_auto_approval().await.unwrap_err();
18508
18509        assert!(format!("{err:#}").contains("invalid approval"));
18510        assert_nonce_reusable(&client.dispatch);
18511    }
18512
18513    #[tokio::test]
18514    async fn skip_window_exhaustion_resyncs_baseline_from_venue() {
18515        let venue_next_nonce = 100;
18516        let mut config = test_config();
18517        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
18518        let (client, _cache, _rx) = create_execution_client_with_config(config);
18519        let credential = test_credential();
18520
18521        let window = i64::from(client.dispatch.nonce_manager.skip_window());
18522        for _ in 0..window {
18523            client
18524                .dispatch
18525                .nonce_manager
18526                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18527                .unwrap();
18528        }
18529
18530        let err = client
18531            .build_tx_context(&credential)
18532            .expect_err("window must trip");
18533        assert!(
18534            err.to_string().contains("skip-window exhausted"),
18535            "unexpected error, was {err}",
18536        );
18537
18538        wait_for_spawned_tasks(&client).await;
18539
18540        assert_eq!(
18541            client
18542                .dispatch
18543                .nonce_manager
18544                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18545            Some(venue_next_nonce - 1),
18546            "venue resync must advance the baseline",
18547        );
18548        assert!(
18549            !client.nonce_recovery_inflight.load(Ordering::Acquire),
18550            "recovery latch must release for the next exhaustion",
18551        );
18552        let context = client.build_tx_context(&credential).unwrap().context;
18553        assert_eq!(
18554            context.nonce, venue_next_nonce,
18555            "allocation must resume at the venue nonce",
18556        );
18557    }
18558
18559    #[tokio::test]
18560    async fn skip_window_recovery_fetch_failure_releases_latch() {
18561        // 404 fails the fetch fast; unroutable addresses retry past the task wait
18562        let app = Router::new();
18563        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
18564        let addr = listener.local_addr().unwrap();
18565        tokio::spawn(async move {
18566            axum::serve(listener, app).await.unwrap();
18567        });
18568
18569        let mut config = test_config();
18570        config.base_url_http = Some(format!("http://{addr}"));
18571        let (client, _cache, _rx) = create_execution_client_with_config(config);
18572        let credential = test_credential();
18573
18574        let window = i64::from(client.dispatch.nonce_manager.skip_window());
18575        for _ in 0..window {
18576            client
18577                .dispatch
18578                .nonce_manager
18579                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18580                .unwrap();
18581        }
18582
18583        client
18584            .build_tx_context(&credential)
18585            .expect_err("window must trip");
18586
18587        wait_for_spawned_tasks(&client).await;
18588
18589        assert_eq!(
18590            client
18591                .dispatch
18592                .nonce_manager
18593                .baseline(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
18594            Some(TEST_NEXT_NONCE - 1),
18595            "failed venue fetch must leave the baseline unchanged",
18596        );
18597        assert!(
18598            !client.nonce_recovery_inflight.load(Ordering::Acquire),
18599            "failed venue fetch must release the recovery latch",
18600        );
18601    }
18602
18603    #[tokio::test]
18604    async fn skip_window_recovery_dedupes_concurrent_fetches() {
18605        let hits = Arc::new(AtomicUsize::new(0));
18606        let server_hits = Arc::clone(&hits);
18607
18608        let body = serde_json::to_string(&LighterNextNonce {
18609            code: 200,
18610            message: None,
18611            nonce: 100,
18612        })
18613        .unwrap();
18614
18615        let app = Router::new().route(
18616            "/api/v1/nextNonce",
18617            get(move || {
18618                server_hits.fetch_add(1, Ordering::AcqRel);
18619                let body = body.clone();
18620                async move { body }
18621            }),
18622        );
18623        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
18624        let addr = listener.local_addr().unwrap();
18625        tokio::spawn(async move {
18626            axum::serve(listener, app).await.unwrap();
18627        });
18628
18629        let mut config = test_config();
18630        config.base_url_http = Some(format!("http://{addr}"));
18631        let (client, _cache, _rx) = create_execution_client_with_config(config);
18632        let credential = test_credential();
18633
18634        let window = i64::from(client.dispatch.nonce_manager.skip_window());
18635        for _ in 0..window {
18636            client
18637                .dispatch
18638                .nonce_manager
18639                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18640                .unwrap();
18641        }
18642
18643        // Back-to-back exhaustions land inside one HTTP round trip; the
18644        // latch must collapse them into a single fetch.
18645        client
18646            .build_tx_context(&credential)
18647            .expect_err("window must trip");
18648        client
18649            .build_tx_context(&credential)
18650            .expect_err("window must still be exhausted");
18651
18652        wait_for_spawned_tasks(&client).await;
18653
18654        assert_eq!(
18655            hits.load(Ordering::Acquire),
18656            1,
18657            "burst exhaustion must trigger a single venue fetch",
18658        );
18659        assert!(!client.nonce_recovery_inflight.load(Ordering::Acquire));
18660    }
18661
18662    #[tokio::test]
18663    async fn refresh_nonce_releases_recovery_latch() {
18664        let venue_next_nonce = 77;
18665        let mut config = test_config();
18666        config.base_url_http = Some(spawn_next_nonce_server(venue_next_nonce).await);
18667        let (client, _cache, _rx) = create_execution_client_with_config(config);
18668
18669        // Simulate a recovery task aborted between latch set and clear
18670        client
18671            .nonce_recovery_inflight
18672            .store(true, Ordering::Release);
18673
18674        client.refresh_nonce().await.unwrap();
18675
18676        assert!(
18677            !client.nonce_recovery_inflight.load(Ordering::Acquire),
18678            "connect-time refresh must release a stuck recovery latch",
18679        );
18680        assert_eq!(
18681            client
18682                .dispatch
18683                .nonce_manager
18684                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18685                .unwrap(),
18686            venue_next_nonce,
18687            "refresh must hard-reset allocation to the venue nonce",
18688        );
18689    }
18690
18691    #[tokio::test]
18692    async fn handle_send_tx_rejection_ack_create_emits_order_rejected() {
18693        let (client, cache, mut rx) = create_execution_client();
18694        let instrument_id = register_test_instrument(&client, &cache);
18695        let mut factory = test_order_factory();
18696        let order = test_limit_order(&mut factory, instrument_id, "REJECT-CREATE");
18697        let client_order_index = enqueue_create(&client, &order, 42);
18698
18699        handle_send_tx_rejection(
18700            &client.dispatch,
18701            &client.emitter,
18702            Some(TEST_ACCOUNT_INDEX_I64),
18703            UnixNanos::from(1_000_000_000),
18704            SendTxRejectionSource::Ack,
18705            Some(21702),
18706            "invalid price",
18707            None,
18708        );
18709
18710        let event = recv_order_event(&mut rx).await;
18711        match event {
18712            OrderEventAny::Rejected(e) => {
18713                assert_eq!(e.client_order_id, order.client_order_id());
18714                assert_eq!(e.reason, "LIGHTER_21702: invalid price");
18715                assert!(!e.due_post_only);
18716            }
18717            other => panic!("expected Rejected, was {other:?}"),
18718        }
18719
18720        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
18721        assert!(client.dispatch.cloid_map.get(&client_order_index).is_none());
18722    }
18723
18724    #[tokio::test]
18725    async fn handle_send_tx_rejection_ack_create_sets_due_post_only() {
18726        let (client, cache, mut rx) = create_execution_client();
18727        let instrument_id = register_test_instrument(&client, &cache);
18728        let mut factory = test_order_factory();
18729        let order = test_limit_order(&mut factory, instrument_id, "REJECT-POST-ONLY");
18730        enqueue_create(&client, &order, 43);
18731
18732        handle_send_tx_rejection(
18733            &client.dispatch,
18734            &client.emitter,
18735            Some(TEST_ACCOUNT_INDEX_I64),
18736            UnixNanos::from(1_000_000_000),
18737            SendTxRejectionSource::Ack,
18738            Some(21700),
18739            "post-only order would execute",
18740            None,
18741        );
18742
18743        let event = recv_order_event(&mut rx).await;
18744        match event {
18745            OrderEventAny::Rejected(e) => {
18746                assert_eq!(e.client_order_id, order.client_order_id());
18747                assert!(e.due_post_only);
18748            }
18749            other => panic!("expected Rejected, was {other:?}"),
18750        }
18751
18752        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
18753    }
18754
18755    #[rstest]
18756    #[case::explicit_enqueued(true)]
18757    #[case::explicit_waiting(false)]
18758    #[tokio::test]
18759    async fn unmarked_cancel_enqueue_preserves_explicit_rejection(#[case] explicit_enqueued: bool) {
18760        let (client, cache, mut rx) = create_execution_client();
18761        let instrument_id = register_test_instrument(&client, &cache);
18762        let mut factory = test_order_factory();
18763        let order = test_limit_order(&mut factory, instrument_id, "CANCEL-ELIGIBILITY");
18764        let id = order.client_order_id();
18765        let venue_id = VenueOrderId::from("123");
18766        cache_pending_cancel_order(&cache, order, venue_id);
18767        let context = client
18768            .fanout_dispatch_context(client.credential.as_ref().unwrap())
18769            .unwrap();
18770
18771        let plan = CancelOrderPlan {
18772            client_order_id: id,
18773            strategy_id: strategy_id(),
18774            instrument_id,
18775            venue_order_id: Some(venue_id),
18776            market_index: TEST_MARKET_INDEX,
18777            venue_index: 123,
18778        };
18779
18780        let first = context.sign_cancel_order(&plan).unwrap();
18781        let second = context.sign_cancel_order(&plan).unwrap();
18782        let (explicit, batch) = if explicit_enqueued {
18783            (first, second)
18784        } else {
18785            (second, first)
18786        };
18787
18788        client
18789            .dispatch
18790            .set_pending_order_action(id, PendingOrderAction::Cancel);
18791
18792        if explicit_enqueued {
18793            context.enqueue_cancel_order(&explicit, true);
18794        }
18795
18796        let mut batch = PreparedBatchTx::from((batch, false));
18797        batch.pending.batch_id = Some("batch-unmarked".to_string());
18798        context.enqueue_batch_tx(&batch);
18799        assert_eq!(
18800            client.dispatch.pending_cancel_nonce(&id),
18801            explicit_enqueued.then_some((0, explicit.nonce))
18802        );
18803
18804        if !explicit_enqueued {
18805            context.enqueue_cancel_order(&explicit, true);
18806        }
18807
18808        handle_send_tx_rejection_for_connection(
18809            &client.dispatch,
18810            &client.emitter,
18811            Some(TEST_ACCOUNT_INDEX_I64),
18812            0,
18813            UnixNanos::default(),
18814            SendTxRejectionSource::Ack,
18815            Some(21727),
18816            "unmarked batch failed",
18817            Some(&batch.pending.tx_hash),
18818        );
18819        assert_eq!(
18820            client.dispatch.pending_cancel_nonce(&id),
18821            Some((0, explicit.nonce))
18822        );
18823        assert!(rx.try_recv().is_err());
18824        handle_send_tx_rejection_for_connection(
18825            &client.dispatch,
18826            &client.emitter,
18827            Some(TEST_ACCOUNT_INDEX_I64),
18828            0,
18829            UnixNanos::default(),
18830            SendTxRejectionSource::Ack,
18831            Some(21727),
18832            "explicit cancel failed",
18833            Some(&explicit.tx_hash),
18834        );
18835        let event = recv_order_event(&mut rx).await;
18836        cache.borrow_mut().update_order(&event).unwrap();
18837        match event {
18838            OrderEventAny::CancelRejected(event) => {
18839                assert_eq!(event.client_order_id, id);
18840                assert_eq!(event.venue_order_id, Some(venue_id));
18841                assert_eq!(event.instrument_id, instrument_id);
18842                assert_eq!(event.strategy_id, strategy_id());
18843                assert_eq!(event.reason, "LIGHTER_21727: explicit cancel failed");
18844            }
18845            other => panic!("expected explicit cancel rejection, was {other:?}"),
18846        }
18847
18848        assert_eq!(
18849            cache.borrow().order(&id).unwrap().status(),
18850            OrderStatus::Accepted
18851        );
18852        assert_eq!(client.dispatch.pending_cancel_nonce(&id), None);
18853        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
18854    }
18855
18856    #[rstest]
18857    #[case::pending(PendingOrderAction::Cancel, true)]
18858    #[case::unmarked(PendingOrderAction::CancelUnmarked, false)]
18859    #[tokio::test]
18860    async fn cancel_rejection_preserves_newer_nonce(
18861        #[case] action: PendingOrderAction,
18862        #[case] emit_cancel_rejected: bool,
18863    ) {
18864        let (client, cache, mut rx) = create_execution_client();
18865        let instrument_id = register_test_instrument(&client, &cache);
18866        let id = ClientOrderId::from("CANCEL-NEWER");
18867
18868        let pending = PendingSendTx {
18869            batch_id: Some("batch-old".to_string()),
18870            connection_epoch: 3,
18871            kind: PendingSendTxKind::Cancel {
18872                emit_cancel_rejected,
18873                strategy_id: strategy_id(),
18874                instrument_id,
18875                client_order_id: id,
18876                venue_order_id: Some(VenueOrderId::from("123")),
18877            },
18878            submitted_at: UnixNanos::default(),
18879            nonce: 41,
18880            api_key_index: TEST_API_KEY_INDEX,
18881            tx_hash: "old-hash".to_string(),
18882        };
18883
18884        client.dispatch.set_pending_order_action(id, action);
18885        client
18886            .dispatch
18887            .set_pending_cancel_nonce(&id, 3, 42, emit_cancel_rejected);
18888        reject_cancel_order(
18889            &client.dispatch,
18890            &client.emitter,
18891            &pending,
18892            "old failure",
18893            UnixNanos::default(),
18894        );
18895        assert_eq!(client.dispatch.pending_cancel_nonce(&id), Some((3, 42)));
18896        assert_eq!(client.dispatch.pending_order_action(&id), Some(action));
18897        assert!(rx.try_recv().is_err());
18898    }
18899
18900    #[tokio::test]
18901    async fn handle_send_tx_rejection_cancel_emits_cancel_rejected() {
18902        let (client, cache, mut rx) = create_execution_client();
18903        let instrument_id = register_test_instrument(&client, &cache);
18904        let client_order_id = ClientOrderId::from("REJECT-CANCEL");
18905        let venue_order_id = VenueOrderId::from("123");
18906        let nonce = client
18907            .dispatch
18908            .nonce_manager
18909            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18910            .unwrap();
18911
18912        client
18913            .dispatch
18914            .set_pending_order_action(client_order_id, PendingOrderAction::Cancel);
18915        client
18916            .dispatch
18917            .set_pending_cancel_nonce(&client_order_id, 0, nonce, true);
18918        client.dispatch.enqueue_pending_sendtx(PendingSendTx {
18919            batch_id: None,
18920            connection_epoch: 0,
18921            kind: PendingSendTxKind::Cancel {
18922                emit_cancel_rejected: true,
18923                strategy_id: strategy_id(),
18924                instrument_id,
18925                client_order_id,
18926                venue_order_id: Some(venue_order_id),
18927            },
18928            submitted_at: UnixNanos::from(1_000_000_000),
18929            nonce,
18930            api_key_index: TEST_API_KEY_INDEX,
18931            tx_hash: format!("hash{nonce:02x}"),
18932        });
18933
18934        handle_send_tx_rejection(
18935            &client.dispatch,
18936            &client.emitter,
18937            Some(TEST_ACCOUNT_INDEX_I64),
18938            UnixNanos::from(1_000_000_000),
18939            SendTxRejectionSource::Ack,
18940            Some(21727),
18941            "order is not cancelable",
18942            None,
18943        );
18944
18945        let event = recv_order_event(&mut rx).await;
18946        match event {
18947            OrderEventAny::CancelRejected(e) => {
18948                assert_eq!(e.client_order_id, client_order_id);
18949                assert_eq!(e.instrument_id, instrument_id);
18950                assert_eq!(e.venue_order_id, Some(venue_order_id));
18951                assert_eq!(e.reason, "LIGHTER_21727: order is not cancelable",);
18952            }
18953            other => panic!("expected CancelRejected, was {other:?}"),
18954        }
18955
18956        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
18957        assert_nonce_reusable(&client.dispatch);
18958    }
18959
18960    #[tokio::test]
18961    async fn handle_send_tx_rejection_modify_emits_modify_rejected() {
18962        let (client, cache, mut rx) = create_execution_client();
18963        let instrument_id = register_test_instrument(&client, &cache);
18964        let client_order_id = ClientOrderId::from("REJECT-MODIFY");
18965        let venue_order_id = VenueOrderId::from("456");
18966        let nonce = client
18967            .dispatch
18968            .nonce_manager
18969            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
18970            .unwrap();
18971
18972        enqueue_modify(
18973            &client,
18974            instrument_id,
18975            client_order_id,
18976            venue_order_id,
18977            nonce,
18978        );
18979
18980        handle_send_tx_rejection(
18981            &client.dispatch,
18982            &client.emitter,
18983            Some(TEST_ACCOUNT_INDEX_I64),
18984            UnixNanos::from(1_000_000_000),
18985            SendTxRejectionSource::Ack,
18986            Some(21702),
18987            "modify rejected by venue",
18988            None,
18989        );
18990
18991        let event = recv_order_event(&mut rx).await;
18992        match event {
18993            OrderEventAny::ModifyRejected(e) => {
18994                assert_eq!(e.client_order_id, client_order_id);
18995                assert_eq!(e.instrument_id, instrument_id);
18996                assert_eq!(e.venue_order_id, Some(venue_order_id));
18997                assert_eq!(e.reason, "LIGHTER_21702: modify rejected by venue",);
18998            }
18999            other => panic!("expected ModifyRejected, was {other:?}"),
19000        }
19001
19002        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
19003        assert_nonce_reusable(&client.dispatch);
19004    }
19005
19006    #[rstest]
19007    #[case::newer_modify(PendingOrderAction::Modify)]
19008    #[case::newer_cancel(PendingOrderAction::Cancel)]
19009    fn handle_send_tx_rejection_preserves_superseding_modify_action(
19010        #[case] action: PendingOrderAction,
19011    ) {
19012        let (client, cache, mut rx) = create_execution_client();
19013        let instrument = register_test_instrument(&client, &cache);
19014        let id = ClientOrderId::from("REJECT-MODIFY-SUPERSEDED");
19015        let venue = VenueOrderId::from("456");
19016        let nonce = client
19017            .dispatch
19018            .nonce_manager
19019            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19020            .unwrap();
19021        enqueue_modify(&client, instrument, id, venue, nonce);
19022        let current_nonce = client
19023            .dispatch
19024            .nonce_manager
19025            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19026            .unwrap();
19027
19028        assert!(client.dispatch.clear_pending_modify_if_nonce(&id, 0, nonce));
19029
19030        if action == PendingOrderAction::Modify {
19031            enqueue_modify(&client, instrument, id, venue, current_nonce);
19032        } else {
19033            client.dispatch.set_pending_order_action(id, action);
19034            client
19035                .dispatch
19036                .set_pending_cancel_nonce(&id, 0, current_nonce, true);
19037        }
19038
19039        handle_send_tx_rejection(
19040            &client.dispatch,
19041            &client.emitter,
19042            Some(TEST_ACCOUNT_INDEX_I64),
19043            UnixNanos::from(1_000_000_000),
19044            SendTxRejectionSource::Ack,
19045            Some(21702),
19046            "older modify rejected by venue",
19047            Some(&format!("hash{nonce:02x}")),
19048        );
19049
19050        assert!(rx.try_recv().is_err());
19051        assert_eq!(client.dispatch.pending_order_action(&id), Some(action));
19052        assert_eq!(
19053            client.dispatch.pending_modify_nonce(&id),
19054            (action == PendingOrderAction::Modify).then_some((0, current_nonce))
19055        );
19056        assert_eq!(
19057            client.dispatch.pending_cancel_nonce(&id),
19058            (action == PendingOrderAction::Cancel).then_some((0, current_nonce))
19059        );
19060        assert_eq!(
19061            client.dispatch.pending_sendtx_len(),
19062            usize::from(action == PendingOrderAction::Modify)
19063        );
19064        assert_eq!(
19065            client
19066                .dispatch
19067                .nonce_manager
19068                .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19069                .unwrap(),
19070            current_nonce + 1
19071        );
19072    }
19073
19074    #[tokio::test]
19075    async fn handle_send_tx_rejection_hash_match_attributes_past_the_head() {
19076        let (client, cache, mut rx) = create_execution_client();
19077        let instrument_id = register_test_instrument(&client, &cache);
19078        let mut factory = test_order_factory();
19079        let order_a = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-A");
19080        let order_b = test_limit_order(&mut factory, instrument_id, "REJECT-HASH-B");
19081        enqueue_create(&client, &order_a, 10);
19082        enqueue_create(&client, &order_b, 11);
19083
19084        // A desynced or out-of-order rejection for B must not consume A.
19085        handle_send_tx_rejection(
19086            &client.dispatch,
19087            &client.emitter,
19088            Some(TEST_ACCOUNT_INDEX_I64),
19089            UnixNanos::from(1_000_000_000),
19090            SendTxRejectionSource::Ack,
19091            Some(21702),
19092            "invalid price",
19093            Some("hash0b"),
19094        );
19095
19096        let event = recv_order_event(&mut rx).await;
19097        match event {
19098            OrderEventAny::Rejected(e) => {
19099                assert_eq!(e.client_order_id, order_b.client_order_id());
19100            }
19101            other => panic!("expected Rejected, was {other:?}"),
19102        }
19103
19104        assert_eq!(client.dispatch.pending_sendtx_len(), 1, "A must survive");
19105        let head = client.dispatch.pop_pending_sendtx_head().unwrap();
19106        match head.kind {
19107            PendingSendTxKind::Create { order, .. } => {
19108                assert_eq!(order.client_order_id(), order_a.client_order_id());
19109            }
19110            _ => panic!("expected Create kind"),
19111        }
19112    }
19113
19114    #[tokio::test]
19115    async fn handle_send_tx_rejection_unmatched_hash_pops_nothing() {
19116        let (client, cache, mut rx) = create_execution_client();
19117        let instrument_id = register_test_instrument(&client, &cache);
19118        let mut factory = test_order_factory();
19119        let order = test_limit_order(&mut factory, instrument_id, "REJECT-UNMATCHED");
19120        enqueue_create(&client, &order, 10);
19121
19122        handle_send_tx_rejection(
19123            &client.dispatch,
19124            &client.emitter,
19125            Some(TEST_ACCOUNT_INDEX_I64),
19126            UnixNanos::from(1_000_000_000),
19127            SendTxRejectionSource::Ack,
19128            Some(21702),
19129            "invalid price",
19130            Some("0xbeef"),
19131        );
19132
19133        assert_eq!(
19134            client.dispatch.pending_sendtx_len(),
19135            1,
19136            "an echoed hash with no matching entry must not pop the head",
19137        );
19138        assert!(
19139            tokio::time::timeout(Duration::from_millis(50), rx.recv())
19140                .await
19141                .is_err(),
19142            "unmatched rejection must not emit an event",
19143        );
19144    }
19145
19146    #[tokio::test]
19147    async fn handle_send_tx_rejection_bare_error_within_window_attributes() {
19148        let (client, cache, mut rx) = create_execution_client();
19149        let instrument_id = register_test_instrument(&client, &cache);
19150        let mut factory = test_order_factory();
19151        let order = test_limit_order(&mut factory, instrument_id, "BARE-IN");
19152        enqueue_create(&client, &order, 50);
19153
19154        let within_window = UnixNanos::from(1_000_000_000 + 500 * 1_000_000);
19155        handle_send_tx_rejection(
19156            &client.dispatch,
19157            &client.emitter,
19158            Some(TEST_ACCOUNT_INDEX_I64),
19159            within_window,
19160            SendTxRejectionSource::BareError,
19161            Some(21149),
19162            "integrator is not approved",
19163            None,
19164        );
19165
19166        let event = recv_order_event(&mut rx).await;
19167        assert!(matches!(event, OrderEventAny::Rejected(_)));
19168        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
19169    }
19170
19171    #[tokio::test]
19172    async fn handle_send_tx_rejection_bare_error_outside_window_skips() {
19173        let (client, cache, mut rx) = create_execution_client();
19174        let instrument_id = register_test_instrument(&client, &cache);
19175        let mut factory = test_order_factory();
19176        let order = test_limit_order(&mut factory, instrument_id, "BARE-OUT");
19177        enqueue_create(&client, &order, 60);
19178
19179        let outside_window = UnixNanos::from(1_000_000_000 + 2_000 * 1_000_000);
19180        handle_send_tx_rejection(
19181            &client.dispatch,
19182            &client.emitter,
19183            Some(TEST_ACCOUNT_INDEX_I64),
19184            outside_window,
19185            SendTxRejectionSource::BareError,
19186            Some(99),
19187            "late error",
19188            None,
19189        );
19190
19191        assert_eq!(
19192            client.dispatch.pending_sendtx_len(),
19193            1,
19194            "head must remain queued past the 1s attribution window",
19195        );
19196        assert!(
19197            tokio::time::timeout(Duration::from_millis(50), rx.recv())
19198                .await
19199                .is_err(),
19200            "no event must be emitted outside the window",
19201        );
19202    }
19203
19204    #[tokio::test]
19205    async fn handle_send_tx_rejection_bare_error_with_multiple_pending_skips() {
19206        let (client, cache, mut rx) = create_execution_client();
19207        let instrument_id = register_test_instrument(&client, &cache);
19208        let mut factory = test_order_factory();
19209        let order_a = test_limit_order(&mut factory, instrument_id, "BARE-MULTI-A");
19210        let order_b = test_limit_order(&mut factory, instrument_id, "BARE-MULTI-B");
19211        enqueue_create(&client, &order_a, 70);
19212        enqueue_create(&client, &order_b, 71);
19213
19214        handle_send_tx_rejection(
19215            &client.dispatch,
19216            &client.emitter,
19217            Some(TEST_ACCOUNT_INDEX_I64),
19218            UnixNanos::from(1_000_000_000 + 500 * 1_000_000),
19219            SendTxRejectionSource::BareError,
19220            Some(21149),
19221            "integrator is not approved",
19222            None,
19223        );
19224
19225        assert_eq!(client.dispatch.pending_sendtx_len(), 2);
19226        assert!(
19227            tokio::time::timeout(Duration::from_millis(50), rx.recv())
19228                .await
19229                .is_err(),
19230            "a bare error cannot be attributed with multiple transactions pending",
19231        );
19232    }
19233
19234    #[tokio::test]
19235    async fn handle_send_tx_rejection_other_kind_rolls_back_latest_nonce() {
19236        let (client, _cache, _rx) = create_execution_client();
19237        let nonce = client
19238            .dispatch
19239            .nonce_manager
19240            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19241            .unwrap();
19242        enqueue_other(&client, nonce);
19243
19244        let needs_resync = handle_send_tx_rejection(
19245            &client.dispatch,
19246            &client.emitter,
19247            Some(TEST_ACCOUNT_INDEX_I64),
19248            UnixNanos::from(1_000_000_000),
19249            SendTxRejectionSource::Ack,
19250            Some(23000),
19251            "Too Many Requests",
19252            None,
19253        );
19254
19255        assert!(!needs_resync, "rate-limit rejection must not force resync");
19256        assert_nonce_reusable(&client.dispatch);
19257    }
19258
19259    #[tokio::test]
19260    async fn handle_send_tx_rejection_other_kind_skips_rollback_with_newer_issuance() {
19261        let (client, _cache, _rx) = create_execution_client();
19262        let rejected_nonce = client
19263            .dispatch
19264            .nonce_manager
19265            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19266            .unwrap();
19267        let newer_nonce = client
19268            .dispatch
19269            .nonce_manager
19270            .next_nonce(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX)
19271            .unwrap();
19272        enqueue_other(&client, rejected_nonce);
19273
19274        handle_send_tx_rejection(
19275            &client.dispatch,
19276            &client.emitter,
19277            Some(TEST_ACCOUNT_INDEX_I64),
19278            UnixNanos::from(1_000_000_000),
19279            SendTxRejectionSource::Ack,
19280            Some(23000),
19281            "Too Many Requests",
19282            None,
19283        );
19284
19285        assert_eq!(
19286            client
19287                .dispatch
19288                .nonce_manager
19289                .last_issued(TEST_ACCOUNT_INDEX_I64, TEST_API_KEY_INDEX),
19290            Some(newer_nonce),
19291            "non-latest rejection must leave last_issued alone",
19292        );
19293    }
19294
19295    #[tokio::test]
19296    async fn handle_send_tx_rejection_invalid_nonce_signals_resync() {
19297        let (client, _cache, _rx) = create_execution_client();
19298
19299        enqueue_other(&client, 70);
19300        let attributed = handle_send_tx_rejection(
19301            &client.dispatch,
19302            &client.emitter,
19303            Some(TEST_ACCOUNT_INDEX_I64),
19304            UnixNanos::from(1_000_000_000),
19305            SendTxRejectionSource::Ack,
19306            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
19307            "invalid nonce",
19308            None,
19309        );
19310        assert!(attributed, "attributed invalid nonce must signal resync");
19311
19312        let unattributed = handle_send_tx_rejection(
19313            &client.dispatch,
19314            &client.emitter,
19315            Some(TEST_ACCOUNT_INDEX_I64),
19316            UnixNanos::from(1_000_000_000),
19317            SendTxRejectionSource::Ack,
19318            Some(LIGHTER_ERROR_CODE_INVALID_NONCE),
19319            "invalid nonce",
19320            None,
19321        );
19322        assert!(
19323            unattributed,
19324            "unattributed invalid nonce must still signal resync",
19325        );
19326
19327        enqueue_other(&client, 71);
19328        let other_code = handle_send_tx_rejection(
19329            &client.dispatch,
19330            &client.emitter,
19331            Some(TEST_ACCOUNT_INDEX_I64),
19332            UnixNanos::from(1_000_000_000),
19333            SendTxRejectionSource::Ack,
19334            Some(21702),
19335            "invalid price",
19336            None,
19337        );
19338        assert!(!other_code, "other rejection codes must not force resync");
19339    }
19340
19341    #[tokio::test]
19342    async fn handle_send_tx_rejection_other_kind_logs_and_skips_emit() {
19343        let (client, _cache, mut rx) = create_execution_client();
19344        enqueue_other(&client, 70);
19345
19346        handle_send_tx_rejection(
19347            &client.dispatch,
19348            &client.emitter,
19349            Some(TEST_ACCOUNT_INDEX_I64),
19350            UnixNanos::from(1_000_000_000),
19351            SendTxRejectionSource::Ack,
19352            Some(21727),
19353            "invalid client order index",
19354            None,
19355        );
19356
19357        assert_eq!(client.dispatch.pending_sendtx_len(), 0, "Other head pops");
19358        assert!(
19359            tokio::time::timeout(Duration::from_millis(50), rx.recv())
19360                .await
19361                .is_err(),
19362            "Other-kind rejection must not emit OrderRejected",
19363        );
19364    }
19365
19366    #[tokio::test]
19367    async fn handle_send_tx_rejection_empty_queue_logs_warn() {
19368        let (client, _cache, mut rx) = create_execution_client();
19369
19370        handle_send_tx_rejection(
19371            &client.dispatch,
19372            &client.emitter,
19373            Some(TEST_ACCOUNT_INDEX_I64),
19374            UnixNanos::from(1_000_000_000),
19375            SendTxRejectionSource::Ack,
19376            Some(1),
19377            "no pending",
19378            None,
19379        );
19380
19381        assert_eq!(client.dispatch.pending_sendtx_len(), 0);
19382        assert!(
19383            tokio::time::timeout(Duration::from_millis(50), rx.recv())
19384                .await
19385                .is_err(),
19386        );
19387    }
19388
19389    #[rstest]
19390    #[case::standard_no_override(LighterAccountTier::Standard, None, 60, None)]
19391    #[case::standard_zero_is_default(LighterAccountTier::Standard, Some(0), 60, None)]
19392    #[case::standard_override_above_tier(
19393        LighterAccountTier::Standard,
19394        Some(24_000),
19395        24_000,
19396        Some(TierCrossCheck::AboveTier { documented: 60 })
19397    )]
19398    #[case::premium_raise_hint(
19399        LighterAccountTier::Premium,
19400        None,
19401        60,
19402        Some(TierCrossCheck::RaiseHint { documented: 24_000 })
19403    )]
19404    #[case::premium_configured_no_advisory(LighterAccountTier::Premium, Some(24_000), 24_000, None)]
19405    #[case::unknown_no_advisory(LighterAccountTier::Unknown(7), None, 60, None)]
19406    fn test_tier_quota_report(
19407        #[case] tier: LighterAccountTier,
19408        #[case] configured: Option<u32>,
19409        #[case] expected_active: u32,
19410        #[case] expected_cross_check: Option<TierCrossCheck>,
19411    ) {
19412        assert_eq!(
19413            tier_quota_report(tier, configured, 60),
19414            (expected_active, expected_cross_check),
19415        );
19416    }
19417
19418    #[rstest]
19419    fn cold_first_frame_updates_cached_shape() {
19420        let mut rig = dispatcher_rig("COLD-SHAPE");
19421        let mut frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
19422        rig.dispatch.forget_cloid(frame.client_order_index);
19423        let order = OrderTestBuilder::new(OrderType::Limit)
19424            .trader_id(trader_id())
19425            .strategy_id(strategy_id())
19426            .instrument_id(rig.instrument_id)
19427            .client_order_id(rig.cloid)
19428            .side(OrderSide::Buy)
19429            .quantity(Quantity::from("0.1000"))
19430            .price(Price::from("2352.74"))
19431            .build();
19432        let accepted =
19433            TestOrderEventStubs::accepted(&order, account_id(), VenueOrderId::new(&frame.order_id));
19434        let mut order = order;
19435        order.apply(accepted).unwrap();
19436        rig.dispatch.seed_order_restorations(vec![order]);
19437        frame.initial_base_amount = Decimal::from_str("0.0400").unwrap();
19438        frame.remaining_base_amount = frame.initial_base_amount;
19439        dispatch_lighter_order(
19440            &frame,
19441            &rig.dispatch,
19442            &rig.emitter,
19443            &rig.registry,
19444            account_id(),
19445            trader_id(),
19446            UnixNanos::from(2),
19447        );
19448        let events = drain_events(&mut rig.rx);
19449        assert_eq!(events.len(), 1);
19450
19451        let ExecutionEvent::Order(OrderEventAny::Updated(update)) = &events[0] else {
19452            panic!("expected restored shape update");
19453        };
19454
19455        assert_eq!(update.quantity, Quantity::from("0.0400"));
19456        assert_eq!(update.price, Some(Price::from("2352.74")));
19457        assert_eq!(update.client_order_id, rig.cloid);
19458        assert_eq!(
19459            update.venue_order_id,
19460            Some(VenueOrderId::new(&frame.order_id))
19461        );
19462    }
19463
19464    #[rstest]
19465    #[case(false)]
19466    #[case(true)]
19467    fn native_group_child_sizing_preserves_real_fill(#[case] trade_first: bool) {
19468        let mut rig = dispatcher_rig("CHILD-SIZING");
19469        let order = OrderTestBuilder::new(OrderType::StopLimit)
19470            .trader_id(trader_id())
19471            .strategy_id(strategy_id())
19472            .instrument_id(rig.instrument_id)
19473            .client_order_id(rig.cloid)
19474            .side(OrderSide::Buy)
19475            .quantity(Quantity::from("0.1000"))
19476            .price(Price::from("2352.74"))
19477            .trigger_price(Price::from("2300.00"))
19478            .parent_order_id(ClientOrderId::new("PARENT-CHILD-SIZING"))
19479            .reduce_only(true)
19480            .build();
19481        rig.dispatch.register_create_identity(&order).unwrap();
19482        rig.dispatch
19483            .mark_order_submission(&rig.cloid, TEST_SUBMISSION_NONCE);
19484        let mut frame = dispatcher_test_order(&rig, LighterOrderStatus::Pending);
19485        frame.initial_base_amount = Decimal::ZERO;
19486        frame.remaining_base_amount = Decimal::ZERO;
19487        frame.order_type = LighterOrderKind::StopLossLimit;
19488        frame.trigger_price = Decimal::from_str("2300.00").unwrap();
19489        frame.reduce_only = true;
19490        let cache = Rc::new(RefCell::new(Cache::default()));
19491        cache
19492            .borrow_mut()
19493            .add_instrument(
19494                LIGHTER_INSTRUMENT_CACHE
19495                    .get(&rig.instrument_id)
19496                    .unwrap()
19497                    .value()
19498                    .clone(),
19499            )
19500            .unwrap();
19501        cache
19502            .borrow_mut()
19503            .add_order(order, None, Some(client_id()), false)
19504            .unwrap();
19505        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::default()));
19506        let mut engine = ExecutionEngine::new(clock, cache.clone(), None);
19507        dispatch_lighter_order(
19508            &frame,
19509            &rig.dispatch,
19510            &rig.emitter,
19511            &rig.registry,
19512            account_id(),
19513            trader_id(),
19514            UnixNanos::from(1),
19515        );
19516
19517        for event in drain_events(&mut rig.rx) {
19518            let ExecutionEvent::Order(event) = event else {
19519                panic!("expected acceptance");
19520            };
19521
19522            engine.process(&event);
19523        }
19524
19525        let mut trade = dispatcher_test_trade(&rig, true);
19526        trade.bid_id = frame.order_index;
19527        trade.bid_id_str = Some(frame.order_id.clone());
19528        trade.size = Decimal::from_str("0.0400").unwrap();
19529        trade.usd_amount = trade.size * trade.price;
19530        frame.status = LighterOrderStatus::Filled;
19531        frame.initial_base_amount = trade.size;
19532        frame.filled_base_amount = trade.size;
19533        frame.filled_quote_amount = trade.usd_amount;
19534        frame.order_type = LighterOrderKind::Limit;
19535        frame.trigger_price = Decimal::ZERO;
19536
19537        if trade_first {
19538            dispatch_lighter_trade(
19539                &trade,
19540                &rig.dispatch,
19541                &rig.emitter,
19542                &rig.registry,
19543                account_id(),
19544                trader_id(),
19545                Some(TEST_ACCOUNT_INDEX_I64),
19546                UnixNanos::from(2),
19547            );
19548        }
19549
19550        dispatch_lighter_order(
19551            &frame,
19552            &rig.dispatch,
19553            &rig.emitter,
19554            &rig.registry,
19555            account_id(),
19556            trader_id(),
19557            UnixNanos::from(3),
19558        );
19559
19560        if !trade_first {
19561            dispatch_lighter_trade(
19562                &trade,
19563                &rig.dispatch,
19564                &rig.emitter,
19565                &rig.registry,
19566                account_id(),
19567                trader_id(),
19568                Some(TEST_ACCOUNT_INDEX_I64),
19569                UnixNanos::from(4),
19570            );
19571        }
19572
19573        let events = drain_events(&mut rig.rx);
19574        for event in events {
19575            match event {
19576                ExecutionEvent::Order(event) => engine.process(&event),
19577                ExecutionEvent::Report(report) => engine.reconcile_execution_report(&report),
19578                _ => panic!("unexpected child event"),
19579            }
19580        }
19581
19582        let cache = cache.borrow();
19583        let order = cache.order(&rig.cloid).unwrap();
19584
19585        let fills = order
19586            .events()
19587            .into_iter()
19588            .filter_map(|event| match event {
19589                OrderEventAny::Filled(fill) => Some(fill),
19590                _ => None,
19591            })
19592            .collect::<Vec<_>>();
19593
19594        assert_eq!(order.status(), OrderStatus::Filled);
19595        assert_eq!(order.quantity(), Quantity::from("0.0400"));
19596        assert_eq!(order.filled_qty(), Quantity::from("0.0400"));
19597        assert_eq!(order.leaves_qty(), Quantity::from("0.0000"));
19598        assert_eq!(order.is_triggered(), Some(true));
19599        assert_eq!(fills.len(), 1);
19600        assert_eq!(fills[0].trade_id, parse_lighter_trade_id(&trade).unwrap());
19601        assert_eq!(fills[0].last_qty, Quantity::from("0.0400"));
19602        assert_eq!(fills[0].last_px, Price::from("2352.73"));
19603        assert_eq!(fills[0].commission, Some(Money::from("0.00263506 USDC")));
19604    }
19605
19606    #[rstest]
19607    #[case::distinct_timestamps(false)]
19608    #[case::same_timestamp(true)]
19609    fn native_group_preserves_position_fill_order(#[case] same_timestamp: bool) {
19610        let mut rig = dispatcher_rig("POSITION-PARENT");
19611        let parent_id = rig.cloid;
19612        let child_id = ClientOrderId::new("POSITION-CHILD");
19613        let extra_id = ClientOrderId::new("POSITION-EXTRA");
19614        let parent = OrderTestBuilder::new(OrderType::Limit)
19615            .trader_id(trader_id())
19616            .strategy_id(strategy_id())
19617            .instrument_id(rig.instrument_id)
19618            .client_order_id(parent_id)
19619            .side(OrderSide::Buy)
19620            .quantity(Quantity::from("0.1000"))
19621            .price(Price::from("100.00"))
19622            .build();
19623        rig.dispatch.register_create_identity(&parent).unwrap();
19624        rig.dispatch
19625            .mark_order_submission(&parent_id, TEST_SUBMISSION_NONCE);
19626        let mut parent_frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
19627        parent_frame.order_index = 562_947_905_631_053;
19628        parent_frame.order_id = parent_frame.order_index.to_string();
19629        parent_frame.initial_base_amount = Decimal::from_str("0.1000").unwrap();
19630        parent_frame.remaining_base_amount = parent_frame.initial_base_amount;
19631        parent_frame.price = Decimal::from_str("100.00").unwrap();
19632        dispatch_lighter_order(
19633            &parent_frame,
19634            &rig.dispatch,
19635            &rig.emitter,
19636            &rig.registry,
19637            account_id(),
19638            trader_id(),
19639            UnixNanos::from(1),
19640        );
19641        let mut opening = dispatcher_test_trade(&rig, true);
19642        opening.bid_id = parent_frame.order_index;
19643        opening.bid_id_str = Some(parent_frame.order_id);
19644        opening.trade_id_str = Some("900001".to_string());
19645        opening.size = Decimal::from_str("0.1000").unwrap();
19646        opening.price = Decimal::from_str("100.00").unwrap();
19647        opening.usd_amount = opening.size * opening.price;
19648        opening.maker_fee = Some(0);
19649        opening.taker_fee = Some(0);
19650        dispatch_lighter_trade(
19651            &opening,
19652            &rig.dispatch,
19653            &rig.emitter,
19654            &rig.registry,
19655            account_id(),
19656            trader_id(),
19657            Some(TEST_ACCOUNT_INDEX_I64),
19658            UnixNanos::from(2),
19659        );
19660        rig.cloid = child_id;
19661        let child = OrderTestBuilder::new(OrderType::StopLimit)
19662            .trader_id(trader_id())
19663            .strategy_id(strategy_id())
19664            .instrument_id(rig.instrument_id)
19665            .client_order_id(child_id)
19666            .side(OrderSide::Sell)
19667            .quantity(Quantity::from("0.1000"))
19668            .price(Price::from("200.00"))
19669            .trigger_price(Price::from("150.00"))
19670            .parent_order_id(parent_id)
19671            .reduce_only(true)
19672            .build();
19673        rig.dispatch.register_create_identity(&child).unwrap();
19674        rig.dispatch
19675            .mark_order_submission(&child_id, TEST_SUBMISSION_NONCE);
19676        let mut child_frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
19677        child_frame.initial_base_amount = Decimal::from_str("0.0400").unwrap();
19678        child_frame.remaining_base_amount = child_frame.initial_base_amount;
19679        child_frame.price = Decimal::from_str("200.00").unwrap();
19680        child_frame.side = Some(LighterOrderSide::Sell);
19681        child_frame.is_ask = true;
19682        child_frame.reduce_only = true;
19683        dispatch_lighter_order(
19684            &child_frame,
19685            &rig.dispatch,
19686            &rig.emitter,
19687            &rig.registry,
19688            account_id(),
19689            trader_id(),
19690            UnixNanos::from(3),
19691        );
19692        let mut first = dispatcher_test_trade(&rig, false);
19693        first.ask_id = child_frame.order_index;
19694        first.ask_id_str = Some(child_frame.order_id.clone());
19695        first.trade_id_str = Some("900002".to_string());
19696        first.timestamp = opening.timestamp + i64::from(!same_timestamp);
19697        first.size = Decimal::from_str("0.0200").unwrap();
19698        first.price = Decimal::from_str("200.00").unwrap();
19699        first.usd_amount = first.size * first.price;
19700        first.maker_fee = Some(0);
19701        first.taker_fee = Some(0);
19702        dispatch_lighter_trade(
19703            &first,
19704            &rig.dispatch,
19705            &rig.emitter,
19706            &rig.registry,
19707            account_id(),
19708            trader_id(),
19709            Some(TEST_ACCOUNT_INDEX_I64),
19710            UnixNanos::from(4),
19711        );
19712        rig.cloid = extra_id;
19713        let extra = OrderTestBuilder::new(OrderType::Limit)
19714            .trader_id(trader_id())
19715            .strategy_id(strategy_id())
19716            .instrument_id(rig.instrument_id)
19717            .client_order_id(extra_id)
19718            .side(OrderSide::Buy)
19719            .quantity(Quantity::from("0.1000"))
19720            .price(Price::from("100.00"))
19721            .time_in_force(TimeInForce::Ioc)
19722            .build();
19723        rig.dispatch.register_create_identity(&extra).unwrap();
19724        rig.dispatch
19725            .mark_order_submission(&extra_id, TEST_SUBMISSION_NONCE + 1);
19726        let mut extra_frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
19727        extra_frame.order_index += 5;
19728        extra_frame.order_id = extra_frame.order_index.to_string();
19729        extra_frame.initial_base_amount = Decimal::from_str("0.1000").unwrap();
19730        extra_frame.remaining_base_amount = extra_frame.initial_base_amount;
19731        extra_frame.price = Decimal::from_str("100.00").unwrap();
19732        dispatch_lighter_order(
19733            &extra_frame,
19734            &rig.dispatch,
19735            &rig.emitter,
19736            &rig.registry,
19737            account_id(),
19738            trader_id(),
19739            UnixNanos::from(5),
19740        );
19741        let mut addition = dispatcher_test_trade(&rig, true);
19742        addition.bid_id = extra_frame.order_index;
19743        addition.bid_id_str = Some(extra_frame.order_id);
19744        addition.trade_id_str = Some("900003".to_string());
19745        addition.timestamp = opening.timestamp + 2 * i64::from(!same_timestamp);
19746        addition.size = Decimal::from_str("0.0600").unwrap();
19747        addition.price = Decimal::from_str("50.00").unwrap();
19748        addition.usd_amount = addition.size * addition.price;
19749        addition.maker_fee = Some(0);
19750        addition.taker_fee = Some(0);
19751        dispatch_lighter_trade(
19752            &addition,
19753            &rig.dispatch,
19754            &rig.emitter,
19755            &rig.registry,
19756            account_id(),
19757            trader_id(),
19758            Some(TEST_ACCOUNT_INDEX_I64),
19759            UnixNanos::from(6),
19760        );
19761        rig.cloid = child_id;
19762        let mut last = first;
19763        last.trade_id_str = Some("900004".to_string());
19764        last.timestamp = opening.timestamp + 3 * i64::from(!same_timestamp);
19765        child_frame.status = LighterOrderStatus::Filled;
19766        child_frame.remaining_base_amount = Decimal::ZERO;
19767        child_frame.filled_base_amount = Decimal::from_str("0.0400").unwrap();
19768        child_frame.filled_quote_amount = Decimal::from(8);
19769        dispatch_lighter_order(
19770            &child_frame,
19771            &rig.dispatch,
19772            &rig.emitter,
19773            &rig.registry,
19774            account_id(),
19775            trader_id(),
19776            UnixNanos::from(7),
19777        );
19778        dispatch_lighter_trade(
19779            &last,
19780            &rig.dispatch,
19781            &rig.emitter,
19782            &rig.registry,
19783            account_id(),
19784            trader_id(),
19785            Some(TEST_ACCOUNT_INDEX_I64),
19786            UnixNanos::from(8),
19787        );
19788        let mut fills = Vec::new();
19789
19790        for event in drain_events(&mut rig.rx) {
19791            match event {
19792                ExecutionEvent::Order(OrderEventAny::Filled(fill)) => fills.push(fill),
19793                ExecutionEvent::Report(EngineExecutionReport::OrderWithFills(_, reports)) => {
19794                    for report in reports {
19795                        fills.push(OrderFilled::new(
19796                            trader_id(),
19797                            strategy_id(),
19798                            rig.instrument_id,
19799                            report.client_order_id.unwrap(),
19800                            report.venue_order_id,
19801                            account_id(),
19802                            report.trade_id,
19803                            report.order_side,
19804                            OrderType::StopLimit,
19805                            report.last_qty,
19806                            report.last_px,
19807                            Currency::from("USDC"),
19808                            report.liquidity_side,
19809                            UUID4::new(),
19810                            report.ts_event,
19811                            report.ts_init,
19812                            true,
19813                            None,
19814                            Some(report.commission),
19815                            None,
19816                        ));
19817                    }
19818                }
19819                _ => {}
19820            }
19821        }
19822
19823        let ids = fills.iter().map(|fill| fill.trade_id).collect::<Vec<_>>();
19824        let position_id = PositionId::new("POSITION-ORDER");
19825        for fill in &mut fills {
19826            fill.position_id = Some(position_id);
19827        }
19828
19829        let instrument = LIGHTER_INSTRUMENT_CACHE.get(&rig.instrument_id).unwrap();
19830        let mut position = Position::new(&instrument, fills[0].clone());
19831        for fill in &fills[1..] {
19832            position.apply(fill);
19833        }
19834
19835        assert_eq!(
19836            ids,
19837            ["900001", "900002", "900003", "900004"].map(TradeId::new)
19838        );
19839        assert_eq!(position.quantity, Quantity::from("0.1200"));
19840        assert_eq!(position.realized_pnl, Some(Money::from("4.42857143 USDC")));
19841    }
19842
19843    #[rstest]
19844    #[case::filled(LighterOrderStatus::Filled)]
19845    #[case::partial_then_canceled(LighterOrderStatus::Canceled)]
19846    fn terminal_after_fill_retires_identity_with_publication(#[case] status: LighterOrderStatus) {
19847        let mut rig = dispatcher_rig("TERMINAL-PUBLICATION");
19848        register_identity(&rig);
19849        let mut frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
19850        dispatch_lighter_order(
19851            &frame,
19852            &rig.dispatch,
19853            &rig.emitter,
19854            &rig.registry,
19855            account_id(),
19856            trader_id(),
19857            UnixNanos::from(1),
19858        );
19859        let mut trade = dispatcher_test_trade(&rig, true);
19860        trade.bid_id = frame.order_index;
19861        trade.bid_id_str = Some(frame.order_id.clone());
19862        trade.size = if status == LighterOrderStatus::Filled {
19863            frame.initial_base_amount
19864        } else {
19865            frame.initial_base_amount / Decimal::from(2)
19866        };
19867
19868        trade.usd_amount = trade.size * trade.price;
19869        dispatch_lighter_trade(
19870            &trade,
19871            &rig.dispatch,
19872            &rig.emitter,
19873            &rig.registry,
19874            account_id(),
19875            trader_id(),
19876            Some(TEST_ACCOUNT_INDEX_I64),
19877            UnixNanos::from(2),
19878        );
19879        frame.status = status;
19880        frame.filled_base_amount = trade.size;
19881        frame.remaining_base_amount = Decimal::ZERO;
19882        dispatch_lighter_order(
19883            &frame,
19884            &rig.dispatch,
19885            &rig.emitter,
19886            &rig.registry,
19887            account_id(),
19888            trader_id(),
19889            UnixNanos::from(3),
19890        );
19891        let identity = rig.dispatch.order_identity(&rig.cloid).unwrap();
19892        let events = drain_events(&mut rig.rx);
19893        assert!(!rig.dispatch.order_identities.contains_key(&rig.cloid));
19894        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
19895        assert!(
19896            !rig.dispatch
19897                .cloid_map
19898                .contains_key(&frame.client_order_index)
19899        );
19900        assert!(!rig.dispatch.order_snapshots.contains_key(&rig.cloid));
19901        assert!(identity.has_publication());
19902        assert!(
19903            rig.dispatch
19904                .any_order_identity(|id, other| *id == rig.cloid && other.has_publication())
19905        );
19906        assert_eq!(
19907            events
19908                .iter()
19909                .filter(|e| matches!(e, ExecutionEvent::Order(OrderEventAny::Filled(_))))
19910                .count(),
19911            1
19912        );
19913    }
19914
19915    #[rstest]
19916    #[case::modify("modify", false)]
19917    #[case::terminal("terminal", false)]
19918    #[case::fill("fill", false)]
19919    #[case::update("update", false)]
19920    #[case::retired_fill("fill", true)]
19921    #[case::retired_update("update", true)]
19922    fn position_reports_wait_for_execution(#[case] hold: &str, #[case] retired: bool) {
19923        let (client, cache, _) = create_execution_client();
19924        let instrument_id = register_test_instrument(&client, &cache);
19925        let mut factory = test_order_factory();
19926        let order = test_limit_order(&mut factory, instrument_id, "POSITION-HOLD");
19927        let cloid = order.client_order_id();
19928        client.dispatch.register_create_identity(&order).unwrap();
19929        let identity = client.dispatch.order_identity(&cloid).unwrap();
19930
19931        match hold {
19932            "modify" => {
19933                enqueue_modify(&client, instrument_id, cloid, VenueOrderId::new("700"), 23);
19934            }
19935            "terminal" => {
19936                let rig = dispatcher_rig("POSITION-TERMINAL");
19937                client
19938                    .dispatch
19939                    .order_events
19940                    .lock()
19941                    .entry(cloid)
19942                    .or_default()
19943                    .order = Some(dispatcher_test_order(&rig, LighterOrderStatus::Canceled));
19944            }
19945            "update" => identity.publish_update(UUID4::new()),
19946            "fill" => {
19947                let instrument = cache.borrow().instrument(&instrument_id).unwrap().clone();
19948
19949                let OrderEventAny::Filled(fill) = TestOrderEventStubs::filled(
19950                    &order,
19951                    &instrument,
19952                    Some(TradeId::new("HELD-FILL")),
19953                    None,
19954                    Some(Price::from("2360.00")),
19955                    Some(Quantity::from("0.0050")),
19956                    None,
19957                    Some(Money::from("0.01000000 USDC")),
19958                    None,
19959                    Some(account_id()),
19960                ) else {
19961                    unreachable!()
19962                };
19963
19964                identity.publish_fill(&fill);
19965            }
19966            _ => unreachable!(),
19967        }
19968
19969        if retired {
19970            client.dispatch.retire_order_identity(&cloid);
19971        }
19972
19973        let report = PositionStatusReport::new(
19974            account_id(),
19975            instrument_id,
19976            PositionSide::Long,
19977            Quantity::from("0.0050"),
19978            UnixNanos::from(4),
19979            UnixNanos::from(5),
19980            None,
19981            None,
19982            Some(Decimal::from_str("2360.00").unwrap()),
19983        );
19984        client
19985            .dispatch
19986            .replace_position_snapshot(std::slice::from_ref(&report), &[], &[]);
19987
19988        let cmd = GeneratePositionStatusReports::new(
19989            UUID4::new(),
19990            UnixNanos::from(6),
19991            Some(instrument_id),
19992            None,
19993            None,
19994            None,
19995            None,
19996        );
19997        let (reports, complete, coverage) = client.cached_position_reports(&cmd).unwrap();
19998        assert!(reports.is_empty());
19999        assert!(!complete);
20000        assert_eq!(coverage, Some(AHashSet::from_iter([TEST_MARKET_INDEX])));
20001        let other_id = client.registry.insert(1, "BTC", LighterProductType::Perp);
20002        let mut other_cmd = cmd;
20003        other_cmd.instrument_id = Some(other_id);
20004        let (reports, complete, _) = client.cached_position_reports(&other_cmd).unwrap();
20005        assert!(reports.is_empty());
20006        assert!(complete);
20007    }
20008
20009    #[rstest]
20010    #[case::explicit_flat(true)]
20011    #[case::empty_snapshot(false)]
20012    #[tokio::test]
20013    async fn restart_held_child_fill_does_not_synthesize_position(#[case] explicit_flat: bool) {
20014        let (mut client, cache, mut rx) = create_execution_client();
20015        let core = client.core.clone();
20016        let instrument_id = register_test_instrument(&client, &cache);
20017        let mut rig = dispatcher_rig("RESTART-CHILD");
20018        rig.dispatch = client.dispatch.clone();
20019        rig.registry = Arc::clone(&client.registry);
20020
20021        let account = MarginAccount::new(
20022            AccountState::new(
20023                account_id(),
20024                AccountType::Margin,
20025                vec![AccountBalance::new(
20026                    Money::from("10000.00000000 USDC"),
20027                    Money::from("0.00000000 USDC"),
20028                    Money::from("10000.00000000 USDC"),
20029                )],
20030                Vec::new(),
20031                true,
20032                UUID4::new(),
20033                UnixNanos::from(1),
20034                UnixNanos::from(1),
20035                Some(Currency::from("USDC")),
20036            ),
20037            true,
20038        );
20039        cache
20040            .borrow_mut()
20041            .add_account(AccountAny::Margin(account))
20042            .unwrap();
20043        let parent_id = ClientOrderId::new("RESTART-PARENT");
20044        let position_id = PositionId::new("RESTART-POSITION");
20045        let parent = OrderTestBuilder::new(OrderType::Limit)
20046            .trader_id(trader_id())
20047            .strategy_id(strategy_id())
20048            .instrument_id(instrument_id)
20049            .client_order_id(parent_id)
20050            .side(OrderSide::Buy)
20051            .quantity(Quantity::from("0.1000"))
20052            .price(Price::from("2352.73"))
20053            .build();
20054        let instrument = LIGHTER_INSTRUMENT_CACHE
20055            .get(&instrument_id)
20056            .unwrap()
20057            .value()
20058            .clone();
20059        let opening = TestOrderEventStubs::filled(
20060            &parent,
20061            &instrument,
20062            Some(TradeId::new("RESTART-OPENING")),
20063            Some(position_id),
20064            Some(Price::from("2352.73")),
20065            Some(Quantity::from("0.1000")),
20066            Some(LiquiditySide::Taker),
20067            Some(Money::from("0.00000000 USDC")),
20068            Some(UnixNanos::from(1)),
20069            Some(account_id()),
20070        );
20071        let mut parent = parent;
20072        parent
20073            .apply(TestOrderEventStubs::accepted(
20074                &parent,
20075                account_id(),
20076                VenueOrderId::new("600"),
20077            ))
20078            .unwrap();
20079        parent.apply(opening.clone()).unwrap();
20080        cache
20081            .borrow_mut()
20082            .add_order(parent, Some(position_id), Some(client_id()), false)
20083            .unwrap();
20084
20085        let OrderEventAny::Filled(opening) = opening else {
20086            unreachable!()
20087        };
20088
20089        let position = Position::new(&instrument, opening);
20090        cache
20091            .borrow_mut()
20092            .add_position(&position, OmsType::Netting)
20093            .unwrap();
20094        let child = OrderTestBuilder::new(OrderType::StopLimit)
20095            .trader_id(trader_id())
20096            .strategy_id(strategy_id())
20097            .instrument_id(instrument_id)
20098            .client_order_id(rig.cloid)
20099            .side(OrderSide::Sell)
20100            .quantity(Quantity::from("0.1000"))
20101            .price(Price::from("2352.74"))
20102            .trigger_price(Price::from("2300.00"))
20103            .parent_order_id(parent_id)
20104            .reduce_only(true)
20105            .build();
20106        let mut frame = dispatcher_test_order(&rig, LighterOrderStatus::Open);
20107        frame.side = Some(LighterOrderSide::Sell);
20108        frame.is_ask = true;
20109        frame.order_type = LighterOrderKind::Limit;
20110        frame.reduce_only = true;
20111        let venue_id = VenueOrderId::new(&frame.order_id);
20112        cache_accepted_order(&cache, child, venue_id, Some(client_id()));
20113        cache
20114            .borrow_mut()
20115            .add_position_id(
20116                &position_id,
20117                &instrument_id.venue,
20118                &rig.cloid,
20119                &strategy_id(),
20120            )
20121            .unwrap();
20122        let child = cache.borrow().order_owned(&rig.cloid).unwrap();
20123        client
20124            .dispatch
20125            .restore_reconciled_order(&child, frame.client_order_index, venue_id, false)
20126            .unwrap();
20127        let mut trade = dispatcher_test_trade(&rig, false);
20128        trade.ask_id = frame.order_index;
20129        trade.ask_id_str = Some(frame.order_id.clone());
20130        trade.size = Decimal::from_str("0.1000").unwrap();
20131        trade.usd_amount = trade.size * trade.price;
20132        trade.maker_fee = Some(0);
20133        trade.taker_fee = Some(0);
20134        dispatch_lighter_trade(
20135            &trade,
20136            &client.dispatch,
20137            &client.emitter,
20138            &client.registry,
20139            account_id(),
20140            trader_id(),
20141            Some(TEST_ACCOUNT_INDEX_I64),
20142            UnixNanos::from(2),
20143        );
20144
20145        let flat = PositionStatusReport::new(
20146            account_id(),
20147            instrument_id,
20148            PositionSide::Flat,
20149            Quantity::from("0.0000"),
20150            UnixNanos::from(3),
20151            UnixNanos::from(4),
20152            None,
20153            None,
20154            None,
20155        );
20156
20157        let positions = if explicit_flat {
20158            vec![flat]
20159        } else {
20160            Vec::new()
20161        };
20162
20163        client
20164            .dispatch
20165            .replace_position_snapshot(&positions, &[], &[]);
20166        let mut external = frame.clone();
20167        external.order_index += 1;
20168        external.order_id = external.order_index.to_string();
20169        external.client_order_index = 0;
20170        external.client_order_id = "0".to_string();
20171        external.reduce_only = false;
20172        let active = serde_json::json!({"code":200,"orders":[external]});
20173        let app = Router::new()
20174            .route(
20175                "/api/v1/accountActiveOrders",
20176                get(move || {
20177                    let active = active.clone();
20178                    async move { axum::Json(active) }
20179                }),
20180            )
20181            .route(
20182                "/api/v1/accountInactiveOrders",
20183                get(|| async { axum::Json(serde_json::json!({"code":200,"orders":[]})) }),
20184            )
20185            .route(
20186                "/api/v1/trades",
20187                get(|| async { axum::Json(serde_json::json!({"code":200,"trades":[]})) }),
20188            );
20189
20190        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
20191        let addr = listener.local_addr().unwrap();
20192        let server = tokio::spawn(async move {
20193            axum::serve(listener, app).await.unwrap();
20194        });
20195
20196        client.http_client = LighterHttpClient::new(
20197            LighterEnvironment::Testnet,
20198            Some(format!("http://{addr}")),
20199            1,
20200            None,
20201        )
20202        .unwrap();
20203        let mass = client
20204            .generate_mass_status(Some(60))
20205            .await
20206            .unwrap()
20207            .unwrap();
20208        server.abort();
20209        let _ = server.await;
20210
20211        let command = GeneratePositionStatusReports::new(
20212            UUID4::new(),
20213            UnixNanos::from(5),
20214            None,
20215            None,
20216            None,
20217            None,
20218            None,
20219        );
20220        let direct = client.generate_position_status_reports(&command).await;
20221        let worker = client
20222            .generate_position_status_reports_task(&command)
20223            .unwrap();
20224        worker.collection.await;
20225        let worker = worker.result.await;
20226        assert!(mass.position_reports().is_empty());
20227        assert!(!mass.reports_complete());
20228        assert!(direct.is_err());
20229        assert!(worker.is_err());
20230        assert!(drain_events(&mut rx).is_empty());
20231        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::default()));
20232
20233        let engine = Rc::new(RefCell::new(ExecutionEngine::new(
20234            clock.clone(),
20235            cache.clone(),
20236            None,
20237        )));
20238        let mut manager =
20239            ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default()).unwrap();
20240        let result = manager.reconcile_execution_mass_status(&mass, &engine);
20241        assert!(
20242            result
20243                .events
20244                .iter()
20245                .all(|event| !matches!(event, OrderEventAny::Filled(_)))
20246        );
20247        assert_eq!(
20248            cache.borrow().position(&position_id).unwrap().quantity,
20249            Quantity::from("0.1000")
20250        );
20251        frame.status = LighterOrderStatus::Filled;
20252        frame.initial_base_amount = trade.size;
20253        frame.filled_base_amount = trade.size;
20254        frame.remaining_base_amount = Decimal::ZERO;
20255        frame.filled_quote_amount = trade.usd_amount;
20256        dispatch_lighter_order(
20257            &frame,
20258            &client.dispatch,
20259            &client.emitter,
20260            &client.registry,
20261            account_id(),
20262            trader_id(),
20263            UnixNanos::from(6),
20264        );
20265        engine
20266            .borrow_mut()
20267            .register_client(Box::new(client))
20268            .unwrap();
20269        let released = drain_events(&mut rx);
20270        assert!(
20271            released
20272                .iter()
20273                .any(|event| matches!(event, ExecutionEvent::Order(OrderEventAny::Filled(_)))),
20274            "released child events: {released:?}"
20275        );
20276
20277        for event in released {
20278            match event {
20279                ExecutionEvent::Order(event) => engine.borrow_mut().process(&event),
20280                ExecutionEvent::Report(report) => {
20281                    engine.borrow_mut().reconcile_execution_report(&report);
20282                }
20283                _ => panic!("unexpected restart event"),
20284            }
20285        }
20286
20287        let cache_ref = cache.borrow();
20288        let position = cache_ref.position(&position_id).unwrap();
20289        let child = cache_ref.order(&rig.cloid).unwrap();
20290        assert_eq!(position.quantity, Quantity::from("0.0000"));
20291        assert_eq!(position.realized_pnl, Some(Money::from("0.00000000 USDC")));
20292        assert_eq!(
20293            child.trade_ids(),
20294            vec![&parse_lighter_trade_id(&trade).unwrap()]
20295        );
20296        assert_eq!(child.filled_qty(), Quantity::from("0.1000"));
20297        assert!(!rig.dispatch.order_identities.contains_key(&rig.cloid));
20298        assert!(!rig.dispatch.venue_id_map.contains_key(&rig.cloid));
20299
20300        let command = GeneratePositionStatusReports::new(
20301            UUID4::new(),
20302            UnixNanos::from(7),
20303            None,
20304            None,
20305            None,
20306            None,
20307            None,
20308        );
20309        let (reports, complete, coverage) =
20310            cached_position_reports(&core, &rig.dispatch, &rig.registry, &command).unwrap();
20311        assert!(complete);
20312        assert_eq!(coverage, Some(AHashSet::new()));
20313        assert_eq!(reports.len(), usize::from(explicit_flat));
20314    }
20315
20316    #[rstest]
20317    fn resting_child_does_not_withhold_position_reports() {
20318        let (client, cache, _) = create_execution_client();
20319        let instrument_id = register_test_instrument(&client, &cache);
20320        let rig = dispatcher_rig("RESTING-POSITION");
20321        let order = OrderTestBuilder::new(OrderType::StopLimit)
20322            .trader_id(trader_id())
20323            .strategy_id(strategy_id())
20324            .instrument_id(instrument_id)
20325            .client_order_id(rig.cloid)
20326            .side(OrderSide::Sell)
20327            .quantity(Quantity::from("0.1000"))
20328            .price(Price::from("2300.00"))
20329            .trigger_price(Price::from("2301.00"))
20330            .parent_order_id(ClientOrderId::new("RESTING-PARENT"))
20331            .reduce_only(true)
20332            .build();
20333        client.dispatch.register_create_identity(&order).unwrap();
20334        client
20335            .dispatch
20336            .order_events
20337            .lock()
20338            .entry(rig.cloid)
20339            .or_default()
20340            .order = Some(dispatcher_test_order(&rig, LighterOrderStatus::Pending));
20341
20342        let report = PositionStatusReport::new(
20343            account_id(),
20344            instrument_id,
20345            PositionSide::Long,
20346            Quantity::from("0.1000"),
20347            UnixNanos::from(4),
20348            UnixNanos::from(5),
20349            None,
20350            None,
20351            Some(Decimal::from_str("2360.00").unwrap()),
20352        );
20353        client
20354            .dispatch
20355            .replace_position_snapshot(std::slice::from_ref(&report), &[], &[]);
20356
20357        let cmd = GeneratePositionStatusReports::new(
20358            UUID4::new(),
20359            UnixNanos::from(6),
20360            None,
20361            None,
20362            None,
20363            None,
20364            None,
20365        );
20366        let (reports, complete, coverage) = client.cached_position_reports(&cmd).unwrap();
20367        assert_eq!(reports, vec![report]);
20368        assert!(complete);
20369        assert_eq!(coverage, Some(AHashSet::new()));
20370    }
20371
20372    #[rstest]
20373    #[case::cached_fill("2352.73", false)]
20374    #[case::price_restatement("2352.74", true)]
20375    fn cached_parent_fill_is_not_deferred_during_child_recovery(
20376        #[case] price: &str,
20377        #[case] deferred: bool,
20378    ) {
20379        let (client, cache, mut rx) = create_execution_client();
20380        let instrument_id = register_test_instrument(&client, &cache);
20381        let instrument = cache.borrow().instrument(&instrument_id).unwrap().clone();
20382        LIGHTER_INSTRUMENT_CACHE.insert(instrument_id, instrument.clone());
20383        let parent_id = ClientOrderId::new("HTTP-CACHED-PARENT");
20384        let parent_venue = VenueOrderId::new("710001");
20385        let child_id = ClientOrderId::new("HTTP-RECOVERING-CHILD");
20386        let child_venue = VenueOrderId::new("710002");
20387        let parent = OrderTestBuilder::new(OrderType::Limit)
20388            .trader_id(trader_id())
20389            .strategy_id(strategy_id())
20390            .instrument_id(instrument_id)
20391            .client_order_id(parent_id)
20392            .side(OrderSide::Buy)
20393            .quantity(Quantity::from("0.1336"))
20394            .price(Price::from("2352.73"))
20395            .build();
20396        cache_accepted_order(&cache, parent, parent_venue, Some(client_id()));
20397        let parent = cache.borrow().order_owned(&parent_id).unwrap();
20398        let mut report =
20399            reconciliation_fill_report(instrument_id, 101, parent_venue, "CACHED-PARENT-FILL");
20400        let opening = TestOrderEventStubs::filled(
20401            &parent,
20402            &instrument,
20403            Some(report.trade_id),
20404            None,
20405            Some(report.last_px),
20406            Some(report.last_qty),
20407            Some(report.liquidity_side),
20408            Some(report.commission),
20409            Some(report.ts_event),
20410            Some(account_id()),
20411        );
20412        cache.borrow_mut().update_order(&opening).unwrap();
20413        let child = OrderTestBuilder::new(OrderType::StopLimit)
20414            .trader_id(trader_id())
20415            .strategy_id(strategy_id())
20416            .instrument_id(instrument_id)
20417            .client_order_id(child_id)
20418            .side(OrderSide::Sell)
20419            .quantity(Quantity::from("0.1336"))
20420            .price(Price::from("2301.00"))
20421            .trigger_price(Price::from("2302.00"))
20422            .parent_order_id(parent_id)
20423            .reduce_only(true)
20424            .build();
20425        cache_accepted_order(&cache, child, child_venue, Some(client_id()));
20426        let child = cache.borrow().order_owned(&child_id).unwrap();
20427        client
20428            .dispatch
20429            .restore_reconciled_order(&child, 102, child_venue, false)
20430            .unwrap();
20431        let identity = client.dispatch.order_identity(&child_id).unwrap();
20432        let mut child_report =
20433            reconciliation_fill_report(instrument_id, 102, child_venue, "NEW-CHILD-FILL");
20434        child_report.order_side = OrderSide::Sell;
20435        child_report.last_qty = Quantity::from("0.0444");
20436        child_report.last_px = Price::from("2301.23");
20437        let child_fill = child_fill_from_report(&child_report, &identity, trader_id()).unwrap();
20438        client.dispatch.defer_fill(
20439            client
20440                .dispatch
20441                .order_events
20442                .lock()
20443                .entry(child_id)
20444                .or_default(),
20445            child_fill.clone(),
20446        );
20447        report.last_px = Price::from(price);
20448
20449        let sweep = finish_fill_reports(
20450            &client.core,
20451            &client.dispatch,
20452            BTreeSet::new(),
20453            Ok(FillSweep {
20454                reports: vec![report.clone()],
20455                covers_window: true,
20456                withheld_instruments: AHashSet::new(),
20457            }),
20458        )
20459        .unwrap();
20460
20461        let events = client.dispatch.order_events.lock();
20462        assert!(sweep.reports.is_empty());
20463        assert!(!sweep.covers_window);
20464        assert_eq!(
20465            sweep.withheld_instruments,
20466            AHashSet::from_iter([instrument_id])
20467        );
20468        assert_eq!(events.get(&parent_id).is_some(), deferred);
20469        assert_eq!(events[&child_id].fills, vec![child_fill]);
20470
20471        if deferred {
20472            let fills = &events[&parent_id].fills;
20473            assert_eq!(fills.len(), 1);
20474            assert_eq!(fills[0].trade_id, report.trade_id);
20475            assert_eq!(fills[0].client_order_id, parent_id);
20476            assert_eq!(fills[0].venue_order_id, parent_venue);
20477            assert_eq!(fills[0].last_qty, report.last_qty);
20478            assert_eq!(fills[0].last_px, report.last_px);
20479            assert_eq!(fills[0].commission, Some(report.commission));
20480        }
20481
20482        assert_eq!(
20483            cache.borrow().order(&parent_id).unwrap().trade_ids(),
20484            vec![&report.trade_id]
20485        );
20486        assert!(drain_events(&mut rx).is_empty());
20487    }
20488}