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