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 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#[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#[derive(Clone, Zeroize, ZeroizeOnDrop)]
51pub struct EvmPrivateKey {
52 formatted_key: String,
53 raw_bytes: Vec<u8>,
54}
55
56impl EvmPrivateKey {
57 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 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 let normalized = Zeroizing::new(hex_key.to_lowercase());
75 let formatted = format!("0x{}", normalized.as_str());
76
77 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 pub fn as_hex(&self) -> &str {
95 &self.formatted_key
96 }
97
98 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#[derive(Clone, Copy)]
118pub struct VaultAddress {
119 bytes: [u8; 20],
120}
121
122impl VaultAddress {
123 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 pub fn to_hex(&self) -> String {
136 hex::encode_prefixed(self.bytes)
137 }
138
139 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#[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 #[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 #[must_use]
187 pub fn env_vars(environment: HyperliquidEnvironment) -> (&'static str, &'static str) {
188 credential_env_vars(environment)
189 }
190
191 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 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
243 Self::resolve(None, None, environment)
244 }
245
246 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 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 content.zeroize();
287
288 result
289 }
290
291 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
327pub 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..]; 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..]; 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}