nautilus_hyperliquid/common/
credential.rs1use std::{env, fmt, fs, path::Path};
17
18use serde::Deserialize;
19use zeroize::{Zeroize, ZeroizeOnDrop};
20
21use crate::http::error::{Error, Result};
22
23#[derive(Clone, ZeroizeOnDrop)]
25pub struct EvmPrivateKey {
26 #[zeroize(skip)]
27 formatted_key: String, #[zeroize(skip)] raw_bytes: Vec<u8>, }
31
32impl EvmPrivateKey {
33 pub fn new(key: String) -> Result<Self> {
35 let key = key.trim().to_string();
36 let hex_key = key.strip_prefix("0x").unwrap_or(&key);
37
38 if hex_key.len() != 64 {
40 return Err(Error::bad_request(
41 "EVM private key must be 32 bytes (64 hex chars)",
42 ));
43 }
44
45 if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
46 return Err(Error::bad_request("EVM private key must be valid hex"));
47 }
48
49 let normalized = hex_key.to_lowercase();
51 let formatted = format!("0x{}", normalized);
52
53 let raw_bytes = hex::decode(&normalized)
55 .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
56
57 if raw_bytes.len() != 32 {
58 return Err(Error::bad_request(
59 "EVM private key must be exactly 32 bytes",
60 ));
61 }
62
63 Ok(Self {
64 formatted_key: formatted,
65 raw_bytes,
66 })
67 }
68
69 pub fn as_hex(&self) -> &str {
71 &self.formatted_key
72 }
73
74 pub fn as_bytes(&self) -> &[u8] {
76 &self.raw_bytes
77 }
78}
79
80impl fmt::Debug for EvmPrivateKey {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str("EvmPrivateKey(***redacted***)")
83 }
84}
85
86impl fmt::Display for EvmPrivateKey {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.write_str("EvmPrivateKey(***redacted***)")
89 }
90}
91
92#[derive(Clone, Copy)]
94pub struct VaultAddress {
95 bytes: [u8; 20],
96}
97
98impl VaultAddress {
99 pub fn parse(s: &str) -> Result<Self> {
101 let s = s.trim();
102 let hex_part = s.strip_prefix("0x").unwrap_or(s);
103
104 if hex_part.len() != 40 {
105 return Err(Error::bad_request(
106 "Vault address must be 20 bytes (40 hex chars)",
107 ));
108 }
109
110 let bytes = hex::decode(hex_part)
111 .map_err(|_| Error::bad_request("Invalid hex in vault address"))?;
112
113 if bytes.len() != 20 {
114 return Err(Error::bad_request("Vault address must be exactly 20 bytes"));
115 }
116
117 let mut addr_bytes = [0u8; 20];
118 addr_bytes.copy_from_slice(&bytes);
119
120 Ok(Self { bytes: addr_bytes })
121 }
122
123 pub fn to_hex(&self) -> String {
125 format!("0x{}", hex::encode(self.bytes))
126 }
127
128 pub fn as_bytes(&self) -> &[u8; 20] {
130 &self.bytes
131 }
132}
133
134impl fmt::Debug for VaultAddress {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 let hex = self.to_hex();
137 write!(f, "VaultAddress({}...{})", &hex[..6], &hex[hex.len() - 4..])
138 }
139}
140
141impl fmt::Display for VaultAddress {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(f, "{}", self.to_hex())
144 }
145}
146
147#[derive(Clone)]
149pub struct Secrets {
150 pub private_key: EvmPrivateKey,
151 pub vault_address: Option<VaultAddress>,
152 pub is_testnet: bool,
153}
154
155impl fmt::Debug for Secrets {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 f.debug_struct(stringify!(Secrets))
158 .field("private_key", &self.private_key)
159 .field("vault_address", &self.vault_address)
160 .field("is_testnet", &self.is_testnet)
161 .finish()
162 }
163}
164
165impl Secrets {
166 pub fn from_env() -> Result<Self> {
176 let (private_key_str, vault_env_var, is_testnet) =
178 if let Ok(testnet_pk) = env::var("HYPERLIQUID_TESTNET_PK") {
179 (testnet_pk, "HYPERLIQUID_TESTNET_VAULT", true)
180 } else if let Ok(mainnet_pk) = env::var("HYPERLIQUID_PK") {
181 (mainnet_pk, "HYPERLIQUID_VAULT", false)
182 } else {
183 return Err(Error::bad_request(
184 "Neither HYPERLIQUID_PK nor HYPERLIQUID_TESTNET_PK environment variable is set",
185 ));
186 };
187
188 let private_key = EvmPrivateKey::new(private_key_str)?;
189
190 let vault_address = match env::var(vault_env_var) {
191 Ok(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(&addr_str)?),
192 _ => None,
193 };
194
195 Ok(Self {
196 private_key,
197 vault_address,
198 is_testnet,
199 })
200 }
201
202 pub fn from_private_key(
213 private_key_str: &str,
214 vault_address_str: Option<&str>,
215 is_testnet: bool,
216 ) -> Result<Self> {
217 let private_key = EvmPrivateKey::new(private_key_str.to_string())?;
218
219 let vault_address = match vault_address_str {
220 Some(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(addr_str)?),
221 _ => None,
222 };
223
224 Ok(Self {
225 private_key,
226 vault_address,
227 is_testnet,
228 })
229 }
230
231 pub fn from_file(path: &Path) -> Result<Self> {
242 let mut content = fs::read_to_string(path).map_err(Error::Io)?;
243
244 let result = Self::from_json(&content);
245
246 content.zeroize();
248
249 result
250 }
251
252 pub fn from_json(json: &str) -> Result<Self> {
254 #[derive(Deserialize)]
255 #[serde(rename_all = "camelCase")]
256 struct RawSecrets {
257 private_key: String,
258 #[serde(default)]
259 vault_address: Option<String>,
260 #[serde(default)]
261 network: Option<String>,
262 }
263
264 let raw: RawSecrets = serde_json::from_str(json)
265 .map_err(|e| Error::bad_request(format!("Invalid JSON: {}", e)))?;
266
267 let private_key = EvmPrivateKey::new(raw.private_key)?;
268
269 let vault_address = match raw.vault_address {
270 Some(addr) => Some(VaultAddress::parse(&addr)?),
271 None => None,
272 };
273
274 let is_testnet = matches!(raw.network.as_deref(), Some("testnet" | "test"));
275
276 Ok(Self {
277 private_key,
278 vault_address,
279 is_testnet,
280 })
281 }
282}
283
284pub fn normalize_address(addr: &str) -> Result<String> {
286 let addr = addr.trim();
287 let hex_part = addr
288 .strip_prefix("0x")
289 .or_else(|| addr.strip_prefix("0X"))
290 .unwrap_or(addr);
291
292 if hex_part.len() != 40 {
293 return Err(Error::bad_request(
294 "Address must be 20 bytes (40 hex chars)",
295 ));
296 }
297
298 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
299 return Err(Error::bad_request("Address must be valid hex"));
300 }
301
302 Ok(format!("0x{}", hex_part.to_lowercase()))
303}
304
305#[cfg(test)]
310mod tests {
311 use rstest::rstest;
312
313 use super::*;
314
315 const TEST_PRIVATE_KEY: &str =
316 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
317 const TEST_VAULT_ADDRESS: &str = "0x1234567890123456789012345678901234567890";
318
319 #[rstest]
320 fn test_evm_private_key_creation() {
321 let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
322 assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
323 assert_eq!(key.as_bytes().len(), 32);
324 }
325
326 #[rstest]
327 fn test_evm_private_key_without_0x_prefix() {
328 let key_without_prefix = &TEST_PRIVATE_KEY[2..]; let key = EvmPrivateKey::new(key_without_prefix.to_string()).unwrap();
330 assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
331 }
332
333 #[rstest]
334 fn test_evm_private_key_invalid_length() {
335 let result = EvmPrivateKey::new("0x123".to_string());
336 assert!(result.is_err());
337 }
338
339 #[rstest]
340 fn test_evm_private_key_invalid_hex() {
341 let result = EvmPrivateKey::new(
342 "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
343 );
344 assert!(result.is_err());
345 }
346
347 #[rstest]
348 fn test_evm_private_key_debug_redacts() {
349 let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
350 let debug_str = format!("{:?}", key);
351 assert_eq!(debug_str, "EvmPrivateKey(***redacted***)");
352 assert!(!debug_str.contains("1234"));
353 }
354
355 #[rstest]
356 fn test_vault_address_creation() {
357 let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
358 assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
359 assert_eq!(addr.as_bytes().len(), 20);
360 }
361
362 #[rstest]
363 fn test_vault_address_without_0x_prefix() {
364 let addr_without_prefix = &TEST_VAULT_ADDRESS[2..]; let addr = VaultAddress::parse(addr_without_prefix).unwrap();
366 assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
367 }
368
369 #[rstest]
370 fn test_vault_address_debug_redacts_middle() {
371 let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
372 let debug_str = format!("{:?}", addr);
373 assert!(debug_str.starts_with("VaultAddress(0x1234"));
374 assert!(debug_str.ends_with("7890)"));
375 assert!(debug_str.contains("..."));
376 }
377
378 #[rstest]
379 fn test_secrets_from_json() {
380 let json = format!(
381 r#"{{
382 "privateKey": "{}",
383 "vaultAddress": "{}",
384 "network": "testnet"
385 }}"#,
386 TEST_PRIVATE_KEY, TEST_VAULT_ADDRESS
387 );
388
389 let secrets = Secrets::from_json(&json).unwrap();
390 assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
391 assert!(secrets.vault_address.is_some());
392 assert_eq!(secrets.vault_address.unwrap().to_hex(), TEST_VAULT_ADDRESS);
393 assert!(secrets.is_testnet);
394 }
395
396 #[rstest]
397 fn test_secrets_from_json_minimal() {
398 let json = format!(
399 r#"{{
400 "privateKey": "{}"
401 }}"#,
402 TEST_PRIVATE_KEY
403 );
404
405 let secrets = Secrets::from_json(&json).unwrap();
406 assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
407 assert!(secrets.vault_address.is_none());
408 assert!(!secrets.is_testnet);
409 }
410
411 #[rstest]
412 fn test_normalize_address() {
413 let test_cases = [
414 (
415 TEST_VAULT_ADDRESS,
416 "0x1234567890123456789012345678901234567890",
417 ),
418 (
419 "1234567890123456789012345678901234567890",
420 "0x1234567890123456789012345678901234567890",
421 ),
422 (
423 "0X1234567890123456789012345678901234567890",
424 "0x1234567890123456789012345678901234567890",
425 ),
426 ];
427
428 for (input, expected) in test_cases {
429 assert_eq!(normalize_address(input).unwrap(), expected);
430 }
431 }
432
433 #[rstest]
434 #[ignore = "This test modifies environment variables - run manually if needed"]
435 fn test_secrets_from_env() {
436 match Secrets::from_env() {
443 Err(e) => {
444 assert!(
445 e.to_string().contains("HYPERLIQUID_PK")
446 || e.to_string().contains("environment variable not set")
447 );
448 }
449 Ok(_) => {
450 }
452 }
453 }
454}