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