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