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