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