1use std::fmt::Debug;
28
29use nautilus_core::string::secret::REDACTED;
30use nautilus_model::{
31 identifiers::{AccountId, Venue},
32 types::Currency,
33};
34use nautilus_network::websocket::TransportBackend;
35use serde::{Deserialize, Serialize};
36
37use crate::common::{
38 credential::credential_env_vars_for_deployment,
39 deployment,
40 enums::{LighterDeployment, LighterEnvironment},
41};
42
43const WS_READONLY_QUERY_PARAM: &str = "readonly";
44
45#[derive(Clone, Serialize, Deserialize, bon::Builder)]
47#[serde(default, deny_unknown_fields)]
48#[cfg_attr(
49 feature = "python",
50 pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
51)]
52#[cfg_attr(
53 feature = "python",
54 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
55)]
56pub struct LighterDataClientConfig {
57 #[builder(default)]
59 pub environment: LighterEnvironment,
60 #[builder(default)]
62 pub deployment: LighterDeployment,
63 pub venue: Option<Venue>,
68 pub account_index: Option<u64>,
71 pub api_key_index: Option<u8>,
74 pub private_key: Option<String>,
77 pub base_url_http: Option<String>,
79 pub base_url_ws: Option<String>,
81 pub proxy_url: Option<String>,
83 #[builder(default = 60)]
85 pub http_timeout_secs: u64,
86 #[builder(default = 30)]
88 pub ws_timeout_secs: u64,
89 #[builder(default = 60)]
91 pub update_instruments_interval_mins: u64,
92 pub rest_quota_per_min: Option<u32>,
95 #[builder(default)]
97 pub transport_backend: TransportBackend,
98}
99
100#[cfg(feature = "python")]
101nautilus_core::impl_pyo3_config_getters!(LighterDataClientConfig {
102 environment: LighterEnvironment,
103 deployment: LighterDeployment,
104 venue: Option<Venue>,
105 account_index: Option<u64>,
106 api_key_index: Option<u8>,
107 base_url_http: Option<String>,
108 base_url_ws: Option<String>,
109 http_timeout_secs: u64,
110 ws_timeout_secs: u64,
111 update_instruments_interval_mins: u64,
112 rest_quota_per_min: Option<u32>,
113 transport_backend: TransportBackend,
114});
115
116impl Default for LighterDataClientConfig {
117 fn default() -> Self {
118 Self::builder().build()
119 }
120}
121
122impl LighterDataClientConfig {
123 #[must_use]
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 #[must_use]
131 pub fn http_url(&self) -> String {
132 self.base_url_http.clone().unwrap_or_else(|| {
133 deployment::http_base_url(self.deployment, self.environment).to_string()
134 })
135 }
136
137 #[must_use]
139 pub fn ws_url(&self) -> String {
140 let url = self
141 .base_url_ws
142 .clone()
143 .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string());
144
145 ensure_readonly_ws_url(url)
146 }
147
148 #[must_use]
150 pub fn resolved_venue(&self) -> Venue {
151 self.venue
152 .unwrap_or_else(|| deployment::venue(self.deployment))
153 }
154
155 #[must_use]
157 pub fn settlement_currency(&self) -> Currency {
158 deployment::settlement_currency(self.deployment)
159 }
160
161 #[must_use]
163 pub fn has_credentials(&self) -> bool {
164 let (key_var, secret_var, account_var) =
165 credential_env_vars_for_deployment(self.deployment, self.environment);
166 let has_key = self.api_key_index.is_some() || env_var_is_set(key_var);
167 let has_account = self.account_index.is_some() || env_var_is_set(account_var);
168 let has_secret = self
169 .private_key
170 .as_deref()
171 .is_some_and(|s| !s.trim().is_empty())
172 || env_var_is_set(secret_var);
173
174 has_key && has_account && has_secret
175 }
176}
177
178impl Debug for LighterDataClientConfig {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct(stringify!(LighterDataClientConfig))
181 .field("environment", &self.environment)
182 .field("deployment", &self.deployment)
183 .field("venue", &self.venue)
184 .field("account_index", &self.account_index)
185 .field("api_key_index", &self.api_key_index)
186 .field("private_key", &self.private_key.as_ref().map(|_| REDACTED))
187 .field("base_url_http", &self.base_url_http)
188 .field("base_url_ws", &self.base_url_ws)
189 .field("proxy_url", &self.proxy_url)
190 .field("http_timeout_secs", &self.http_timeout_secs)
191 .field("ws_timeout_secs", &self.ws_timeout_secs)
192 .field(
193 "update_instruments_interval_mins",
194 &self.update_instruments_interval_mins,
195 )
196 .field("rest_quota_per_min", &self.rest_quota_per_min)
197 .field("transport_backend", &self.transport_backend)
198 .finish()
199 }
200}
201
202fn env_var_is_set(name: &str) -> bool {
203 std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
204}
205
206fn ensure_readonly_ws_url(url: String) -> String {
207 let Ok(mut parsed) = url::Url::parse(&url) else {
208 return url;
209 };
210
211 let pairs = parsed
212 .query_pairs()
213 .filter(|(key, _)| key != WS_READONLY_QUERY_PARAM)
214 .map(|(key, value)| (key.into_owned(), value.into_owned()))
215 .collect::<Vec<_>>();
216
217 parsed.set_query(None);
218 {
219 let mut query = parsed.query_pairs_mut();
220 for (key, value) in pairs {
221 query.append_pair(&key, &value);
222 }
223 query.append_pair(WS_READONLY_QUERY_PARAM, "true");
224 }
225
226 parsed.to_string()
227}
228
229#[derive(Clone, Serialize, Deserialize, bon::Builder)]
231#[serde(default, deny_unknown_fields)]
232#[cfg_attr(
233 feature = "python",
234 pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
235)]
236#[cfg_attr(
237 feature = "python",
238 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
239)]
240pub struct LighterExecutionClientConfig {
241 #[builder(default)]
243 pub environment: LighterEnvironment,
244 #[builder(default)]
246 pub deployment: LighterDeployment,
247 pub venue: Option<Venue>,
252 #[builder(default = AccountId::from("LIGHTER-001"))]
254 pub account_id: AccountId,
255 pub account_index: Option<u64>,
258 pub api_key_index: Option<u8>,
262 pub private_key: Option<String>,
265 pub base_url_http: Option<String>,
267 pub base_url_ws: Option<String>,
269 pub proxy_url: Option<String>,
271 #[builder(default = 60)]
273 pub http_timeout_secs: u64,
274 #[builder(default = 30)]
276 pub ws_timeout_secs: u64,
277 #[builder(default = 50)]
279 pub market_order_slippage_bps: u32,
280 pub rest_quota_per_min: Option<u32>,
283 pub sendtx_quota_per_min: Option<u32>,
286 #[builder(default)]
288 pub transport_backend: TransportBackend,
289}
290
291#[cfg(feature = "python")]
292nautilus_core::impl_pyo3_config_getters!(LighterExecutionClientConfig {
293 environment: LighterEnvironment,
294 deployment: LighterDeployment,
295 venue: Option<Venue>,
296 account_id: AccountId,
297 account_index: Option<u64>,
298 api_key_index: Option<u8>,
299 base_url_http: Option<String>,
300 base_url_ws: Option<String>,
301 http_timeout_secs: u64,
302 ws_timeout_secs: u64,
303 market_order_slippage_bps: u32,
304 rest_quota_per_min: Option<u32>,
305 sendtx_quota_per_min: Option<u32>,
306 transport_backend: TransportBackend,
307});
308
309impl Default for LighterExecutionClientConfig {
310 fn default() -> Self {
311 Self::builder().build()
312 }
313}
314
315impl Debug for LighterExecutionClientConfig {
316 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317 f.debug_struct(stringify!(LighterExecutionClientConfig))
318 .field("environment", &self.environment)
319 .field("deployment", &self.deployment)
320 .field("venue", &self.venue)
321 .field("account_id", &self.account_id)
322 .field("account_index", &self.account_index)
323 .field("api_key_index", &self.api_key_index)
324 .field("private_key", &self.private_key.as_ref().map(|_| REDACTED))
325 .field("base_url_http", &self.base_url_http)
326 .field("base_url_ws", &self.base_url_ws)
327 .field("proxy_url", &self.proxy_url)
328 .field("http_timeout_secs", &self.http_timeout_secs)
329 .field("ws_timeout_secs", &self.ws_timeout_secs)
330 .field("market_order_slippage_bps", &self.market_order_slippage_bps)
331 .field("rest_quota_per_min", &self.rest_quota_per_min)
332 .field("sendtx_quota_per_min", &self.sendtx_quota_per_min)
333 .field("transport_backend", &self.transport_backend)
334 .finish()
335 }
336}
337
338impl LighterExecutionClientConfig {
339 #[must_use]
345 pub fn has_credentials(&self) -> bool {
346 let key_set = self
347 .private_key
348 .as_deref()
349 .is_some_and(|s| !s.trim().is_empty());
350 key_set && self.account_index.is_some() && self.api_key_index.is_some()
351 }
352
353 #[must_use]
355 pub fn http_url(&self) -> String {
356 self.base_url_http.clone().unwrap_or_else(|| {
357 deployment::http_base_url(self.deployment, self.environment).to_string()
358 })
359 }
360
361 #[must_use]
363 pub fn ws_url(&self) -> String {
364 self.base_url_ws
365 .clone()
366 .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string())
367 }
368
369 #[must_use]
371 pub fn resolved_venue(&self) -> Venue {
372 self.venue
373 .unwrap_or_else(|| deployment::venue(self.deployment))
374 }
375
376 #[must_use]
378 pub fn settlement_currency(&self) -> Currency {
379 deployment::settlement_currency(self.deployment)
380 }
381
382 #[must_use]
384 pub const fn chain_id(&self) -> u32 {
385 deployment::chain_id(self.deployment, self.environment)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use rstest::rstest;
392
393 use super::*;
394
395 const PRIVATE_KEY_HEX: &str =
396 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
397
398 #[rstest]
399 fn data_config_has_credentials_when_all_fields_set() {
400 let config = LighterDataClientConfig {
401 api_key_index: Some(5),
402 account_index: Some(12_345),
403 private_key: Some(PRIVATE_KEY_HEX.to_string()),
404 ..Default::default()
405 };
406
407 assert!(config.has_credentials());
408 }
409
410 #[rstest]
411 fn data_config_debug_redacts_private_key() {
412 let config = LighterDataClientConfig {
413 api_key_index: Some(5),
414 account_index: Some(12_345),
415 private_key: Some(PRIVATE_KEY_HEX.to_string()),
416 ..Default::default()
417 };
418
419 let dbg_out = format!("{config:?}");
420
421 assert!(dbg_out.contains(REDACTED));
422 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
423 }
424
425 #[rstest]
426 fn data_config_debug_omits_private_key_when_unset() {
427 let config = LighterDataClientConfig::default();
428
429 let dbg_out = format!("{config:?}");
430
431 assert!(dbg_out.contains("private_key: None"));
432 }
433
434 #[rstest]
435 fn data_config_ws_url_sets_readonly_query() {
436 let config = LighterDataClientConfig::default();
437
438 assert_eq!(
439 config.ws_url(),
440 "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
441 );
442 }
443
444 #[rstest]
445 fn data_config_ws_url_preserves_existing_query_params() {
446 let config = LighterDataClientConfig {
447 base_url_ws: Some("wss://mainnet.zklighter.elliot.ai/stream?foo=bar".to_string()),
448 ..Default::default()
449 };
450
451 assert_eq!(
452 config.ws_url(),
453 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
454 );
455 }
456
457 #[rstest]
458 fn data_config_ws_url_overrides_readonly_query() {
459 let config = LighterDataClientConfig {
460 base_url_ws: Some(
461 "wss://mainnet.zklighter.elliot.ai/stream?readonly=false&foo=bar".to_string(),
462 ),
463 ..Default::default()
464 };
465
466 assert_eq!(
467 config.ws_url(),
468 "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
469 );
470 }
471
472 #[derive(Debug)]
473 struct ExpectedDeploymentSettings {
474 http_url: &'static str,
475 data_ws_url: &'static str,
476 chain_id: u32,
477 venue: &'static str,
478 currency: &'static str,
479 }
480
481 #[rstest]
482 #[case::lighter_mainnet(
483 LighterDeployment::Lighter,
484 LighterEnvironment::Mainnet,
485 ExpectedDeploymentSettings {
486 http_url: "https://mainnet.zklighter.elliot.ai",
487 data_ws_url: "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
488 chain_id: 304,
489 venue: "LIGHTER",
490 currency: "USDC",
491 }
492 )]
493 #[case::lighter_testnet(
494 LighterDeployment::Lighter,
495 LighterEnvironment::Testnet,
496 ExpectedDeploymentSettings {
497 http_url: "https://testnet.zklighter.elliot.ai",
498 data_ws_url: "wss://testnet.zklighter.elliot.ai/stream?readonly=true",
499 chain_id: 300,
500 venue: "LIGHTER",
501 currency: "USDC",
502 }
503 )]
504 #[case::robinhood_mainnet(
505 LighterDeployment::Robinhood,
506 LighterEnvironment::Mainnet,
507 ExpectedDeploymentSettings {
508 http_url: "https://api.rh.lighter.xyz",
509 data_ws_url: "wss://api.rh.lighter.xyz/stream?readonly=true",
510 chain_id: 466_324,
511 venue: "LIGHTER_ROBINHOOD",
512 currency: "USDG",
513 }
514 )]
515 #[case::robinhood_testnet(
516 LighterDeployment::Robinhood,
517 LighterEnvironment::Testnet,
518 ExpectedDeploymentSettings {
519 http_url: "https://api.rh-testnet.lighter.xyz",
520 data_ws_url: "wss://api.rh-testnet.lighter.xyz/stream?readonly=true",
521 chain_id: 300,
522 venue: "LIGHTER_ROBINHOOD",
523 currency: "USDG",
524 }
525 )]
526 fn configs_resolve_deployment_settings(
527 #[case] deployment: LighterDeployment,
528 #[case] environment: LighterEnvironment,
529 #[case] expected: ExpectedDeploymentSettings,
530 ) {
531 let data = LighterDataClientConfig {
532 environment,
533 deployment,
534 ..Default::default()
535 };
536
537 let execution = LighterExecutionClientConfig {
538 environment,
539 deployment,
540 ..Default::default()
541 };
542
543 assert_eq!(data.http_url(), expected.http_url);
544 assert_eq!(data.ws_url(), expected.data_ws_url);
545 assert_eq!(data.resolved_venue().as_str(), expected.venue);
546 assert_eq!(data.settlement_currency().code.as_str(), expected.currency);
547 assert_eq!(execution.http_url(), expected.http_url);
548 assert_eq!(
549 execution.ws_url(),
550 expected.data_ws_url.replace("?readonly=true", "")
551 );
552 assert_eq!(execution.resolved_venue().as_str(), expected.venue);
553 assert_eq!(
554 execution.settlement_currency().code.as_str(),
555 expected.currency
556 );
557 assert_eq!(execution.chain_id(), expected.chain_id);
558 }
559
560 #[rstest]
561 fn configs_preserve_custom_venue() {
562 let venue = Venue::from("LIGHTER_CUSTOM");
563 let data = LighterDataClientConfig {
564 deployment: LighterDeployment::Robinhood,
565 venue: Some(venue),
566 ..Default::default()
567 };
568
569 let execution = LighterExecutionClientConfig {
570 deployment: LighterDeployment::Robinhood,
571 venue: Some(venue),
572 ..Default::default()
573 };
574
575 assert_eq!(data.resolved_venue(), venue);
576 assert_eq!(execution.resolved_venue(), venue);
577 assert_eq!(execution.chain_id(), 466_324);
578 }
579
580 #[rstest]
581 fn exec_config_debug_redacts_private_key() {
582 let config = LighterExecutionClientConfig {
583 account_id: AccountId::from("LIGHTER-001"),
584 api_key_index: Some(5),
585 account_index: Some(12_345),
586 private_key: Some(PRIVATE_KEY_HEX.to_string()),
587 base_url_http: None,
588 base_url_ws: None,
589 proxy_url: None,
590 environment: LighterEnvironment::Mainnet,
591 deployment: LighterDeployment::Lighter,
592 venue: None,
593 http_timeout_secs: 60,
594 ws_timeout_secs: 30,
595 market_order_slippage_bps: 50,
596 rest_quota_per_min: None,
597 sendtx_quota_per_min: None,
598 transport_backend: TransportBackend::default(),
599 };
600
601 let dbg_out = format!("{config:?}");
602
603 assert!(dbg_out.contains(REDACTED));
604 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
605 }
606
607 #[rstest]
608 fn exec_config_ws_url_keeps_regular_stream_url() {
609 let config = LighterExecutionClientConfig {
610 account_id: AccountId::from("LIGHTER-001"),
611 environment: LighterEnvironment::Mainnet,
612 ..Default::default()
613 };
614
615 assert_eq!(config.ws_url(), "wss://mainnet.zklighter.elliot.ai/stream");
616 }
617}