Skip to main content

nautilus_hyperliquid/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#![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 for the specified network.
174    ///
175    /// Expected environment variables:
176    /// - `HYPERLIQUID_PK`: EVM private key for mainnet (required when `is_testnet=false`)
177    /// - `HYPERLIQUID_TESTNET_PK`: EVM private key for testnet (required when `is_testnet=true`)
178    /// - `HYPERLIQUID_VAULT`: Vault address for mainnet (optional)
179    /// - `HYPERLIQUID_TESTNET_VAULT`: Vault address for testnet (optional)
180    pub fn from_env(is_testnet: bool) -> Result<Self> {
181        let (pk_env_var, vault_env_var) = if is_testnet {
182            ("HYPERLIQUID_TESTNET_PK", "HYPERLIQUID_TESTNET_VAULT")
183        } else {
184            ("HYPERLIQUID_PK", "HYPERLIQUID_VAULT")
185        };
186
187        let private_key_str = env::var(pk_env_var).map_err(|_| {
188            Error::bad_request(format!("{pk_env_var} environment variable is not set"))
189        })?;
190
191        let private_key = EvmPrivateKey::new(private_key_str)?;
192
193        let vault_address = match env::var(vault_env_var) {
194            Ok(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(&addr_str)?),
195            _ => None,
196        };
197
198        Ok(Self {
199            private_key,
200            vault_address,
201            is_testnet,
202        })
203    }
204
205    /// Create secrets from explicit private key and vault address.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the private key or vault address is invalid.
210    pub fn from_private_key(
211        private_key_str: &str,
212        vault_address_str: Option<&str>,
213        is_testnet: bool,
214    ) -> Result<Self> {
215        let private_key = EvmPrivateKey::new(private_key_str.to_string())?;
216
217        let vault_address = match vault_address_str {
218            Some(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(addr_str)?),
219            _ => None,
220        };
221
222        Ok(Self {
223            private_key,
224            vault_address,
225            is_testnet,
226        })
227    }
228
229    /// Load secrets from JSON file
230    ///
231    /// Expected JSON format:
232    /// ```json
233    /// {
234    ///   "privateKey": "0x...",
235    ///   "vaultAddress": "0x..." (optional),
236    ///   "network": "mainnet" | "testnet" (optional)
237    /// }
238    /// ```
239    pub fn from_file(path: &Path) -> Result<Self> {
240        let mut content = fs::read_to_string(path).map_err(Error::Io)?;
241
242        let result = Self::from_json(&content);
243
244        // Zeroize the file content from memory
245        content.zeroize();
246
247        result
248    }
249
250    /// Parse secrets from JSON string
251    pub fn from_json(json: &str) -> Result<Self> {
252        #[derive(Deserialize)]
253        #[serde(rename_all = "camelCase")]
254        struct RawSecrets {
255            private_key: String,
256            #[serde(default)]
257            vault_address: Option<String>,
258            #[serde(default)]
259            network: Option<String>,
260        }
261
262        let raw: RawSecrets = serde_json::from_str(json)
263            .map_err(|e| Error::bad_request(format!("Invalid JSON: {e}")))?;
264
265        let private_key = EvmPrivateKey::new(raw.private_key)?;
266
267        let vault_address = match raw.vault_address {
268            Some(addr) => Some(VaultAddress::parse(&addr)?),
269            None => None,
270        };
271
272        let is_testnet = matches!(raw.network.as_deref(), Some("testnet" | "test"));
273
274        Ok(Self {
275            private_key,
276            vault_address,
277            is_testnet,
278        })
279    }
280}
281
282/// Normalize EVM address to lowercase hex format
283pub fn normalize_address(addr: &str) -> Result<String> {
284    let addr = addr.trim();
285    let hex_part = addr
286        .strip_prefix("0x")
287        .or_else(|| addr.strip_prefix("0X"))
288        .unwrap_or(addr);
289
290    if hex_part.len() != 40 {
291        return Err(Error::bad_request(
292            "Address must be 20 bytes (40 hex chars)",
293        ));
294    }
295
296    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
297        return Err(Error::bad_request("Address must be valid hex"));
298    }
299
300    Ok(format!("0x{}", hex_part.to_lowercase()))
301}
302
303#[cfg(test)]
304mod tests {
305    use rstest::rstest;
306
307    use super::*;
308
309    const TEST_PRIVATE_KEY: &str =
310        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
311    const TEST_VAULT_ADDRESS: &str = "0x1234567890123456789012345678901234567890";
312
313    #[rstest]
314    fn test_evm_private_key_creation() {
315        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
316        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
317        assert_eq!(key.as_bytes().len(), 32);
318    }
319
320    #[rstest]
321    fn test_evm_private_key_without_0x_prefix() {
322        let key_without_prefix = &TEST_PRIVATE_KEY[2..]; // Remove 0x
323        let key = EvmPrivateKey::new(key_without_prefix.to_string()).unwrap();
324        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
325    }
326
327    #[rstest]
328    fn test_evm_private_key_invalid_length() {
329        let result = EvmPrivateKey::new("0x123".to_string());
330        assert!(result.is_err());
331    }
332
333    #[rstest]
334    fn test_evm_private_key_invalid_hex() {
335        let result = EvmPrivateKey::new(
336            "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
337        );
338        assert!(result.is_err());
339    }
340
341    #[rstest]
342    fn test_evm_private_key_debug_redacts() {
343        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
344        let debug_str = format!("{key:?}");
345        assert_eq!(debug_str, "EvmPrivateKey(***redacted***)");
346        assert!(!debug_str.contains("1234"));
347    }
348
349    #[rstest]
350    fn test_vault_address_creation() {
351        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
352        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
353        assert_eq!(addr.as_bytes().len(), 20);
354    }
355
356    #[rstest]
357    fn test_vault_address_without_0x_prefix() {
358        let addr_without_prefix = &TEST_VAULT_ADDRESS[2..]; // Remove 0x
359        let addr = VaultAddress::parse(addr_without_prefix).unwrap();
360        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
361    }
362
363    #[rstest]
364    fn test_vault_address_debug_redacts_middle() {
365        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
366        let debug_str = format!("{addr:?}");
367        assert!(debug_str.starts_with("VaultAddress(0x1234"));
368        assert!(debug_str.ends_with("7890)"));
369        assert!(debug_str.contains("..."));
370    }
371
372    #[rstest]
373    fn test_secrets_from_json() {
374        let json = format!(
375            r#"{{
376            "privateKey": "{TEST_PRIVATE_KEY}",
377            "vaultAddress": "{TEST_VAULT_ADDRESS}",
378            "network": "testnet"
379        }}"#
380        );
381
382        let secrets = Secrets::from_json(&json).unwrap();
383        assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
384        assert!(secrets.vault_address.is_some());
385        assert_eq!(secrets.vault_address.unwrap().to_hex(), TEST_VAULT_ADDRESS);
386        assert!(secrets.is_testnet);
387    }
388
389    #[rstest]
390    fn test_secrets_from_json_minimal() {
391        let json = format!(
392            r#"{{
393            "privateKey": "{TEST_PRIVATE_KEY}"
394        }}"#
395        );
396
397        let secrets = Secrets::from_json(&json).unwrap();
398        assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
399        assert!(secrets.vault_address.is_none());
400        assert!(!secrets.is_testnet);
401    }
402
403    #[rstest]
404    fn test_normalize_address() {
405        let test_cases = [
406            (
407                TEST_VAULT_ADDRESS,
408                "0x1234567890123456789012345678901234567890",
409            ),
410            (
411                "1234567890123456789012345678901234567890",
412                "0x1234567890123456789012345678901234567890",
413            ),
414            (
415                "0X1234567890123456789012345678901234567890",
416                "0x1234567890123456789012345678901234567890",
417            ),
418        ];
419
420        for (input, expected) in test_cases {
421            assert_eq!(normalize_address(input).unwrap(), expected);
422        }
423    }
424}