nautilus_hyperliquid/common/
credential.rs1use std::{env, fmt, fs, path::Path};
17
18use serde::Deserialize;
19use zeroize::{Zeroize, ZeroizeOnDrop};
20
21use crate::{
22 common::consts::HyperliquidNetwork,
23 http::error::{Error, Result},
24};
25
26#[derive(Clone, ZeroizeOnDrop)]
28pub struct EvmPrivateKey {
29 #[zeroize(skip)]
30 formatted_key: String, raw_bytes: Vec<u8>, }
33
34impl EvmPrivateKey {
35 pub fn new(key: String) -> Result<Self> {
37 let key = key.trim().to_string();
38 let hex_key = key.strip_prefix("0x").unwrap_or(&key);
39
40 if hex_key.len() != 64 {
42 return Err(Error::bad_request(
43 "EVM private key must be 32 bytes (64 hex chars)",
44 ));
45 }
46
47 if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
48 return Err(Error::bad_request("EVM private key must be valid hex"));
49 }
50
51 let normalized = hex_key.to_lowercase();
53 let formatted = format!("0x{}", normalized);
54
55 let raw_bytes = hex::decode(&normalized)
57 .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
58
59 if raw_bytes.len() != 32 {
60 return Err(Error::bad_request(
61 "EVM private key must be exactly 32 bytes",
62 ));
63 }
64
65 Ok(Self {
66 formatted_key: formatted,
67 raw_bytes,
68 })
69 }
70
71 pub fn as_hex(&self) -> &str {
73 &self.formatted_key
74 }
75
76 pub fn as_bytes(&self) -> &[u8] {
78 &self.raw_bytes
79 }
80}
81
82impl fmt::Debug for EvmPrivateKey {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.write_str("EvmPrivateKey(***redacted***)")
85 }
86}
87
88impl fmt::Display for EvmPrivateKey {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str("EvmPrivateKey(***redacted***)")
91 }
92}
93
94#[derive(Clone, Copy)]
96pub struct VaultAddress {
97 bytes: [u8; 20],
98}
99
100impl VaultAddress {
101 pub fn parse(s: &str) -> Result<Self> {
103 let s = s.trim();
104 let hex_part = s.strip_prefix("0x").unwrap_or(s);
105
106 if hex_part.len() != 40 {
107 return Err(Error::bad_request(
108 "Vault address must be 20 bytes (40 hex chars)",
109 ));
110 }
111
112 let bytes = hex::decode(hex_part)
113 .map_err(|_| Error::bad_request("Invalid hex in vault address"))?;
114
115 if bytes.len() != 20 {
116 return Err(Error::bad_request("Vault address must be exactly 20 bytes"));
117 }
118
119 let mut addr_bytes = [0u8; 20];
120 addr_bytes.copy_from_slice(&bytes);
121
122 Ok(Self { bytes: addr_bytes })
123 }
124
125 pub fn to_hex(&self) -> String {
127 format!("0x{}", hex::encode(self.bytes))
128 }
129
130 pub fn as_bytes(&self) -> &[u8; 20] {
132 &self.bytes
133 }
134}
135
136impl fmt::Debug for VaultAddress {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 let hex = self.to_hex();
139 write!(f, "VaultAddress({}...{})", &hex[..6], &hex[hex.len() - 4..])
140 }
141}
142
143impl fmt::Display for VaultAddress {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 write!(f, "{}", self.to_hex())
146 }
147}
148
149#[derive(Clone)]
151pub struct Secrets {
152 pub private_key: EvmPrivateKey,
153 pub vault_address: Option<VaultAddress>,
154 pub network: HyperliquidNetwork,
155}
156
157impl fmt::Debug for Secrets {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 f.debug_struct("Secrets")
160 .field("private_key", &self.private_key)
161 .field("vault_address", &self.vault_address)
162 .field("network", &self.network)
163 .finish()
164 }
165}
166
167impl Secrets {
168 pub fn from_env() -> Result<Self> {
175 let private_key_str = env::var("HYPERLIQUID_PK")
176 .map_err(|_| Error::bad_request("HYPERLIQUID_PK environment variable not set"))?;
177
178 let private_key = EvmPrivateKey::new(private_key_str)?;
179
180 let vault_address = match env::var("HYPERLIQUID_VAULT") {
181 Ok(addr_str) if !addr_str.trim().is_empty() => Some(VaultAddress::parse(&addr_str)?),
182 _ => None,
183 };
184
185 let network = HyperliquidNetwork::from_env();
186
187 Ok(Self {
188 private_key,
189 vault_address,
190 network,
191 })
192 }
193
194 pub fn from_file(path: &Path) -> Result<Self> {
205 let mut content = fs::read_to_string(path).map_err(Error::Io)?;
206
207 let result = Self::from_json(&content);
208
209 content.zeroize();
211
212 result
213 }
214
215 pub fn from_json(json: &str) -> Result<Self> {
217 #[derive(Deserialize)]
218 #[serde(rename_all = "camelCase")]
219 struct RawSecrets {
220 private_key: String,
221 #[serde(default)]
222 vault_address: Option<String>,
223 #[serde(default)]
224 network: Option<String>,
225 }
226
227 let raw: RawSecrets = serde_json::from_str(json)
228 .map_err(|e| Error::bad_request(format!("Invalid JSON: {}", e)))?;
229
230 let private_key = EvmPrivateKey::new(raw.private_key)?;
231
232 let vault_address = match raw.vault_address {
233 Some(addr) => Some(VaultAddress::parse(&addr)?),
234 None => None,
235 };
236
237 let network = match raw.network.as_deref() {
238 Some("testnet") | Some("test") => HyperliquidNetwork::Testnet,
239 _ => HyperliquidNetwork::Mainnet,
240 };
241
242 Ok(Self {
243 private_key,
244 vault_address,
245 network,
246 })
247 }
248}
249
250pub fn normalize_address(addr: &str) -> Result<String> {
252 let addr = addr.trim();
253 let hex_part = addr
254 .strip_prefix("0x")
255 .or_else(|| addr.strip_prefix("0X"))
256 .unwrap_or(addr);
257
258 if hex_part.len() != 40 {
259 return Err(Error::bad_request(
260 "Address must be 20 bytes (40 hex chars)",
261 ));
262 }
263
264 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
265 return Err(Error::bad_request("Address must be valid hex"));
266 }
267
268 Ok(format!("0x{}", hex_part.to_lowercase()))
269}
270
271#[cfg(test)]
276mod tests {
277 use rstest::rstest;
278
279 use super::*;
280
281 const TEST_PRIVATE_KEY: &str =
282 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
283 const TEST_VAULT_ADDRESS: &str = "0x1234567890123456789012345678901234567890";
284
285 #[rstest]
286 fn test_evm_private_key_creation() {
287 let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
288 assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
289 assert_eq!(key.as_bytes().len(), 32);
290 }
291
292 #[rstest]
293 fn test_evm_private_key_without_0x_prefix() {
294 let key_without_prefix = &TEST_PRIVATE_KEY[2..]; let key = EvmPrivateKey::new(key_without_prefix.to_string()).unwrap();
296 assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
297 }
298
299 #[rstest]
300 fn test_evm_private_key_invalid_length() {
301 let result = EvmPrivateKey::new("0x123".to_string());
302 assert!(result.is_err());
303 }
304
305 #[rstest]
306 fn test_evm_private_key_invalid_hex() {
307 let result = EvmPrivateKey::new(
308 "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
309 );
310 assert!(result.is_err());
311 }
312
313 #[rstest]
314 fn test_evm_private_key_debug_redacts() {
315 let key = EvmPrivateKey::new(TEST_PRIVATE_KEY.to_string()).unwrap();
316 let debug_str = format!("{:?}", key);
317 assert_eq!(debug_str, "EvmPrivateKey(***redacted***)");
318 assert!(!debug_str.contains("1234"));
319 }
320
321 #[rstest]
322 fn test_vault_address_creation() {
323 let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
324 assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
325 assert_eq!(addr.as_bytes().len(), 20);
326 }
327
328 #[rstest]
329 fn test_vault_address_without_0x_prefix() {
330 let addr_without_prefix = &TEST_VAULT_ADDRESS[2..]; let addr = VaultAddress::parse(addr_without_prefix).unwrap();
332 assert_eq!(addr.to_hex(), TEST_VAULT_ADDRESS);
333 }
334
335 #[rstest]
336 fn test_vault_address_debug_redacts_middle() {
337 let addr = VaultAddress::parse(TEST_VAULT_ADDRESS).unwrap();
338 let debug_str = format!("{:?}", addr);
339 assert!(debug_str.starts_with("VaultAddress(0x1234"));
340 assert!(debug_str.ends_with("7890)"));
341 assert!(debug_str.contains("..."));
342 }
343
344 #[rstest]
345 fn test_secrets_from_json() {
346 let json = format!(
347 r#"{{
348 "privateKey": "{}",
349 "vaultAddress": "{}",
350 "network": "testnet"
351 }}"#,
352 TEST_PRIVATE_KEY, TEST_VAULT_ADDRESS
353 );
354
355 let secrets = Secrets::from_json(&json).unwrap();
356 assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
357 assert!(secrets.vault_address.is_some());
358 assert_eq!(secrets.vault_address.unwrap().to_hex(), TEST_VAULT_ADDRESS);
359 assert_eq!(secrets.network, HyperliquidNetwork::Testnet);
360 }
361
362 #[rstest]
363 fn test_secrets_from_json_minimal() {
364 let json = format!(
365 r#"{{
366 "privateKey": "{}"
367 }}"#,
368 TEST_PRIVATE_KEY
369 );
370
371 let secrets = Secrets::from_json(&json).unwrap();
372 assert_eq!(secrets.private_key.as_hex(), TEST_PRIVATE_KEY);
373 assert!(secrets.vault_address.is_none());
374 assert_eq!(secrets.network, HyperliquidNetwork::Mainnet);
375 }
376
377 #[rstest]
378 fn test_normalize_address() {
379 let test_cases = [
380 (
381 TEST_VAULT_ADDRESS,
382 "0x1234567890123456789012345678901234567890",
383 ),
384 (
385 "1234567890123456789012345678901234567890",
386 "0x1234567890123456789012345678901234567890",
387 ),
388 (
389 "0X1234567890123456789012345678901234567890",
390 "0x1234567890123456789012345678901234567890",
391 ),
392 ];
393
394 for (input, expected) in test_cases {
395 assert_eq!(normalize_address(input).unwrap(), expected);
396 }
397 }
398
399 #[rstest]
400 #[ignore] fn test_secrets_from_env() {
402 match Secrets::from_env() {
409 Err(e) => {
410 assert!(
411 e.to_string().contains("HYPERLIQUID_PK missing")
412 || e.to_string().contains("invalid EVM private key")
413 );
414 }
415 Ok(_) => {
416 }
418 }
419 }
420}