WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/exchange/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
89 changes: 72 additions & 17 deletions src/exchange/exchange_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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)]
Expand Down Expand Up @@ -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<Address>,
}

Expand All @@ -73,7 +73,6 @@ pub enum Actions {
BatchModify(BulkModify),
ApproveAgent(ApproveAgent),
Withdraw3(Withdraw3),
SpotUser(SpotUser),
SendAsset(SendAsset),
VaultTransfer(VaultTransfer),
SpotSend(SpotSend),
Expand All @@ -82,6 +81,7 @@ pub enum Actions {
EvmUserModify(EvmUserModify),
ScheduleCancel(ScheduleCancel),
ClaimRewards(ClaimRewards),
UsdClassTransfer(UsdClassTransfer),
}

impl Actions {
Expand Down Expand Up @@ -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()))?;
Expand Down Expand Up @@ -218,25 +223,36 @@ impl ExchangeClient {

pub async fn class_transfer(
&self,
usdc: f64,
usd_amount: f64,
to_perp: bool,
wallet: Option<&PrivateKeySigner>,
) -> Result<ExchangeResponseStatus> {
// 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(
Expand Down Expand Up @@ -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(())
}
}
29 changes: 29 additions & 0 deletions tests/user_fees_mainnet.rs
Original file line number Diff line number Diff line change
@@ -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::<f64>().is_ok(),
"`fee_trial_escrow` should be numeric but was {}",
response.fee_trial_escrow
);
}
Loading