nautilus_hyperliquid/signing/
signers.rs1use std::str::FromStr;
17
18use alloy_primitives::{Address, B256, keccak256};
19use alloy_signer::SignerSync;
20use alloy_signer_local::PrivateKeySigner;
21use alloy_sol_types::{SolStruct, eip712_domain};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use super::{nonce::TimeNonce, types::HyperliquidActionType};
26use crate::{
27 common::credential::EvmPrivateKey,
28 http::error::{Error, Result},
29};
30
31alloy_sol_types::sol! {
33 #[derive(Debug, Serialize, Deserialize)]
34 struct Agent {
35 string source;
36 bytes32 connectionId;
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct SignRequest {
43 pub action: Value, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
46 pub action_type: HyperliquidActionType,
47 pub is_testnet: bool,
48 pub vault_address: Option<String>,
49}
50
51#[derive(Debug, Clone)]
53pub struct SignatureBundle {
54 pub signature: String,
55}
56
57#[derive(Debug, Clone)]
59pub struct HyperliquidEip712Signer {
60 private_key: EvmPrivateKey,
61}
62
63impl HyperliquidEip712Signer {
64 pub fn new(private_key: EvmPrivateKey) -> Self {
65 Self { private_key }
66 }
67
68 pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
69 let signature = match request.action_type {
70 HyperliquidActionType::L1 => self.sign_l1_action(request)?,
71 HyperliquidActionType::UserSigned => {
72 self.sign_user_signed_action(&request.action, request.time_nonce)?
73 }
74 };
75
76 Ok(SignatureBundle { signature })
77 }
78
79 pub fn sign_l1_action(&self, request: &SignRequest) -> Result<String> {
80 let connection_id = self.compute_connection_id(request)?;
89
90 let source = if request.is_testnet {
92 "b".to_string()
93 } else {
94 "a".to_string()
95 };
96
97 let agent = Agent {
98 source,
99 connectionId: connection_id,
100 };
101
102 let domain = eip712_domain! {
104 name: "Exchange",
105 version: "1",
106 chain_id: 1337,
107 verifying_contract: Address::ZERO,
108 };
109
110 let signing_hash = agent.eip712_signing_hash(&domain);
111
112 self.sign_hash(&signing_hash.0)
114 }
115
116 fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
117 let mut bytes = if let Some(action_bytes) = &request.action_bytes {
119 action_bytes.clone()
120 } else {
121 rmp_serde::to_vec_named(&request.action)
123 .map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
124 };
125
126 let timestamp = request.time_nonce.as_millis() as u64;
128 bytes.extend_from_slice(×tamp.to_be_bytes());
129
130 if let Some(vault_addr) = &request.vault_address {
132 bytes.push(1); let vault_hex = vault_addr.trim_start_matches("0x");
135 let vault_bytes = hex::decode(vault_hex)
136 .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
137 bytes.extend_from_slice(&vault_bytes);
138 } else {
139 bytes.push(0); }
141
142 Ok(keccak256(&bytes))
144 }
145
146 pub fn sign_user_signed_action(&self, action: &Value, _nonce: TimeNonce) -> Result<String> {
147 let canonicalized = Self::canonicalize_action(action)?;
148
149 let domain_hash = self.get_domain_hash()?;
151
152 let action_hash = self.hash_typed_data(&canonicalized)?;
154
155 let message_hash = self.create_eip712_hash(&domain_hash, &action_hash)?;
157
158 self.sign_hash(&message_hash)
160 }
161
162 fn get_domain_hash(&self) -> Result<[u8; 32]> {
163 let domain_type_hash = keccak256(
166 b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
167 );
168
169 let name_hash = keccak256(b"Hyperliquid");
170 let version_hash = keccak256(b"1");
171
172 let chain_id: [u8; 32] = {
174 let mut bytes = [0u8; 32];
175 bytes[31] = 1; bytes
177 };
178
179 let verifying_contract = hex::decode("0000000000000000000000000000000000000000")
182 .map_err(|e| Error::transport(format!("Failed to decode verifying contract: {e}")))?;
183 let mut contract_bytes = [0u8; 32];
184 contract_bytes[12..].copy_from_slice(&verifying_contract);
185
186 let mut combined = Vec::with_capacity(160);
188 combined.extend_from_slice(domain_type_hash.as_slice());
189 combined.extend_from_slice(name_hash.as_slice());
190 combined.extend_from_slice(version_hash.as_slice());
191 combined.extend_from_slice(&chain_id);
192 combined.extend_from_slice(&contract_bytes);
193
194 Ok(*keccak256(&combined))
195 }
196
197 fn hash_typed_data(&self, data: &Value) -> Result<[u8; 32]> {
198 let json_str = serde_json::to_string(data)?;
201 Ok(*keccak256(json_str.as_bytes()))
202 }
203
204 fn create_eip712_hash(
205 &self,
206 domain_hash: &[u8; 32],
207 message_hash: &[u8; 32],
208 ) -> Result<[u8; 32]> {
209 let mut combined = Vec::with_capacity(66);
211 combined.extend_from_slice(b"\x19\x01");
212 combined.extend_from_slice(domain_hash);
213 combined.extend_from_slice(message_hash);
214 Ok(*keccak256(&combined))
215 }
216
217 fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
218 let key_hex = self.private_key.as_hex();
220 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
221
222 let signer = PrivateKeySigner::from_str(key_hex)
224 .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
225
226 let hash_b256 = B256::from(*hash);
228
229 let signature = signer
231 .sign_hash_sync(&hash_b256)
232 .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
233
234 let r = signature.r();
237 let s = signature.s();
238 let v = signature.v(); let v_byte = if v { 28u8 } else { 27u8 };
242
243 Ok(format!("0x{r:064x}{s:064x}{v_byte:02x}"))
245 }
246
247 fn canonicalize_action(action: &Value) -> Result<Value> {
248 match action {
249 Value::Object(obj) => {
250 let mut canonicalized = serde_json::Map::new();
251 for (key, value) in obj {
252 let canon_value = match key.as_str() {
253 "destination" | "address" | "user" if value.is_string() => {
254 Value::String(Self::canonicalize_address(value.as_str().unwrap()))
255 }
256 "amount" | "px" | "sz" | "price" | "size" if value.is_string() => {
257 Value::String(Self::canonicalize_decimal(value.as_str().unwrap()))
258 }
259 _ => Self::canonicalize_action(value)?,
260 };
261 canonicalized.insert(key.clone(), canon_value);
262 }
263 Ok(Value::Object(canonicalized))
264 }
265 Value::Array(arr) => {
266 let canonicalized: Result<Vec<_>> =
267 arr.iter().map(Self::canonicalize_action).collect();
268 Ok(Value::Array(canonicalized?))
269 }
270 _ => Ok(action.clone()),
271 }
272 }
273
274 fn canonicalize_address(addr: &str) -> String {
275 if addr.starts_with("0x") || addr.starts_with("0X") {
276 format!("0x{}", &addr[2..].to_lowercase())
277 } else {
278 format!("0x{}", addr.to_lowercase())
279 }
280 }
281
282 fn canonicalize_decimal(decimal: &str) -> String {
283 if let Ok(num) = decimal.parse::<f64>() {
284 if num.fract() == 0.0 {
285 format!("{num:.0}")
286 } else {
287 let trimmed = format!("{num}")
288 .trim_end_matches('0')
289 .trim_end_matches('.')
290 .to_string();
291 if trimmed.is_empty() || trimmed == "-" {
292 "0".to_string()
293 } else {
294 trimmed
295 }
296 }
297 } else {
298 decimal.to_string()
299 }
300 }
301
302 pub fn address(&self) -> Result<String> {
303 let key_hex = self.private_key.as_hex();
305 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
306
307 let signer = PrivateKeySigner::from_str(key_hex)
309 .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
310
311 let address = format!("{:#x}", signer.address());
313 Ok(address)
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use rstest::rstest;
320 use serde_json::json;
321
322 use super::*;
323
324 #[rstest]
325 fn test_address_canonicalization() {
326 assert_eq!(
327 HyperliquidEip712Signer::canonicalize_address("0xABCDEF123456789"),
328 "0xabcdef123456789"
329 );
330 assert_eq!(
331 HyperliquidEip712Signer::canonicalize_address("ABCDEF123456789"),
332 "0xabcdef123456789"
333 );
334 assert_eq!(
335 HyperliquidEip712Signer::canonicalize_address("0XABCDEF123456789"),
336 "0xabcdef123456789"
337 );
338 }
339
340 #[rstest]
341 fn test_decimal_canonicalization() {
342 assert_eq!(
343 HyperliquidEip712Signer::canonicalize_decimal("100.000"),
344 "100"
345 );
346 assert_eq!(
347 HyperliquidEip712Signer::canonicalize_decimal("100.100"),
348 "100.1"
349 );
350 assert_eq!(HyperliquidEip712Signer::canonicalize_decimal("0.000"), "0");
351 assert_eq!(
352 HyperliquidEip712Signer::canonicalize_decimal("123.456"),
353 "123.456"
354 );
355 assert_eq!(
356 HyperliquidEip712Signer::canonicalize_decimal("123.450"),
357 "123.45"
358 );
359 }
360
361 #[rstest]
362 fn test_action_canonicalization() {
363 let action = json!({
364 "destination": "0xABCDEF123456789",
365 "amount": "100.000",
366 "other": "unchanged"
367 });
368
369 let canonicalized = HyperliquidEip712Signer::canonicalize_action(&action).unwrap();
370
371 assert_eq!(canonicalized["destination"], "0xabcdef123456789");
372 assert_eq!(canonicalized["amount"], "100");
373 assert_eq!(canonicalized["other"], "unchanged");
374 }
375
376 #[rstest]
377 fn test_sign_request_l1_action() {
378 let private_key = EvmPrivateKey::new(
379 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
380 )
381 .unwrap();
382 let signer = HyperliquidEip712Signer::new(private_key);
383
384 let request = SignRequest {
385 action: json!({
386 "type": "withdraw",
387 "destination": "0xABCDEF123456789",
388 "amount": "100.000"
389 }),
390 action_bytes: None,
391 time_nonce: TimeNonce::from_millis(1640995200000),
392 action_type: HyperliquidActionType::L1,
393 is_testnet: false,
394 vault_address: None,
395 };
396
397 let result = signer.sign(&request).unwrap();
398 assert!(result.signature.starts_with("0x"));
400 assert_eq!(result.signature.len(), 132); }
402
403 #[rstest]
404 fn test_sign_request_user_action() {
405 let private_key = EvmPrivateKey::new(
406 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
407 )
408 .unwrap();
409 let signer = HyperliquidEip712Signer::new(private_key);
410
411 let request = SignRequest {
412 action: json!({
413 "type": "order",
414 "coin": "BTC",
415 "px": "50000.00",
416 "sz": "0.1"
417 }),
418 action_bytes: None,
419 time_nonce: TimeNonce::from_millis(1640995200000),
420 action_type: HyperliquidActionType::UserSigned,
421 is_testnet: false,
422 vault_address: None,
423 };
424
425 let result = signer.sign(&request).unwrap();
426 assert!(result.signature.starts_with("0x"));
428 assert_eq!(result.signature.len(), 132); }
430}