Skip to content

Commit 922844f

Browse files
authored
Add SendAsset action with EIP-712 signing (#155)
Implement sendAsset functionality using proper EIP-712 typed data signing, aligned with the Hyperliquid API and hyperliquid-python-sdk specification. Key changes: - Add SendAsset struct with EIP-712 implementation - Implement send_asset() method using sign_typed_data - Support all required parameters: destination, sourceDex, destinationDex, token, amount - Handle fromSubAccount for vault/subaccount transfers - Support mainnet/testnet chain selection - Set vault_address to None for sendAsset actions (handled in post method) - Add comprehensive tests for signing with mainnet/testnet and vault scenarios This follows the same pattern as usdClassTransfer, replacing legacy L1 action signing with EIP-712 typed data for proper API compatibility.
1 parent 5aca1a0 commit 922844f

File tree

3 files changed

+147
-5
lines changed

3 files changed

+147
-5
lines changed

src/exchange/actions.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,42 @@ pub struct ClassTransfer {
197197
pub to_perp: bool,
198198
}
199199

200+
#[derive(Serialize, Deserialize, Debug, Clone)]
201+
#[serde(rename_all = "camelCase")]
202+
pub struct SendAsset {
203+
#[serde(serialize_with = "serialize_hex")]
204+
pub signature_chain_id: u64,
205+
pub hyperliquid_chain: String,
206+
pub destination: String,
207+
pub source_dex: String,
208+
pub destination_dex: String,
209+
pub token: String,
210+
pub amount: String,
211+
pub from_sub_account: String,
212+
pub nonce: u64,
213+
}
214+
215+
impl Eip712 for SendAsset {
216+
fn domain(&self) -> Eip712Domain {
217+
eip_712_domain(self.signature_chain_id)
218+
}
219+
220+
fn struct_hash(&self) -> B256 {
221+
let items = (
222+
keccak256("HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)"),
223+
keccak256(&self.hyperliquid_chain),
224+
keccak256(&self.destination),
225+
keccak256(&self.source_dex),
226+
keccak256(&self.destination_dex),
227+
keccak256(&self.token),
228+
keccak256(&self.amount),
229+
keccak256(&self.from_sub_account),
230+
&self.nonce,
231+
);
232+
keccak256(items.abi_encode())
233+
}
234+
}
235+
200236
#[derive(Serialize, Deserialize, Debug, Clone)]
201237
#[serde(rename_all = "camelCase")]
202238
pub struct VaultTransfer {

src/exchange/exchange_client.rs

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use crate::{
1212
exchange::{
1313
actions::{
1414
ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, ClaimRewards,
15-
EvmUserModify, ScheduleCancel, SetReferrer, UpdateIsolatedMargin, UpdateLeverage,
16-
UsdSend,
15+
EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, UpdateIsolatedMargin,
16+
UpdateLeverage, UsdSend,
1717
},
1818
cancel::{CancelRequest, CancelRequestCloid, ClientCancelRequestCloid},
1919
modify::{ClientModifyRequest, ModifyRequest},
@@ -74,6 +74,7 @@ pub enum Actions {
7474
ApproveAgent(ApproveAgent),
7575
Withdraw3(Withdraw3),
7676
SpotUser(SpotUser),
77+
SendAsset(SendAsset),
7778
VaultTransfer(VaultTransfer),
7879
SpotSend(SpotSend),
7980
SetReferrer(SetReferrer),
@@ -238,6 +239,49 @@ impl ExchangeClient {
238239
self.post(action, signature, timestamp).await
239240
}
240241

242+
pub async fn send_asset(
243+
&self,
244+
destination: &str,
245+
source_dex: &str,
246+
destination_dex: &str,
247+
token: &str,
248+
amount: f64,
249+
wallet: Option<&PrivateKeySigner>,
250+
) -> Result<ExchangeResponseStatus> {
251+
let wallet = wallet.unwrap_or(&self.wallet);
252+
253+
let hyperliquid_chain = if self.http_client.is_mainnet() {
254+
"Mainnet".to_string()
255+
} else {
256+
"Testnet".to_string()
257+
};
258+
259+
let timestamp = next_nonce();
260+
261+
// Build fromSubAccount string (similar to Python SDK)
262+
let from_sub_account = self
263+
.vault_address
264+
.map_or_else(String::new, |vault_addr| format!("{vault_addr:?}"));
265+
266+
let send_asset = SendAsset {
267+
signature_chain_id: 421614,
268+
hyperliquid_chain,
269+
destination: destination.to_string(),
270+
source_dex: source_dex.to_string(),
271+
destination_dex: destination_dex.to_string(),
272+
token: token.to_string(),
273+
amount: amount.to_string(),
274+
from_sub_account,
275+
nonce: timestamp,
276+
};
277+
278+
let signature = sign_typed_data(&send_asset, wallet)?;
279+
let action = serde_json::to_value(Actions::SendAsset(send_asset))
280+
.map_err(|e| Error::JsonParse(e.to_string()))?;
281+
282+
self.post(action, signature, timestamp).await
283+
}
284+
241285
pub async fn vault_transfer(
242286
&self,
243287
is_deposit: bool,
@@ -1063,4 +1107,61 @@ mod tests {
10631107

10641108
Ok(())
10651109
}
1110+
1111+
#[test]
1112+
fn test_send_asset_signing() -> Result<()> {
1113+
let wallet = get_wallet()?;
1114+
1115+
// Test mainnet - send asset to another address
1116+
let mainnet_send = SendAsset {
1117+
signature_chain_id: 421614,
1118+
hyperliquid_chain: "Mainnet".to_string(),
1119+
destination: "0x1234567890123456789012345678901234567890".to_string(),
1120+
source_dex: "".to_string(),
1121+
destination_dex: "spot".to_string(),
1122+
token: "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2".to_string(),
1123+
amount: "100".to_string(),
1124+
from_sub_account: "".to_string(),
1125+
nonce: 1583838,
1126+
};
1127+
1128+
let mainnet_signature = sign_typed_data(&mainnet_send, &wallet)?;
1129+
// Signature generated successfully - just verify it's a valid signature object
1130+
1131+
// Test testnet - send different token
1132+
let testnet_send = SendAsset {
1133+
signature_chain_id: 421614,
1134+
hyperliquid_chain: "Testnet".to_string(),
1135+
destination: "0x1234567890123456789012345678901234567890".to_string(),
1136+
source_dex: "spot".to_string(),
1137+
destination_dex: "".to_string(),
1138+
token: "USDC".to_string(),
1139+
amount: "50".to_string(),
1140+
from_sub_account: "".to_string(),
1141+
nonce: 1583838,
1142+
};
1143+
1144+
let testnet_signature = sign_typed_data(&testnet_send, &wallet)?;
1145+
// Verify signatures are different for mainnet vs testnet
1146+
assert_ne!(mainnet_signature, testnet_signature);
1147+
1148+
// Test with vault/subaccount
1149+
let vault_send = SendAsset {
1150+
signature_chain_id: 421614,
1151+
hyperliquid_chain: "Mainnet".to_string(),
1152+
destination: "0x1234567890123456789012345678901234567890".to_string(),
1153+
source_dex: "".to_string(),
1154+
destination_dex: "spot".to_string(),
1155+
token: "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2".to_string(),
1156+
amount: "100".to_string(),
1157+
from_sub_account: "0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd".to_string(),
1158+
nonce: 1583838,
1159+
};
1160+
1161+
let vault_signature = sign_typed_data(&vault_send, &wallet)?;
1162+
// Verify vault signature is different from non-vault signature
1163+
assert_ne!(mainnet_signature, vault_signature);
1164+
1165+
Ok(())
1166+
}
10661167
}

src/info/info_client.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use tokio::sync::mpsc::UnboundedSender;
77

88
use crate::{
99
info::{
10-
CandlesSnapshotResponse, FundingHistoryResponse, L2SnapshotResponse, OpenOrdersResponse,
11-
OrderInfo, RecentTradesResponse, UserFillsResponse, UserStateResponse, ActiveAssetDataResponse,
10+
ActiveAssetDataResponse, CandlesSnapshotResponse, FundingHistoryResponse,
11+
L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, UserFillsResponse,
12+
UserStateResponse,
1213
},
1314
meta::{AssetContext, Meta, SpotMeta, SpotMetaAndAssetCtxs},
1415
prelude::*,
@@ -311,7 +312,11 @@ impl InfoClient {
311312
self.send_info_request(input).await
312313
}
313314

314-
pub async fn active_asset_data(&self, user: Address, coin: String) -> Result<ActiveAssetDataResponse> {
315+
pub async fn active_asset_data(
316+
&self,
317+
user: Address,
318+
coin: String,
319+
) -> Result<ActiveAssetDataResponse> {
315320
let input = InfoRequest::ActiveAssetData { user, coin };
316321
self.send_info_request(input).await
317322
}

0 commit comments

Comments
 (0)