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 nautilus_model::identifiers::{AccountId, TraderId};
19use serde::{Deserialize, Serialize};
20
21use crate::{
22    common::{
23        consts::{DYDX_CHAIN_ID, DYDX_TESTNET_CHAIN_ID},
24        enums::DydxNetwork,
25        urls,
26    },
27    grpc::types::ChainId,
28};
29
30/// Configuration for the dYdX adapter.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DydxAdapterConfig {
33    /// Network environment (mainnet or testnet).
34    #[serde(default)]
35    pub network: DydxNetwork,
36    /// Base URL for the HTTP API.
37    pub base_url: String,
38    /// Base URL for the WebSocket API.
39    pub ws_url: String,
40    /// Base URL for the gRPC API (Cosmos SDK transactions).
41    ///
42    /// For backwards compatibility, a single URL can be provided.
43    /// Consider using `grpc_urls` for fallback support.
44    pub grpc_url: String,
45    /// List of gRPC URLs with fallback support.
46    ///
47    /// If provided, the client will attempt to connect to each URL in order
48    /// until a successful connection is established. This is recommended for
49    /// production use in DEX environments where nodes can fail.
50    #[serde(default)]
51    pub grpc_urls: Vec<String>,
52    /// Chain ID (e.g., "dydx-mainnet-1" for mainnet, "dydx-testnet-4" for testnet).
53    pub chain_id: String,
54    /// Request timeout in seconds.
55    pub timeout_secs: u64,
56    /// Wallet address for the account (optional, can be derived from mnemonic).
57    #[serde(default)]
58    pub wallet_address: Option<String>,
59    /// Subaccount number (default: 0).
60    #[serde(default)]
61    pub subaccount: u32,
62    /// Whether this is a testnet configuration.
63    ///
64    /// Precedence: `network` is canonical. If both `network` and `is_testnet`
65    /// are provided and conflict, `network` takes precedence internally.
66    /// This flag exists for backwards compatibility and may be derived from
67    /// `network` in future versions.
68    #[serde(default)]
69    pub is_testnet: bool,
70    /// Mnemonic phrase for wallet (optional, loaded from environment if not provided).
71    #[serde(default)]
72    pub mnemonic: Option<String>,
73    /// Authenticator IDs for permissioned key trading.
74    ///
75    /// When provided, transactions will include a TxExtension to enable trading
76    /// via sub-accounts using delegated signing keys. This is an advanced feature
77    /// for institutional setups with separated hot/cold wallet architectures.
78    ///
79    /// See <https://docs.dydx.xyz/concepts/trading/authenticators> for details on
80    /// permissioned keys and authenticator configuration.
81    #[serde(default)]
82    pub authenticator_ids: Vec<u64>,
83    /// Maximum number of retries for failed requests (default: 3).
84    #[serde(default = "default_max_retries")]
85    pub max_retries: u32,
86    /// Initial retry delay in milliseconds (default: 1000ms).
87    #[serde(default = "default_retry_delay_initial_ms")]
88    pub retry_delay_initial_ms: u64,
89    /// Maximum retry delay in milliseconds (default: 10000ms).
90    #[serde(default = "default_retry_delay_max_ms")]
91    pub retry_delay_max_ms: u64,
92}
93
94fn default_max_retries() -> u32 {
95    3
96}
97
98fn default_retry_delay_initial_ms() -> u64 {
99    1000
100}
101
102fn default_retry_delay_max_ms() -> u64 {
103    10000
104}
105
106impl DydxAdapterConfig {
107    /// Get the list of gRPC URLs to use for connection with fallback support.
108    ///
109    /// Returns `grpc_urls` if non-empty, otherwise falls back to a single-element
110    /// vector containing `grpc_url`.
111    #[must_use]
112    pub fn get_grpc_urls(&self) -> Vec<String> {
113        if self.grpc_urls.is_empty() {
114            vec![self.grpc_url.clone()]
115        } else {
116            self.grpc_urls.clone()
117        }
118    }
119
120    /// Map the configured network to the underlying chain ID.
121    ///
122    /// This is the recommended way to get the chain ID for transaction submission.
123    #[must_use]
124    pub const fn get_chain_id(&self) -> ChainId {
125        self.network.chain_id()
126    }
127
128    /// Convenience: compute `is_testnet` from `network`.
129    ///
130    /// Prefer `network` as the source of truth; this method is provided to
131    /// avoid ambiguity when legacy configs include `is_testnet`.
132    #[must_use]
133    pub const fn compute_is_testnet(&self) -> bool {
134        matches!(self.network, DydxNetwork::Testnet)
135    }
136}
137
138impl Default for DydxAdapterConfig {
139    fn default() -> Self {
140        let network = DydxNetwork::default();
141        let is_testnet = matches!(network, DydxNetwork::Testnet);
142        let grpc_urls = urls::grpc_urls(is_testnet);
143        Self {
144            network,
145            base_url: urls::http_base_url(is_testnet).to_string(),
146            ws_url: urls::ws_url(is_testnet).to_string(),
147            grpc_url: grpc_urls[0].to_string(),
148            grpc_urls: grpc_urls.iter().map(|&s| s.to_string()).collect(),
149            chain_id: if is_testnet {
150                DYDX_TESTNET_CHAIN_ID
151            } else {
152                DYDX_CHAIN_ID
153            }
154            .to_string(),
155            timeout_secs: 30,
156            wallet_address: None,
157            subaccount: 0,
158            is_testnet,
159            mnemonic: None,
160            authenticator_ids: Vec::new(),
161            max_retries: default_max_retries(),
162            retry_delay_initial_ms: default_retry_delay_initial_ms(),
163            retry_delay_max_ms: default_retry_delay_max_ms(),
164        }
165    }
166}
167
168/// Configuration for the dYdX data client.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct DydxDataClientConfig {
171    /// Base URL for the HTTP API.
172    pub base_url_http: Option<String>,
173    /// Base URL for the WebSocket API.
174    pub base_url_ws: Option<String>,
175    /// HTTP request timeout in seconds.
176    pub http_timeout_secs: Option<u64>,
177    /// Maximum number of retry attempts for failed HTTP requests.
178    pub max_retries: Option<u64>,
179    /// Initial retry delay in milliseconds.
180    pub retry_delay_initial_ms: Option<u64>,
181    /// Maximum retry delay in milliseconds.
182    pub retry_delay_max_ms: Option<u64>,
183    /// Whether this is a testnet configuration.
184    pub is_testnet: bool,
185    /// HTTP proxy URL.
186    pub http_proxy_url: Option<String>,
187    /// WebSocket proxy URL.
188    pub ws_proxy_url: Option<String>,
189    /// Orderbook snapshot refresh interval in seconds (prevents stale books from missed messages).
190    /// Set to None to disable periodic refresh. Default: 60 seconds.
191    pub orderbook_refresh_interval_secs: Option<u64>,
192    /// Instrument refresh interval in seconds (updates instrument definitions periodically).
193    /// Set to None to disable periodic refresh. Default: 3600 seconds (60 minutes).
194    pub instrument_refresh_interval_secs: Option<u64>,
195}
196
197impl Default for DydxDataClientConfig {
198    fn default() -> Self {
199        Self {
200            base_url_http: None,
201            base_url_ws: None,
202            http_timeout_secs: Some(60),
203            max_retries: Some(3),
204            retry_delay_initial_ms: Some(100),
205            retry_delay_max_ms: Some(5000),
206            is_testnet: false,
207            http_proxy_url: None,
208            ws_proxy_url: None,
209            orderbook_refresh_interval_secs: Some(60),
210            instrument_refresh_interval_secs: Some(3600),
211        }
212    }
213}
214
215/// Configuration for the dYdX execution client.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct DYDXExecClientConfig {
218    /// The trader ID for the client.
219    pub trader_id: TraderId,
220    /// The account ID for the client.
221    pub account_id: AccountId,
222    /// Network environment (mainnet or testnet).
223    #[serde(default)]
224    pub network: DydxNetwork,
225    /// gRPC endpoint URL (optional, uses default for network if not provided).
226    pub grpc_endpoint: Option<String>,
227    /// Additional gRPC URLs for fallback support.
228    #[serde(default)]
229    pub grpc_urls: Vec<String>,
230    /// WebSocket endpoint URL (optional, uses default for network if not provided).
231    pub ws_endpoint: Option<String>,
232    /// HTTP endpoint URL (optional, uses default for network if not provided).
233    pub http_endpoint: Option<String>,
234    /// Wallet mnemonic for signing transactions.
235    pub mnemonic: Option<String>,
236    /// Wallet address (optional, derived from mnemonic if not provided).
237    pub wallet_address: Option<String>,
238    /// Subaccount number (default: 0).
239    #[serde(default)]
240    pub subaccount_number: u32,
241    /// Authenticator IDs for permissioned key trading.
242    #[serde(default)]
243    pub authenticator_ids: Vec<u64>,
244    /// HTTP request timeout in seconds.
245    pub http_timeout_secs: Option<u64>,
246    /// Maximum number of retry attempts.
247    pub max_retries: Option<u32>,
248    /// Initial retry delay in milliseconds.
249    pub retry_delay_initial_ms: Option<u64>,
250    /// Maximum retry delay in milliseconds.
251    pub retry_delay_max_ms: Option<u64>,
252}
253
254impl DYDXExecClientConfig {
255    /// Returns the gRPC URLs to use, with fallback support.
256    ///
257    /// Returns `grpc_urls` if non-empty, otherwise uses `grpc_endpoint` if provided,
258    /// otherwise uses the default URLs for the configured network.
259    #[must_use]
260    pub fn get_grpc_urls(&self) -> Vec<String> {
261        if !self.grpc_urls.is_empty() {
262            return self.grpc_urls.clone();
263        }
264        if let Some(ref endpoint) = self.grpc_endpoint {
265            return vec![endpoint.clone()];
266        }
267        let is_testnet = matches!(self.network, DydxNetwork::Testnet);
268        urls::grpc_urls(is_testnet)
269            .iter()
270            .map(|&s| s.to_string())
271            .collect()
272    }
273
274    /// Returns the WebSocket URL for the configured network.
275    #[must_use]
276    pub fn get_ws_url(&self) -> String {
277        self.ws_endpoint.clone().unwrap_or_else(|| {
278            let is_testnet = matches!(self.network, DydxNetwork::Testnet);
279            urls::ws_url(is_testnet).to_string()
280        })
281    }
282
283    /// Returns the HTTP URL for the configured network.
284    #[must_use]
285    pub fn get_http_url(&self) -> String {
286        self.http_endpoint.clone().unwrap_or_else(|| {
287            let is_testnet = matches!(self.network, DydxNetwork::Testnet);
288            urls::http_base_url(is_testnet).to_string()
289        })
290    }
291
292    /// Returns the chain ID for the configured network.
293    #[must_use]
294    pub const fn get_chain_id(&self) -> ChainId {
295        self.network.chain_id()
296    }
297
298    /// Returns whether this is a testnet configuration.
299    #[must_use]
300    pub const fn is_testnet(&self) -> bool {
301        matches!(self.network, DydxNetwork::Testnet)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use rstest::rstest;
308
309    use super::*;
310
311    #[rstest]
312    fn test_config_get_chain_id_mainnet() {
313        let config = DydxAdapterConfig {
314            network: DydxNetwork::Mainnet,
315            ..Default::default()
316        };
317        assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
318    }
319
320    #[rstest]
321    fn test_config_get_chain_id_testnet() {
322        let config = DydxAdapterConfig {
323            network: DydxNetwork::Testnet,
324            ..Default::default()
325        };
326        assert_eq!(config.get_chain_id(), ChainId::Testnet4);
327    }
328
329    #[rstest]
330    fn test_config_compute_is_testnet() {
331        let mainnet_config = DydxAdapterConfig {
332            network: DydxNetwork::Mainnet,
333            ..Default::default()
334        };
335        assert!(!mainnet_config.compute_is_testnet());
336
337        let testnet_config = DydxAdapterConfig {
338            network: DydxNetwork::Testnet,
339            ..Default::default()
340        };
341        assert!(testnet_config.compute_is_testnet());
342    }
343
344    #[rstest]
345    fn test_config_default_uses_mainnet() {
346        let config = DydxAdapterConfig::default();
347        assert_eq!(config.network, DydxNetwork::Mainnet);
348        assert!(!config.is_testnet);
349    }
350
351    #[rstest]
352    fn test_config_network_canonical_over_is_testnet() {
353        // When network=mainnet but is_testnet=true, get_chain_id uses network
354        let config = DydxAdapterConfig {
355            network: DydxNetwork::Mainnet,
356            is_testnet: true, // Conflicting value
357            ..Default::default()
358        };
359        assert_eq!(config.get_chain_id(), ChainId::Mainnet1); // network wins
360        assert!(!config.compute_is_testnet()); // compute_is_testnet derives from network
361    }
362
363    #[rstest]
364    fn test_config_serde_backwards_compat() {
365        // Test that configs missing network field can deserialize with default
366        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,"is_testnet":false,"max_retries":3,"retry_delay_initial_ms":1000,"retry_delay_max_ms":10000}"#;
367
368        let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
369        assert!(config.is_ok());
370        let config = config.unwrap();
371        // Should default to Mainnet when network field is missing
372        assert_eq!(config.network, DydxNetwork::Mainnet);
373    }
374
375    #[rstest]
376    fn test_config_get_grpc_urls_fallback() {
377        let config = DydxAdapterConfig {
378            grpc_url: "https://primary.example.com".to_string(),
379            grpc_urls: vec![],
380            ..Default::default()
381        };
382
383        let urls = config.get_grpc_urls();
384        assert_eq!(urls.len(), 1);
385        assert_eq!(urls[0], "https://primary.example.com");
386    }
387
388    #[rstest]
389    fn test_config_get_grpc_urls_multiple() {
390        let config = DydxAdapterConfig {
391            grpc_url: "https://primary.example.com".to_string(),
392            grpc_urls: vec![
393                "https://fallback1.example.com".to_string(),
394                "https://fallback2.example.com".to_string(),
395            ],
396            ..Default::default()
397        };
398
399        let urls = config.get_grpc_urls();
400        assert_eq!(urls.len(), 2);
401        assert_eq!(urls[0], "https://fallback1.example.com");
402        assert_eq!(urls[1], "https://fallback2.example.com");
403    }
404}