Skip to main content

nautilus_lighter/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration structures for the Lighter adapter.
17
18use 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/// Configuration for the Lighter data client.
32#[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    /// Optional REST URL override.
44    pub base_url_http: Option<String>,
45    /// Optional WebSocket URL override.
46    pub base_url_ws: Option<String>,
47    /// Optional proxy URL for HTTP and WebSocket transports.
48    pub proxy_url: Option<String>,
49    /// Target environment.
50    #[builder(default)]
51    pub environment: LighterEnvironment,
52    /// Lighter account index for authenticated REST data requests. Falls back
53    /// to `LIGHTER_ACCOUNT_INDEX` / `LIGHTER_TESTNET_ACCOUNT_INDEX`.
54    pub account_index: Option<u64>,
55    /// API key index for authenticated REST data requests. Falls back to
56    /// `LIGHTER_API_KEY_INDEX` / `LIGHTER_TESTNET_API_KEY_INDEX`.
57    pub api_key_index: Option<u8>,
58    /// Hex-encoded private key for REST auth tokens. Falls back to
59    /// `LIGHTER_API_SECRET` / `LIGHTER_TESTNET_API_SECRET`.
60    pub private_key: Option<String>,
61    /// HTTP request timeout in seconds.
62    #[builder(default = 60)]
63    pub http_timeout_secs: u64,
64    /// WebSocket connect timeout in seconds.
65    #[builder(default = 30)]
66    pub ws_timeout_secs: u64,
67    /// Refresh interval for instrument metadata in minutes.
68    #[builder(default = 60)]
69    pub update_instruments_interval_mins: u64,
70    /// Optional REST read-bucket quota override in requests per minute; unset keeps
71    /// the conservative 60 req/min default (raising it requires venue IP registration).
72    pub rest_quota_per_min: Option<u32>,
73    /// WebSocket transport backend.
74    #[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    /// Creates a new configuration with default settings.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Returns the resolved REST base URL.
92    #[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    /// Returns the resolved WebSocket URL.
100    #[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    /// Returns `true` when all REST auth credential fields are available.
111    #[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/// Configuration for the Lighter execution client.
176#[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    /// Trader identifier.
188    #[builder(default = TraderId::from("TRADER-001"))]
189    pub trader_id: TraderId,
190    /// Account identifier on the venue.
191    #[builder(default = AccountId::from("LIGHTER-001"))]
192    pub account_id: AccountId,
193    /// Lighter account index (numeric, assigned at registration). Falls back
194    /// to `LIGHTER_ACCOUNT_INDEX` / `LIGHTER_TESTNET_ACCOUNT_INDEX` when
195    /// resolved through `common::credential`.
196    pub account_index: Option<u64>,
197    /// API key index for a user-created Lighter key. Low indexes are reserved
198    /// for Lighter clients; 255 is the `apikeys` all-keys sentinel. Falls back
199    /// to `LIGHTER_API_KEY_INDEX` /
200    /// `LIGHTER_TESTNET_API_KEY_INDEX` when resolved through
201    /// `common::credential`.
202    pub api_key_index: Option<u8>,
203    /// Hex-encoded private key for the API key (Schnorr / ecgfp5). Falls back
204    /// to `LIGHTER_API_SECRET` / `LIGHTER_TESTNET_API_SECRET` when resolved
205    /// through `common::credential`.
206    pub private_key: Option<String>,
207    /// Optional REST URL override.
208    pub base_url_http: Option<String>,
209    /// Optional WebSocket URL override.
210    pub base_url_ws: Option<String>,
211    /// Optional proxy URL for HTTP and WebSocket transports.
212    pub proxy_url: Option<String>,
213    /// Target environment.
214    #[builder(default)]
215    pub environment: LighterEnvironment,
216    /// HTTP request timeout in seconds.
217    #[builder(default = 60)]
218    pub http_timeout_secs: u64,
219    /// WebSocket connect timeout in seconds.
220    #[builder(default = 30)]
221    pub ws_timeout_secs: u64,
222    /// Slippage buffer in basis points for market-style orders.
223    #[builder(default = 50)]
224    pub market_order_slippage_bps: u32,
225    /// Optional REST read-bucket quota override in requests per minute; unset keeps
226    /// the conservative 60 req/min default (raising it requires venue IP registration).
227    pub rest_quota_per_min: Option<u32>,
228    /// Optional transaction quota override (req/min), independent of `rest_quota_per_min`;
229    /// unset keeps 60. Enforced across the HTTP and WebSocket sendTx paths (execution only).
230    pub sendtx_quota_per_min: Option<u32>,
231    /// WebSocket transport backend.
232    #[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    /// Returns `true` when all fields required to sign and submit
266    /// authenticated transactions are configured.
267    ///
268    /// Lighter signing requires the private key, the account index, and the
269    /// API key index together; any missing field invalidates the credential.
270    #[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    /// Returns the resolved REST base URL.
280    #[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    /// Returns the resolved WebSocket URL.
288    #[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    // Tests that observe the `env_var_is_set` fallback live in the workspace
418    // `serial_tests` group (see `.config/nextest.toml`) so env-var mutation is
419    // pinned to a single thread.
420    #[allow(unsafe_code)] // env-var mutation in tests; restored via `EnvGuard`.
421    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        /// Snapshots and clears the Lighter credential env vars, restoring the
434        /// original values on drop.
435        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                    // SAFETY: the `serial_tests` nextest group serializes
447                    // these tests, and no other lighter test reads or writes
448                    // the LIGHTER_* env vars.
449                    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                        // SAFETY: see `EnvGuard::clear_lighter`.
460                        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            // SAFETY: see `EnvGuard::clear_lighter`; the guard restores values on drop.
517            unsafe { std::env::set_var("LIGHTER_TESTNET_API_KEY_INDEX", "5") };
518            // SAFETY: see above.
519            unsafe { std::env::set_var("LIGHTER_TESTNET_API_SECRET", PRIVATE_KEY_HEX) };
520            // SAFETY: see above.
521            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            // SAFETY: see `EnvGuard::clear_lighter`.
534            unsafe { std::env::set_var("LIGHTER_TESTNET_API_KEY_INDEX", "5") };
535            // SAFETY: see above.
536            unsafe { std::env::set_var("LIGHTER_TESTNET_API_SECRET", PRIVATE_KEY_HEX) };
537            // SAFETY: see above.
538            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}