ccxt-exchanges 0.1.5

Exchange implementations for CCXT Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! HyperLiquid authentication module.
//!
//! Implements EIP-712 typed data signing for HyperLiquid API authentication.
//! Unlike centralized exchanges that use HMAC-SHA256, HyperLiquid uses
//! Ethereum's secp256k1 signatures with EIP-712 typed data.

use ccxt_core::credentials::SecretBytes;
use ccxt_core::error::{Error, Result};

/// HyperLiquid EIP-712 authenticator.
///
/// Handles wallet address derivation and EIP-712 typed data signing
/// for authenticated API requests.
///
/// The private key is automatically zeroed from memory when dropped.
#[derive(Debug, Clone)]
pub struct HyperLiquidAuth {
    /// The 32-byte private key (automatically zeroed on drop).
    private_key: SecretBytes,
    /// The derived wallet address (checksummed).
    wallet_address: String,
}

impl HyperLiquidAuth {
    /// Creates a new HyperLiquidAuth from a hex-encoded private key.
    ///
    /// # Arguments
    ///
    /// * `private_key_hex` - The private key as a hex string (with or without "0x" prefix).
    ///
    /// # Returns
    ///
    /// A new `HyperLiquidAuth` instance with the derived wallet address.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The private key is not valid hex
    /// - The private key is not exactly 32 bytes
    /// - The private key is not a valid secp256k1 scalar
    ///
    /// # Security
    ///
    /// The private key is automatically zeroed from memory when the authenticator is dropped.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use ccxt_exchanges::hyperliquid::HyperLiquidAuth;
    ///
    /// let auth = HyperLiquidAuth::from_private_key(
    ///     "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
    /// ).unwrap();
    /// println!("Wallet: {}", auth.wallet_address());
    /// ```
    pub fn from_private_key(private_key_hex: &str) -> Result<Self> {
        // Remove 0x prefix if present
        let hex_str = private_key_hex
            .strip_prefix("0x")
            .or_else(|| private_key_hex.strip_prefix("0X"))
            .unwrap_or(private_key_hex);

        // Decode hex
        let bytes = hex::decode(hex_str)
            .map_err(|e| Error::invalid_argument(format!("Invalid private key hex: {}", e)))?;

        // Validate length
        if bytes.len() != 32 {
            return Err(Error::invalid_argument(format!(
                "Private key must be 32 bytes, got {}",
                bytes.len()
            )));
        }

        // Convert to fixed-size array for address derivation
        let mut private_key_array = [0u8; 32];
        private_key_array.copy_from_slice(&bytes);

        // Derive wallet address using k256
        let wallet_address = derive_address(&private_key_array)?;

        // Store as SecretBytes for automatic zeroization
        let private_key = SecretBytes::from_array(private_key_array);

        // Zero the temporary array
        private_key_array.fill(0);

        Ok(Self {
            private_key,
            wallet_address,
        })
    }

    /// Returns the wallet address (checksummed).
    pub fn wallet_address(&self) -> &str {
        &self.wallet_address
    }

    /// Returns the private key bytes.
    ///
    /// # Security
    ///
    /// This method exposes the private key. Use with caution.
    /// The returned reference should not be stored.
    pub fn private_key_bytes(&self) -> &[u8] {
        self.private_key.expose_secret()
    }

    /// Signs an L1 action using EIP-712 typed data signing.
    ///
    /// # Arguments
    ///
    /// * `action` - The action data to sign (serialized as JSON).
    /// * `nonce` - The nonce (typically timestamp in milliseconds).
    /// * `is_mainnet` - Whether this is for mainnet (affects chain ID).
    ///
    /// # Returns
    ///
    /// The signature as (r, s, v) components.
    pub fn sign_l1_action(
        &self,
        action: &serde_json::Value,
        nonce: u64,
        is_mainnet: bool,
    ) -> Result<Eip712Signature> {
        // Build the typed data hash
        let typed_data_hash = build_typed_data_hash(action, nonce, is_mainnet)?;

        // Get private key as array for signing
        let key_bytes = self.private_key.expose_secret();
        let mut key_array = [0u8; 32];
        key_array.copy_from_slice(key_bytes);

        // Sign the hash
        let result = sign_hash(&key_array, &typed_data_hash);

        // Zero the temporary array
        key_array.fill(0);

        result
    }

    /// Signs a user agent connection request.
    ///
    /// # Arguments
    ///
    /// * `agent_address` - The agent's Ethereum address.
    ///
    /// # Returns
    ///
    /// The signature for agent authorization.
    pub fn sign_agent(&self, agent_address: &str) -> Result<Eip712Signature> {
        let message = format!("I authorize {} to trade on my behalf.", agent_address);

        // Get private key as array for signing
        let key_bytes = self.private_key.expose_secret();
        let mut key_array = [0u8; 32];
        key_array.copy_from_slice(key_bytes);

        // Sign as personal message
        let result = sign_personal_message(&key_array, &message);

        // Zero the temporary array
        key_array.fill(0);

        result
    }
}

