nautilus_hyperliquid/common/
credential.rs1#![allow(unused_assignments)] use 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#[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#[derive(Clone, Zeroize, ZeroizeOnDrop)]
50pub struct EvmPrivateKey {
51 formatted_key: String,
52 raw_bytes: Vec<u8>,
53}
54
55impl EvmPrivateKey {
56 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 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 let normalized = hex_key.to_lowercase();
74 let formatted = format!("0x{normalized}");
75
76 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 pub fn as_hex(&self) -> &str {
94 &self.formatted_key
95 }
96
97 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#[derive(Clone, Copy)]
117pub struct VaultAddress {
118 bytes: [u8; 20],
119}
120
121impl VaultAddress {
122 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 pub fn to_hex(&self) -> String {
135 hex::encode_prefixed(self.bytes)
136 }
137
138 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#[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 #[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 #[must_use]
186 pub fn env_vars(environment: HyperliquidEnvironment) -> (&'static str, &'static str) {
187 credential_env_vars(environment)
188 }
189
190 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 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
238 Self::resolve(None, None, environment)
239 }
240
241 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 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 content.zeroize();
282
283 result
284 }
285
286 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
322pub 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..]; 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..]; 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}