1use 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
154const DEFAULT_TX_EXPIRY_MS: i64 = 5 * 60 * 1_000;
157
158const 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
165const AUTH_TOKEN_REFRESH_INTERVAL: std::time::Duration =
170 std::time::Duration::from_secs(6 * 60 * 60);
171
172const _: () = 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
179const AUTH_TOKEN_REFRESH_RETRY_WINDOW: Duration = Duration::from_secs(60 * 60);
182const AUTH_TOKEN_REFRESH_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(30);
183
184const 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);
193const ACCOUNT_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
196const REFERRAL_ATTRIBUTION_TIMEOUT: Duration = Duration::from_secs(10);
197
198const 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 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 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(®istry));
313
314 let ws_client = Self::create_ws_client(&config, Arc::clone(®istry), &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 #[must_use]
355 pub fn config(&self) -> &LighterExecutionClientConfig {
356 &self.config
357 }
358
359 #[must_use]
361 pub fn has_credentials(&self) -> bool {
362 self.credential.is_some()
363 }
364
365 #[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 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 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 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 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 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 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 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(®istry_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 ®istry_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 ®istry_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 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 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 log::debug!("Lighter WebSocket reconnected (execution stream)");
1207 auth_refresh_notify.notify_one();
1208
1209 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(®istry_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, PartialEq, Eq)]
3814enum TierCrossCheck {
3815 AboveTier { documented: u32 },
3816 RaiseHint { documented: u32 },
3817}
3818
3819fn 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
3895fn 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 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 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#[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
5121fn 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
5292fn 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 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 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 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 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 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 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 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 ®istry,
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, ®istry, &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 ®istry,
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(®istry);
5966
5967 let lookup = OrderReportLookup::new(
5968 ®istry,
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 ®istry,
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, ®istry, &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 let lookback_start = lookback_mins
6283 .map(DurationNanos::try_from_mins)
6284 .transpose()?
6285 .map(|lookback| ts_init.saturating_sub(lookback));
6286
6287 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 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
6537struct 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 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 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 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 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 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 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 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 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 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
8039fn 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 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#[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 return;
8345 }
8346
8347 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 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#[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 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#[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 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 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 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 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 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 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"), 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 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 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 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 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 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 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 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 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 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 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"), 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 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 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 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 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 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 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 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 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 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 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 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, 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 ®istry,
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 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 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 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 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 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 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 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 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 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 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 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 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 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}