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, Deserializer, Serialize};
35
36use crate::common::{
37    consts::{
38        SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS, SCHEDULED_CANCEL_MAX_DELAY_SECS,
39        SCHEDULED_CANCEL_MIN_DELAY_SECS, SCHEDULED_CANCEL_REQUEST_TIMEOUT,
40    },
41    credential::credential_env_vars_for_deployment,
42    deployment,
43    enums::{LighterDeployment, LighterEnvironment},
44};
45
46const WS_READONLY_QUERY_PARAM: &str = "readonly";
47
48/// Configuration for the Lighter data client.
49#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
50#[serde(default, deny_unknown_fields)]
51#[cfg_attr(
52    feature = "python",
53    pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
54)]
55#[cfg_attr(
56    feature = "python",
57    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
58)]
59pub struct LighterDataClientConfig {
60    /// Target environment within the selected deployment.
61    #[builder(default)]
62    pub environment: LighterEnvironment,
63    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
64    #[builder(default)]
65    pub deployment: LighterDeployment,
66    /// Optional Nautilus venue identifier override.
67    ///
68    /// This scopes instruments, cache entries, and message routing without changing the
69    /// deployment's signing or settlement settings.
70    pub venue: Option<Venue>,
71    /// Lighter account index for authenticated REST data requests. Falls back
72    /// to the environment variable selected by `deployment` and `environment`.
73    pub account_index: Option<u64>,
74    /// API key index for authenticated REST data requests. Falls back to the
75    /// environment variable selected by `deployment` and `environment`.
76    pub api_key_index: Option<u8>,
77    /// Hex-encoded private key for REST auth tokens. Falls back to the
78    /// environment variable selected by `deployment` and `environment`.
79    pub private_key: Option<SecretString>,
80    /// Optional REST URL override.
81    pub base_url_http: Option<String>,
82    /// Optional WebSocket URL override.
83    pub base_url_ws: Option<String>,
84    /// Optional proxy URL for HTTP and WebSocket transports.
85    pub proxy_url: Option<SecretString>,
86    /// HTTP request timeout in seconds.
87    #[builder(default = 60)]
88    pub http_timeout_secs: u64,
89    /// WebSocket connection and reconnection timeout in seconds.
90    #[builder(default = 30)]
91    pub ws_timeout_secs: u64,
92    /// Refresh interval for instrument metadata in minutes.
93    #[builder(default = 60)]
94    pub update_instruments_interval_mins: u64,
95    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
96    /// snapshot in seconds.
97    ///
98    /// Set to 0 to disable snapshot deadlines.
99    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
100    pub book_snapshot_timeout_secs: u64,
101    /// Optional REST read-bucket quota override in requests per minute; unset keeps
102    /// the conservative 60 req/min default (raising it requires venue IP registration).
103    pub rest_quota_per_min: Option<u32>,
104    /// WebSocket transport backend.
105    #[builder(default)]
106    pub transport_backend: TransportBackend,
107}
108
109#[cfg(feature = "python")]
110nautilus_core::impl_pyo3_config_getters!(LighterDataClientConfig {
111    environment: LighterEnvironment,
112    deployment: LighterDeployment,
113    venue: Option<Venue>,
114    account_index: Option<u64>,
115    api_key_index: Option<u8>,
116    base_url_http: Option<String>,
117    base_url_ws: Option<String>,
118    http_timeout_secs: u64,
119    ws_timeout_secs: u64,
120    update_instruments_interval_mins: u64,
121    book_snapshot_timeout_secs: u64,
122    rest_quota_per_min: Option<u32>,
123    transport_backend: TransportBackend,
124});
125
126impl Default for LighterDataClientConfig {
127    fn default() -> Self {
128        Self::builder().build()
129    }
130}
131
132impl LighterDataClientConfig {
133    /// Creates a new configuration with default settings.
134    #[must_use]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Returns the resolved REST base URL.
140    #[must_use]
141    pub fn http_url(&self) -> String {
142        self.base_url_http.clone().unwrap_or_else(|| {
143            deployment::http_base_url(self.deployment, self.environment).to_string()
144        })
145    }
146
147    /// Returns the resolved WebSocket URL.
148    #[must_use]
149    pub fn ws_url(&self) -> String {
150        let url = self
151            .base_url_ws
152            .clone()
153            .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string());
154
155        ensure_readonly_ws_url(url)
156    }
157
158    /// Returns the configured venue or the deployment default.
159    #[must_use]
160    pub fn resolved_venue(&self) -> Venue {
161        self.venue
162            .unwrap_or_else(|| deployment::venue(self.deployment))
163    }
164
165    /// Returns the deployment settlement currency.
166    #[must_use]
167    pub fn settlement_currency(&self) -> Currency {
168        deployment::settlement_currency(self.deployment)
169    }
170
171    /// Returns `true` when all REST auth credential fields are available.
172    #[must_use]
173    pub fn has_credentials(&self) -> bool {
174        let (key_var, secret_var, account_var) =
175            credential_env_vars_for_deployment(self.deployment, self.environment);
176        let has_key = self.api_key_index.is_some() || env_var_is_set(key_var);
177        let has_account = self.account_index.is_some() || env_var_is_set(account_var);
178        let has_secret = self
179            .private_key
180            .as_ref()
181            .map(SecretString::expose_secret)
182            .is_some_and(|s| !s.trim().is_empty())
183            || env_var_is_set(secret_var);
184
185        has_key && has_account && has_secret
186    }
187}
188
189fn env_var_is_set(name: &str) -> bool {
190    std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
191}
192
193fn ensure_readonly_ws_url(url: String) -> String {
194    let Ok(mut parsed) = url::Url::parse(&url) else {
195        return url;
196    };
197
198    let pairs = parsed
199        .query_pairs()
200        .filter(|(key, _)| key != WS_READONLY_QUERY_PARAM)
201        .map(|(key, value)| (key.into_owned(), value.into_owned()))
202        .collect::<Vec<_>>();
203
204    parsed.set_query(None);
205    {
206        let mut query = parsed.query_pairs_mut();
207        for (key, value) in pairs {
208            query.append_pair(&key, &value);
209        }
210        query.append_pair(WS_READONLY_QUERY_PARAM, "true");
211    }
212
213    parsed.to_string()
214}
215
216/// Configuration for the Lighter execution client.
217#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
218#[serde(default, deny_unknown_fields)]
219#[cfg_attr(
220    feature = "python",
221    pyo3::pyclass(module = "nautilus_trader.adapters.lighter", from_py_object,)
222)]
223#[cfg_attr(
224    feature = "python",
225    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.lighter")
226)]
227pub struct LighterExecutionClientConfig {
228    /// Target environment within the selected deployment.
229    #[builder(default)]
230    pub environment: LighterEnvironment,
231    /// Lighter protocol deployment, which controls endpoint defaults and protocol settings.
232    #[builder(default)]
233    pub deployment: LighterDeployment,
234    /// Optional Nautilus venue identifier override.
235    ///
236    /// This scopes instruments, cache entries, and execution routing without changing the
237    /// deployment's signing, settlement, or protocol behavior.
238    pub venue: Option<Venue>,
239    /// Account identifier on the venue. Its issuer must match the resolved venue.
240    #[builder(default = AccountId::from("LIGHTER-001"))]
241    pub account_id: AccountId,
242    /// Lighter account index (numeric, assigned at registration). Falls back
243    /// to the environment variable selected by `deployment` and `environment`.
244    pub account_index: Option<u64>,
245    /// API key index for a user-created Lighter key. Low indexes are reserved
246    /// for Lighter clients; 255 is the `apikeys` all-keys sentinel. Falls back
247    /// to the environment variable selected by `deployment` and `environment`.
248    pub api_key_index: Option<u8>,
249    /// Hex-encoded private key for the API key (Schnorr / ecgfp5). Falls back
250    /// to the environment variable selected by `deployment` and `environment`.
251    pub private_key: Option<SecretString>,
252    /// Optional REST URL override.
253    pub base_url_http: Option<String>,
254    /// Optional WebSocket URL override.
255    pub base_url_ws: Option<String>,
256    /// Optional proxy URL for HTTP and WebSocket transports.
257    pub proxy_url: Option<SecretString>,
258    /// HTTP request timeout in seconds.
259    #[builder(default = 60)]
260    pub http_timeout_secs: u64,
261    /// WebSocket connection and reconnection timeout in seconds.
262    #[builder(default = 30)]
263    pub ws_timeout_secs: u64,
264    /// Slippage buffer in basis points for market-style orders.
265    #[builder(default = 50)]
266    pub market_order_slippage_bps: u32,
267    /// Optional REST read-bucket quota override in requests per minute; unset keeps
268    /// the conservative 60 req/min default (raising it requires venue IP registration).
269    pub rest_quota_per_min: Option<u32>,
270    /// Optional transaction quota override (req/min), independent of `rest_quota_per_min`;
271    /// unset keeps 60. Enforced across the HTTP and WebSocket sendTx paths (execution only).
272    pub sendtx_quota_per_min: Option<u32>,
273    /// WebSocket transport backend.
274    #[builder(default)]
275    pub transport_backend: TransportBackend,
276    /// Whether to use Lighter-native GTD orders.
277    ///
278    /// The current Lighter venue validation requires a `GoodTillTime` expiry of at least five
279    /// minutes, so a shorter strategy GTD lifetime cannot be represented as an explicit venue
280    /// expiry. Set to false only when the strategy manages GTD expiry locally. Lighter then uses
281    /// a 28-day fallback expiry and the strategy must enable `manage_gtd_expiry` so the local
282    /// expiry timer sends the cancel. Local strategy expiries beyond 28 days are denied because
283    /// the fallback would expire first; use native GTD for those orders.
284    #[builder(default = true)]
285    pub use_gtd: bool,
286    /// Arms and renews account-wide scheduled cancellation with this timeout in seconds.
287    ///
288    /// Valid values are `[420, 1_295_940]`. `None` leaves the venue schedule untouched.
289    /// Connection fails if arming does not execute within 30 seconds after account streams are ready.
290    /// Shutdown stops renewal and preserves the last deadline. Scheduled cancellation does not close positions.
291    #[serde(deserialize_with = "deserialize_scheduled_cancel_timeout")]
292    pub scheduled_cancel_timeout_secs: Option<u64>,
293}
294
295#[cfg(feature = "python")]
296nautilus_core::impl_pyo3_config_getters!(LighterExecutionClientConfig {
297    environment: LighterEnvironment,
298    deployment: LighterDeployment,
299    venue: Option<Venue>,
300    account_id: AccountId,
301    account_index: Option<u64>,
302    api_key_index: Option<u8>,
303    base_url_http: Option<String>,
304    base_url_ws: Option<String>,
305    http_timeout_secs: u64,
306    ws_timeout_secs: u64,
307    market_order_slippage_bps: u32,
308    rest_quota_per_min: Option<u32>,
309    sendtx_quota_per_min: Option<u32>,
310    transport_backend: TransportBackend,
311    use_gtd: bool,
312    scheduled_cancel_timeout_secs: Option<u64>,
313});
314
315impl Default for LighterExecutionClientConfig {
316    fn default() -> Self {
317        Self::builder().build()
318    }
319}
320
321impl LighterExecutionClientConfig {
322    /// Returns `true` when all fields required to sign and submit
323    /// authenticated transactions are configured.
324    ///
325    /// Lighter signing requires the private key, the account index, and the
326    /// API key index together; any missing field invalidates the credential.
327    #[must_use]
328    pub fn has_credentials(&self) -> bool {
329        let key_set = self
330            .private_key
331            .as_ref()
332            .map(SecretString::expose_secret)
333            .is_some_and(|s| !s.trim().is_empty());
334        key_set && self.account_index.is_some() && self.api_key_index.is_some()
335    }
336
337    /// Returns the resolved REST base URL.
338    #[must_use]
339    pub fn http_url(&self) -> String {
340        self.base_url_http.clone().unwrap_or_else(|| {
341            deployment::http_base_url(self.deployment, self.environment).to_string()
342        })
343    }
344
345    /// Returns the resolved WebSocket URL.
346    #[must_use]
347    pub fn ws_url(&self) -> String {
348        self.base_url_ws
349            .clone()
350            .unwrap_or_else(|| deployment::ws_url(self.deployment, self.environment).to_string())
351    }
352
353    /// Returns the configured venue or the deployment default.
354    #[must_use]
355    pub fn resolved_venue(&self) -> Venue {
356        self.venue
357            .unwrap_or_else(|| deployment::venue(self.deployment))
358    }
359
360    /// Returns the deployment settlement currency.
361    #[must_use]
362    pub fn settlement_currency(&self) -> Currency {
363        deployment::settlement_currency(self.deployment)
364    }
365
366    /// Returns the L2 signing-domain chain ID for the deployment and environment.
367    #[must_use]
368    pub const fn chain_id(&self) -> u32 {
369        deployment::chain_id(self.deployment, self.environment)
370    }
371}
372
373fn deserialize_scheduled_cancel_timeout<'de, D: Deserializer<'de>>(
374    deserializer: D,
375) -> Result<Option<u64>, D::Error> {
376    let timeout = Option::<u64>::deserialize(deserializer)?;
377    validate_scheduled_cancel_timeout(timeout).map_err(serde::de::Error::custom)?;
378    Ok(timeout)
379}
380
381pub(crate) fn validate_scheduled_cancel_timeout(timeout_secs: Option<u64>) -> anyhow::Result<()> {
382    let min = SCHEDULED_CANCEL_MIN_DELAY_SECS
383        + SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS
384        + 2 * SCHEDULED_CANCEL_REQUEST_TIMEOUT.as_secs();
385    let max = SCHEDULED_CANCEL_MAX_DELAY_SECS - SCHEDULED_CANCEL_CLOCK_HEADROOM_SECS;
386
387    if let Some(timeout_secs) = timeout_secs {
388        anyhow::ensure!(
389            (min..=max).contains(&timeout_secs),
390            "scheduled_cancel_timeout_secs must be in [{min}, {max}]"
391        );
392    }
393
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use nautilus_core::string::secret::REDACTED;
400    use rstest::rstest;
401
402    use super::*;
403
404    const PRIVATE_KEY_HEX: &str =
405        "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
406
407    #[rstest]
408    #[case(None, true)]
409    #[case(Some(0), false)]
410    #[case(Some(15), false)]
411    #[case(Some(419), false)]
412    #[case(Some(420), true)]
413    #[case(Some(1_295_940), true)]
414    #[case(Some(1_295_941), false)]
415    #[case(Some(u64::MAX), false)]
416    fn test_scheduled_cancel_timeout(#[case] timeout: Option<u64>, #[case] valid: bool) {
417        let config = LighterExecutionClientConfig {
418            scheduled_cancel_timeout_secs: timeout,
419            ..Default::default()
420        };
421
422        let encoded = serde_json::to_value(&config).unwrap();
423        assert_eq!(
424            encoded["scheduled_cancel_timeout_secs"],
425            serde_json::json!(timeout)
426        );
427        let decoded = serde_json::from_value::<LighterExecutionClientConfig>(encoded);
428        assert_eq!(validate_scheduled_cancel_timeout(timeout).is_ok(), valid);
429        assert_eq!(decoded.is_ok(), valid);
430
431        if valid {
432            assert_eq!(decoded.unwrap().scheduled_cancel_timeout_secs, timeout);
433        }
434    }
435
436    #[rstest]
437    fn test_scheduled_cancel_timeout_default() {
438        assert_eq!(
439            LighterExecutionClientConfig::default().scheduled_cancel_timeout_secs,
440            None
441        );
442        let decoded: LighterExecutionClientConfig =
443            serde_json::from_value(serde_json::json!({})).unwrap();
444        assert_eq!(decoded.scheduled_cancel_timeout_secs, None);
445    }
446
447    #[rstest]
448    fn data_config_has_credentials_when_all_fields_set() {
449        let config = LighterDataClientConfig {
450            api_key_index: Some(5),
451            account_index: Some(12_345),
452            private_key: Some(PRIVATE_KEY_HEX.into()),
453            ..Default::default()
454        };
455
456        assert!(config.has_credentials());
457    }
458
459    #[rstest]
460    fn data_config_debug_redacts_private_key() {
461        let config = LighterDataClientConfig {
462            api_key_index: Some(5),
463            account_index: Some(12_345),
464            private_key: Some(PRIVATE_KEY_HEX.into()),
465            ..Default::default()
466        };
467
468        let dbg_out = format!("{config:?}");
469
470        assert!(dbg_out.contains(REDACTED));
471        assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
472    }
473
474    #[rstest]
475    fn data_config_debug_omits_private_key_when_unset() {
476        let config = LighterDataClientConfig::default();
477
478        let dbg_out = format!("{config:?}");
479
480        assert!(dbg_out.contains("private_key: None"));
481    }
482
483    #[rstest]
484    fn data_config_ws_url_sets_readonly_query() {
485        let config = LighterDataClientConfig::default();
486
487        assert_eq!(
488            config.ws_url(),
489            "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
490        );
491    }
492
493    #[rstest]
494    fn data_config_ws_url_preserves_existing_query_params() {
495        let config = LighterDataClientConfig {
496            base_url_ws: Some("wss://mainnet.zklighter.elliot.ai/stream?foo=bar".to_string()),
497            ..Default::default()
498        };
499
500        assert_eq!(
501            config.ws_url(),
502            "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
503        );
504    }
505
506    #[rstest]
507    fn data_config_ws_url_overrides_readonly_query() {
508        let config = LighterDataClientConfig {
509            base_url_ws: Some(
510                "wss://mainnet.zklighter.elliot.ai/stream?readonly=false&foo=bar".to_string(),
511            ),
512            ..Default::default()
513        };
514
515        assert_eq!(
516            config.ws_url(),
517            "wss://mainnet.zklighter.elliot.ai/stream?foo=bar&readonly=true",
518        );
519    }
520
521    #[rstest]
522    fn data_config_book_snapshot_timeout_default_is_ten_seconds() {
523        let config = LighterDataClientConfig::default();
524
525        assert_eq!(config.book_snapshot_timeout_secs, 10);
526    }
527
528    #[derive(Debug)]
529    struct ExpectedDeploymentSettings {
530        http_url: &'static str,
531        data_ws_url: &'static str,
532        chain_id: u32,
533        venue: &'static str,
534        currency: &'static str,
535    }
536
537    #[rstest]
538    #[case::lighter_mainnet(
539        LighterDeployment::Lighter,
540        LighterEnvironment::Mainnet,
541        ExpectedDeploymentSettings {
542            http_url: "https://mainnet.zklighter.elliot.ai",
543            data_ws_url: "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
544            chain_id: 304,
545            venue: "LIGHTER",
546            currency: "USDC",
547        }
548    )]
549    #[case::lighter_testnet(
550        LighterDeployment::Lighter,
551        LighterEnvironment::Testnet,
552        ExpectedDeploymentSettings {
553            http_url: "https://testnet.zklighter.elliot.ai",
554            data_ws_url: "wss://testnet.zklighter.elliot.ai/stream?readonly=true",
555            chain_id: 300,
556            venue: "LIGHTER",
557            currency: "USDC",
558        }
559    )]
560    #[case::robinhood_mainnet(
561        LighterDeployment::Robinhood,
562        LighterEnvironment::Mainnet,
563        ExpectedDeploymentSettings {
564            http_url: "https://api.rh.lighter.xyz",
565            data_ws_url: "wss://api.rh.lighter.xyz/stream?readonly=true",
566            chain_id: 466_324,
567            venue: "LIGHTER_ROBINHOOD",
568            currency: "USDG",
569        }
570    )]
571    #[case::robinhood_testnet(
572        LighterDeployment::Robinhood,
573        LighterEnvironment::Testnet,
574        ExpectedDeploymentSettings {
575            http_url: "https://api.rh-testnet.lighter.xyz",
576            data_ws_url: "wss://api.rh-testnet.lighter.xyz/stream?readonly=true",
577            chain_id: 300,
578            venue: "LIGHTER_ROBINHOOD",
579            currency: "USDG",
580        }
581    )]
582    fn configs_resolve_deployment_settings(
583        #[case] deployment: LighterDeployment,
584        #[case] environment: LighterEnvironment,
585        #[case] expected: ExpectedDeploymentSettings,
586    ) {
587        let data = LighterDataClientConfig {
588            environment,
589            deployment,
590            ..Default::default()
591        };
592
593        let execution = LighterExecutionClientConfig {
594            environment,
595            deployment,
596            ..Default::default()
597        };
598
599        assert_eq!(data.http_url(), expected.http_url);
600        assert_eq!(data.ws_url(), expected.data_ws_url);
601        assert_eq!(data.resolved_venue().as_str(), expected.venue);
602        assert_eq!(data.settlement_currency().code, expected.currency);
603        assert_eq!(execution.http_url(), expected.http_url);
604        assert_eq!(
605            execution.ws_url(),
606            expected.data_ws_url.replace("?readonly=true", "")
607        );
608        assert_eq!(execution.resolved_venue().as_str(), expected.venue);
609        assert_eq!(execution.settlement_currency().code, expected.currency);
610        assert_eq!(execution.chain_id(), expected.chain_id);
611    }
612
613    #[rstest]
614    fn configs_preserve_custom_venue() {
615        let venue = Venue::from("LIGHTER_CUSTOM");
616        let data = LighterDataClientConfig {
617            deployment: LighterDeployment::Robinhood,
618            venue: Some(venue),
619            ..Default::default()
620        };
621
622        let execution = LighterExecutionClientConfig {
623            deployment: LighterDeployment::Robinhood,
624            venue: Some(venue),
625            ..Default::default()
626        };
627
628        assert_eq!(data.resolved_venue(), venue);
629        assert_eq!(execution.resolved_venue(), venue);
630        assert_eq!(execution.chain_id(), 466_324);
631    }
632
633    #[rstest]
634    fn exec_config_debug_redacts_private_key() {
635        let config = LighterExecutionClientConfig {
636            account_id: AccountId::from("LIGHTER-001"),
637            api_key_index: Some(5),
638            account_index: Some(12_345),
639            private_key: Some(PRIVATE_KEY_HEX.into()),
640            base_url_http: None,
641            base_url_ws: None,
642            proxy_url: None,
643            environment: LighterEnvironment::Mainnet,
644            deployment: LighterDeployment::Lighter,
645            venue: None,
646            http_timeout_secs: 60,
647            ws_timeout_secs: 30,
648            market_order_slippage_bps: 50,
649            rest_quota_per_min: None,
650            sendtx_quota_per_min: None,
651            transport_backend: TransportBackend::default(),
652            use_gtd: true,
653            scheduled_cancel_timeout_secs: None,
654        };
655
656        let dbg_out = format!("{config:?}");
657
658        assert!(dbg_out.contains(REDACTED));
659        assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
660    }
661
662    #[rstest]
663    fn exec_config_use_gtd_defaults_to_true() {
664        // Backwards compatibility: the default preserves the native Lighter GTD
665        // behavior so existing configs are unchanged.
666        let config = LighterExecutionClientConfig::default();
667
668        assert!(config.use_gtd);
669    }
670
671    #[rstest]
672    fn exec_config_toml_use_gtd_override() {
673        let config: LighterExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
674
675        assert!(!config.use_gtd);
676    }
677
678    #[rstest]
679    fn exec_config_ws_url_keeps_regular_stream_url() {
680        let config = LighterExecutionClientConfig {
681            account_id: AccountId::from("LIGHTER-001"),
682            environment: LighterEnvironment::Mainnet,
683            ..Default::default()
684        };
685
686        assert_eq!(config.ws_url(), "wss://mainnet.zklighter.elliot.ai/stream");
687    }
688}