From 5af799f6159a339c0bfd27c038600eecb779b634 Mon Sep 17 00:00:00 2001 From: Konstantin Fastov Date: Wed, 17 Sep 2025 10:48:46 +0300 Subject: [PATCH 1/2] fix serialization format in UsdClassTransfer --- src/exchange/actions.rs | 29 ++++++++++++++++++------ src/exchange/exchange_client.rs | 40 +++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index 71e9f4f1..b857ca65 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 70b686b8..ee96b45d 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, SendAsset, SetReferrer, UpdateIsolatedMargin, - UpdateLeverage, UsdSend, + UpdateLeverage, 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)] @@ -57,6 +56,8 @@ struct ExchangePayload { #[serde(serialize_with = "serialize_sig")] signature: Signature, nonce: u64, + #[serde(skip_serializing_if = "Option::is_none")] + // TODO: check if skip is needed vault_address: Option
, } @@ -73,7 +74,6 @@ pub enum Actions { BatchModify(BulkModify), ApproveAgent(ApproveAgent), Withdraw3(Withdraw3), - SpotUser(SpotUser), SendAsset(SendAsset), VaultTransfer(VaultTransfer), SpotSend(SpotSend), @@ -82,6 +82,7 @@ pub enum Actions { EvmUserModify(EvmUserModify), ScheduleCancel(ScheduleCancel), ClaimRewards(ClaimRewards), + UsdClassTransfer(UsdClassTransfer), } impl Actions { @@ -218,25 +219,30 @@ impl ExchangeClient { pub async fn class_transfer( &self, - usdc: f64, + usd_amount: f64, 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)?; + let nonce = next_nonce(); + let payload = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain, + amount: format!("{}", usd_amount), + to_perp, + nonce, + }; + let signature = sign_typed_data(&payload, wallet)?; + let action = serde_json::to_value(Actions::UsdClassTransfer(payload)) + .map_err(|e| Error::JsonParse(e.to_string()))?; - self.post(action, signature, timestamp).await + self.post(action, signature, nonce).await } pub async fn send_asset( From 122134c7905dcabe9199b3c4140cf44989a6fca4 Mon Sep 17 00:00:00 2001 From: Konstantin Fastov Date: Wed, 22 Oct 2025 09:53:57 +0300 Subject: [PATCH 2/2] fix: align usd class transfer signing with vault handling --- src/exchange/exchange_client.rs | 55 +++++++++++++++++++++++++++++++-- tests/user_fees_mainnet.rs | 29 +++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/user_fees_mainnet.rs diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index ee96b45d..47479a70 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -57,7 +57,6 @@ struct ExchangePayload { signature: Signature, nonce: u64, #[serde(skip_serializing_if = "Option::is_none")] - // TODO: check if skip is needed vault_address: Option
, } @@ -152,11 +151,16 @@ impl ExchangeClient { // v: 27 + signature.v() as u64, // }; + let vault_address = match action.get("type").and_then(|value| value.as_str()) { + Some("usdClassTransfer") | Some("sendAsset") => None, + _ => 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()))?; @@ -231,10 +235,16 @@ impl ExchangeClient { }; let nonce = next_nonce(); + + let mut amount = usd_amount.to_string(); + if let Some(vault_addr) = self.vault_address { + amount = format!("{amount} subaccount:{vault_addr:?}"); + } + let payload = UsdClassTransfer { signature_chain_id: 421614, hyperliquid_chain, - amount: format!("{}", usd_amount), + amount, to_perp, nonce, }; @@ -1170,4 +1180,43 @@ mod tests { Ok(()) } + + #[test] + fn test_usd_class_transfer_signing() -> Result<()> { + let wallet = get_wallet()?; + + // Mainnet transfer should sign differently from testnet + 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)?; + + let testnet_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + amount: "100".to_string(), + to_perp: true, + nonce: 1583838, + }; + let testnet_signature = sign_typed_data(&testnet_transfer, &wallet)?; + assert_ne!(mainnet_signature, testnet_signature); + + // Subaccount suffix should influence the signature as well + let vault_addr = address!("0x1234567890123456789012345678901234567890"); + let vault_transfer = UsdClassTransfer { + signature_chain_id: 421614, + hyperliquid_chain: "Mainnet".to_string(), + amount: format!("100 subaccount:{vault_addr:?}"), + to_perp: true, + nonce: 1583838, + }; + let vault_signature = sign_typed_data(&vault_transfer, &wallet)?; + assert_ne!(mainnet_signature, vault_signature); + + Ok(()) + } } diff --git a/tests/user_fees_mainnet.rs b/tests/user_fees_mainnet.rs new file mode 100644 index 00000000..d786f773 --- /dev/null +++ b/tests/user_fees_mainnet.rs @@ -0,0 +1,29 @@ +use alloy::primitives::Address; +use hyperliquid_rust_sdk::{BaseUrl, InfoClient}; + +/// Exercise the live mainnet `/info` endpoint to make sure the freshly added +/// `fee_trial_escrow` field round-trips through deserialization. +#[tokio::test] +async fn user_fees_includes_fee_trial_escrow_on_mainnet() { + let client = InfoClient::new(None, Some(BaseUrl::Mainnet)) + .await + .expect("create mainnet info client"); + let user: Address = "0xc64cc00b46101bd40aa1c3121195e85c0b0918d8" + .parse() + .expect("parse hard-coded mainnet address"); + + let response = client + .user_fees(user) + .await + .expect("fetch mainnet user fees"); + + assert!( + !response.fee_trial_escrow.is_empty(), + "expected `fee_trial_escrow` to be present in the mainnet response" + ); + assert!( + response.fee_trial_escrow.parse::().is_ok(), + "`fee_trial_escrow` should be numeric but was {}", + response.fee_trial_escrow + ); +}