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::fmt::{Debug, Display};
19
20use nautilus_core::{
21    env::{get_or_env_var, get_or_env_var_opt},
22    hex,
23    string::secret::REDACTED,
24};
25use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
26
27use crate::{
28    common::enums::HyperliquidEnvironment,
29    http::error::{Error, Result},
30};
31
32/// Returns the environment variable names for credentials,
33/// based on environment.
34///
35/// Returns `(private_key_var, vault_address_var)`.
36#[must_use]
37pub fn credential_env_vars(environment: HyperliquidEnvironment) -> (&'static str, &'static str) {
38    match environment {
39        HyperliquidEnvironment::Testnet => ("HYPERLIQUID_TESTNET_PK", "HYPERLIQUID_TESTNET_VAULT"),
40        HyperliquidEnvironment::Mainnet => ("HYPERLIQUID_PK", "HYPERLIQUID_VAULT"),
41    }
42}
43
44/// Represents a secure wrapper for EVM private key with zeroization on drop.
45#[derive(Clone, Zeroize, ZeroizeOnDrop)]
46pub struct EvmPrivateKey {
47    formatted_key: String,
48    raw_bytes: Vec<u8>,
49}
50
51impl EvmPrivateKey {
52    /// Creates a new EVM private key from hex string.
53    pub fn new(key: &str) -> Result<Self> {
54        let key = Zeroizing::new(key.trim().to_string());
55        let hex_key = key.strip_prefix("0x").unwrap_or(&key);
56
57        // Validate hex format and length
58        if hex_key.len() != 64 {
59            return Err(Error::bad_request(
60                "EVM private key must be 32 bytes (64 hex chars)",
61            ));
62        }
63
64        if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
65            return Err(Error::bad_request("EVM private key must be valid hex"));
66        }
67
68        // Convert to lowercase for consistency
69        let normalized = Zeroizing::new(hex_key.to_lowercase());
70        let formatted = format!("0x{}", normalized.as_str());
71
72        // Parse to bytes for validation
73        let raw_bytes = hex::decode(&normalized)
74            .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
75
76        if raw_bytes.len() != 32 {
77            return Err(Error::bad_request(
78                "EVM private key must be exactly 32 bytes",
79            ));
80        }
81
82        Ok(Self {
83            formatted_key: formatted,
84            raw_bytes,
85        })
86    }
87
88    /// Get the formatted hex key (0x-prefixed)
89    pub fn as_hex(&self) -> &str {
90        &self.formatted_key
91    }
92
93    /// Gets the raw bytes (for signing operations).
94    pub fn as_bytes(&self) -> &[u8] {
95        &self.raw_bytes
96    }
97}
98
99impl Debug for EvmPrivateKey {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        write!(f, "EvmPrivateKey({REDACTED})")
102    }
103}
104
105impl Display for EvmPrivateKey {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        write!(f, "EvmPrivateKey({REDACTED})")
108    }
109}
110
111/// Represents a secure wrapper for vault address.
112#[derive(Clone, Copy)]
113pub struct VaultAddress {
114    bytes: [u8; 20],
115}
116
117impl VaultAddress {
118    /// Parses vault address from hex string.
119    pub fn parse(s: &str) -> Result<Self> {
120        let s = s.trim();
121        let hex_part = s.strip_prefix("0x").unwrap_or(s);
122
123        let bytes: [u8; 20] = hex::decode_array(hex_part)
124            .map_err(|_| Error::bad_request("Vault address must be 20 bytes of valid hex"))?;
125
126        Ok(Self { bytes })
127    }
128
129    /// Get address as 0x-prefixed hex string
130    pub fn to_hex(&self) -> String {
131        hex::encode_prefixed(self.bytes)
132    }
133
134    /// Get raw bytes
135    pub fn as_bytes(&self) -> &[u8; 20] {
136        &self.bytes
137    }
138}
139
140impl Debug for VaultAddress {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let hex = self.to_hex();
143        write!(f, "VaultAddress({}...{})", &hex[..6], &hex[hex.len() - 4..])
144    }
145}
146
147impl Display for VaultAddress {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "{}", self.to_hex())
150    }
151}
152
153/// Complete secrets configuration for Hyperliquid
154#[derive(Clone)]
155pub struct Secrets {
156    pub private_key: EvmPrivateKey,
157    pub vault_address: Option<VaultAddress>,
158    pub environment: HyperliquidEnvironment,
159}
160
161impl Secrets {
162    /// Returns whether this secrets configuration targets the testnet environment.
163    #[must_use]
164    pub fn is_testnet(&self) -> bool {
165        self.environment == HyperliquidEnvironment::Testnet
166    }
167}
168
169impl Debug for Secrets {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct(stringify!(Secrets))
172            .field("private_key", &self.private_key)
173            .field("vault_address", &self.vault_address)
174            .field("environment", &self.environment)
175            .finish()
176    }
177}
178
179impl Secrets {
180    /// Returns the environment variable names for the specified environment.
181    #[must_use]
182    pub fn env_vars(environment: HyperliquidEnvironment) -> (&'static str, &'static str) {
183        credential_env_vars(environment)
184    }
185
186    /// Resolves secrets from provided values or environment variables.
187    ///
188    /// If `private_key` is provided, uses it directly. Otherwise falls back
189    /// to environment variables based on the environment.
190    pub fn resolve(
191        private_key: Option<&str>,
192        vault_address: Option<&str>,
193        environment: HyperliquidEnvironment,
194    ) -> Result<Self> {
195        let (pk_env_var, vault_env_var) = credential_env_vars(environment);
196
197        let pk_str = Zeroizing::new(
198            get_or_env_var(
199                private_key
200                    .filter(|s| !s.trim().is_empty())
201                    .map(String::from),
202                pk_env_var,
203            )
204            .map_err(|_| {
205                Error::bad_request(format!("{pk_env_var} environment variable is not set"))
206            })?,
207        );
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
266/// Normalize EVM address to lowercase hex format
267pub fn normalize_address(addr: &str) -> Result<String> {
268    let addr = addr.trim();
269    let hex_part = addr
270        .strip_prefix("0x")
271        .or_else(|| addr.strip_prefix("0X"))
272        .unwrap_or(addr);
273
274    if hex_part.len() != 40 {
275        return Err(Error::bad_request(
276            "Address must be 20 bytes (40 hex chars)",
277        ));
278    }
279
280    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
281        return Err(Error::bad_request("Address must be valid hex"));
282    }
283
284    Ok(format!("0x{}", hex_part.to_lowercase()))
285}
286
287#[cfg(test)]
288mod tests {
289    use rstest::rstest;
290
291    use super::*;
292
293    const TEST_PRIVATE_KEY: &str =
294        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
295    const TEST_VAULT_ADDRESS: &str = "0x1234567890123456789012345678901234567890";
296
297    #[rstest]
298    fn test_evm_private_key_creation() {
299        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
300        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
301        assert_eq!(key.as_bytes().len(), 32);
302    }
303
304    #[rstest]
305    fn test_evm_private_key_without_0x_prefix() {
306        let key_without_prefix = &TEST_PRIVATE_KEY[2..]; // Remove 0x
307        let key = EvmPrivateKey::new(key_without_prefix).unwrap();
308        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
309    }
310
311    #[rstest]
312    fn test_evm_private_key_invalid_length() {
313        let result = EvmPrivateKey::new("0x123");
314        assert!(result.is_err());
315    }
316
317    #[rstest]
318    fn test_evm_private_key_invalid_hex() {
319        let result = EvmPrivateKey::new(
320            "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
321        );
322        assert!(result.is_err());
323    }
324
325    #[rstest]
326    fn test_evm_private_key_debug_redacts() {
327        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
328        let debug_str = format!("{key:?}");
329        assert_eq!(debug_str, format!("EvmPrivateKey({REDACTED})"));
330        assert!(!debug_str.contains("1234"));
331    }
332
333    #[rstest]
334    fn test_vault_address_creation() {
335        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
336        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
337        assert_eq!(addr.as_bytes().len(), 20);
338    }
339
340    #[rstest]
341    fn test_vault_address_without_0x_prefix() {
342        let addr_without_prefix = &TEST_VAULT_ADDRESS[2..]; // Remove 0x
343        let addr = VaultAddress::parse(addr_without_prefix).unwrap();
344        assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
345    }
346
347    #[rstest]
348    fn test_vault_address_debug_redacts_middle() {
349        let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
350        let debug_str = format!("{addr:?}");
351        assert!(debug_str.starts_with("VaultAddress(0x1234"));
352        assert!(debug_str.ends_with("7890)"));
353        assert!(debug_str.contains("..."));
354    }
355
356    #[rstest]
357    fn test_normalize_address() {
358        let test_cases = [
359            (
360                TEST_VAULT_ADDRESS,
361                "0x1234567890123456789012345678901234567890",
362            ),
363            (
364                "1234567890123456789012345678901234567890",
365                "0x1234567890123456789012345678901234567890",
366            ),
367            (
368                "0X1234567890123456789012345678901234567890",
369                "0x1234567890123456789012345678901234567890",
370            ),
371        ];
372
373        for (input, expected) in test_cases {
374            assert_eq!(normalize_address(input).unwrap(), expected);
375        }
376    }
377}