nautilus_dydx/
config.rs

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