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