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