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#![allow(unused_assignments)] // Fields are accessed externally, false positive from nightly
22
23use std::fmt::Debug;
24
25use anyhow::Context;
26use cosmrs::{AccountId, crypto::secp256k1::SigningKey, tx::SignDoc};
27
28use crate::common::consts::DYDX_BECH32_PREFIX;
29
30/// dYdX wallet credentials for signing blockchain transactions.
31///
32/// Uses secp256k1 for signing as per Cosmos SDK specifications.
33///
34/// # Security
35///
36/// The underlying `SigningKey` from cosmrs (backed by k256) securely zeroizes
37/// private key material from memory on drop.
38pub struct DydxCredential {
39    /// The secp256k1 signing key.
40    signing_key: SigningKey,
41    /// Bech32-encoded account address (e.g., dydx1...).
42    pub address: String,
43    /// Optional authenticator IDs for permissioned key trading.
44    pub authenticator_ids: Vec<u64>,
45}
46
47impl Debug for DydxCredential {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct(stringify!(DydxCredential))
50            .field("address", &self.address)
51            .field("authenticator_ids", &self.authenticator_ids)
52            .field("signing_key", &"<redacted>")
53            .finish()
54    }
55}
56
57impl DydxCredential {
58    /// Creates a new [`DydxCredential`] from a mnemonic phrase.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the mnemonic is invalid or key derivation fails.
63    pub fn from_mnemonic(
64        mnemonic_phrase: &str,
65        account_index: u32,
66        authenticator_ids: Vec<u64>,
67    ) -> anyhow::Result<Self> {
68        use std::str::FromStr;
69
70        use bip32::{DerivationPath, Language, Mnemonic};
71
72        // Derive seed from mnemonic
73        // Note: Both Mnemonic and Seed implement Drop for secure cleanup
74        let mnemonic =
75            Mnemonic::new(mnemonic_phrase, Language::English).context("Invalid mnemonic phrase")?;
76        let seed = mnemonic.to_seed("");
77
78        // BIP-44 derivation path: m/44'/118'/0'/0/{account_index}
79        // 118 is the Cosmos SLIP-0044 coin type
80        let derivation_path = format!("m/44'/118'/0'/0/{account_index}");
81        let path = DerivationPath::from_str(&derivation_path).context("Invalid derivation path")?;
82
83        let signing_key =
84            SigningKey::derive_from_path(&seed, &path).context("Failed to derive signing key")?;
85
86        // Derive bech32 address
87        let public_key = signing_key.public_key();
88        let account_id = public_key
89            .account_id(DYDX_BECH32_PREFIX)
90            .map_err(|e| anyhow::anyhow!("Failed to derive account ID: {e}"))?;
91        let address = account_id.to_string();
92
93        Ok(Self {
94            signing_key,
95            address,
96            authenticator_ids,
97        })
98    }
99
100    /// Creates a new [`DydxCredential`] from a raw private key.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if private key is invalid.
105    pub fn from_private_key(
106        private_key_hex: &str,
107        authenticator_ids: Vec<u64>,
108    ) -> anyhow::Result<Self> {
109        // Decode hex private key
110        let key_bytes = hex::decode(private_key_hex.trim_start_matches("0x"))
111            .context("Invalid hex private key")?;
112
113        let signing_key = SigningKey::from_slice(&key_bytes)
114            .map_err(|e| anyhow::anyhow!("Invalid secp256k1 private key: {e}"))?;
115
116        // Derive bech32 address
117        let public_key = signing_key.public_key();
118        let account_id = public_key
119            .account_id(DYDX_BECH32_PREFIX)
120            .map_err(|e| anyhow::anyhow!("Failed to derive account ID: {e}"))?;
121        let address = account_id.to_string();
122
123        Ok(Self {
124            signing_key,
125            address,
126            authenticator_ids,
127        })
128    }
129
130    /// Returns the account ID for this credential.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the address cannot be parsed as a valid account ID.
135    pub fn account_id(&self) -> anyhow::Result<AccountId> {
136        self.address
137            .parse()
138            .map_err(|e| anyhow::anyhow!("Failed to parse account ID: {e}"))
139    }
140
141    /// Signs a transaction SignDoc.
142    ///
143    /// This produces the signature bytes that will be included in the transaction.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if SignDoc serialization or signing fails.
148    pub fn sign(&self, sign_doc: &SignDoc) -> anyhow::Result<Vec<u8>> {
149        let sign_bytes = sign_doc
150            .clone()
151            .into_bytes()
152            .map_err(|e| anyhow::anyhow!("Failed to serialize SignDoc: {e}"))?;
153
154        let signature = self
155            .signing_key
156            .sign(&sign_bytes)
157            .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))?;
158        Ok(signature.to_bytes().to_vec())
159    }
160
161    /// Signs raw message bytes.
162    ///
163    /// Used for custom signing operations outside of standard transaction flow.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if signing fails.
168    pub fn sign_bytes(&self, message: &[u8]) -> anyhow::Result<Vec<u8>> {
169        let signature = self
170            .signing_key
171            .sign(message)
172            .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))?;
173        Ok(signature.to_bytes().to_vec())
174    }
175
176    /// Returns the public key for this credential.
177    pub fn public_key(&self) -> cosmrs::crypto::PublicKey {
178        self.signing_key.public_key()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use rstest::rstest;
185
186    use super::*;
187
188    // Test mnemonic from dYdX v4 client examples
189    const TEST_MNEMONIC: &str = "mirror actor skill push coach wait confirm orchard lunch mobile athlete gossip awake miracle matter bus reopen team ladder lazy list timber render wait";
190
191    #[rstest]
192    fn test_from_mnemonic() {
193        let credential = DydxCredential::from_mnemonic(TEST_MNEMONIC, 0, vec![])
194            .expect("Failed to create credential");
195
196        assert!(credential.address.starts_with("dydx"));
197        assert!(credential.authenticator_ids.is_empty());
198    }
199
200    #[rstest]
201    fn test_from_mnemonic_with_authenticators() {
202        let credential = DydxCredential::from_mnemonic(TEST_MNEMONIC, 0, vec![1, 2, 3])
203            .expect("Failed to create credential");
204
205        assert_eq!(credential.authenticator_ids, vec![1, 2, 3]);
206    }
207
208    #[rstest]
209    fn test_from_private_key() {
210        // Use a valid test private key (small non-zero value)
211        // This is a valid secp256k1 private key: 32 bytes with value 1
212        let test_key = format!("{:0>64}", "1");
213
214        let credential = DydxCredential::from_private_key(&test_key, vec![])
215            .expect("Failed to create credential from private key");
216
217        assert!(credential.address.starts_with("dydx"));
218        assert!(credential.authenticator_ids.is_empty());
219    }
220
221    #[rstest]
222    fn test_account_id() {
223        let credential = DydxCredential::from_mnemonic(TEST_MNEMONIC, 0, vec![])
224            .expect("Failed to create credential");
225
226        let account_id = credential.account_id().expect("Failed to get account ID");
227        assert_eq!(account_id.to_string(), credential.address);
228    }
229
230    #[rstest]
231    fn test_sign_bytes() {
232        let credential = DydxCredential::from_mnemonic(TEST_MNEMONIC, 0, vec![])
233            .expect("Failed to create credential");
234
235        let message = b"test message";
236        let signature = credential
237            .sign_bytes(message)
238            .expect("Failed to sign bytes");
239
240        // secp256k1 signatures are 64 bytes
241        assert_eq!(signature.len(), 64);
242    }
243
244    #[rstest]
245    fn test_debug_redacts_key() {
246        let credential = DydxCredential::from_mnemonic(TEST_MNEMONIC, 0, vec![])
247            .expect("Failed to create credential");
248
249        let debug_str = format!("{credential:?}");
250        // Should contain redacted marker
251        assert!(debug_str.contains("<redacted>"));
252        // Should contain the struct name
253        assert!(debug_str.contains("DydxCredential"));
254        // Should show address
255        assert!(debug_str.contains(&credential.address));
256    }
257}