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//!
18//! Fields follow this order:
19//!
20//! - Environment
21//! - Deployment
22//! - Nautilus identity
23//! - Authentication
24//! - Connectivity
25//! - Operational behavior
26
27use 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/// Configuration for the Lighter data client.
46#[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    /// Target environment within the selected deployment.
58    #[builder(default)]
59    pub environment: LighterEnvironment,
60    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
61    #[builder(default)]
62    pub deployment: LighterDeployment,
63    /// Optional Nautilus venue identifier override.
64    ///
65    /// This scopes instruments, cache entries, and message routing without changing the
66    /// deployment's signing or settlement settings.
67    pub venue: Option<Venue>,
68    /// Lighter account index for authenticated REST data requests. Falls back
69    /// to the environment variable selected by `deployment` and `environment`.
70    pub account_index: Option<u64>,
71    /// API key index for authenticated REST data requests. Falls back to the
72    /// environment variable selected by `deployment` and `environment`.
73    pub api_key_index: Option<u8>,
74    /// Hex-encoded private key for REST auth tokens. Falls back to the
75    /// environment variable selected by `deployment` and `environment`.
76    pub private_key: Option<String>,
77    /// Optional REST URL override.
78    pub base_url_http: Option<String>,
79    /// Optional WebSocket URL override.
80    pub base_url_ws: Option<String>,
81    /// Optional proxy URL for HTTP and WebSocket transports.
82    pub proxy_url: Option<String>,
83    /// HTTP request timeout in seconds.
84    #[builder(default = 60)]
85    pub http_timeout_secs: u64,
86    /// WebSocket connection and reconnection timeout in seconds.
87    #[builder(default = 30)]
88    pub ws_timeout_secs: u64,
89    /// Refresh interval for instrument metadata in minutes.
90    #[builder(default = 60)]
91    pub update_instruments_interval_mins: u64,
92    /// Optional REST read-bucket quota override in requests per minute; unset keeps
93    /// the conservative 60 req/min default (raising it requires venue IP registration).
94    pub rest_quota_per_min: Option<u32>,
95    /// WebSocket transport backend.
96    #[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    /// Creates a new configuration with default settings.
124    #[must_use]
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Returns the resolved REST base URL.
130    #[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    /// Returns the resolved WebSocket URL.
138    #[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    /// Returns the configured venue or the deployment default.
149    #[must_use]
150    pub fn resolved_venue(&self) -> Venue {
151        self.venue
152            .unwrap_or_else(|| deployment::venue(self.deployment))
153    }
154
155    /// Returns the deployment settlement currency.
156    #[must_use]
157    pub fn settlement_currency(&self) -> Currency {
158        deployment::settlement_currency(self.deployment)
159    }
160
161    /// Returns `true` when all REST auth credential fields are available.
162    #[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/// Configuration for the Lighter execution client.
230#[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    /// Target environment within the selected deployment.
242    #[builder(default)]
243    pub environment: LighterEnvironment,
244    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
245    #[builder(default)]
246    pub deployment: LighterDeployment,
247    /// Optional Nautilus venue identifier override.
248    ///
249    /// This scopes instruments, cache entries, and execution routing without changing the
250    /// deployment's signing, settlement, or protocol behavior.
251    pub venue: Option<Venue>,
252    /// Account identifier on the venue. Its issuer must match the resolved venue.
253    #[builder(default = AccountId::from("LIGHTER-001"))]
254    pub account_id: AccountId,
255    /// Lighter account index (numeric, assigned at registration). Falls back
256    /// to the environment variable selected by `deployment` and `environment`.
257    pub account_index: Option<u64>,
258    /// API key index for a user-created Lighter key. Low indexes are reserved
259    /// for Lighter clients; 255 is the `apikeys` all-keys sentinel. Falls back
260    /// to the environment variable selected by `deployment` and `environment`.
261    pub api_key_index: Option<u8>,
262    /// Hex-encoded private key for the API key (Schnorr / ecgfp5). Falls back
263    /// to the environment variable selected by `deployment` and `environment`.
264    pub private_key: Option<String>,
265    /// Optional REST URL override.
266    pub base_url_http: Option<String>,
267    /// Optional WebSocket URL override.
268    pub base_url_ws: Option<String>,
269    /// Optional proxy URL for HTTP and WebSocket transports.
270    pub proxy_url: Option<String>,
271    /// HTTP request timeout in seconds.
272    #[builder(default = 60)]
273    pub http_timeout_secs: u64,
274    /// WebSocket connection and reconnection timeout in seconds.
275    #[builder(default = 30)]
276    pub ws_timeout_secs: u64,
277    /// Slippage buffer in basis points for market-style orders.
278    #[builder(default = 50)]
279    pub market_order_slippage_bps: u32,
280    /// Optional REST read-bucket quota override in requests per minute; unset keeps
281    /// the conservative 60 req/min default (raising it requires venue IP registration).
282    pub rest_quota_per_min: Option<u32>,
283    /// Optional transaction quota override (req/min), independent of `rest_quota_per_min`;
284    /// unset keeps 60. Enforced across the HTTP and WebSocket sendTx paths (execution only).
285    pub sendtx_quota_per_min: Option<u32>,
286    /// WebSocket transport backend.
287    #[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    /// Returns `true` when all fields required to sign and submit
340    /// authenticated transactions are configured.
341    ///
342    /// Lighter signing requires the private key, the account index, and the
343    /// API key index together; any missing field invalidates the credential.
344    #[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    /// Returns the resolved REST base URL.
354    #[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    /// Returns the resolved WebSocket URL.
362    #[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    /// Returns the configured venue or the deployment default.
370    #[must_use]
371    pub fn resolved_venue(&self) -> Venue {
372        self.venue
373            .unwrap_or_else(|| deployment::venue(self.deployment))
374    }
375
376    /// Returns the deployment settlement currency.
377    #[must_use]
378    pub fn settlement_currency(&self) -> Currency {
379        deployment::settlement_currency(self.deployment)
380    }
381
382    /// Returns the L2 signing-domain chain ID for the deployment and environment.
383    #[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}