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