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