/// EIP-712 signature components.
#[derive(Debug, Clone)]
pub struct Eip712Signature {
    /// R component (32 bytes hex).
    pub r: String,
    /// S component (32 bytes hex).
    pub s: String,
    /// V component (recovery id).
    pub v: u8,
}

impl Eip712Signature {
    /// Converts the signature to a hex string (r + s + v).
    pub fn to_hex(&self) -> String {
        format!("0x{}{}{:02x}", self.r, self.s, self.v)
    }
}

/// Derives an Ethereum address from a private key.
fn derive_address(private_key: &[u8; 32]) -> Result<String> {
    use k256::ecdsa::SigningKey;
    use sha3::{Digest, Keccak256};

    // Create signing key
    let signing_key = SigningKey::from_bytes(private_key.into())
        .map_err(|e| Error::invalid_argument(format!("Invalid private key: {}", e)))?;

    // Get public key
    let public_key = signing_key.verifying_key();
    let public_key_bytes = public_key.to_encoded_point(false);

    // Skip the 0x04 prefix (uncompressed point indicator)
    let public_key_data = &public_key_bytes.as_bytes()[1..];

    // Keccak256 hash of public key
    let mut hasher = Keccak256::new();
    hasher.update(public_key_data);
    let hash = hasher.finalize();

    // Take last 20 bytes as address
    let address_bytes = &hash[12..];
    let address = format!("0x{}", hex::encode(address_bytes));

    // Return checksummed address
    Ok(checksum_address(&address))
}

/// Applies EIP-55 checksum to an address.
fn checksum_address(address: &str) -> String {
    use sha3::{Digest, Keccak256};

    let addr = address.strip_prefix("0x").unwrap_or(address).to_lowercase();

    let mut hasher = Keccak256::new();
    hasher.update(addr.as_bytes());
    let hash = hasher.finalize();
    let hash_hex = hex::encode(hash);

    let mut checksummed = String::with_capacity(42);
    checksummed.push_str("0x");

    for (i, c) in addr.chars().enumerate() {
        if c.is_ascii_digit() {
            checksummed.push(c);
        } else {
            let hash_char = hash_hex.chars().nth(i).unwrap_or('0');
            let hash_val = hash_char.to_digit(16).unwrap_or(0);
            if hash_val >= 8 {
                checksummed.push(c.to_ascii_uppercase());
            } else {
                checksummed.push(c);
            }
        }
    }

    checksummed
}

/// Builds the EIP-712 typed data hash for an L1 action.
fn build_typed_data_hash(
    action: &serde_json::Value,
    nonce: u64,
    is_mainnet: bool,
) -> Result<[u8; 32]> {
    #![allow(unused_imports)]
    use sha3::{Digest, Keccak256};

    // HyperLiquid uses a specific domain
    let chain_id: u64 = if is_mainnet { 42161 } else { 421614 };

    // Domain separator
    let domain_type_hash = keccak256(
        b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
    );

    let name_hash = keccak256(b"HyperliquidSignTransaction");
    let version_hash = keccak256(b"1");
    let verifying_contract = [0u8; 20]; // Zero address

    let mut domain_data = Vec::new();
    domain_data.extend_from_slice(&domain_type_hash);
    domain_data.extend_from_slice(&name_hash);
    domain_data.extend_from_slice(&version_hash);
    domain_data.extend_from_slice(&pad_u256(chain_id));
    domain_data.extend_from_slice(&[0u8; 12]); // Padding for address
    domain_data.extend_from_slice(&verifying_contract);

    let domain_separator = keccak256(&domain_data);

    // Action type hash (simplified - actual implementation needs proper type encoding)
    let action_str = serde_json::to_string(action)
        .map_err(|e| Error::invalid_argument(format!("Failed to serialize action: {}", e)))?;

    // Build message hash
    let mut message_data = Vec::new();
    message_data.extend_from_slice(&keccak256(action_str.as_bytes()));
    message_data.extend_from_slice(&pad_u256(nonce));

    let message_hash = keccak256(&message_data);

    // Final hash: keccak256("\x19\x01" + domain_separator + message_hash)
    let mut final_data = Vec::new();
    final_data.push(0x19);
    final_data.push(0x01);
    final_data.extend_from_slice(&domain_separator);
    final_data.extend_from_slice(&message_hash);

    Ok(keccak256(&final_data))
}

