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