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