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