1use std::fmt::Debug;
19
20use nautilus_core::string::secret::REDACTED;
21use nautilus_model::identifiers::{AccountId, TraderId};
22use nautilus_network::websocket::TransportBackend;
23use serde::{Deserialize, Serialize};
24
25use crate::common::{
26 credential::credential_env_vars,
27 enums::LighterEnvironment,
28 urls::{lighter_http_base_url, lighter_ws_url},
29};
30
31#[derive(Clone, Serialize, Deserialize, bon::Builder)]
33#[serde(default, deny_unknown_fields)]
34#[cfg_attr(
35 feature = "python",
36 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.lighter", from_py_object,)
37)]
38#[cfg_attr(
39 feature = "python",
40 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
41)]
42pub struct LighterDataClientConfig {
43 pub base_url_http: Option<String>,
45 pub base_url_ws: Option<String>,
47 pub proxy_url: Option<String>,
49 #[builder(default)]
51 pub environment: LighterEnvironment,
52 pub account_index: Option<u64>,
55 pub api_key_index: Option<u8>,
58 pub private_key: Option<String>,
61 #[builder(default = 60)]
63 pub http_timeout_secs: u64,
64 #[builder(default = 30)]
66 pub ws_timeout_secs: u64,
67 #[builder(default = 60)]
69 pub update_instruments_interval_mins: u64,
70 pub rest_quota_per_min: Option<u32>,
73 #[builder(default)]
75 pub transport_backend: TransportBackend,
76}
77
78impl Default for LighterDataClientConfig {
79 fn default() -> Self {
80 Self::builder().build()
81 }
82}
83
84impl LighterDataClientConfig {
85 #[must_use]
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 #[must_use]
93 pub fn http_url(&self) -> String {
94 self.base_url_http
95 .clone()
96 .unwrap_or_else(|| lighter_http_base_url(self.environment).to_string())
97 }
98
99 #[must_use]
101 pub fn ws_url(&self) -> String {
102 let url = self
103 .base_url_ws
104 .clone()
105 .unwrap_or_else(|| lighter_ws_url(self.environment).to_string());
106
107 ensure_readonly_ws_url(url)
108 }
109
110 #[must_use]
112 pub fn has_credentials(&self) -> bool {
113 let (key_var, secret_var, account_var) = credential_env_vars(self.environment);
114 let has_key = self.api_key_index.is_some() || env_var_is_set(key_var);
115 let has_account = self.account_index.is_some() || env_var_is_set(account_var);
116 let has_secret = self
117 .private_key
118 .as_deref()
119 .is_some_and(|s| !s.trim().is_empty())
120 || env_var_is_set(secret_var);
121
122 has_key && has_account && has_secret
123 }
124}
125
126impl Debug for LighterDataClientConfig {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct(stringify!(LighterDataClientConfig))
129 .field("base_url_http", &self.base_url_http)
130 .field("base_url_ws", &self.base_url_ws)
131 .field("proxy_url", &self.proxy_url)
132 .field("environment", &self.environment)
133 .field("account_index", &self.account_index)
134 .field("api_key_index", &self.api_key_index)
135 .field("private_key", &self.private_key.as_ref().map(|_| REDACTED))
136 .field("http_timeout_secs", &self.http_timeout_secs)
137 .field("ws_timeout_secs", &self.ws_timeout_secs)
138 .field(
139 "update_instruments_interval_mins",
140 &self.update_instruments_interval_mins,
141 )
142 .field("rest_quota_per_min", &self.rest_quota_per_min)
143 .field("transport_backend", &self.transport_backend)
144 .finish()
145 }
146}
147
148fn env_var_is_set(name: &str) -> bool {
149 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
150}
151
152fn ensure_readonly_ws_url(url: String) -> String {
153 let Ok(mut parsed) = url::Url::parse(&url) else {
154 return url;
155 };
156
157 let pairs = parsed
158 .query_pairs()
159 .filter(|(key, _)| key != "readonly")
160 .map(|(key, value)| (key.into_owned(), value.into_owned()))
161 .collect::<Vec<_>>();
162
163 parsed.set_query(None);
164 {
165 let mut query = parsed.query_pairs_mut();
166 for (key, value) in pairs {
167 query.append_pair(&key, &value);
168 }
169 query.append_pair("readonly", "true");
170 }
171
172 parsed.to_string()
173}
174
175#[derive(Clone, Serialize, Deserialize, bon::Builder)]
177#[serde(default, deny_unknown_fields)]
178#[cfg_attr(
179 feature = "python",
180 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.lighter", from_py_object,)
181)]
182#[cfg_attr(
183 feature = "python",
184 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
185)]
186pub struct LighterExecClientConfig {
187 #[builder(default = TraderId::from("TRADER-001"))]
189 pub trader_id: TraderId,
190 #[builder(default = AccountId::from("LIGHTER-001"))]
192 pub account_id: AccountId,
193 pub account_index: Option<u64>,
197 pub api_key_index: Option<u8>,
203 pub private_key: Option<String>,
207 pub base_url_http: Option<String>,
209 pub base_url_ws: Option<String>,
211 pub proxy_url: Option<String>,
213 #[builder(default)]
215 pub environment: LighterEnvironment,
216 #[builder(default = 60)]
218 pub http_timeout_secs: u64,
219 #[builder(default = 30)]
221 pub ws_timeout_secs: u64,
222 #[builder(default = 50)]
224 pub market_order_slippage_bps: u32,
225 pub rest_quota_per_min: Option<u32>,
228 pub sendtx_quota_per_min: Option<u32>,
231 #[builder(default)]
233 pub transport_backend: TransportBackend,
234}
235
236impl Default for LighterExecClientConfig {
237 fn default() -> Self {
238 Self::builder().build()
239 }
240}
241
242impl Debug for LighterExecClientConfig {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct(stringify!(LighterExecClientConfig))
245 .field("trader_id", &self.trader_id)
246 .field("account_id", &self.account_id)
247 .field("account_index", &self.account_index)
248 .field("api_key_index", &self.api_key_index)
249 .field("private_key", &self.private_key.as_ref().map(|_| REDACTED))
250 .field("base_url_http", &self.base_url_http)
251 .field("base_url_ws", &self.base_url_ws)
252 .field("proxy_url", &self.proxy_url)
253 .field("environment", &self.environment)
254 .field("http_timeout_secs", &self.http_timeout_secs)
255 .field("ws_timeout_secs", &self.ws_timeout_secs)
256 .field("market_order_slippage_bps", &self.market_order_slippage_bps)
257 .field("rest_quota_per_min", &self.rest_quota_per_min)
258 .field("sendtx_quota_per_min", &self.sendtx_quota_per_min)
259 .field("transport_backend", &self.transport_backend)
260 .finish()
261 }
262}
263
264impl LighterExecClientConfig {
265 #[must_use]
271 pub fn has_credentials(&self) -> bool {
272 let key_set = self
273 .private_key
274 .as_deref()
275 .is_some_and(|s| !s.trim().is_empty());
276 key_set && self.account_index.is_some() && self.api_key_index.is_some()
277 }
278
279 #[must_use]
281 pub fn http_url(&self) -> String {
282 self.base_url_http
283 .clone()
284 .unwrap_or_else(|| lighter_http_base_url(self.environment).to_string())
285 }
286
287 #[must_use]
289 pub fn ws_url(&self) -> String {
290 self.base_url_ws
291 .clone()
292 .unwrap_or_else(|| lighter_ws_url(self.environment).to_string())
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use rstest::rstest;
299
300 use super::*;
301
302 const PRIVATE_KEY_HEX: &str =
303 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
304
305 #[rstest]
306 fn data_config_has_credentials_when_all_fields_set() {
307 let config = LighterDataClientConfig {
308 api_key_index: Some(5),
309 account_index: Some(12_345),
310 private_key: Some(PRIVATE_KEY_HEX.to_string()),
311 ..Default::default()
312 };
313
314 assert!(config.has_credentials());
315 }
316
317 #[rstest]
318 fn data_config_debug_redacts_private_key() {
319 let config = LighterDataClientConfig {
320 api_key_index: Some(5),
321 account_index: Some(12_345),
322 private_key: Some(PRIVATE_KEY_HEX.to_string()),
323 ..Default::default()
324 };
325
326 let dbg_out = format!("{config:?}");
327
328 assert!(dbg_out.contains(REDACTED));
329 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
330 }
331
332 #[rstest]
333 fn data_config_debug_omits_private_key_when_unset() {
334 let config = LighterDataClientConfig::default();
335
336 let dbg_out = format!("{config:?}");
337
338 assert!(dbg_out.contains("private_key: None"));
339 }
340
341 #[rstest]
342 fn data_config_ws_url_sets_readonly_query() {
343 let config = LighterDataClientConfig::default();
344
345 assert_eq!(
346 config.ws_url(),
347 "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
348 );
349 }
350
351 #[rstest]
352 fn data_config_ws_url_preserves_existing_query_params() {
353 let config = LighterDataClientConfig {
354 base_url_ws: Some("wss://mainnet.zklighter.elliot.ai/stream?foo=bar".to_string()),
355 ..Default::default()
356 };
357
358 assert_eq!(
359 config.ws_url(),
360 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
361 );
362 }
363
364 #[rstest]
365 fn data_config_ws_url_overrides_readonly_query() {
366 let config = LighterDataClientConfig {
367 base_url_ws: Some(
368 "wss://mainnet.zklighter.elliot.ai/stream?readonly=false&foo=bar".to_string(),
369 ),
370 ..Default::default()
371 };
372
373 assert_eq!(
374 config.ws_url(),
375 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
376 );
377 }
378
379 #[rstest]
380 fn exec_config_debug_redacts_private_key() {
381 let config = LighterExecClientConfig {
382 trader_id: TraderId::from("TRADER-001"),
383 account_id: AccountId::from("LIGHTER-001"),
384 api_key_index: Some(5),
385 account_index: Some(12_345),
386 private_key: Some(PRIVATE_KEY_HEX.to_string()),
387 base_url_http: None,
388 base_url_ws: None,
389 proxy_url: None,
390 environment: LighterEnvironment::Mainnet,
391 http_timeout_secs: 60,
392 ws_timeout_secs: 30,
393 market_order_slippage_bps: 50,
394 rest_quota_per_min: None,
395 sendtx_quota_per_min: None,
396 transport_backend: TransportBackend::default(),
397 };
398
399 let dbg_out = format!("{config:?}");
400
401 assert!(dbg_out.contains(REDACTED));
402 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
403 }
404
405 #[rstest]
406 fn exec_config_ws_url_keeps_regular_stream_url() {
407 let config = LighterExecClientConfig {
408 trader_id: TraderId::from("TRADER-001"),
409 account_id: AccountId::from("LIGHTER-001"),
410 environment: LighterEnvironment::Mainnet,
411 ..Default::default()
412 };
413
414 assert_eq!(config.ws_url(), "wss://mainnet.zklighter.elliot.ai/stream");
415 }
416
417 #[allow(unsafe_code)] mod serial_tests {
422 use super::*;
423
424 const LIGHTER_ENV_VARS: &[&str] = &[
425 "LIGHTER_API_KEY_INDEX",
426 "LIGHTER_API_SECRET",
427 "LIGHTER_ACCOUNT_INDEX",
428 "LIGHTER_TESTNET_API_KEY_INDEX",
429 "LIGHTER_TESTNET_API_SECRET",
430 "LIGHTER_TESTNET_ACCOUNT_INDEX",
431 ];
432
433 struct EnvGuard {
436 saved: Vec<(&'static str, Option<String>)>,
437 }
438
439 impl EnvGuard {
440 fn clear_lighter() -> Self {
441 let saved = LIGHTER_ENV_VARS
442 .iter()
443 .map(|&name| (name, std::env::var(name).ok()))
444 .collect::<Vec<_>>();
445 for &(name, _) in &saved {
446 unsafe { std::env::remove_var(name) };
450 }
451 Self { saved }
452 }
453 }
454
455 impl Drop for EnvGuard {
456 fn drop(&mut self) {
457 for (name, original) in &self.saved {
458 match original {
459 Some(value) => unsafe { std::env::set_var(name, value) },
461 None => unsafe { std::env::remove_var(name) },
462 }
463 }
464 }
465 }
466
467 #[rstest]
468 fn data_config_has_credentials_false_when_all_unset() {
469 let _guard = EnvGuard::clear_lighter();
470 let config = LighterDataClientConfig::default();
471
472 assert!(!config.has_credentials());
473 }
474
475 #[rstest]
476 #[case::only_api_key_index(Some(5), None, None)]
477 #[case::only_account_index(None, Some(12_345), None)]
478 #[case::only_private_key(None, None, Some(PRIVATE_KEY_HEX.to_string()))]
479 #[case::missing_api_key_index(None, Some(12_345), Some(PRIVATE_KEY_HEX.to_string()))]
480 #[case::missing_account_index(Some(5), None, Some(PRIVATE_KEY_HEX.to_string()))]
481 #[case::missing_private_key(Some(5), Some(12_345), None)]
482 fn data_config_has_credentials_false_for_partial_config(
483 #[case] api_key_index: Option<u8>,
484 #[case] account_index: Option<u64>,
485 #[case] private_key: Option<String>,
486 ) {
487 let _guard = EnvGuard::clear_lighter();
488 let config = LighterDataClientConfig {
489 account_index,
490 api_key_index,
491 private_key,
492 ..Default::default()
493 };
494
495 assert!(!config.has_credentials());
496 }
497
498 #[rstest]
499 #[case::empty("")]
500 #[case::whitespace(" ")]
501 fn data_config_has_credentials_false_for_blank_private_key(#[case] private_key: &str) {
502 let _guard = EnvGuard::clear_lighter();
503 let config = LighterDataClientConfig {
504 api_key_index: Some(5),
505 account_index: Some(12_345),
506 private_key: Some(private_key.to_string()),
507 ..Default::default()
508 };
509
510 assert!(!config.has_credentials());
511 }
512
513 #[rstest]
514 fn data_config_has_credentials_reads_testnet_env_vars() {
515 let _guard = EnvGuard::clear_lighter();
516 unsafe { std::env::set_var("LIGHTER_TESTNET_API_KEY_INDEX", "5") };
518 unsafe { std::env::set_var("LIGHTER_TESTNET_API_SECRET", PRIVATE_KEY_HEX) };
520 unsafe { std::env::set_var("LIGHTER_TESTNET_ACCOUNT_INDEX", "12345") };
522 let config = LighterDataClientConfig {
523 environment: LighterEnvironment::Testnet,
524 ..Default::default()
525 };
526
527 assert!(config.has_credentials());
528 }
529
530 #[rstest]
531 fn data_config_has_credentials_ignores_mismatched_environment_env_vars() {
532 let _guard = EnvGuard::clear_lighter();
533 unsafe { std::env::set_var("LIGHTER_TESTNET_API_KEY_INDEX", "5") };
535 unsafe { std::env::set_var("LIGHTER_TESTNET_API_SECRET", PRIVATE_KEY_HEX) };
537 unsafe { std::env::set_var("LIGHTER_TESTNET_ACCOUNT_INDEX", "12345") };
539 let config = LighterDataClientConfig {
540 environment: LighterEnvironment::Mainnet,
541 ..Default::default()
542 };
543
544 assert!(!config.has_credentials());
545 }
546 }
547}