nautilus_hyperliquid/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
16use std::{env, fmt, fs, path::Path};
17
18use serde::Deserialize;
19use zeroize::{Zeroize, ZeroizeOnDrop};
20
21use crate::http::error::{Error, Result};
22
23/// Represents a secure wrapper for EVM private key with zeroization on drop.
24#[derive(Clone, ZeroizeOnDrop)]
25pub struct EvmPrivateKey {
26    #[zeroize(skip)]
27    formatted_key: String, // Keep the formatted version for display
28    #[zeroize(skip)] // Skip zeroization to allow safe cloning
29    raw_bytes: Vec<u8>, // The actual key bytes
30}
31
32impl EvmPrivateKey {
33    /// Creates a new EVM private key from hex string.
34    pub fn new(key: String) -> Result<Self> {
35        let key = key.trim().to_string();
36        let hex_key = key.strip_prefix("0x").unwrap_or(&key);
37
38        // Validate hex format and length
39        if hex_key.len() != 64 {
40            return Err(Error::bad_request(
41                "EVM private key must be 32 bytes (64 hex chars)",
42            ));
43        }
44
45        if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
46            return Err(Error::bad_request("EVM private key must be valid hex"));
47        }
48
49        // Convert to lowercase for consistency
50        let normalized = hex_key.to_lowercase();
51        let formatted = format!("0x{}", normalized);
52
53        // Parse to bytes for validation
54        let raw_bytes = hex::decode(&normalized)
55            .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
56
57        if raw_bytes.len() != 32 {
58            return Err(Error::bad_request(
59                "EVM private key must be exactly 32 bytes",
60            ));
61        }
62
63        Ok(Self {
64            formatted_key: formatted,
65            raw_bytes,
66        })
67    }
68
69    /// Get the formatted hex key (0x-prefixed)
70    pub fn as_hex(&self) -> &str {
71        &self.formatted_key
72    }
73
74    /// Gets the raw bytes (for signing operations).
75    pub fn as_bytes(&self) -> &[u8] {
76        &self.raw_bytes
77    }
78}
79
80impl fmt::Debug for EvmPrivateKey {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str("EvmPrivateKey(***redacted***)")
83    }
84}
85
86impl fmt::Display for EvmPrivateKey {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str("EvmPrivateKey(***redacted***)")
89    }
90}
91
92/// Represents a secure wrapper for vault address.
93#[derive(Clone, Copy)]
94pub struct VaultAddress {
95    bytes: [u8; 20],
96}
97
98impl VaultAddress {
99    /// Parses vault address from hex string.
100    pub fn parse(s: &str) -> Result<Self> {
101        let s = s.trim();
102        let hex_part = s.strip_prefix("0x").unwrap_or(s);
103
104        if hex_part.len() != 40 {
105            return Err(Error::bad_request(
106                "Vault address must be 20 bytes (40 hex chars)",
107            ));
108        }
109
110        let bytes = hex::decode(hex_part)
111            .map_err(|_| Error::bad_request("Invalid hex in vault address"))?;
112
113        if bytes.len() != 20 {
114            return Err(Error::bad_request("Vault address must be exactly 20 bytes"));
115        }
116
117        let mut addr_bytes = [0u8; 20];
118        addr_bytes.copy_from_slice(&bytes);
119
120        Ok(Self { bytes: addr_bytes })
121    }
122
123    /// Get address as 0x-prefixed hex string
124    pub fn to_hex(&self) -> String {
125        format!("0x{}", hex::encode(self.bytes))
126    }
127
128    /// Get raw bytes
129    pub fn as_bytes(&self) -> &[u8; 20] {
130        &self.bytes
131    }
132}
133
134impl fmt::Debug for VaultAddress {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        let hex = self.to_hex();
137        write!(f, "VaultAddress({}...{})", &hex[..6], &hex[hex.len() - 4..])
138    }
139}
140
141impl fmt::Display for VaultAddress {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}", self.to_hex())
144    }
145}
146
147/// Complete secrets configuration for Hyperliquid
148#[derive(Clone)]
149pub struct Secrets {
150    pub private_key: EvmPrivateKey,
151    pub vault_address: Option<VaultAddress>,
152    pub is_testnet: bool,
153}
154
155impl fmt::Debug for Secrets {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.debug_struct(stringify!(Secrets))
158            .field("private_key", &self.private_key)
159            .field("vault_address", &self.vault_address)
160            .field("is_testnet", &self.is_testnet)
161            .finish()
162    }
163}
164
165impl Secrets {
166    /// Load secrets from environment variables
167    ///
168    /// Expected environment variables:
169    /// - `HYPERLIQUID_PK`: EVM private key for mainnet (required for mainnet)
170    /// - `HYPERLIQUID_TESTNET_PK`: EVM private key for testnet (required for testnet)
171    /// - `HYPERLIQUID_VAULT`: Vault address for mainnet (optional)
172    /// - `HYPERLIQUID_TESTNET_VAULT`: Vault address for testnet (optional)
173    ///
174    /// The method will first try to load testnet credentials. If not found, it will fall back to mainnet.
175    pub fn from_env() -> Result<Self> {
176        // Try testnet credentials first
177        let (private_key_str, vault_env_var, is_testnet) =
178            if let Ok(testnet_pk) = env::var("HYPERLIQUID_TESTNET_PK") {
179                (testnet_pk, "HYPERLIQUID_TESTNET_VAULT", true)
180            } else if let Ok(mainnet_pk) = env::var("HYPERLIQUID_PK") {
181                (mainnet_pk, "HYPERLIQUID_VAULT", false)
182            } else {
183                return Err(Error::bad_request(
184                    "Neither HYPERLIQUID_PK nor HYPERLIQUID_TESTNET_PK environment variable is set",
185                ));
186            };
187
188        let private_key = EvmPrivateKey::new(private_key_str)?;
189
190        let vault_address = match env::var(vault_env_var) {
191            Ok(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(&addr_str)?),
192            _ => None,
193        };
194
195        Ok(Self {
196            private_key,
197            vault_address,
198            is_testnet,
199        })
200    }
201
202    /// Create secrets from explicit private key and vault address.
203    ///
204    /// # Arguments
205    ///
206    /// * `private_key_str` - The private key hex string (with or without 0x prefix)
207    /// * `vault_address_str` - Optional vault address for vault trading
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the private key or vault address is invalid.
212    pub fn from_private_key(
213        private_key_str: &str,
214        vault_address_str: Option<&str>,
215        is_testnet: bool,
216    ) -> Result<Self> {
217        let private_key = EvmPrivateKey::new(private_key_str.to_string())?;
218
219        let vault_address = match vault_address_str {
220            Some(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(addr_str)?),
221            _ => None,
222        };
223
224        Ok(Self {
225            private_key,
226            vault_address,
227            is_testnet,
228        })
229    }
230
231    /// Load secrets from JSON file
232    ///
233    /// Expected JSON format:
234    /// ```json
235    /// {
236    ///   "privateKey": "0x...",
237    ///   "vaultAddress": "0x..." (optional),
238    ///   "network": "mainnet" | "testnet" (optional)
239    /// }
240    /// ```
241    pub fn from_file(path: &Path) -> Result<Self> {
242        let mut content = fs::read_to_string(path).map_err(Error::Io)?;
243
244        let result = Self::from_json(&content);
245
246        // Zeroize the file content from memory
247        content.zeroize();
248
249        result
250    }
251
252    /// Parse secrets from JSON string
253    pub fn from_json(json: &str) -> Result<Self> {
254        #[derive(Deserialize)]
255        #[serde(rename_all = "camelCase")]
256        struct RawSecrets {
257            private_key: String,
258            #[serde(default)]
259            vault_address: Option<String>,
260            #[serde(default)]
261            network: Option<String>,
262        }
263
264        let raw: RawSecrets = serde_json::from_str(json)
265            .map_err(|e| Error::bad_request(format!("Invalid JSON: {}", e)))?;
266
267        let private_key = EvmPrivateKey::new(raw.private_key)?;
268
269        let vault_address = match raw.vault_address {
270            Some(addr) => Some(VaultAddress::parse(&addr)?),
271            None => None,
272        };
273
274        let is_testnet = matches!(raw.network.as_deref(), Some("testnet" | "test"));
275
276        Ok(Self {
277            private_key,
278            vault_address,
279            is_testnet,
280        })
281    }
282}
283
284/// Normalize EVM address to lowercase hex format
285pub fn normalize_address(addr: &str) -> Result<String> {
286    let addr = addr.trim();
287    let hex_part = addr
288        .strip_prefix("0x")
289        .or_else(|| addr.strip_prefix("0X"))
290        .unwrap_or(addr);
291
292    if hex_part.len() != 40 {
293        return Err(Error::bad_request(
294            "Address must be 20 bytes (40 hex chars)",
295        ));
296    }
297
298    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
299        return Err(Error::bad_request("Address must be valid hex"));
300    }
301
302    Ok(format!("0x{}", hex_part.to_lowercase()))
303}
304
305////////////////////////////////////////////////////////////////////////////////
306// Tests
307////////////////////////////////////////////////////////////////////////////////
308
309#[cfg(test)]
310mod tests {
311    use rstest::rstest;
312
313    use super::*;
314
315    const TEST_PRIVATE_KEY: &str =
316        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
317    const TEST_VAULT_ADDRESS: &str = "0x1234567890123456789012345678901234567890";
318
319    #[rstest]
320    fn test_evm_private_key_creation() {
321        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
322        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
323        assert_eq!(key.as_bytes().len(), 32);
324    }
325
326    #[rstest]
327    fn test_evm_private_key_without_0x_prefix() {
328        let key_without_prefix = &TEST_PRIVATE_KEY[2..]; // Remove 0x
329        let key = EvmPrivateKey::new(key_without_prefix.to_string()).unwrap();
330        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
331    }
332
333    #[rstest]
334    fn test_evm_private_key_invalid_length() {
335        let result = EvmPrivateKey::new("0x123".to_string());
336        assert!(result.is_err());
337    }
338
339    #[rstest]
340    fn test_evm_private_key_invalid_hex() {
341        let result = EvmPrivateKey::new(
342            "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
343        );
344        assert!(result.is_err());
345    }
346
347    #[rstest]
348    fn test_evm_private_key_debug_redacts() {
349        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
350        let debug_str = format!("{:?}", key);
351        assert_eq!(debug_str, "EvmPrivateKey(***redacted***)");
352        assert!(!debug_str.contains("1234"));
353    }
354
355    #[rstest]
356    fn test_vault_address_creation() {
357        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
358        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
359        assert_eq!(addr.as_bytes().len(), 20);
360    }
361
362    #[rstest]
363    fn test_vault_address_without_0x_prefix() {
364        let addr_without_prefix = &TEST_VAULT_ADDRESS[2..]; // Remove 0x
365        let addr = VaultAddress::parse(addr_without_prefix).unwrap();
366        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
367    }
368
369    #[rstest]
370    fn test_vault_address_debug_redacts_middle() {
371        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
372        let debug_str = format!("{:?}", addr);
373        assert!(debug_str.starts_with("VaultAddress(0x1234"));
374        assert!(debug_str.ends_with("7890)"));
375        assert!(debug_str.contains("..."));
376    }
377
378    #[rstest]
379    fn test_secrets_from_json() {
380        let json = format!(
381            r#"{{
382            "privateKey": "{}",
383            "vaultAddress": "{}",
384            "network": "testnet"
385        }}"#,
386            TEST_PRIVATE_KEY, TEST_VAULT_ADDRESS
387        );
388
389        let secrets = Secrets::from_json(&json).unwrap();
390        assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
391        assert!(secrets.vault_address.is_some());
392        assert_eq!(secrets.vault_address.unwrap().to_hex(), TEST_VAULT_ADDRESS);
393        assert!(secrets.is_testnet);
394    }
395
396    #[rstest]
397    fn test_secrets_from_json_minimal() {
398        let json = format!(
399            r#"{{
400            "privateKey": "{}"
401        }}"#,
402            TEST_PRIVATE_KEY
403        );
404
405        let secrets = Secrets::from_json(&json).unwrap();
406        assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
407        assert!(secrets.vault_address.is_none());
408        assert!(!secrets.is_testnet);
409    }
410
411    #[rstest]
412    fn test_normalize_address() {
413        let test_cases = [
414            (
415                TEST_VAULT_ADDRESS,
416                "0x1234567890123456789012345678901234567890",
417            ),
418            (
419                "1234567890123456789012345678901234567890",
420                "0x1234567890123456789012345678901234567890",
421            ),
422            (
423                "0X1234567890123456789012345678901234567890",
424                "0x1234567890123456789012345678901234567890",
425            ),
426        ];
427
428        for (input, expected) in test_cases {
429            assert_eq!(normalize_address(input).unwrap(), expected);
430        }
431    }
432
433    #[rstest]
434    #[ignore = "This test modifies environment variables - run manually if needed"]
435    fn test_secrets_from_env() {
436        // Note: This test requires setting environment variables manually
437        // HYPERLIQUID_PK=1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
438        // HYPERLIQUID_VAULT=0x1234567890abcdef1234567890abcdef12345678
439        // HYPERLIQUID_NETWORK=testnet
440
441        // For now, just test the error case when variables are not set
442        match Secrets::from_env() {
443            Err(e) => {
444                assert!(
445                    e.to_string().contains("HYPERLIQUID_PK")
446                        || e.to_string().contains("environment variable not set")
447                );
448            }
449            Ok(_) => {
450                // If environment variables are actually set, that's fine too
451            }
452        }
453    }
454}