Skip to main content

nautilus_dydx/common/
credential.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//! dYdX credential storage and wallet-based transaction signing helpers.
17//!
18//! dYdX v4 uses Cosmos SDK-style wallet signing rather than API key authentication.
19//! Trading operations require signing transactions with a secp256k1 private key.
20//!
21//! # Credential Resolution
22//!
23//! Credentials are resolved in the following priority order:
24//!
25//! 1. `private_key` from config
26//! 2. `DYDX_PRIVATE_KEY` / `DYDX_TESTNET_PRIVATE_KEY` env var
27//!
28//! Wallet address env vars: `DYDX_WALLET_ADDRESS` / `DYDX_TESTNET_WALLET_ADDRESS`
29
30#![allow(unused_assignments)] // Fields are accessed externally, false positive from nightly
31
32use std::fmt::Debug;
33
34use anyhow::Context;
35use cosmrs::{AccountId, crypto::secp256k1::SigningKey, tx::SignDoc};
36use nautilus_core::env::get_or_env_var_opt;
37
38use crate::common::consts::DYDX_BECH32_PREFIX;
39
40/// dYdX wallet credentials for signing blockchain transactions.
41///
42/// Uses secp256k1 for signing as per Cosmos SDK specifications.
43///
44/// # Security
45///
46/// The underlying `SigningKey` from cosmrs (backed by k256) securely zeroizes
47/// private key material from memory on drop.
48pub struct DydxCredential {
49    /// The secp256k1 signing key.
50    signing_key: SigningKey,
51    /// Bech32-encoded account address (e.g., dydx1...).
52    pub address: String,
53    /// Optional authenticator IDs for permissioned key trading.
54    pub authenticator_ids: Vec<u64>,
55}
56
57impl Debug for DydxCredential {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct(stringify!(DydxCredential))
60            .field("address", &self.address)
61            .field("authenticator_ids", &self.authenticator_ids)
62            .field("signing_key", &"<redacted>")
63            .finish()
64    }
65}
66
67impl DydxCredential {
68    /// Creates a new [`DydxCredential`] from a raw private key.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if private key is invalid.
73    pub fn from_private_key(
74        private_key_hex: &str,
75        authenticator_ids: Vec<u64>,
76    ) -> anyhow::Result<Self> {
77        // Decode hex private key
78        let key_bytes = hex::decode(private_key_hex.trim_start_matches("0x"))
79            .context("Invalid hex private key")?;
80
81        let signing_key = SigningKey::from_slice(&key_bytes)
82            .map_err(|e| anyhow::anyhow!("Invalid secp256k1 private key: {e}"))?;
83
84        // Derive bech32 address
85        let public_key = signing_key.public_key();
86        let account_id = public_key
87            .account_id(DYDX_BECH32_PREFIX)
88            .map_err(|e| anyhow::anyhow!("Failed to derive account ID: {e}"))?;
89        let address = account_id.to_string();
90
91        Ok(Self {
92            signing_key,
93            address,
94            authenticator_ids,
95        })
96    }
97
98    /// Creates a [`DydxCredential`] from environment variables.
99    ///
100    /// Checks for private key: `DYDX_PRIVATE_KEY` / `DYDX_TESTNET_PRIVATE_KEY`
101    ///
102    /// Returns `None` if no environment variable is set.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if a credential is set but invalid.
107    pub fn from_env(is_testnet: bool, authenticator_ids: Vec<u64>) -> anyhow::Result<Option<Self>> {
108        let private_key_env = if is_testnet {
109            "DYDX_TESTNET_PRIVATE_KEY"
110        } else {
111            "DYDX_PRIVATE_KEY"
112        };
113
114        if let Some(private_key) = std::env::var(private_key_env)
115            .ok()
116            .filter(|s| !s.trim().is_empty())
117        {
118            return Ok(Some(Self::from_private_key(
119                &private_key,
120                authenticator_ids,
121            )?));
122        }
123
124        Ok(None)
125    }
126
127    /// Resolves a [`DydxCredential`] from config values or environment variables.
128    ///
129    /// Priority:
130    /// 1. `private_key` config value
131    /// 2. `DYDX_PRIVATE_KEY` / `DYDX_TESTNET_PRIVATE_KEY` env var
132    ///
133    /// Returns `None` if no credential is available.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if a credential is provided but invalid.
138    pub fn resolve(
139        private_key: Option<String>,
140        is_testnet: bool,
141        authenticator_ids: Vec<u64>,
142    ) -> anyhow::Result<Option<Self>> {
143        // 1. Try private key from config
144        if let Some(ref pk) = private_key
145            && !pk.trim().is_empty()
146        {
147            return Ok(Some(Self::from_private_key(pk, authenticator_ids)?));
148        }
149
150        // 2. Try private key from env var
151        let private_key_env = if is_testnet {
152            "DYDX_TESTNET_PRIVATE_KEY"
153        } else {
154            "DYDX_PRIVATE_KEY"
155        };
156        if let Some(pk) = std::env::var(private_key_env)
157            .ok()
158            .filter(|s| !s.trim().is_empty())
159        {
160            return Ok(Some(Self::from_private_key(&pk, authenticator_ids)?));
161        }
162
163        Ok(None)
164    }
165
166    /// Returns the account ID for this credential.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the address cannot be parsed as a valid account ID.
171    pub fn account_id(&self) -> anyhow::Result<AccountId> {
172        self.address
173            .parse()
174            .map_err(|e| anyhow::anyhow!("Failed to parse account ID: {e}"))
175    }
176
177    /// Signs a transaction SignDoc.
178    ///
179    /// This produces the signature bytes that will be included in the transaction.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if SignDoc serialization or signing fails.
184    pub fn sign(&self, sign_doc: &SignDoc) -> anyhow::Result<Vec<u8>> {
185        let sign_bytes = sign_doc
186            .clone()
187            .into_bytes()
188            .map_err(|e| anyhow::anyhow!("Failed to serialize SignDoc: {e}"))?;
189
190        let signature = self
191            .signing_key
192            .sign(&sign_bytes)
193            .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))?;
194        Ok(signature.to_bytes().to_vec())
195    }
196
197    /// Signs raw message bytes.
198    ///
199    /// Used for custom signing operations outside of standard transaction flow.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if signing fails.
204    pub fn sign_bytes(&self, message: &[u8]) -> anyhow::Result<Vec<u8>> {
205        let signature = self
206            .signing_key
207            .sign(message)
208            .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))?;
209        Ok(signature.to_bytes().to_vec())
210    }
211
212    /// Returns the public key for this credential.
213    pub fn public_key(&self) -> cosmrs::crypto::PublicKey {
214        self.signing_key.public_key()
215    }
216}
217
218/// Resolves wallet address from config value or environment variable.
219///
220/// Priority:
221/// 1. If `wallet_address` is `Some`, use it directly.
222/// 2. Otherwise, try to read from environment variable.
223///
224/// Environment variables:
225/// - Mainnet: `DYDX_WALLET_ADDRESS`
226/// - Testnet: `DYDX_TESTNET_WALLET_ADDRESS`
227///
228/// Returns `None` if neither config nor env var provides a wallet address.
229#[must_use]
230pub fn resolve_wallet_address(wallet_address: Option<String>, is_testnet: bool) -> Option<String> {
231    let env_var = if is_testnet {
232        "DYDX_TESTNET_WALLET_ADDRESS"
233    } else {
234        "DYDX_WALLET_ADDRESS"
235    };
236
237    get_or_env_var_opt(wallet_address, env_var).filter(|s| !s.trim().is_empty())
238}
239
240#[cfg(test)]
241mod tests {
242    use rstest::rstest;
243
244    use super::*;
245
246    // Valid test private key (32 bytes, value 1 - simplest valid secp256k1 key)
247    const TEST_PRIVATE_KEY: &str =
248        "0000000000000000000000000000000000000000000000000000000000000001";
249
250    #[rstest]
251    fn test_from_private_key() {
252        let credential = DydxCredential::from_private_key(TEST_PRIVATE_KEY, vec![])
253            .expect("Failed to create credential from private key");
254
255        assert!(credential.address.starts_with("dydx"));
256        assert!(credential.authenticator_ids.is_empty());
257    }
258
259    #[rstest]
260    fn test_from_private_key_with_authenticators() {
261        let credential = DydxCredential::from_private_key(TEST_PRIVATE_KEY, vec![1, 2, 3])
262            .expect("Failed to create credential");
263
264        assert_eq!(credential.authenticator_ids, vec![1, 2, 3]);
265    }
266
267    #[rstest]
268    fn test_from_private_key_with_0x_prefix() {
269        let key_with_prefix = format!("0x{TEST_PRIVATE_KEY}");
270        let credential = DydxCredential::from_private_key(&key_with_prefix, vec![])
271            .expect("Failed to create credential from private key with 0x prefix");
272
273        assert!(credential.address.starts_with("dydx"));
274    }
275
276    #[rstest]
277    fn test_account_id() {
278        let credential = DydxCredential::from_private_key(TEST_PRIVATE_KEY, vec![])
279            .expect("Failed to create credential");
280
281        let account_id = credential.account_id().expect("Failed to get account ID");
282        assert_eq!(account_id.to_string(), credential.address);
283    }
284
285    #[rstest]
286    fn test_sign_bytes() {
287        let credential = DydxCredential::from_private_key(TEST_PRIVATE_KEY, vec![])
288            .expect("Failed to create credential");
289
290        let message = b"test message";
291        let signature = credential
292            .sign_bytes(message)
293            .expect("Failed to sign bytes");
294
295        // secp256k1 signatures are 64 bytes
296        assert_eq!(signature.len(), 64);
297    }
298
299    #[rstest]
300    fn test_debug_redacts_key() {
301        let credential = DydxCredential::from_private_key(TEST_PRIVATE_KEY, vec![])
302            .expect("Failed to create credential");
303
304        let debug_str = format!("{credential:?}");
305        // Should contain redacted marker
306        assert!(debug_str.contains("<redacted>"));
307        // Should contain the struct name
308        assert!(debug_str.contains("DydxCredential"));
309        // Should show address
310        assert!(debug_str.contains(&credential.address));
311    }
312
313    #[rstest]
314    fn test_resolve_with_provided_private_key() {
315        let result = DydxCredential::resolve(Some(TEST_PRIVATE_KEY.to_string()), false, vec![])
316            .expect("Failed to resolve credential");
317
318        assert!(result.is_some());
319        let credential = result.unwrap();
320        assert!(credential.address.starts_with("dydx"));
321    }
322
323    #[rstest]
324    fn test_resolve_with_none_and_no_env_var() {
325        // Use testnet env var which is unlikely to be set in dev environment
326        let result = DydxCredential::resolve(None, true, vec![])
327            .expect("Should not error when credential not available");
328
329        // Will be None unless DYDX_TESTNET_PRIVATE_KEY is set
330        if std::env::var("DYDX_TESTNET_PRIVATE_KEY").is_err() {
331            assert!(result.is_none());
332        }
333    }
334
335    #[rstest]
336    fn test_resolve_wallet_address_with_provided_value() {
337        let result = resolve_wallet_address(Some("dydx1abc123".to_string()), false);
338        assert_eq!(result, Some("dydx1abc123".to_string()));
339    }
340
341    #[rstest]
342    fn test_resolve_wallet_address_empty_string_returns_none() {
343        let result = resolve_wallet_address(Some(String::new()), false);
344        assert!(result.is_none());
345
346        let result = resolve_wallet_address(Some("   ".to_string()), false);
347        assert!(result.is_none());
348    }
349
350    #[rstest]
351    fn test_resolve_wallet_address_with_none_and_no_env_var() {
352        // Use testnet env var which is unlikely to be set in dev environment
353        let result = resolve_wallet_address(None, true);
354
355        // Will be None unless DYDX_TESTNET_WALLET_ADDRESS is set
356        if std::env::var("DYDX_TESTNET_WALLET_ADDRESS").is_err() {
357            assert!(result.is_none());
358        }
359    }
360}