1use nautilus_core::string::secret::SecretString;
28use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
29use nautilus_model::{
30 identifiers::{AccountId, Venue},
31 types::Currency,
32};
33use nautilus_network::websocket::TransportBackend;
34use serde::{Deserialize, Deserializer, Serialize};
35
36use crate::common::{
37 consts::{
38 SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS, SCHEDULED_CANCEL_MAX_DELAY_SECS,
39 SCHEDULED_CANCEL_MIN_DELAY_SECS, SCHEDULED_CANCEL_REQUEST_TIMEOUT,
40 },
41 credential::credential_env_vars_for_deployment,
42 deployment,
43 enums::{LighterDeployment, LighterEnvironment},
44};
45
46const WS_READONLY_QUERY_PARAM: &str = "readonly";
47
48#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
50#[serde(default, deny_unknown_fields)]
51#[cfg_attr(
52 feature = "python",
53 pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
54)]
55#[cfg_attr(
56 feature = "python",
57 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
58)]
59pub struct LighterDataClientConfig {
60 #[builder(default)]
62 pub environment: LighterEnvironment,
63 #[builder(default)]
65 pub deployment: LighterDeployment,
66 pub venue: Option<Venue>,
71 pub account_index: Option<u64>,
74 pub api_key_index: Option<u8>,
77 pub private_key: Option<SecretString>,
80 pub base_url_http: Option<String>,
82 pub base_url_ws: Option<String>,
84 pub proxy_url: Option<SecretString>,
86 #[builder(default = 60)]
88 pub http_timeout_secs: u64,
89 #[builder(default = 30)]
91 pub ws_timeout_secs: u64,
92 #[builder(default = 60)]
94 pub update_instruments_interval_mins: u64,
95 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
100 pub book_snapshot_timeout_secs: u64,
101 pub rest_quota_per_min: Option<u32>,
104 #[builder(default)]
106 pub transport_backend: TransportBackend,
107}
108
109#[cfg(feature = "python")]
110nautilus_core::impl_pyo3_config_getters!(LighterDataClientConfig {
111 environment: LighterEnvironment,
112 deployment: LighterDeployment,
113 venue: Option<Venue>,
114 account_index: Option<u64>,
115 api_key_index: Option<u8>,
116 base_url_http: Option<String>,
117 base_url_ws: Option<String>,
118 http_timeout_secs: u64,
119 ws_timeout_secs: u64,
120 update_instruments_interval_mins: u64,
121 book_snapshot_timeout_secs: u64,
122 rest_quota_per_min: Option<u32>,
123 transport_backend: TransportBackend,
124});
125
126impl Default for LighterDataClientConfig {
127 fn default() -> Self {
128 Self::builder().build()
129 }
130}
131
132impl LighterDataClientConfig {
133 #[must_use]
135 pub fn new() -> Self {
136 Self::default()
137 }
138
139 #[must_use]
141 pub fn http_url(&self) -> String {
142 self.base_url_http.clone().unwrap_or_else(|| {
143 deployment::http_base_url(self.deployment, self.environment).to_string()
144 })
145 }
146
147 #[must_use]
149 pub fn ws_url(&self) -> String {
150 let url = self
151 .base_url_ws
152 .clone()
153 .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string());
154
155 ensure_readonly_ws_url(url)
156 }
157
158 #[must_use]
160 pub fn resolved_venue(&self) -> Venue {
161 self.venue
162 .unwrap_or_else(|| deployment::venue(self.deployment))
163 }
164
165 #[must_use]
167 pub fn settlement_currency(&self) -> Currency {
168 deployment::settlement_currency(self.deployment)
169 }
170
171 #[must_use]
173 pub fn has_credentials(&self) -> bool {
174 let (key_var, secret_var, account_var) =
175 credential_env_vars_for_deployment(self.deployment, self.environment);
176 let has_key = self.api_key_index.is_some() || env_var_is_set(key_var);
177 let has_account = self.account_index.is_some() || env_var_is_set(account_var);
178 let has_secret = self
179 .private_key
180 .as_ref()
181 .map(SecretString::expose_secret)
182 .is_some_and(|s| !s.trim().is_empty())
183 || env_var_is_set(secret_var);
184
185 has_key && has_account && has_secret
186 }
187}
188
189fn env_var_is_set(name: &str) -> bool {
190 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
191}
192
193fn ensure_readonly_ws_url(url: String) -> String {
194 let Ok(mut parsed) = url::Url::parse(&url) else {
195 return url;
196 };
197
198 let pairs = parsed
199 .query_pairs()
200 .filter(|(key, _)| key != WS_READONLY_QUERY_PARAM)
201 .map(|(key, value)| (key.into_owned(), value.into_owned()))
202 .collect::<Vec<_>>();
203
204 parsed.set_query(None);
205 {
206 let mut query = parsed.query_pairs_mut();
207 for (key, value) in pairs {
208 query.append_pair(&key, &value);
209 }
210 query.append_pair(WS_READONLY_QUERY_PARAM, "true");
211 }
212
213 parsed.to_string()
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
218#[serde(default, deny_unknown_fields)]
219#[cfg_attr(
220 feature = "python",
221 pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
222)]
223#[cfg_attr(
224 feature = "python",
225 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
226)]
227pub struct LighterExecutionClientConfig {
228 #[builder(default)]
230 pub environment: LighterEnvironment,
231 #[builder(default)]
233 pub deployment: LighterDeployment,
234 pub venue: Option<Venue>,
239 #[builder(default = AccountId::from("LIGHTER-001"))]
241 pub account_id: AccountId,
242 pub account_index: Option<u64>,
245 pub api_key_index: Option<u8>,
249 pub private_key: Option<SecretString>,
252 pub base_url_http: Option<String>,
254 pub base_url_ws: Option<String>,
256 pub proxy_url: Option<SecretString>,
258 #[builder(default = 60)]
260 pub http_timeout_secs: u64,
261 #[builder(default = 30)]
263 pub ws_timeout_secs: u64,
264 #[builder(default = 50)]
266 pub market_order_slippage_bps: u32,
267 pub rest_quota_per_min: Option<u32>,
270 pub sendtx_quota_per_min: Option<u32>,
273 #[builder(default)]
275 pub transport_backend: TransportBackend,
276 #[builder(default = true)]
285 pub use_gtd: bool,
286 #[serde(deserialize_with = "deserialize_scheduled_cancel_timeout")]
292 pub scheduled_cancel_timeout_secs: Option<u64>,
293}
294
295#[cfg(feature = "python")]
296nautilus_core::impl_pyo3_config_getters!(LighterExecutionClientConfig {
297 environment: LighterEnvironment,
298 deployment: LighterDeployment,
299 venue: Option<Venue>,
300 account_id: AccountId,
301 account_index: Option<u64>,
302 api_key_index: Option<u8>,
303 base_url_http: Option<String>,
304 base_url_ws: Option<String>,
305 http_timeout_secs: u64,
306 ws_timeout_secs: u64,
307 market_order_slippage_bps: u32,
308 rest_quota_per_min: Option<u32>,
309 sendtx_quota_per_min: Option<u32>,
310 transport_backend: TransportBackend,
311 use_gtd: bool,
312 scheduled_cancel_timeout_secs: Option<u64>,
313});
314
315impl Default for LighterExecutionClientConfig {
316 fn default() -> Self {
317 Self::builder().build()
318 }
319}
320
321impl LighterExecutionClientConfig {
322 #[must_use]
328 pub fn has_credentials(&self) -> bool {
329 let key_set = self
330 .private_key
331 .as_ref()
332 .map(SecretString::expose_secret)
333 .is_some_and(|s| !s.trim().is_empty());
334 key_set && self.account_index.is_some() && self.api_key_index.is_some()
335 }
336
337 #[must_use]
339 pub fn http_url(&self) -> String {
340 self.base_url_http.clone().unwrap_or_else(|| {
341 deployment::http_base_url(self.deployment, self.environment).to_string()
342 })
343 }
344
345 #[must_use]
347 pub fn ws_url(&self) -> String {
348 self.base_url_ws
349 .clone()
350 .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string())
351 }
352
353 #[must_use]
355 pub fn resolved_venue(&self) -> Venue {
356 self.venue
357 .unwrap_or_else(|| deployment::venue(self.deployment))
358 }
359
360 #[must_use]
362 pub fn settlement_currency(&self) -> Currency {
363 deployment::settlement_currency(self.deployment)
364 }
365
366 #[must_use]
368 pub const fn chain_id(&self) -> u32 {
369 deployment::chain_id(self.deployment, self.environment)
370 }
371}
372
373fn deserialize_scheduled_cancel_timeout<'de, D: Deserializer<'de>>(
374 deserializer: D,
375) -> Result<Option<u64>, D::Error> {
376 let timeout = Option::<u64>::deserialize(deserializer)?;
377 validate_scheduled_cancel_timeout(timeout).map_err(serde::de::Error::custom)?;
378 Ok(timeout)
379}
380
381pub(crate) fn validate_scheduled_cancel_timeout(timeout_secs: Option<u64>) -> anyhow::Result<()> {
382 let min = SCHEDULED_CANCEL_MIN_DELAY_SECS
383 + SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS
384 + 2 * SCHEDULED_CANCEL_REQUEST_TIMEOUT.as_secs();
385 let max = SCHEDULED_CANCEL_MAX_DELAY_SECS - SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS;
386
387 if let Some(timeout_secs) = timeout_secs {
388 anyhow::ensure!(
389 (min..=max).contains(&timeout_secs),
390 "scheduled_cancel_timeout_secs must be in [{min}, {max}]"
391 );
392 }
393
394 Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399 use nautilus_core::string::secret::REDACTED;
400 use rstest::rstest;
401
402 use super::*;
403
404 const PRIVATE_KEY_HEX: &str =
405 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
406
407 #[rstest]
408 #[case(None, true)]
409 #[case(Some(0), false)]
410 #[case(Some(15), false)]
411 #[case(Some(419), false)]
412 #[case(Some(420), true)]
413 #[case(Some(1_295_940), true)]
414 #[case(Some(1_295_941), false)]
415 #[case(Some(u64::MAX), false)]
416 fn test_scheduled_cancel_timeout(#[case] timeout: Option<u64>, #[case] valid: bool) {
417 let config = LighterExecutionClientConfig {
418 scheduled_cancel_timeout_secs: timeout,
419 ..Default::default()
420 };
421
422 let encoded = serde_json::to_value(&config).unwrap();
423 assert_eq!(
424 encoded["scheduled_cancel_timeout_secs"],
425 serde_json::json!(timeout)
426 );
427 let decoded = serde_json::from_value::<LighterExecutionClientConfig>(encoded);
428 assert_eq!(validate_scheduled_cancel_timeout(timeout).is_ok(), valid);
429 assert_eq!(decoded.is_ok(), valid);
430
431 if valid {
432 assert_eq!(decoded.unwrap().scheduled_cancel_timeout_secs, timeout);
433 }
434 }
435
436 #[rstest]
437 fn test_scheduled_cancel_timeout_default() {
438 assert_eq!(
439 LighterExecutionClientConfig::default().scheduled_cancel_timeout_secs,
440 None
441 );
442 let decoded: LighterExecutionClientConfig =
443 serde_json::from_value(serde_json::json!({})).unwrap();
444 assert_eq!(decoded.scheduled_cancel_timeout_secs, None);
445 }
446
447 #[rstest]
448 fn data_config_has_credentials_when_all_fields_set() {
449 let config = LighterDataClientConfig {
450 api_key_index: Some(5),
451 account_index: Some(12_345),
452 private_key: Some(PRIVATE_KEY_HEX.into()),
453 ..Default::default()
454 };
455
456 assert!(config.has_credentials());
457 }
458
459 #[rstest]
460 fn data_config_debug_redacts_private_key() {
461 let config = LighterDataClientConfig {
462 api_key_index: Some(5),
463 account_index: Some(12_345),
464 private_key: Some(PRIVATE_KEY_HEX.into()),
465 ..Default::default()
466 };
467
468 let dbg_out = format!("{config:?}");
469
470 assert!(dbg_out.contains(REDACTED));
471 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
472 }
473
474 #[rstest]
475 fn data_config_debug_omits_private_key_when_unset() {
476 let config = LighterDataClientConfig::default();
477
478 let dbg_out = format!("{config:?}");
479
480 assert!(dbg_out.contains("private_key: None"));
481 }
482
483 #[rstest]
484 fn data_config_ws_url_sets_readonly_query() {
485 let config = LighterDataClientConfig::default();
486
487 assert_eq!(
488 config.ws_url(),
489 "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
490 );
491 }
492
493 #[rstest]
494 fn data_config_ws_url_preserves_existing_query_params() {
495 let config = LighterDataClientConfig {
496 base_url_ws: Some("wss://mainnet.zklighter.elliot.ai/stream?foo=bar".to_string()),
497 ..Default::default()
498 };
499
500 assert_eq!(
501 config.ws_url(),
502 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
503 );
504 }
505
506 #[rstest]
507 fn data_config_ws_url_overrides_readonly_query() {
508 let config = LighterDataClientConfig {
509 base_url_ws: Some(
510 "wss://mainnet.zklighter.elliot.ai/stream?readonly=false&foo=bar".to_string(),
511 ),
512 ..Default::default()
513 };
514
515 assert_eq!(
516 config.ws_url(),
517 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
518 );
519 }
520
521 #[rstest]
522 fn data_config_book_snapshot_timeout_default_is_ten_seconds() {
523 let config = LighterDataClientConfig::default();
524
525 assert_eq!(config.book_snapshot_timeout_secs, 10);
526 }
527
528 #[derive(Debug)]
529 struct ExpectedDeploymentSettings {
530 http_url: &'static str,
531 data_ws_url: &'static str,
532 chain_id: u32,
533 venue: &'static str,
534 currency: &'static str,
535 }
536
537 #[rstest]
538 #[case::lighter_mainnet(
539 LighterDeployment::Lighter,
540 LighterEnvironment::Mainnet,
541 ExpectedDeploymentSettings {
542 http_url: "https://mainnet.zklighter.elliot.ai",
543 data_ws_url: "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
544 chain_id: 304,
545 venue: "LIGHTER",
546 currency: "USDC",
547 }
548 )]
549 #[case::lighter_testnet(
550 LighterDeployment::Lighter,
551 LighterEnvironment::Testnet,
552 ExpectedDeploymentSettings {
553 http_url: "https://testnet.zklighter.elliot.ai",
554 data_ws_url: "wss://testnet.zklighter.elliot.ai/stream?readonly=true",
555 chain_id: 300,
556 venue: "LIGHTER",
557 currency: "USDC",
558 }
559 )]
560 #[case::robinhood_mainnet(
561 LighterDeployment::Robinhood,
562 LighterEnvironment::Mainnet,
563 ExpectedDeploymentSettings {
564 http_url: "https://api.rh.lighter.xyz",
565 data_ws_url: "wss://api.rh.lighter.xyz/stream?readonly=true",
566 chain_id: 466_324,
567 venue: "LIGHTER_ROBINHOOD",
568 currency: "USDG",
569 }
570 )]
571 #[case::robinhood_testnet(
572 LighterDeployment::Robinhood,
573 LighterEnvironment::Testnet,
574 ExpectedDeploymentSettings {
575 http_url: "https://api.rh-testnet.lighter.xyz",
576 data_ws_url: "wss://api.rh-testnet.lighter.xyz/stream?readonly=true",
577 chain_id: 300,
578 venue: "LIGHTER_ROBINHOOD",
579 currency: "USDG",
580 }
581 )]
582 fn configs_resolve_deployment_settings(
583 #[case] deployment: LighterDeployment,
584 #[case] environment: LighterEnvironment,
585 #[case] expected: ExpectedDeploymentSettings,
586 ) {
587 let data = LighterDataClientConfig {
588 environment,
589 deployment,
590 ..Default::default()
591 };
592
593 let execution = LighterExecutionClientConfig {
594 environment,
595 deployment,
596 ..Default::default()
597 };
598
599 assert_eq!(data.http_url(), expected.http_url);
600 assert_eq!(data.ws_url(), expected.data_ws_url);
601 assert_eq!(data.resolved_venue().as_str(), expected.venue);
602 assert_eq!(data.settlement_currency().code, expected.currency);
603 assert_eq!(execution.http_url(), expected.http_url);
604 assert_eq!(
605 execution.ws_url(),
606 expected.data_ws_url.replace("?readonly=true", "")
607 );
608 assert_eq!(execution.resolved_venue().as_str(), expected.venue);
609 assert_eq!(execution.settlement_currency().code, expected.currency);
610 assert_eq!(execution.chain_id(), expected.chain_id);
611 }
612
613 #[rstest]
614 fn configs_preserve_custom_venue() {
615 let venue = Venue::from("LIGHTER_CUSTOM");
616 let data = LighterDataClientConfig {
617 deployment: LighterDeployment::Robinhood,
618 venue: Some(venue),
619 ..Default::default()
620 };
621
622 let execution = LighterExecutionClientConfig {
623 deployment: LighterDeployment::Robinhood,
624 venue: Some(venue),
625 ..Default::default()
626 };
627
628 assert_eq!(data.resolved_venue(), venue);
629 assert_eq!(execution.resolved_venue(), venue);
630 assert_eq!(execution.chain_id(), 466_324);
631 }
632
633 #[rstest]
634 fn exec_config_debug_redacts_private_key() {
635 let config = LighterExecutionClientConfig {
636 account_id: AccountId::from("LIGHTER-001"),
637 api_key_index: Some(5),
638 account_index: Some(12_345),
639 private_key: Some(PRIVATE_KEY_HEX.into()),
640 base_url_http: None,
641 base_url_ws: None,
642 proxy_url: None,
643 environment: LighterEnvironment::Mainnet,
644 deployment: LighterDeployment::Lighter,
645 venue: None,
646 http_timeout_secs: 60,
647 ws_timeout_secs: 30,
648 market_order_slippage_bps: 50,
649 rest_quota_per_min: None,
650 sendtx_quota_per_min: None,
651 transport_backend: TransportBackend::default(),
652 use_gtd: true,
653 scheduled_cancel_timeout_secs: None,
654 };
655
656 let dbg_out = format!("{config:?}");
657
658 assert!(dbg_out.contains(REDACTED));
659 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
660 }
661
662 #[rstest]
663 fn exec_config_use_gtd_defaults_to_true() {
664 let config = LighterExecutionClientConfig::default();
667
668 assert!(config.use_gtd);
669 }
670
671 #[rstest]
672 fn exec_config_toml_use_gtd_override() {
673 let config: LighterExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
674
675 assert!(!config.use_gtd);
676 }
677
678 #[rstest]
679 fn exec_config_ws_url_keeps_regular_stream_url() {
680 let config = LighterExecutionClientConfig {
681 account_id: AccountId::from("LIGHTER-001"),
682 environment: LighterEnvironment::Mainnet,
683 ..Default::default()
684 };
685
686 assert_eq!(config.ws_url(), "wss://mainnet.zklighter.elliot.ai/stream");
687 }
688}