ccxt_exchanges/hyperliquid/
auth.rs

1//! HyperLiquid authentication module.
2//!
3//! Implements EIP-712 typed data signing for HyperLiquid API authentication.
4//! Unlike centralized exchanges that use HMAC-SHA256, HyperLiquid uses
5//! Ethereum's secp256k1 signatures with EIP-712 typed data.
6
7use ccxt_core::credentials::SecretBytes;
8use ccxt_core::error::{Error, Result};
9
10/// HyperLiquid EIP-712 authenticator.
11///
12/// Handles wallet address derivation and EIP-712 typed data signing
13/// for authenticated API requests.
14///
15/// The private key is automatically zeroed from memory when dropped.
16#[derive(Debug, Clone)]
17pub struct HyperLiquidAuth {
18    /// The 32-byte private key (automatically zeroed on drop).
19    private_key: SecretBytes,
20    /// The derived wallet address (checksummed).
21    wallet_address: String,
22}
23
24impl HyperLiquidAuth {
25    /// Creates a new HyperLiquidAuth from a hex-encoded private key.
26    ///
27    /// # Arguments
28    ///
29    /// * `private_key_hex` - The private key as a hex string (with or without "0x" prefix).
30    ///
31    /// # Returns
32    ///
33    /// A new `HyperLiquidAuth` instance with the derived wallet address.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if:
38    /// - The private key is not valid hex
39    /// - The private key is not exactly 32 bytes
40    /// - The private key is not a valid secp256k1 scalar
41    ///
42    /// # Security
43    ///
44    /// The private key is automatically zeroed from memory when the authenticator is dropped.
45    ///
46    /// # Example
47    ///
48    /// ```no_run
49    /// use ccxt_exchanges::hyperliquid::HyperLiquidAuth;
50    ///
51    /// let auth = HyperLiquidAuth::from_private_key(
52    ///     "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
53    /// ).unwrap();
54    /// println!("Wallet: {}", auth.wallet_address());
55    /// ```
56    pub fn from_private_key(private_key_hex: &str) -> Result<Self> {
57        // Remove 0x prefix if present
58        let hex_str = private_key_hex
59            .strip_prefix("0x")
60            .or_else(|| private_key_hex.strip_prefix("0X"))
61            .unwrap_or(private_key_hex);
62
63        // Decode hex
64        let bytes = hex::decode(hex_str)
65            .map_err(|e| Error::invalid_argument(format!("Invalid private key hex: {}", e)))?;
66
67        // Validate length
68        if bytes.len() != 32 {
69            return Err(Error::invalid_argument(format!(
70                "Private key must be 32 bytes, got {}",
71                bytes.len()
72            )));
73        }
74
75        // Convert to fixed-size array for address derivation
76        let mut private_key_array = [0u8; 32];
77        private_key_array.copy_from_slice(&bytes);
78
79        // Derive wallet address using k256
80        let wallet_address = derive_address(&private_key_array)?;
81
82        // Store as SecretBytes for automatic zeroization
83        let private_key = SecretBytes::from_array(private_key_array);
84
85        // Zero the temporary array
86        private_key_array.fill(0);
87
88        Ok(Self {
89            private_key,
90            wallet_address,
91        })
92    }
93
94    /// Returns the wallet address (checksummed).
95    pub fn wallet_address(&self) -> &str {
96        &self.wallet_address
97    }
98
99    /// Returns the private key bytes.
100    ///
101    /// # Security
102    ///
103    /// This method exposes the private key. Use with caution.
104    /// The returned reference should not be stored.
105    pub fn private_key_bytes(&self) -> &[u8] {
106        self.private_key.expose_secret()
107    }
108
109    /// Signs an L1 action using EIP-712 typed data signing.
110    ///
111    /// # Arguments
112    ///
113    /// * `action` - The action data to sign (serialized as JSON).
114    /// * `nonce` - The nonce (typically timestamp in milliseconds).
115    /// * `is_mainnet` - Whether this is for mainnet (affects chain ID).
116    ///
117    /// # Returns
118    ///
119    /// The signature as (r, s, v) components.
120    pub fn sign_l1_action(
121        &self,
122        action: &serde_json::Value,
123        nonce: u64,
124        is_mainnet: bool,
125    ) -> Result<Eip712Signature> {
126        // Build the typed data hash
127        let typed_data_hash = build_typed_data_hash(action, nonce, is_mainnet)?;
128
129        // Get private key as array for signing
130        let key_bytes = self.private_key.expose_secret();
131        let mut key_array = [0u8; 32];
132        key_array.copy_from_slice(key_bytes);
133
134        // Sign the hash
135        let result = sign_hash(&key_array, &typed_data_hash);
136
137        // Zero the temporary array
138        key_array.fill(0);
139
140        result
141    }
142
143    /// Signs a user agent connection request.
144    ///
145    /// # Arguments
146    ///
147    /// * `agent_address` - The agent's Ethereum address.
148    ///
149    /// # Returns
150    ///
151    /// The signature for agent authorization.
152    pub fn sign_agent(&self, agent_address: &str) -> Result<Eip712Signature> {
153        let message = format!("I authorize {} to trade on my behalf.", agent_address);
154
155        // Get private key as array for signing
156        let key_bytes = self.private_key.expose_secret();
157        let mut key_array = [0u8; 32];
158        key_array.copy_from_slice(key_bytes);
159
160        // Sign as personal message
161        let result = sign_personal_message(&key_array, &message);
162
163        // Zero the temporary array
164        key_array.fill(0);
165
166        result
167    }
168}
169
170/// EIP-712 signature components.
171#[derive(Debug, Clone)]
172pub struct Eip712Signature {
173    /// R component (32 bytes hex).
174    pub r: String,
175    /// S component (32 bytes hex).
176    pub s: String,
177    /// V component (recovery id).
178    pub v: u8,
179}
180
181impl Eip712Signature {
182    /// Converts the signature to a hex string (r + s + v).
183    pub fn to_hex(&self) -> String {
184        format!("0x{}{}{:02x}", self.r, self.s, self.v)
185    }
186}
187
188/// Derives an Ethereum address from a private key.
189fn derive_address(private_key: &[u8; 32]) -> Result<String> {
190    use k256::ecdsa::SigningKey;
191    use sha3::{Digest, Keccak256};
192
193    // Create signing key
194    let signing_key = SigningKey::from_bytes(private_key.into())
195        .map_err(|e| Error::invalid_argument(format!("Invalid private key: {}", e)))?;
196
197    // Get public key
198    let public_key = signing_key.verifying_key();
199    let public_key_bytes = public_key.to_encoded_point(false);
200
201    // Skip the 0x04 prefix (uncompressed point indicator)
202    let public_key_data = &public_key_bytes.as_bytes()[1..];
203
204    // Keccak256 hash of public key
205    let mut hasher = Keccak256::new();
206    hasher.update(public_key_data);
207    let hash = hasher.finalize();
208
209    // Take last 20 bytes as address
210    let address_bytes = &hash[12..];
211    let address = format!("0x{}", hex::encode(address_bytes));
212
213    // Return checksummed address
214    Ok(checksum_address(&address))
215}
216
217/// Applies EIP-55 checksum to an address.
218fn checksum_address(address: &str) -> String {
219    use sha3::{Digest, Keccak256};
220
221    let addr = address.strip_prefix("0x").unwrap_or(address).to_lowercase();
222
223    let mut hasher = Keccak256::new();
224    hasher.update(addr.as_bytes());
225    let hash = hasher.finalize();
226    let hash_hex = hex::encode(hash);
227
228    let mut checksummed = String::with_capacity(42);
229    checksummed.push_str("0x");
230
231    for (i, c) in addr.chars().enumerate() {
232        if c.is_ascii_digit() {
233            checksummed.push(c);
234        } else {
235            let hash_char = hash_hex.chars().nth(i).unwrap_or('0');
236            let hash_val = hash_char.to_digit(16).unwrap_or(0);
237            if hash_val >= 8 {
238                checksummed.push(c.to_ascii_uppercase());
239            } else {
240                checksummed.push(c);
241            }
242        }
243    }
244
245    checksummed
246}
247
248/// Builds the EIP-712 typed data hash for an L1 action.
249fn build_typed_data_hash(
250    action: &serde_json::Value,
251    nonce: u64,
252    is_mainnet: bool,
253) -> Result<[u8; 32]> {
254    #![allow(unused_imports)]
255    use sha3::{Digest, Keccak256};
256
257    // HyperLiquid uses a specific domain
258    let chain_id: u64 = if is_mainnet { 42161 } else { 421614 };
259
260    // Domain separator
261    let domain_type_hash = keccak256(
262        b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
263    );
264
265    let name_hash = keccak256(b"HyperliquidSignTransaction");
266    let version_hash = keccak256(b"1");
267    let verifying_contract = [0u8; 20]; // Zero address
268
269    let mut domain_data = Vec::new();
270    domain_data.extend_from_slice(&domain_type_hash);
271    domain_data.extend_from_slice(&name_hash);
272    domain_data.extend_from_slice(&version_hash);
273    domain_data.extend_from_slice(&pad_u256(chain_id));
274    domain_data.extend_from_slice(&[0u8; 12]); // Padding for address
275    domain_data.extend_from_slice(&verifying_contract);
276
277    let domain_separator = keccak256(&domain_data);
278
279    // Action type hash (simplified - actual implementation needs proper type encoding)
280    let action_str = serde_json::to_string(action)
281        .map_err(|e| Error::invalid_argument(format!("Failed to serialize action: {}", e)))?;
282
283    // Build message hash
284    let mut message_data = Vec::new();
285    message_data.extend_from_slice(&keccak256(action_str.as_bytes()));
286    message_data.extend_from_slice(&pad_u256(nonce));
287
288    let message_hash = keccak256(&message_data);
289
290    // Final hash: keccak256("\x19\x01" + domain_separator + message_hash)
291    let mut final_data = Vec::new();
292    final_data.push(0x19);
293    final_data.push(0x01);
294    final_data.extend_from_slice(&domain_separator);
295    final_data.extend_from_slice(&message_hash);
296
297    Ok(keccak256(&final_data))
298}
299
300/// Signs a hash with the private key.
301fn sign_hash(private_key: &[u8; 32], hash: &[u8; 32]) -> Result<Eip712Signature> {
302    use k256::ecdsa::{Signature, SigningKey, signature::Signer};
303
304    let signing_key = SigningKey::from_bytes(private_key.into())
305        .map_err(|e| Error::invalid_argument(format!("Invalid private key: {}", e)))?;
306
307    let signature: Signature = signing_key.sign(hash);
308    let sig_bytes = signature.to_bytes();
309
310    // Split into r and s
311    let r = hex::encode(&sig_bytes[..32]);
312    let s = hex::encode(&sig_bytes[32..]);
313
314    // Recovery ID (simplified - actual implementation needs proper recovery)
315    let v = 27u8;
316
317    Ok(Eip712Signature { r, s, v })
318}
319
320/// Signs a personal message (EIP-191).
321fn sign_personal_message(private_key: &[u8; 32], message: &str) -> Result<Eip712Signature> {
322    let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
323    let mut data = prefix.into_bytes();
324    data.extend_from_slice(message.as_bytes());
325
326    let hash = keccak256(&data);
327    sign_hash(private_key, &hash)
328}
329
330/// Computes Keccak256 hash.
331fn keccak256(data: &[u8]) -> [u8; 32] {
332    use sha3::{Digest, Keccak256};
333    let mut hasher = Keccak256::new();
334    hasher.update(data);
335    hasher.finalize().into()
336}
337
338/// Pads a u64 to 32 bytes (big-endian).
339fn pad_u256(value: u64) -> [u8; 32] {
340    let mut result = [0u8; 32];
341    result[24..].copy_from_slice(&value.to_be_bytes());
342    result
343}
344
345/// Computes Keccak256 hash (standalone function for use in build_typed_data_hash).
346#[allow(dead_code)]
347fn keccak256_hash(data: &[u8]) -> [u8; 32] {
348    use sha3::{Digest, Keccak256};
349    let mut hasher = Keccak256::new();
350    hasher.update(data);
351    hasher.finalize().into()
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    // Test private key (DO NOT USE IN PRODUCTION)
359    const TEST_PRIVATE_KEY: &str =
360        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
361
362    #[test]
363    fn test_from_private_key_with_prefix() {
364        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY);
365        assert!(auth.is_ok());
366        let auth = auth.unwrap();
367        assert!(auth.wallet_address().starts_with("0x"));
368        assert_eq!(auth.wallet_address().len(), 42);
369    }
370
371    #[test]
372    fn test_from_private_key_without_prefix() {
373        let key = TEST_PRIVATE_KEY.strip_prefix("0x").unwrap();
374        let auth = HyperLiquidAuth::from_private_key(key);
375        assert!(auth.is_ok());
376    }
377
378    #[test]
379    fn test_invalid_private_key_length() {
380        let result = HyperLiquidAuth::from_private_key("0x1234");
381        assert!(result.is_err());
382    }
383
384    #[test]
385    fn test_invalid_private_key_hex() {
386        let result = HyperLiquidAuth::from_private_key("0xGGGG");
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_address_derivation_deterministic() {
392        let auth1 = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
393        let auth2 = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
394        assert_eq!(auth1.wallet_address(), auth2.wallet_address());
395    }
396
397    #[test]
398    fn test_checksum_address() {
399        // Known checksummed address
400        let addr = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
401        let checksummed = checksum_address(addr);
402        // Should have mixed case
403        assert!(checksummed.chars().any(|c| c.is_uppercase()));
404        assert!(
405            checksummed
406                .chars()
407                .any(|c| c.is_lowercase() && c.is_alphabetic())
408        );
409    }
410
411    #[test]
412    fn test_signature_to_hex() {
413        let sig = Eip712Signature {
414            r: "a".repeat(64),
415            s: "b".repeat(64),
416            v: 27,
417        };
418        let hex = sig.to_hex();
419        assert!(hex.starts_with("0x"));
420        assert_eq!(hex.len(), 132); // 0x + 64 + 64 + 2
421    }
422
423    #[test]
424    fn test_sign_l1_action() {
425        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
426        let action = serde_json::json!({"type": "order", "data": {}});
427        let result = auth.sign_l1_action(&action, 1234567890, false);
428        assert!(result.is_ok());
429
430        let sig = result.unwrap();
431        assert_eq!(sig.r.len(), 64);
432        assert_eq!(sig.s.len(), 64);
433    }
434
435    #[test]
436    fn test_sign_deterministic() {
437        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
438        let action = serde_json::json!({"type": "test"});
439
440        let sig1 = auth.sign_l1_action(&action, 1000, false).unwrap();
441        let sig2 = auth.sign_l1_action(&action, 1000, false).unwrap();
442
443        assert_eq!(sig1.r, sig2.r);
444        assert_eq!(sig1.s, sig2.s);
445    }
446}