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 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/// Configuration for the Lighter data client.
45#[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    /// Target environment within the selected deployment.
57    #[builder(default)]
58    pub environment: LighterEnvironment,
59    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
60    #[builder(default)]
61    pub deployment: LighterDeployment,
62    /// Optional Nautilus venue identifier override.
63    ///
64    /// This scopes instruments, cache entries, and message routing without changing the
65    /// deployment's signing or settlement settings.
66    pub venue: Option<Venue>,
67    /// Lighter account index for authenticated REST data requests. Falls back
68    /// to the environment variable selected by `deployment` and `environment`.
69    pub account_index: Option<u64>,
70    /// API key index for authenticated REST data requests. Falls back to the
71    /// environment variable selected by `deployment` and `environment`.
72    pub api_key_index: Option<u8>,
73    /// Hex-encoded private key for REST auth tokens. Falls back to the
74    /// environment variable selected by `deployment` and `environment`.
75    pub private_key: Option<SecretString>,
76    /// Optional REST URL override.
77    pub base_url_http: Option<String>,
78    /// Optional WebSocket URL override.
79    pub base_url_ws: Option<String>,
80    /// Optional proxy URL for HTTP and WebSocket transports.
81    pub proxy_url: Option<SecretString>,
82    /// HTTP request timeout in seconds.
83    #[builder(default = 60)]
84    pub http_timeout_secs: u64,
85    /// WebSocket connection and reconnection timeout in seconds.
86    #[builder(default = 30)]
87    pub ws_timeout_secs: u64,
88    /// Refresh interval for instrument metadata in minutes.
89    #[builder(default = 60)]
90    pub update_instruments_interval_mins: u64,
91    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
92    /// snapshot in seconds.
93    ///
94    /// Set to 0 to disable snapshot deadlines.
95    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
96    pub book_snapshot_timeout_secs: u64,
97    /// Optional REST read-bucket quota override in requests per minute; unset keeps
98    /// the conservative 60 req/min default (raising it requires venue IP registration).
99    pub rest_quota_per_min: Option<u32>,
100    /// WebSocket transport backend.
101    #[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    /// Creates a new configuration with default settings.
130    #[must_use]
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Returns the resolved REST base URL.
136    #[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    /// Returns the resolved WebSocket URL.
144    #[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    /// Returns the configured venue or the deployment default.
155    #[must_use]
156    pub fn resolved_venue(&self) -> Venue {
157        self.venue
158            .unwrap_or_else(|| deployment::venue(self.deployment))
159    }
160
161    /// Returns the deployment settlement currency.
162    #[must_use]
163    pub fn settlement_currency(&self) -> Currency {
164        deployment::settlement_currency(self.deployment)
165    }
166
167    /// Returns `true` when all REST auth credential fields are available.
168    #[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/// Configuration for the Lighter execution client.
213#[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    /// Target environment within the selected deployment.
225    #[builder(default)]
226    pub environment: LighterEnvironment,
227    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
228    #[builder(default)]
229    pub deployment: LighterDeployment,
230    /// Optional Nautilus venue identifier override.
231    ///
232    /// This scopes instruments, cache entries, and execution routing without changing the
233    /// deployment's signing, settlement, or protocol behavior.
234    pub venue: Option<Venue>,
235    /// Account identifier on the venue. Its issuer must match the resolved venue.
236    #[builder(default = AccountId::from("LIGHTER-001"))]
237    pub account_id: AccountId,
238    /// Lighter account index (numeric, assigned at registration). Falls back
239    /// to the environment variable selected by `deployment` and `environment`.
240    pub account_index: Option<u64>,
241    /// API key index for a user-created Lighter key. Low indexes are reserved
242    /// for Lighter clients; 255 is the `apikeys` all-keys sentinel. Falls back
243    /// to the environment variable selected by `deployment` and `environment`.
244    pub api_key_index: Option<u8>,
245    /// Hex-encoded private key for the API key (Schnorr / ecgfp5). Falls back
246    /// to the environment variable selected by `deployment` and `environment`.
247    pub private_key: Option<SecretString>,
248    /// Optional REST URL override.
249    pub base_url_http: Option<String>,
250    /// Optional WebSocket URL override.
251    pub base_url_ws: Option<String>,
252    /// Optional proxy URL for HTTP and WebSocket transports.
253    pub proxy_url: Option<SecretString>,
254    /// HTTP request timeout in seconds.
255    #[builder(default = 60)]
256    pub http_timeout_secs: u64,
257    /// WebSocket connection and reconnection timeout in seconds.
258    #[builder(default = 30)]
259    pub ws_timeout_secs: u64,
260    /// Slippage buffer in basis points for market-style orders.
261    #[builder(default = 50)]
262    pub market_order_slippage_bps: u32,
263    /// Optional REST read-bucket quota override in requests per minute; unset keeps
264    /// the conservative 60 req/min default (raising it requires venue IP registration).
265    pub rest_quota_per_min: Option<u32>,
266    /// Optional transaction quota override (req/min), independent of `rest_quota_per_min`;
267    /// unset keeps 60. Enforced across the HTTP and WebSocket sendTx paths (execution only).
268    pub sendtx_quota_per_min: Option<u32>,
269    /// WebSocket transport backend.
270    #[builder(default)]
271    pub transport_backend: TransportBackend,
272    /// Whether to use Lighter-native GTD orders.
273    ///
274    /// The current Lighter venue validation requires a `GoodTillTime` expiry of at least five
275    /// minutes, so a shorter strategy GTD lifetime cannot be represented as an explicit venue
276    /// expiry. Set to false only when the strategy manages GTD expiry locally. Lighter then uses
277    /// a 28-day fallback expiry and the strategy must enable `manage_gtd_expiry` so the local
278    /// expiry timer sends the cancel. Local strategy expiries beyond 28 days are denied because
279    /// the fallback would expire first; use native GTD for those orders.
280    #[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    /// Returns `true` when all fields required to sign and submit
311    /// authenticated transactions are configured.
312    ///
313    /// Lighter signing requires the private key, the account index, and the
314    /// API key index together; any missing field invalidates the credential.
315    #[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    /// Returns the resolved REST base URL.
326    #[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    /// Returns the resolved WebSocket URL.
334    #[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    /// Returns the configured venue or the deployment default.
342    #[must_use]
343    pub fn resolved_venue(&self) -> Venue {
344        self.venue
345            .unwrap_or_else(|| deployment::venue(self.deployment))
346    }
347
348    /// Returns the deployment settlement currency.
349    #[must_use]
350    pub fn settlement_currency(&self) -> Currency {
351        deployment::settlement_currency(self.deployment)
352    }
353
354    /// Returns the L2 signing-domain chain ID for the deployment and environment.
355    #[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        // Backwards compatibility: the default preserves the native Lighter GTD
588        // behavior so existing configs are unchanged.
589        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}