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