Skip to main content

quicknode_hyperliquid_sdk/
signing.rs

1//! Signing utilities for Hyperliquid transactions.
2//!
3//! Implements EIP-712 signing and MessagePack hashing for order authentication.
4
5use alloy::primitives::{keccak256, Address, B256};
6use alloy::signers::local::PrivateKeySigner;
7use alloy::signers::Signer;
8use alloy::sol;
9use alloy::sol_types::SolStruct;
10use serde::Serialize;
11
12use crate::types::{Chain, Signature, CORE_MAINNET_EIP712_DOMAIN};
13
14// EIP-712 Agent struct for signing
15sol! {
16    struct Agent {
17        string source;
18        bytes32 connectionId;
19    }
20}
21
22/// Compute the EIP-712 signing hash for an Agent struct wrapping a connection ID.
23///
24/// This is the final hash that users sign for MessagePack-based actions.
25#[inline]
26pub fn agent_signing_hash(chain: Chain, connection_id: B256) -> B256 {
27    let agent = Agent {
28        source: if chain.is_mainnet() { "a" } else { "b" }.to_string(),
29        connectionId: connection_id,
30    };
31    agent.eip712_signing_hash(&CORE_MAINNET_EIP712_DOMAIN)
32}
33
34/// Compute the MessagePack hash of a value for signing.
35///
36/// Serializes to named MessagePack, appends nonce, optional vault address
37/// (1-byte tag + 20 bytes) and optional expiry (1-byte tag + 8 bytes),
38/// then returns keccak256 of the concatenation.
39pub fn rmp_hash<T: Serialize>(
40    value: &T,
41    nonce: u64,
42    vault_address: Option<Address>,
43    expires_after: Option<u64>,
44) -> Result<B256, rmp_serde::encode::Error> {
45    let mut bytes = rmp_serde::to_vec_named(value)?;
46    bytes.extend(nonce.to_be_bytes());
47
48    if let Some(vault_address) = vault_address {
49        bytes.push(1);
50        bytes.extend(vault_address.as_slice());
51    } else {
52        bytes.push(0);
53    }
54
55    if let Some(expires_after) = expires_after {
56        bytes.push(0);
57        bytes.extend(expires_after.to_be_bytes());
58    }
59
60    Ok(keccak256(bytes))
61}
62
63/// Sign a hash with a private key.
64pub async fn sign_hash(signer: &PrivateKeySigner, hash: B256) -> crate::Result<Signature> {
65    let sig = signer
66        .sign_hash(&hash)
67        .await
68        .map_err(|e| crate::Error::SigningError(e.to_string()))?;
69    Ok(sig.into())
70}
71
72/// Sign an action for the Hyperliquid exchange.
73pub async fn sign_action<T: Serialize>(
74    signer: &PrivateKeySigner,
75    chain: Chain,
76    action: &T,
77    nonce: u64,
78    vault_address: Option<Address>,
79    expires_after: Option<u64>,
80) -> crate::Result<Signature> {
81    // Step 1: Compute MessagePack hash
82    let connection_id = rmp_hash(action, nonce, vault_address, expires_after)
83        .map_err(|e| crate::Error::SigningError(format!("MessagePack serialization failed: {}", e)))?;
84
85    // Step 2: Compute EIP-712 Agent signing hash
86    let signing_hash = agent_signing_hash(chain, connection_id);
87
88    // Step 3: Sign the hash
89    sign_hash(signer, signing_hash).await
90}
91
92/// Recover signer address from a signature.
93pub fn recover_signer(hash: B256, sig: &Signature) -> crate::Result<Address> {
94    let alloy_sig = alloy::signers::Signature::new(
95        alloy::primitives::U256::from(sig.r),
96        alloy::primitives::U256::from(sig.s),
97        sig.v == 28,
98    );
99
100    alloy_sig
101        .recover_address_from_prehash(&hash)
102        .map_err(|e| crate::Error::SigningError(format!("Failed to recover signer: {}", e)))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use alloy::primitives::B256;
109
110    #[test]
111    fn test_agent_signing_hash_mainnet() {
112        let connection_id = B256::ZERO;
113        let hash = agent_signing_hash(Chain::Mainnet, connection_id);
114        // Hash should be deterministic
115        assert!(!hash.is_zero());
116    }
117
118    #[test]
119    fn test_agent_signing_hash_testnet() {
120        let connection_id = B256::ZERO;
121        let hash_mainnet = agent_signing_hash(Chain::Mainnet, connection_id);
122        let hash_testnet = agent_signing_hash(Chain::Testnet, connection_id);
123        // Different chains should produce different hashes
124        assert_ne!(hash_mainnet, hash_testnet);
125    }
126
127    #[test]
128    fn test_rmp_hash_deterministic() {
129        #[derive(Serialize)]
130        struct TestAction {
131            value: u64,
132        }
133
134        let action = TestAction { value: 42 };
135        let hash1 = rmp_hash(&action, 1000, None, None).unwrap();
136        let hash2 = rmp_hash(&action, 1000, None, None).unwrap();
137        assert_eq!(hash1, hash2);
138    }
139
140    #[test]
141    fn test_rmp_hash_with_vault() {
142        #[derive(Serialize)]
143        struct TestAction {
144            value: u64,
145        }
146
147        let action = TestAction { value: 42 };
148        let vault = Address::ZERO;
149        let hash_no_vault = rmp_hash(&action, 1000, None, None).unwrap();
150        let hash_with_vault = rmp_hash(&action, 1000, Some(vault), None).unwrap();
151        // Vault should change the hash
152        assert_ne!(hash_no_vault, hash_with_vault);
153    }
154}