From b85a588189ac1aa5df7749cec3307e7ea2da7cb2 Mon Sep 17 00:00:00 2001 From: BillyWooo Date: Tue, 30 Sep 2025 17:13:49 +0200 Subject: [PATCH 1/2] Refactor USD class transfer to use EIP-712 signing Replace the old SpotUser/ClassTransfer implementation with a new UsdClassTransfer action that uses proper EIP-712 typed data signing. This aligns with the Hyperliquid API's expected signature format, aswell as keeping alignment with hyperliquid-python-sdk. Key changes: - Add UsdClassTransfer struct with EIP-712 implementation - Remove deprecated SpotUser and ClassTransfer structs - Update usd_class_transfer() to use sign_typed_data instead of sign_l1_action - Handle vault_address properly (set to None for usdClassTransfer actions) - Support subaccount suffix in amount string for vault transfers - Add comprehensive tests for signing with mainnet/testnet and vault scenarios --- src/exchange/actions.rs | 29 ++++++--- src/exchange/exchange_client.rs | 103 ++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index 18ed3687..141d325b 100644 --- a/src/exchange/actions.rs +++ b/src/exchange/actions.rs @@ -186,15 +186,30 @@ impl Eip712 for SpotSend { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct SpotUser { - pub class_transfer: ClassTransfer, +pub struct UsdClassTransfer { + #[serde(serialize_with = "serialize_hex")] + pub signature_chain_id: u64, + pub hyperliquid_chain: String, + pub amount: String, + pub to_perp: bool, + pub nonce: u64, } -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ClassTransfer { - pub usdc: u64, - pub to_perp: bool, +impl Eip712 for UsdClassTransfer { + fn domain(&self) -> Eip712Domain { + eip_712_domain(self.signature_chain_id) + } + + fn struct_hash(&self) -> B256 { + let items = ( + keccak256("HyperliquidTransaction:UsdClassTransfer(string hyperliquidChain,string amount,bool toPerp,uint64 nonce)"), + keccak256(&self.hyperliquid_chain), + keccak256(&self.amount), + self.to_perp, + &self.nonce, + ); + keccak256(items.abi_encode()) + } } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index fd1fb703..8eb3434f 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -13,7 +13,7 @@ use crate::{ actions::{ ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, ClaimRewards, EvmUserModify, ScheduleCancel, SetReferrer, UpdateIsolatedMargin, UpdateLeverage, - UsdSend, + UsdClassTransfer, UsdSend, }, cancel::{CancelRequest, CancelRequestCloid, ClientCancelRequestCloid}, modify::{ClientModifyRequest, ModifyRequest}, @@ -26,8 +26,7 @@ use crate::{ prelude::*, req::HttpClient, signature::{sign_l1_action, sign_typed_data}, - BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, SpotSend, SpotUser, - VaultTransfer, Withdraw3, + BaseUrl, BulkCancelCloid, Error, ExchangeResponseStatus, SpotSend, VaultTransfer, Withdraw3, }; #[derive(Debug)] @@ -73,7 +72,7 @@ pub enum Actions { BatchModify(BulkModify), ApproveAgent(ApproveAgent), Withdraw3(Withdraw3), - SpotUser(SpotUser), + UsdClassTransfer(UsdClassTransfer), VaultTransfer(VaultTransfer), SpotSend(SpotSend), SetReferrer(SetReferrer), @@ -144,17 +143,23 @@ impl ExchangeClient { signature: Signature, nonce: u64, ) -> Result { - // let signature = ExchangeSignature { - // r: signature.r(), - // s: signature.s(), - // v: 27 + signature.v() as u64, - // }; + // Determine if vault_address should be None based on action type + // Similar to Python SDK: vaultAddress is None for "usdClassTransfer" and "sendAsset" + let vault_address = if let Some(action_type) = action.get("type").and_then(|v| v.as_str()) { + if action_type == "usdClassTransfer" || action_type == "sendAsset" { + None + } else { + self.vault_address + } + } else { + self.vault_address + }; let exchange_payload = ExchangePayload { action, signature, nonce, - vault_address: self.vault_address, + vault_address, }; let res = serde_json::to_string(&exchange_payload) .map_err(|e| Error::JsonParse(e.to_string()))?; @@ -221,19 +226,33 @@ impl ExchangeClient { to_perp: bool, wallet: Option<&PrivateKeySigner>, ) -> Result { - // payload expects usdc without decimals - let usdc = (usdc * 1e6).round() as u64; let wallet = wallet.unwrap_or(&self.wallet); + let hyperliquid_chain = if self.http_client.is_mainnet() { + "Mainnet".to_string() + } else { + "Testnet".to_string() + }; + let timestamp = next_nonce(); - let action = Actions::SpotUser(SpotUser { - class_transfer: ClassTransfer { usdc, to_perp }, - }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; - let is_mainnet = self.http_client.is_mainnet(); - let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; + // Build amount string with optional subaccount suffix (similar to Python SDK) + let mut amount = usdc.to_string(); + if let Some(vault_addr) = self.vault_address { + amount = format!("{} subaccount:{:?}", amount, vault_addr); + } + + let usd_class_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain, + amount, + to_perp, + nonce: timestamp, + }; + + let signature = sign_typed_data(&usd_class_transfer, wallet)?; + let action = serde_json::to_value(Actions::UsdClassTransfer(usd_class_transfer)) + .map_err(|e| Error::JsonParse(e.to_string()))?; self.post(action, signature, timestamp).await } @@ -1063,4 +1082,50 @@ mod tests { Ok(()) } + + #[test] + fn test_usd_class_transfer_signing() -> Result<()> { + let wallet = get_wallet()?; + + // Test mainnet - transfer 100 USDC to perp + let mainnet_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain: "Mainnet".to_string(), + amount: "100".to_string(), + to_perp: true, + nonce: 1583838, + }; + + let mainnet_signature = sign_typed_data(&mainnet_transfer, &wallet)?; + // Just verify the signature is generated successfully (v should be true for even parity) + assert!(mainnet_signature.v()); + + // Test testnet - transfer 50 USDC from perp + let testnet_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + amount: "50".to_string(), + to_perp: false, + nonce: 1583838, + }; + + let testnet_signature = sign_typed_data(&testnet_transfer, &wallet)?; + // Verify signatures are different for mainnet vs testnet + assert_ne!(mainnet_signature, testnet_signature); + + // Test with vault address in amount + let vault_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain: "Mainnet".to_string(), + amount: "100 subaccount:0x1234567890123456789012345678901234567890".to_string(), + to_perp: true, + nonce: 1583838, + }; + + let vault_signature = sign_typed_data(&vault_transfer, &wallet)?; + // Verify vault signature is different from non-vault signature + assert_ne!(mainnet_signature, vault_signature); + + Ok(()) + } } From e5ddd173be74c249371491fdbc732676f5b0b43e Mon Sep 17 00:00:00 2001 From: BillyWooo Date: Wed, 22 Oct 2025 13:36:29 +0200 Subject: [PATCH 2/2] fix fmt --- src/exchange/exchange_client.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 17b169d5..c8ef4620 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -12,8 +12,8 @@ use crate::{ exchange::{ actions::{ ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, ClaimRewards, - EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, UpdateIsolatedMargin, UpdateLeverage, - UsdClassTransfer, UsdSend, + EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, UpdateIsolatedMargin, + UpdateLeverage, UsdClassTransfer, UsdSend, }, cancel::{CancelRequest, CancelRequestCloid, ClientCancelRequestCloid}, modify::{ClientModifyRequest, ModifyRequest},