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