/// Signs a hash with the private key.
fn sign_hash(private_key: &[u8; 32], hash: &[u8; 32]) -> Result<Eip712Signature> {
    use k256::ecdsa::{Signature, SigningKey, signature::Signer};

    let signing_key = SigningKey::from_bytes(private_key.into())
        .map_err(|e| Error::invalid_argument(format!("Invalid private key: {}", e)))?;

    let signature: Signature = signing_key.sign(hash);
    let sig_bytes = signature.to_bytes();

    // Split into r and s
    let r = hex::encode(&sig_bytes[..32]);
    let s = hex::encode(&sig_bytes[32..]);

    // Recovery ID (simplified - actual implementation needs proper recovery)
    let v = 27u8;

    Ok(Eip712Signature { r, s, v })
}

/// Signs a personal message (EIP-191).
fn sign_personal_message(private_key: &[u8; 32], message: &str) -> Result<Eip712Signature> {
    let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
    let mut data = prefix.into_bytes();
    data.extend_from_slice(message.as_bytes());

    let hash = keccak256(&data);
    sign_hash(private_key, &hash)
}

/// Computes Keccak256 hash.
fn keccak256(data: &[u8]) -> [u8; 32] {
    use sha3::{Digest, Keccak256};
    let mut hasher = Keccak256::new();
    hasher.update(data);
    hasher.finalize().into()
}

/// Pads a u64 to 32 bytes (big-endian).
fn pad_u256(value: u64) -> [u8; 32] {
    let mut result = [0u8; 32];
    result[24..].copy_from_slice(&value.to_be_bytes());
    result
}

/// Computes Keccak256 hash (standalone function for use in build_typed_data_hash).
#[allow(dead_code)]
fn keccak256_hash(data: &[u8]) -> [u8; 32] {
    use sha3::{Digest, Keccak256};
    let mut hasher = Keccak256::new();
    hasher.update(data);
    hasher.finalize().into()
}

#[cfg(test)]
mod tests {
    use super::*;

    // Test private key (DO NOT USE IN PRODUCTION)
    const TEST_PRIVATE_KEY: &str =
        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

    #[test]
    fn test_from_private_key_with_prefix() {
        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY);
        assert!(auth.is_ok());
        let auth = auth.unwrap();
        assert!(auth.wallet_address().starts_with("0x"));
        assert_eq!(auth.wallet_address().len(), 42);
    }

    #[test]
    fn test_from_private_key_without_prefix() {
        let key = TEST_PRIVATE_KEY.strip_prefix("0x").unwrap();
        let auth = HyperLiquidAuth::from_private_key(key);
        assert!(auth.is_ok());
    }

    #[test]
    fn test_invalid_private_key_length() {
        let result = HyperLiquidAuth::from_private_key("0x1234");
        assert!(result.is_err());
    }

    #[test]
    fn test_invalid_private_key_hex() {
        let result = HyperLiquidAuth::from_private_key("0xGGGG");
        assert!(result.is_err());
    }

    #[test]
    fn test_address_derivation_deterministic() {
        let auth1 = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
        let auth2 = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
        assert_eq!(auth1.wallet_address(), auth2.wallet_address());
    }

    #[test]
    fn test_checksum_address() {
        // Known checksummed address
        let addr = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
        let checksummed = checksum_address(addr);
        // Should have mixed case
        assert!(checksummed.chars().any(|c| c.is_uppercase()));
        assert!(
            checksummed
                .chars()
                .any(|c| c.is_lowercase() && c.is_alphabetic())
        );
    }

    #[test]
    fn test_signature_to_hex() {
        let sig = Eip712Signature {
            r: "a".repeat(64),
            s: "b".repeat(64),
            v: 27,
        };
        let hex = sig.to_hex();
        assert!(hex.starts_with("0x"));
        assert_eq!(hex.len(), 132); // 0x + 64 + 64 + 2
    }

    #[test]
    fn test_sign_l1_action() {
        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
        let action = serde_json::json!({"type": "order", "data": {}});
        let result = auth.sign_l1_action(&action, 1234567890, false);
        assert!(result.is_ok());

        let sig = result.unwrap();
        assert_eq!(sig.r.len(), 64);
        assert_eq!(sig.s.len(), 64);
    }

    #[test]
    fn test_sign_deterministic() {
        let auth = HyperLiquidAuth::from_private_key(TEST_PRIVATE_KEY).unwrap();
        let action = serde_json::json!({"type": "test"});

        let sig1 = auth.sign_l1_action(&action, 1000, false).unwrap();
        let sig2 = auth.sign_l1_action(&action, 1000, false).unwrap();

        assert_eq!(sig1.r, sig2.r);
        assert_eq!(sig1.s, sig2.s);
    }
}