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..47479a70 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,7 @@ struct ExchangePayload {
#[serde(serialize_with = "serialize_sig")]
signature: Signature,
nonce: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
vault_address: Option
,
}
@@ -73,7 +73,6 @@ pub enum Actions {
BatchModify(BulkModify),
ApproveAgent(ApproveAgent),
Withdraw3(Withdraw3),
- SpotUser(SpotUser),
SendAsset(SendAsset),
VaultTransfer(VaultTransfer),
SpotSend(SpotSend),
@@ -82,6 +81,7 @@ pub enum Actions {
EvmUserModify(EvmUserModify),
ScheduleCancel(ScheduleCancel),
ClaimRewards(ClaimRewards),
+ UsdClassTransfer(UsdClassTransfer),
}
impl Actions {
@@ -151,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()))?;
@@ -218,25 +223,36 @@ 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 nonce = 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 mut amount = usd_amount.to_string();
+ if let Some(vault_addr) = self.vault_address {
+ amount = format!("{amount} subaccount:{vault_addr:?}");
+ }
- self.post(action, signature, timestamp).await
+ let payload = UsdClassTransfer {
+ signature_chain_id: 421614,
+ hyperliquid_chain,
+ 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, nonce).await
}
pub async fn send_asset(
@@ -1164,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
+ );
+}