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