Skip to main content

nautilus_dydx/
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 dYdX adapter.
17
18use std::num::NonZeroU32;
19
20#[cfg(test)]
21use nautilus_core::string::secret::REDACTED;
22use nautilus_core::string::secret::SecretString;
23use nautilus_model::identifiers::AccountId;
24use nautilus_network::{ratelimiter::quota::Quota, websocket::TransportBackend};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28    common::{consts::DYDX_CHAIN_ID, enums::DydxNetwork, urls},
29    grpc::types::ChainId,
30};
31
32/// Configuration for the dYdX adapter.
33///
34/// URL fields (`base_url`, `ws_url`, `grpc_url`, `grpc_urls`) default to mainnet in the
35/// builder. Use [`DydxAdapterConfig::for_network`] to build a config whose URLs and chain
36/// ID match the target network, or override each URL explicitly.
37#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
38#[serde(deny_unknown_fields)]
39pub struct DydxAdapterConfig {
40    /// Network environment (mainnet or testnet).
41    #[serde(default)]
42    #[builder(default)]
43    pub network: DydxNetwork,
44    /// Base URL for the HTTP API.
45    #[builder(default = urls::http_base_url(DydxNetwork::Mainnet).to_string())]
46    pub base_url: String,
47    /// Base URL for the WebSocket API.
48    #[builder(default = urls::ws_url(DydxNetwork::Mainnet).to_string())]
49    pub ws_url: String,
50    /// Base URL for the gRPC API (Cosmos SDK transactions).
51    ///
52    /// For backwards compatibility, a single URL can be provided.
53    /// Consider using `grpc_urls` for fallback support.
54    #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet)[0].to_string())]
55    pub grpc_url: String,
56    /// List of gRPC URLs with fallback support.
57    ///
58    /// If provided, the client will attempt to connect to each URL in order
59    /// until a successful connection is established. This is recommended for
60    /// production use in DEX environments where nodes can fail.
61    #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet).iter().map(|&s| s.to_string()).collect())]
62    pub grpc_urls: Vec<String>,
63    /// Chain ID (e.g., "dydx-mainnet-1" for mainnet, "dydx-testnet-4" for testnet).
64    #[builder(default = DYDX_CHAIN_ID.to_string())]
65    pub chain_id: String,
66    /// Request timeout in seconds.
67    #[builder(default = 30)]
68    pub timeout_secs: u64,
69    /// Wallet address for the account.
70    ///
71    /// If not provided, falls back to environment variable:
72    /// - Mainnet: `DYDX_WALLET_ADDRESS`
73    /// - Testnet: `DYDX_TESTNET_WALLET_ADDRESS`
74    ///
75    /// Use `resolve_wallet_address()` to resolve from config or environment.
76    #[serde(default)]
77    pub wallet_address: Option<String>,
78    /// Subaccount number (default: 0).
79    #[serde(default)]
80    #[builder(default)]
81    pub subaccount: u32,
82    /// Private key (hex) for wallet signing.
83    ///
84    /// If not provided, falls back to environment variable:
85    /// - Mainnet: `DYDX_PRIVATE_KEY`
86    /// - Testnet: `DYDX_TESTNET_PRIVATE_KEY`
87    ///
88    /// Use `DydxCredential::resolve()` to resolve from config or environment.
89    #[serde(default)]
90    pub private_key: Option<SecretString>,
91    /// Authenticator IDs for permissioned key trading.
92    ///
93    /// When provided, transactions will include a TxExtension to enable trading
94    /// via sub-accounts using delegated signing keys. This is an advanced feature
95    /// for institutional setups with separated hot/cold wallet architectures.
96    ///
97    /// See <https://docs.dydx.xyz/concepts/trading/authenticators> for details on
98    /// permissioned keys and authenticator configuration.
99    #[serde(default)]
100    #[builder(default)]
101    pub authenticator_ids: Vec<u64>,
102    /// Maximum number of retries for failed requests (default: 3).
103    #[serde(default = "default_max_retries")]
104    #[builder(default = 3)]
105    pub max_retries: u32,
106    /// Initial retry delay in milliseconds (default: 1000ms).
107    #[serde(default = "default_retry_delay_initial_ms")]
108    #[builder(default = 1000)]
109    pub retry_delay_initial_ms: u64,
110    /// Maximum retry delay in milliseconds (default: 10000ms).
111    #[serde(default = "default_retry_delay_max_ms")]
112    #[builder(default = 10000)]
113    pub retry_delay_max_ms: u64,
114    /// gRPC rate limit: maximum broadcast requests per second.
115    ///
116    /// Controls the rate of gRPC `broadcast_tx` calls to prevent 429 (ResourceExhausted)
117    /// errors from validator nodes. Known provider limits:
118    /// - Polkachu: 300 req/min (~5 req/s)
119    /// - KingNodes: 250 req/min (~4.2 req/s)
120    /// - AutoStake: 4 req/s
121    ///
122    /// Default: 4 requests per second (conservative, works across all public providers).
123    /// When `None`, rate limiting is disabled.
124    #[serde(default = "default_grpc_rate_limit_per_second")]
125    pub grpc_rate_limit_per_second: Option<u32>,
126    /// Optional proxy URL for HTTP and WebSocket transports.
127    #[serde(default)]
128    pub proxy_url: Option<SecretString>,
129    /// WebSocket transport backend (defaults to `Tungstenite`).
130    #[serde(default)]
131    #[builder(default)]
132    pub transport_backend: TransportBackend,
133}
134
135fn default_max_retries() -> u32 {
136    3
137}
138
139fn default_retry_delay_initial_ms() -> u64 {
140    1000
141}
142
143fn default_retry_delay_max_ms() -> u64 {
144    10000
145}
146
147#[expect(
148    clippy::unnecessary_wraps,
149    reason = "serde default must match field type Option<u32>"
150)]
151fn default_grpc_rate_limit_per_second() -> Option<u32> {
152    Some(4)
153}
154
155fn default_data_http_timeout_secs() -> u64 {
156    60
157}
158
159fn default_data_max_retries() -> u64 {
160    3
161}
162
163fn default_data_retry_delay_initial_ms() -> u64 {
164    100
165}
166
167fn default_data_retry_delay_max_ms() -> u64 {
168    5000
169}
170
171fn default_max_ws_connections() -> usize {
172    8
173}
174
175fn default_per_channel_subscription_limit() -> usize {
176    32
177}
178
179impl DydxAdapterConfig {
180    /// Creates a config with URLs and chain ID resolved for the given network.
181    ///
182    /// Use this instead of `Default::default()` when constructing a testnet config
183    /// without explicit URL overrides. Retains the non-URL defaults from
184    /// [`Default::default`] (retries, timeouts, gRPC rate limit).
185    #[must_use]
186    pub fn for_network(network: DydxNetwork) -> Self {
187        let chain_id = match network {
188            DydxNetwork::Mainnet => crate::common::consts::DYDX_CHAIN_ID,
189            DydxNetwork::Testnet => crate::common::consts::DYDX_TESTNET_CHAIN_ID,
190        };
191        Self {
192            network,
193            base_url: urls::http_base_url(network).to_string(),
194            ws_url: urls::ws_url(network).to_string(),
195            grpc_url: urls::grpc_urls(network)[0].to_string(),
196            grpc_urls: urls::grpc_urls(network)
197                .iter()
198                .map(|&s| s.to_string())
199                .collect(),
200            chain_id: chain_id.to_string(),
201            ..Self::default()
202        }
203    }
204
205    /// Get the list of gRPC URLs to use for connection with fallback support.
206    ///
207    /// Returns `grpc_urls` if non-empty, otherwise falls back to a single-element
208    /// vector containing `grpc_url`.
209    #[must_use]
210    pub fn get_grpc_urls(&self) -> Vec<String> {
211        if self.grpc_urls.is_empty() {
212            vec![self.grpc_url.clone()]
213        } else {
214            self.grpc_urls.clone()
215        }
216    }
217
218    /// Map the configured network to the underlying chain ID.
219    ///
220    /// This is the recommended way to get the chain ID for transaction submission.
221    #[must_use]
222    pub const fn get_chain_id(&self) -> ChainId {
223        self.network.chain_id()
224    }
225
226    /// Returns whether this is a testnet configuration.
227    #[must_use]
228    pub const fn is_testnet(&self) -> bool {
229        matches!(self.network, DydxNetwork::Testnet)
230    }
231
232    /// Returns the gRPC rate limiting quota, if configured.
233    #[must_use]
234    pub fn grpc_quota(&self) -> Option<Quota> {
235        self.grpc_rate_limit_per_second
236            .and_then(NonZeroU32::new)
237            .and_then(Quota::per_second)
238    }
239}
240
241impl Default for DydxAdapterConfig {
242    fn default() -> Self {
243        Self {
244            grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
245            ..Self::builder().build()
246        }
247    }
248}
249
250/// Configuration for the dYdX data client.
251#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
252#[serde(default, deny_unknown_fields)]
253#[cfg_attr(
254    feature = "python",
255    pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
256)]
257#[cfg_attr(
258    feature = "python",
259    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
260)]
261pub struct DydxDataClientConfig {
262    /// Network environment (mainnet or testnet).
263    #[serde(default)]
264    #[builder(default)]
265    pub network: DydxNetwork,
266    /// Base URL for the HTTP API.
267    pub base_url_http: Option<String>,
268    /// Base URL for the WebSocket API.
269    pub base_url_ws: Option<String>,
270    /// Optional proxy URL for HTTP and WebSocket transports.
271    pub proxy_url: Option<SecretString>,
272    /// HTTP request timeout in seconds.
273    #[serde(default = "default_data_http_timeout_secs")]
274    #[builder(default = 60)]
275    pub http_timeout_secs: u64,
276    /// Maximum number of retry attempts for failed HTTP requests.
277    #[serde(default = "default_data_max_retries")]
278    #[builder(default = 3)]
279    pub max_retries: u64,
280    /// Initial retry delay in milliseconds.
281    #[serde(default = "default_data_retry_delay_initial_ms")]
282    #[builder(default = 100)]
283    pub retry_delay_initial_ms: u64,
284    /// Maximum retry delay in milliseconds.
285    #[serde(default = "default_data_retry_delay_max_ms")]
286    #[builder(default = 5000)]
287    pub retry_delay_max_ms: u64,
288    /// WebSocket transport backend (defaults to `Tungstenite`).
289    #[serde(default)]
290    #[builder(default)]
291    pub transport_backend: TransportBackend,
292    /// Maximum number of WebSocket connections in the Indexer pool.
293    ///
294    /// New connections are spun up lazily once the per-channel subscription
295    /// limit (32 by default on hosted Indexer) is reached on every existing
296    /// connection. Default `8` supports up to 256 markets per 32-limit channel.
297    #[serde(default = "default_max_ws_connections")]
298    #[builder(default = default_max_ws_connections())]
299    pub max_ws_connections: usize,
300    /// Per-connection subscription limit for each rate-limited Indexer channel
301    /// (`v4_trades`, `v4_candles`, `v4_orderbook`, `v4_markets`).
302    ///
303    /// Defaults to `32`, matching the hosted Indexer. Self-hosted Indexer
304    /// deployments may raise this.
305    #[serde(default = "default_per_channel_subscription_limit")]
306    #[builder(default = default_per_channel_subscription_limit())]
307    pub per_channel_subscription_limit: usize,
308}
309
310#[cfg(feature = "python")]
311nautilus_core::impl_pyo3_config_getters!(DydxDataClientConfig {
312    network: DydxNetwork,
313});
314
315impl DydxDataClientConfig {
316    /// Returns whether this is a testnet configuration.
317    #[must_use]
318    pub const fn is_testnet(&self) -> bool {
319        matches!(self.network, DydxNetwork::Testnet)
320    }
321}
322
323impl Default for DydxDataClientConfig {
324    fn default() -> Self {
325        Self::builder().build()
326    }
327}
328
329/// Configuration for the dYdX execution client.
330#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
331#[serde(default, deny_unknown_fields)]
332#[cfg_attr(
333    feature = "python",
334    pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
335)]
336#[cfg_attr(
337    feature = "python",
338    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
339)]
340pub struct DydxExecutionClientConfig {
341    /// The account ID for the client.
342    #[builder(default = AccountId::from("DYDX-001"))]
343    pub account_id: AccountId,
344    /// Network environment (mainnet or testnet).
345    #[serde(default)]
346    #[builder(default)]
347    pub network: DydxNetwork,
348    /// gRPC endpoint URL (optional, uses default for network if not provided).
349    pub grpc_endpoint: Option<String>,
350    /// Additional gRPC URLs for fallback support.
351    #[serde(default)]
352    #[builder(default)]
353    pub grpc_urls: Vec<String>,
354    /// HTTP endpoint URL (optional, uses default for network if not provided).
355    pub http_endpoint: Option<String>,
356    /// WebSocket endpoint URL (optional, uses default for network if not provided).
357    pub ws_endpoint: Option<String>,
358    /// Optional proxy URL for HTTP and WebSocket transports.
359    pub proxy_url: Option<SecretString>,
360    /// Private key (hex) for wallet signing.
361    ///
362    /// If not provided, falls back to environment variable:
363    /// - Mainnet: `DYDX_PRIVATE_KEY`
364    /// - Testnet: `DYDX_TESTNET_PRIVATE_KEY`
365    pub private_key: Option<SecretString>,
366    /// Wallet address.
367    ///
368    /// If not provided, falls back to environment variable:
369    /// - Mainnet: `DYDX_WALLET_ADDRESS`
370    /// - Testnet: `DYDX_TESTNET_WALLET_ADDRESS`
371    pub wallet_address: Option<String>,
372    /// Subaccount number (default: 0).
373    #[serde(default)]
374    #[builder(default)]
375    pub subaccount_number: u32,
376    /// Authenticator IDs for permissioned key trading.
377    #[serde(default)]
378    #[builder(default)]
379    pub authenticator_ids: Vec<u64>,
380    /// HTTP request timeout in seconds.
381    pub http_timeout_secs: Option<u64>,
382    /// Maximum number of retry attempts.
383    pub max_retries: Option<u32>,
384    /// Initial retry delay in milliseconds.
385    pub retry_delay_initial_ms: Option<u64>,
386    /// Maximum retry delay in milliseconds.
387    pub retry_delay_max_ms: Option<u64>,
388    /// gRPC rate limit: maximum broadcast requests per second.
389    /// When `None`, rate limiting is disabled.
390    #[serde(default = "default_grpc_rate_limit_per_second")]
391    pub grpc_rate_limit_per_second: Option<u32>,
392    /// WebSocket transport backend (defaults to `Tungstenite`).
393    #[serde(default)]
394    #[builder(default)]
395    pub transport_backend: TransportBackend,
396}
397
398#[cfg(feature = "python")]
399nautilus_core::impl_pyo3_config_getters!(DydxExecutionClientConfig {
400    account_id: AccountId,
401    network: DydxNetwork,
402    wallet_address: Option<String>,
403    subaccount_number: u32,
404});
405
406impl Default for DydxExecutionClientConfig {
407    fn default() -> Self {
408        Self {
409            grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
410            ..Self::builder().build()
411        }
412    }
413}
414
415impl DydxExecutionClientConfig {
416    /// Returns the gRPC URLs to use, with fallback support.
417    ///
418    /// Returns `grpc_urls` if non-empty, otherwise uses `grpc_endpoint` if provided,
419    /// otherwise uses the default URLs for the configured network.
420    #[must_use]
421    pub fn get_grpc_urls(&self) -> Vec<String> {
422        if !self.grpc_urls.is_empty() {
423            return self.grpc_urls.clone();
424        }
425
426        if let Some(ref endpoint) = self.grpc_endpoint {
427            return vec![endpoint.clone()];
428        }
429        urls::grpc_urls(self.network)
430            .iter()
431            .map(|&s| s.to_string())
432            .collect()
433    }
434
435    /// Returns the WebSocket URL for the configured network.
436    #[must_use]
437    pub fn get_ws_url(&self) -> String {
438        self.ws_endpoint
439            .clone()
440            .unwrap_or_else(|| urls::ws_url(self.network).to_string())
441    }
442
443    /// Returns the HTTP URL for the configured network.
444    #[must_use]
445    pub fn get_http_url(&self) -> String {
446        self.http_endpoint
447            .clone()
448            .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
449    }
450
451    /// Returns the chain ID for the configured network.
452    #[must_use]
453    pub const fn get_chain_id(&self) -> ChainId {
454        self.network.chain_id()
455    }
456
457    /// Returns whether this is a testnet configuration.
458    #[must_use]
459    pub const fn is_testnet(&self) -> bool {
460        matches!(self.network, DydxNetwork::Testnet)
461    }
462
463    /// Returns the gRPC rate limiting quota, if configured.
464    #[must_use]
465    pub fn grpc_quota(&self) -> Option<Quota> {
466        self.grpc_rate_limit_per_second
467            .and_then(NonZeroU32::new)
468            .and_then(Quota::per_second)
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use rstest::rstest;
475
476    use super::*;
477
478    #[rstest]
479    fn test_config_debug_redacts_credentials() {
480        let adapter = DydxAdapterConfig {
481            private_key: Some("adapter-private-key".into()),
482            proxy_url: Some("http://user:adapter-proxy@localhost".into()),
483            ..Default::default()
484        };
485        let data = DydxDataClientConfig {
486            proxy_url: Some("http://user:data-proxy@localhost".into()),
487            ..Default::default()
488        };
489        let execution = DydxExecutionClientConfig {
490            private_key: Some("exec-private-key".into()),
491            proxy_url: Some("http://user:exec-proxy@localhost".into()),
492            ..Default::default()
493        };
494
495        let formatted = format!("{adapter:?} {data:?} {execution:?}");
496
497        assert_eq!(formatted.matches(REDACTED).count(), 5);
498
499        for secret in [
500            "adapter-private-key",
501            "adapter-proxy",
502            "data-proxy",
503            "exec-private-key",
504            "exec-proxy",
505        ] {
506            assert!(!formatted.contains(secret));
507        }
508    }
509
510    #[rstest]
511    fn test_config_get_chain_id_mainnet() {
512        let config = DydxAdapterConfig {
513            network: DydxNetwork::Mainnet,
514            ..Default::default()
515        };
516        assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
517    }
518
519    #[rstest]
520    fn test_config_get_chain_id_testnet() {
521        let config = DydxAdapterConfig {
522            network: DydxNetwork::Testnet,
523            ..Default::default()
524        };
525        assert_eq!(config.get_chain_id(), ChainId::Testnet4);
526    }
527
528    #[rstest]
529    fn test_config_is_testnet() {
530        let mainnet_config = DydxAdapterConfig {
531            network: DydxNetwork::Mainnet,
532            ..Default::default()
533        };
534        assert!(!mainnet_config.is_testnet());
535
536        let testnet_config = DydxAdapterConfig {
537            network: DydxNetwork::Testnet,
538            ..Default::default()
539        };
540        assert!(testnet_config.is_testnet());
541    }
542
543    #[rstest]
544    fn test_config_default_uses_mainnet() {
545        let config = DydxAdapterConfig::default();
546        assert_eq!(config.network, DydxNetwork::Mainnet);
547        assert!(!config.is_testnet());
548    }
549
550    #[rstest]
551    fn test_config_serde_backwards_compat() {
552        // Test that configs missing network field can deserialize with default
553        let json = r#"{"base_url":"https://indexer.dydx.trade","ws_url":"wss://indexer.dydx.trade/v4/ws","grpc_url":"https://dydx-ops-grpc.kingnodes.com:443","grpc_urls":[],"chain_id":"dydx-mainnet-1","timeout_secs":30,"subaccount":0,"max_retries":3,"retry_delay_initial_ms":1000,"retry_delay_max_ms":10000}"#;
554
555        let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
556        assert!(config.is_ok());
557        let config = config.unwrap();
558        // Should default to Mainnet when network field is missing
559        assert_eq!(config.network, DydxNetwork::Mainnet);
560    }
561
562    #[rstest]
563    fn test_config_get_grpc_urls_fallback() {
564        let config = DydxAdapterConfig {
565            grpc_url: "https://primary.example.com".to_string(),
566            grpc_urls: vec![],
567            ..Default::default()
568        };
569
570        let urls = config.get_grpc_urls();
571        assert_eq!(urls.len(), 1);
572        assert_eq!(urls[0], "https://primary.example.com");
573    }
574
575    #[rstest]
576    fn test_config_get_grpc_urls_multiple() {
577        let config = DydxAdapterConfig {
578            grpc_url: "https://primary.example.com".to_string(),
579            grpc_urls: vec![
580                "https://fallback1.example.com".to_string(),
581                "https://fallback2.example.com".to_string(),
582            ],
583            ..Default::default()
584        };
585
586        let urls = config.get_grpc_urls();
587        assert_eq!(urls.len(), 2);
588        assert_eq!(urls[0], "https://fallback1.example.com");
589        assert_eq!(urls[1], "https://fallback2.example.com");
590    }
591
592    #[rstest]
593    fn test_for_network_mainnet_resolves_urls_and_chain_id() {
594        let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
595
596        assert_eq!(config.network, DydxNetwork::Mainnet);
597        assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
598        assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
599        assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
600        let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
601            .iter()
602            .map(|s| (*s).to_string())
603            .collect();
604        assert_eq!(config.grpc_urls, expected_grpc);
605        assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
606        assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
607    }
608
609    #[rstest]
610    fn test_for_network_testnet_resolves_urls_and_chain_id() {
611        let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
612
613        assert_eq!(config.network, DydxNetwork::Testnet);
614        assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
615        assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
616        assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
617        let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
618            .iter()
619            .map(|s| (*s).to_string())
620            .collect();
621        assert_eq!(config.grpc_urls, expected_grpc);
622        assert_eq!(
623            config.chain_id,
624            crate::common::consts::DYDX_TESTNET_CHAIN_ID,
625        );
626        assert_eq!(config.get_chain_id(), ChainId::Testnet4);
627    }
628
629    #[rstest]
630    #[case(DydxNetwork::Mainnet)]
631    #[case(DydxNetwork::Testnet)]
632    fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
633        // Regression guard: earlier implementations spread `..Self::builder().build()`,
634        // which returned `None` and silently disabled gRPC throttling. The constructor must
635        // retain the `Some(4)` default from `Default::default()`.
636        let config = DydxAdapterConfig::for_network(network);
637        assert_eq!(config.grpc_rate_limit_per_second, Some(4));
638        assert!(config.grpc_quota().is_some());
639    }
640
641    #[rstest]
642    fn test_adapter_config_toml_requires_url_fields() {
643        // URL fields and chain_id are intentionally required: deserializing a
644        // partial TOML must not silently mix the configured `network` with
645        // mainnet-defaulted URLs. Callers wanting per-network defaults should
646        // use `DydxAdapterConfig::for_network`.
647        let err = toml::from_str::<DydxAdapterConfig>(r#"network = "testnet""#).unwrap_err();
648        let message = err.to_string();
649        assert!(message.contains("missing field"), "{message}");
650    }
651
652    #[rstest]
653    fn test_exec_config_toml_empty_uses_defaults() {
654        let config: DydxExecutionClientConfig = toml::from_str("").unwrap();
655        let expected = DydxExecutionClientConfig::default();
656        assert_eq!(config.account_id, expected.account_id);
657        assert_eq!(config.network, expected.network);
658        assert_eq!(config.subaccount_number, expected.subaccount_number);
659        assert_eq!(
660            config.grpc_rate_limit_per_second,
661            expected.grpc_rate_limit_per_second,
662        );
663    }
664}