Skip to main content

hyper_exchange/
signing.rs

1//! Hyperliquid EIP-712 signing delegated to motosan-wallet-core.
2//!
3//! Public API signatures are preserved for backward compatibility.
4//! Internally, a [`SingleAddressSigner`](crate::adapter::SingleAddressSigner)
5//! bridges the hyper-exchange [`Signer`] trait to motosan-wallet-core's
6//! [`HlSigner`] so the actual EIP-712 logic lives in one place.
7
8use crate::adapter::SingleAddressSigner;
9use crate::signer::Signer;
10use crate::types::{ExchangeError, Signature};
11
12use motosan_wallet_core::HlTypeField;
13
14// Re-export so existing `use hyper_exchange::EIP712Field` keeps working.
15// The struct is now a thin wrapper that converts to HlTypeField for delegation.
16
17// ============================================================
18// EIP-712 Field Descriptor (public API, unchanged)
19// ============================================================
20
21/// Describes a single field in an EIP-712 struct type.
22#[derive(Debug, Clone)]
23pub struct EIP712Field {
24    pub name: String,
25    pub field_type: String,
26}
27
28impl EIP712Field {
29    pub fn new(name: &str, field_type: &str) -> Self {
30        Self {
31            name: name.to_string(),
32            field_type: field_type.to_string(),
33        }
34    }
35}
36
37// ============================================================
38// Action Hash Computation
39// ============================================================
40
41/// Compute action hash for L1 signing.
42///
43/// Algorithm:
44/// 1. msgpack-encode the action (named/map format)
45/// 2. Append nonce as 8 bytes big-endian
46/// 3. Append vault address flag:
47///    - If None: append 0x00
48///    - If Some: append 0x01 + 20 address bytes
49/// 4. keccak256 the whole thing
50pub fn compute_action_hash(
51    action: &serde_json::Value,
52    vault_address: Option<&str>,
53    nonce: u64,
54) -> Result<[u8; 32], ExchangeError> {
55    motosan_wallet_core::compute_action_hash(action, nonce, vault_address)
56        .map_err(|e| ExchangeError::SerializationError(e.to_string()))
57}
58
59/// Compute action hash with optional expiresAfter.
60///
61/// Note: the `expires_after` extension is not yet in motosan-wallet-core,
62/// so this keeps a local implementation that appends the extra 8 bytes.
63pub fn compute_action_hash_with_expiry(
64    action: &serde_json::Value,
65    vault_address: Option<&str>,
66    nonce: u64,
67    expires_after: Option<u64>,
68) -> Result<[u8; 32], ExchangeError> {
69    match expires_after {
70        None => compute_action_hash(action, vault_address, nonce),
71        Some(expires) => {
72            // Delegate the base hash computation manually since we need to
73            // append extra bytes before hashing.
74            use alloy_primitives::keccak256;
75
76            let action_bytes = rmp_serde::to_vec_named(action)
77                .map_err(|e| ExchangeError::SerializationError(e.to_string()))?;
78
79            let mut data = Vec::with_capacity(action_bytes.len() + 8 + 21 + 8);
80            data.extend_from_slice(&action_bytes);
81            data.extend_from_slice(&nonce.to_be_bytes());
82
83            match vault_address {
84                None => data.push(0x00),
85                Some(addr) => {
86                    data.push(0x01);
87                    let addr_bytes = parse_address_bytes(addr)?;
88                    data.extend_from_slice(&addr_bytes);
89                }
90            }
91
92            data.extend_from_slice(&expires.to_be_bytes());
93            Ok(keccak256(&data).into())
94        }
95    }
96}
97
98// ============================================================
99// L1 Action Signing
100// ============================================================
101
102/// Sign an L1 action using EIP-712 typed data.
103///
104/// Domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0...0 }
105/// primaryType: "Agent"
106/// message: { source: 0xa (mainnet) or 0xb (testnet), connectionId: action_hash }
107pub fn sign_l1_action(
108    signer: &dyn Signer,
109    address: &str,
110    action: &serde_json::Value,
111    nonce: u64,
112    is_mainnet: bool,
113    vault_address: Option<&str>,
114) -> Result<Signature, ExchangeError> {
115    let adapter = SingleAddressSigner::new(signer, address.to_string());
116    let hl_sig =
117        motosan_wallet_core::sign_l1_action(&adapter, action, nonce, is_mainnet, vault_address)
118            .map_err(|e| ExchangeError::SigningError(e.to_string()))?;
119    Ok(hl_signature_to_signature(&hl_sig))
120}
121
122// ============================================================
123// User-Signed Action Signing
124// ============================================================
125
126/// Sign a user-signed action (e.g., approveAgent).
127///
128/// Domain: { name: "HyperliquidSignTransaction", version: "1", chainId: 421614, verifyingContract: 0x0...0 }
129///
130/// This builds the EIP-712 struct hash manually from the provided type fields
131/// and the action values, since the struct shape varies per action.
132pub fn sign_user_signed_action(
133    signer: &dyn Signer,
134    address: &str,
135    action: &serde_json::Value,
136    types: &[EIP712Field],
137    primary_type: &str,
138    is_mainnet: bool,
139) -> Result<Signature, ExchangeError> {
140    // Convert EIP712Field -> HlTypeField for motosan-wallet-core
141    let hl_fields: Vec<HlTypeField<'_>> = types
142        .iter()
143        .map(|f| HlTypeField::new(&f.name, &f.field_type))
144        .collect();
145
146    let adapter = SingleAddressSigner::new(signer, address.to_string());
147    let hl_sig = motosan_wallet_core::sign_user_signed_action(
148        &adapter,
149        action,
150        &hl_fields,
151        primary_type,
152        is_mainnet,
153    )
154    .map_err(|e| ExchangeError::SigningError(e.to_string()))?;
155
156    Ok(hl_signature_to_signature(&hl_sig))
157}
158
159// ============================================================
160// Internal Helpers
161// ============================================================
162
163/// Convert motosan-wallet-core's HlSignature to our Signature type.
164fn hl_signature_to_signature(hl: &motosan_wallet_core::HlSignature) -> Signature {
165    Signature {
166        r: hl.r.clone(),
167        s: hl.s.clone(),
168        v: hl.v,
169    }
170}
171
172/// Parse a hex address string (0x-prefixed) into 20 bytes.
173/// Kept for compute_action_hash_with_expiry local fallback.
174fn parse_address_bytes(address: &str) -> Result<[u8; 20], ExchangeError> {
175    let stripped = address
176        .strip_prefix("0x")
177        .or_else(|| address.strip_prefix("0X"))
178        .unwrap_or(address);
179    let bytes = hex::decode(stripped)
180        .map_err(|e| ExchangeError::InvalidAddress(format!("Invalid hex: {}", e)))?;
181    if bytes.len() != 20 {
182        return Err(ExchangeError::InvalidAddress(format!(
183            "Expected 20 bytes, got {}",
184            bytes.len()
185        )));
186    }
187    let mut result = [0u8; 20];
188    result.copy_from_slice(&bytes);
189    Ok(result)
190}
191
192// ============================================================
193// Tests
194// ============================================================
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// A minimal test signer using k256 for testing.
201    struct TestSigner {
202        key: k256::ecdsa::SigningKey,
203        address: String,
204    }
205
206    impl TestSigner {
207        fn new(hex_key: &str) -> Self {
208            let stripped = hex_key.strip_prefix("0x").unwrap_or(hex_key);
209            let key_bytes = hex::decode(stripped).unwrap();
210            let key = k256::ecdsa::SigningKey::from_bytes((&key_bytes[..]).into()).unwrap();
211
212            // Derive address
213            let verifying_key = key.verifying_key();
214            let point = verifying_key.to_encoded_point(false);
215            let pubkey_bytes = &point.as_bytes()[1..];
216            use sha3::{Digest, Keccak256};
217            let hash = Keccak256::digest(pubkey_bytes);
218            let address = format!("0x{}", hex::encode(&hash[12..]));
219
220            Self { key, address }
221        }
222
223        fn address(&self) -> &str {
224            &self.address
225        }
226    }
227
228    impl Signer for TestSigner {
229        fn sign_hash(&self, _address: &str, hash: &[u8; 32]) -> Result<[u8; 65], ExchangeError> {
230            use k256::ecdsa::{signature::hazmat::PrehashSigner, RecoveryId};
231            let (signature, recovery_id): (k256::ecdsa::Signature, RecoveryId) = self
232                .key
233                .sign_prehash(hash)
234                .map_err(|e| ExchangeError::SigningError(format!("k256 signing error: {}", e)))?;
235            let mut result = [0u8; 65];
236            result[..64].copy_from_slice(&signature.to_bytes());
237            result[64] = recovery_id.to_byte();
238            Ok(result)
239        }
240    }
241
242    const TEST_KEY: &str = "0x4c0883a69102937d6231471b5dbb6204fe512961708279f22a82e1e0e3e1d0a2";
243
244    #[test]
245    fn test_compute_action_hash_deterministic() {
246        let action = serde_json::json!({
247            "type": "order",
248            "orders": [{"a": 0, "b": true, "p": "30000", "s": "0.1"}],
249            "grouping": "na"
250        });
251        let hash1 = compute_action_hash(&action, None, 1234567890).unwrap();
252        let hash2 = compute_action_hash(&action, None, 1234567890).unwrap();
253        assert_eq!(hash1, hash2);
254    }
255
256    #[test]
257    fn test_compute_action_hash_different_nonce() {
258        let action = serde_json::json!({"type": "order"});
259        let hash1 = compute_action_hash(&action, None, 100).unwrap();
260        let hash2 = compute_action_hash(&action, None, 200).unwrap();
261        assert_ne!(hash1, hash2);
262    }
263
264    #[test]
265    fn test_compute_action_hash_with_vault_address() {
266        let action = serde_json::json!({"type": "order"});
267        let hash_no_vault = compute_action_hash(&action, None, 100).unwrap();
268        let hash_with_vault = compute_action_hash(
269            &action,
270            Some("0x1234567890abcdef1234567890abcdef12345678"),
271            100,
272        )
273        .unwrap();
274        assert_ne!(hash_no_vault, hash_with_vault);
275    }
276
277    #[test]
278    fn test_sign_l1_action_produces_valid_signature() {
279        let signer = TestSigner::new(TEST_KEY);
280        let addr = signer.address().to_string();
281        let action = serde_json::json!({
282            "type": "order",
283            "orders": [{"a": 0, "b": true, "p": "30000", "s": "0.1"}],
284            "grouping": "na"
285        });
286
287        let sig = sign_l1_action(&signer, &addr, &action, 1234567890, true, None).unwrap();
288
289        // Verify signature format
290        assert!(sig.r.starts_with("0x"));
291        assert!(sig.s.starts_with("0x"));
292        assert_eq!(sig.r.len(), 66); // 0x + 64 hex chars
293        assert_eq!(sig.s.len(), 66);
294        assert!(sig.v == 27 || sig.v == 28);
295    }
296
297    #[test]
298    fn test_sign_l1_action_mainnet_vs_testnet() {
299        let signer = TestSigner::new(TEST_KEY);
300        let addr = signer.address().to_string();
301        let action = serde_json::json!({"type": "order"});
302
303        let sig_mainnet = sign_l1_action(&signer, &addr, &action, 100, true, None).unwrap();
304        let sig_testnet = sign_l1_action(&signer, &addr, &action, 100, false, None).unwrap();
305
306        // Different source address should produce different signatures
307        assert_ne!(sig_mainnet.r, sig_testnet.r);
308    }
309
310    #[test]
311    fn test_sign_l1_action_recoverable() {
312        let signer = TestSigner::new(TEST_KEY);
313        let addr = signer.address().to_string();
314        let action = serde_json::json!({"type": "order"});
315
316        let sig = sign_l1_action(&signer, &addr, &action, 100, true, None).unwrap();
317
318        // Recompute the EIP-712 hash to verify recovery.
319        // We use motosan-wallet-core's compute_action_hash (via our wrapper).
320        let action_hash = compute_action_hash(&action, None, 100).unwrap();
321
322        // Build the Agent EIP-712 hash the same way motosan-wallet-core does.
323        use alloy_primitives::keccak256;
324        let connection_id_hex = format!("0x{}", hex::encode(action_hash));
325        let _agent_action = serde_json::json!({
326            "source": "a",
327            "connectionId": connection_id_hex,
328        });
329
330        // Type hash
331        let type_string = "Agent(string source,bytes32 connectionId)";
332        let type_hash: [u8; 32] = keccak256(type_string.as_bytes()).into();
333
334        // Domain separator (Exchange, v1, chainId 1337, verifyingContract zero)
335        let domain_type_hash = keccak256(
336            b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
337        );
338        let name_hash = keccak256(b"Exchange");
339        let version_hash = keccak256(b"1");
340        let mut chain_id_bytes = [0u8; 32];
341        chain_id_bytes[24..32].copy_from_slice(&1337u64.to_be_bytes());
342        let contract_bytes = [0u8; 32];
343
344        let mut domain_buf = Vec::with_capacity(32 * 5);
345        domain_buf.extend_from_slice(domain_type_hash.as_slice());
346        domain_buf.extend_from_slice(name_hash.as_slice());
347        domain_buf.extend_from_slice(version_hash.as_slice());
348        domain_buf.extend_from_slice(&chain_id_bytes);
349        domain_buf.extend_from_slice(&contract_bytes);
350        let domain_sep: [u8; 32] = keccak256(&domain_buf).into();
351
352        // Struct hash
353        let source_hash: [u8; 32] = keccak256(b"a").into();
354        let conn_bytes = hex::decode(connection_id_hex.strip_prefix("0x").unwrap()).unwrap();
355        let mut struct_buf = Vec::with_capacity(32 * 3);
356        struct_buf.extend_from_slice(&type_hash);
357        struct_buf.extend_from_slice(&source_hash);
358        struct_buf.extend_from_slice(&conn_bytes);
359        let struct_hash: [u8; 32] = keccak256(&struct_buf).into();
360
361        let mut eip712_data = Vec::with_capacity(66);
362        eip712_data.extend_from_slice(&[0x19, 0x01]);
363        eip712_data.extend_from_slice(&domain_sep);
364        eip712_data.extend_from_slice(&struct_hash);
365        let final_hash: [u8; 32] = keccak256(&eip712_data).into();
366
367        // Recover the signer
368        let r_bytes = hex::decode(sig.r.strip_prefix("0x").unwrap()).unwrap();
369        let s_bytes = hex::decode(sig.s.strip_prefix("0x").unwrap()).unwrap();
370        let v = sig.v - 27;
371
372        let mut sig_bytes = [0u8; 64];
373        sig_bytes[..32].copy_from_slice(&r_bytes);
374        sig_bytes[32..].copy_from_slice(&s_bytes);
375
376        use k256::ecdsa::{RecoveryId, Signature as K256Sig, VerifyingKey};
377        let signature = K256Sig::from_slice(&sig_bytes).unwrap();
378        let recovery_id = RecoveryId::from_byte(v).unwrap();
379        let recovered =
380            VerifyingKey::recover_from_prehash(&final_hash, &signature, recovery_id).unwrap();
381
382        // Derive address from recovered key
383        let point = recovered.to_encoded_point(false);
384        let pubkey_bytes = &point.as_bytes()[1..];
385        use sha3::{Digest, Keccak256};
386        let hash = Keccak256::digest(pubkey_bytes);
387        let recovered_addr = format!("0x{}", hex::encode(&hash[12..]));
388
389        assert_eq!(recovered_addr, addr);
390    }
391
392    #[test]
393    fn test_sign_user_signed_action() {
394        let signer = TestSigner::new(TEST_KEY);
395        let addr = signer.address().to_string();
396
397        let action = serde_json::json!({
398            "type": "approveAgent",
399            "agentAddress": "0x1234567890abcdef1234567890abcdef12345678",
400            "agentName": "test-agent",
401            "nonce": 1000
402        });
403
404        let types = vec![
405            EIP712Field::new("hyperliquidChain", "string"),
406            EIP712Field::new("agentAddress", "address"),
407            EIP712Field::new("agentName", "string"),
408            EIP712Field::new("nonce", "uint64"),
409        ];
410
411        let sig = sign_user_signed_action(
412            &signer,
413            &addr,
414            &action,
415            &types,
416            "HyperliquidTransaction:ApproveAgent",
417            true,
418        )
419        .unwrap();
420
421        assert!(sig.r.starts_with("0x"));
422        assert!(sig.s.starts_with("0x"));
423        assert_eq!(sig.r.len(), 66);
424        assert_eq!(sig.s.len(), 66);
425        assert!(sig.v == 27 || sig.v == 28);
426    }
427
428    #[test]
429    fn test_parse_address_bytes_valid() {
430        let addr = "0x1234567890abcdef1234567890abcdef12345678";
431        let bytes = parse_address_bytes(addr).unwrap();
432        assert_eq!(bytes.len(), 20);
433        assert_eq!(bytes[0], 0x12);
434        assert_eq!(bytes[19], 0x78);
435    }
436
437    #[test]
438    fn test_parse_address_bytes_invalid() {
439        let result = parse_address_bytes("0xinvalid");
440        assert!(result.is_err());
441    }
442
443    #[test]
444    fn test_exchange_client_url_construction() {
445        let mainnet_url = crate::client::ExchangeClient::base_url_for(true);
446        let testnet_url = crate::client::ExchangeClient::base_url_for(false);
447        assert_eq!(mainnet_url, "https://api.hyperliquid.xyz");
448        assert_eq!(testnet_url, "https://api.hyperliquid-testnet.xyz");
449    }
450}