1use alloy_primitives::Address;
2use guilder_abstraction::{self, L2Update, Fill, AssetContext, PredictedFunding, Liquidation, BoxStream, Side, OrderSide, OrderStatus, OrderType, TimeInForce, OrderPlacement, Position, OpenOrder, UserFill, OrderUpdate, FundingPayment, Deposit, Withdrawal};
3use futures_util::{stream, SinkExt, StreamExt};
4use reqwest::Client;
5use rust_decimal::Decimal;
6use serde::Deserialize;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::str::FromStr;
10use tokio_tungstenite::{connect_async, tungstenite::Message};
11
12const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
13const HYPERLIQUID_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
14const HYPERLIQUID_WS_URL: &str = "wss://api.hyperliquid.xyz/ws";
15
16async fn parse_response<T: for<'de> serde::Deserialize<'de>>(resp: reqwest::Response) -> Result<T, String> {
17 let text = resp.text().await.map_err(|e| e.to_string())?;
18 serde_json::from_str(&text).map_err(|e| format!("{e}: {text}"))
19}
20
21pub struct HyperliquidClient {
22 client: Client,
23 user_address: Option<Address>,
24 private_key: Option<String>,
25}
26
27impl HyperliquidClient {
28 pub fn new() -> Self {
29 HyperliquidClient { client: Client::new(), user_address: None, private_key: None }
30 }
31
32 pub fn with_auth(user_address: Address, private_key: String) -> Self {
33 HyperliquidClient { client: Client::new(), user_address: Some(user_address), private_key: Some(private_key) }
34 }
35
36 fn require_user_address(&self) -> Result<String, String> {
37 self.user_address
38 .map(|a| format!("{:#x}", a))
39 .ok_or_else(|| "user address required: use HyperliquidClient::with_auth".to_string())
40 }
41
42 fn require_private_key(&self) -> Result<&str, String> {
43 self.private_key.as_deref().ok_or_else(|| "private key required: use HyperliquidClient::with_auth".to_string())
44 }
45
46 async fn get_asset_index(&self, symbol: &str) -> Result<usize, String> {
47 let resp = self.client
48 .post(HYPERLIQUID_INFO_URL)
49 .json(&serde_json::json!({"type": "meta"}))
50 .send()
51 .await
52 .map_err(|e| e.to_string())?;
53 let meta: MetaResponse = parse_response(resp).await?;
54 meta.universe.iter()
55 .position(|a| a.name == symbol)
56 .ok_or_else(|| format!("symbol {} not found", symbol))
57 }
58
59 async fn submit_signed_action(&self, action: Value, vault_address: Option<&str>) -> Result<Value, String> {
60 let private_key = self.require_private_key()?;
61 let nonce = std::time::SystemTime::now()
62 .duration_since(std::time::UNIX_EPOCH)
63 .unwrap()
64 .as_millis() as u64;
65
66 let (r, s, v) = sign_action(private_key, &action, vault_address, nonce)?;
67
68 let payload = serde_json::json!({
69 "action": action,
70 "nonce": nonce,
71 "signature": {"r": r, "s": s, "v": v},
72 "vaultAddress": null
73 });
74
75 let resp = self.client
76 .post(HYPERLIQUID_EXCHANGE_URL)
77 .json(&payload)
78 .send()
79 .await
80 .map_err(|e| e.to_string())?;
81
82 let body: Value = parse_response(resp).await?;
83 if body["status"].as_str() == Some("err") {
84 return Err(body["response"].as_str().unwrap_or("unknown error").to_string());
85 }
86 Ok(body)
87 }
88}
89
90#[derive(Deserialize)]
93struct MetaResponse {
94 universe: Vec<AssetInfo>,
95}
96
97#[derive(Deserialize)]
98struct AssetInfo {
99 name: String,
100}
101
102type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
103
104#[derive(Deserialize)]
105#[serde(rename_all = "camelCase")]
106#[allow(dead_code)]
107struct RestAssetCtx {
108 open_interest: String,
109 funding: String,
110 mark_px: String,
111 day_ntl_vlm: String,
112 mid_px: Option<String>,
113 oracle_px: Option<String>,
114 premium: Option<String>,
115 prev_day_px: Option<String>,
116}
117
118#[derive(Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct ClearinghouseStateResponse {
121 margin_summary: MarginSummary,
122 asset_positions: Vec<AssetPosition>,
123}
124
125#[derive(Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct MarginSummary {
128 account_value: String,
129}
130
131#[derive(Deserialize)]
132struct AssetPosition {
133 position: PositionDetail,
134}
135
136#[derive(Deserialize)]
137#[serde(rename_all = "camelCase")]
138struct PositionDetail {
139 coin: String,
140 szi: String,
142 entry_px: Option<String>,
143}
144
145#[derive(Deserialize)]
146#[serde(rename_all = "camelCase")]
147struct RestOpenOrder {
148 coin: String,
149 side: String,
150 limit_px: String,
151 sz: String,
152 oid: i64,
153 orig_sz: String,
154}
155
156type PredictedFundingsResponse = Vec<(String, Vec<(String, Option<PredictedFundingEntry>)>)>;
159
160#[derive(Deserialize)]
161#[serde(rename_all = "camelCase")]
162struct PredictedFundingEntry {
163 funding_rate: String,
164 next_funding_time: i64,
165}
166
167#[derive(Deserialize)]
170struct WsEnvelope {
171 channel: String,
172 #[serde(default)]
173 data: Value,
174}
175
176#[derive(Deserialize)]
177struct WsBook {
178 coin: String,
179 levels: Vec<Vec<WsLevel>>,
180 time: i64,
181}
182
183#[derive(Deserialize)]
184struct WsLevel {
185 px: String,
186 sz: String,
187}
188
189#[derive(Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct WsAssetCtx {
192 coin: String,
193 ctx: WsPerpsCtx,
194}
195
196#[derive(Deserialize)]
197#[serde(rename_all = "camelCase")]
198struct WsPerpsCtx {
199 open_interest: String,
200 funding: String,
201 mark_px: String,
202 day_ntl_vlm: String,
203 mid_px: Option<String>,
204 oracle_px: Option<String>,
205 premium: Option<String>,
206 prev_day_px: Option<String>,
207}
208
209#[derive(Deserialize)]
210struct WsUserEvent {
211 liquidation: Option<WsLiquidation>,
212 fills: Option<Vec<WsUserFill>>,
213 funding: Option<WsFunding>,
214}
215
216#[derive(Deserialize)]
217struct WsLiquidation {
218 liquidated_user: String,
219 liquidated_ntl_pos: String,
220 liquidated_account_value: String,
221}
222
223#[derive(Deserialize)]
224struct WsUserFill {
225 coin: String,
226 px: String,
227 sz: String,
228 side: String,
229 time: i64,
230 oid: i64,
231 fee: String,
232}
233
234#[derive(Deserialize)]
235struct WsFunding {
236 time: i64,
237 coin: String,
238 usdc: String,
239}
240
241#[derive(Deserialize)]
242struct WsTrade {
243 coin: String,
244 side: String,
245 px: String,
246 sz: String,
247 time: i64,
248 tid: i64,
249}
250
251#[derive(Deserialize)]
252struct WsOrderUpdate {
253 order: WsOrderInfo,
254 status: String,
255 #[serde(rename = "statusTimestamp")]
256 status_timestamp: i64,
257}
258
259#[derive(Deserialize)]
260#[serde(rename_all = "camelCase")]
261struct WsOrderInfo {
262 coin: String,
263 side: String,
264 limit_px: String,
265 sz: String,
266 oid: i64,
267 orig_sz: String,
268}
269
270#[derive(Deserialize)]
273struct WsLedgerUpdates {
274 updates: Vec<WsLedgerEntry>,
275}
276
277#[derive(Deserialize)]
278struct WsLedgerEntry {
279 time: i64,
280 delta: WsLedgerDelta,
281}
282
283#[derive(Deserialize)]
284struct WsLedgerDelta {
285 #[serde(rename = "type")]
286 kind: String,
287 usdc: Option<String>,
288}
289
290fn parse_decimal(s: &str) -> Option<Decimal> {
293 Decimal::from_str(s).ok()
294}
295
296fn keccak256(data: &[u8]) -> [u8; 32] {
297 use sha3::{Digest, Keccak256};
298 Keccak256::digest(data).into()
299}
300
301fn hyperliquid_domain_separator() -> [u8; 32] {
303 let type_hash = keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
304 let name_hash = keccak256(b"Exchange");
305 let version_hash = keccak256(b"1");
306 let mut chain_id = [0u8; 32];
307 chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
308 let verifying_contract = [0u8; 32];
309
310 let mut data = [0u8; 160];
311 data[..32].copy_from_slice(&type_hash);
312 data[32..64].copy_from_slice(&name_hash);
313 data[64..96].copy_from_slice(&version_hash);
314 data[96..128].copy_from_slice(&chain_id);
315 data[128..160].copy_from_slice(&verifying_contract);
316 keccak256(&data)
317}
318
319fn sign_action(private_key: &str, action: &Value, vault_address: Option<&str>, nonce: u64) -> Result<(String, String, u8), String> {
322 use k256::ecdsa::SigningKey;
323
324 let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
326 let mut data = msgpack_bytes;
327 data.extend_from_slice(&nonce.to_be_bytes());
328 match vault_address {
329 None => data.push(0u8),
330 Some(addr) => {
331 data.push(1u8);
332 let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
333 .map_err(|e| format!("invalid vault address: {}", e))?;
334 data.extend_from_slice(&addr_bytes);
335 }
336 }
337 let connection_id = keccak256(&data);
338
339 let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
341 let source_hash = keccak256(b"a"); let mut struct_data = [0u8; 96];
343 struct_data[..32].copy_from_slice(&agent_type_hash);
344 struct_data[32..64].copy_from_slice(&source_hash);
345 struct_data[64..96].copy_from_slice(&connection_id);
346 let struct_hash = keccak256(&struct_data);
347
348 let domain_sep = hyperliquid_domain_separator();
350 let mut final_data = Vec::with_capacity(66);
351 final_data.extend_from_slice(b"\x19\x01");
352 final_data.extend_from_slice(&domain_sep);
353 final_data.extend_from_slice(&struct_hash);
354 let final_hash = keccak256(&final_data);
355
356 let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
358 .map_err(|e| format!("invalid private key: {}", e))?;
359 let signing_key = SigningKey::from_bytes(key_bytes.as_slice().into())
360 .map_err(|e| e.to_string())?;
361 let (sig, recovery_id) = signing_key.sign_prehash_recoverable(&final_hash)
362 .map_err(|e| e.to_string())?;
363
364 let sig_bytes = sig.to_bytes();
365 let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
366 let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
367 let v = 27u8 + recovery_id.to_byte();
368
369 Ok((r, s, v))
370}
371
372fn ws_subscribe<T, F>(subscription: Value, mut parse: F) -> BoxStream<Result<T, String>>
381where
382 T: Send + 'static,
383 F: FnMut(WsEnvelope) -> Vec<T> + Send + 'static,
384{
385 Box::pin(async_stream::stream! {
386 loop {
387 let ws = match connect_async(HYPERLIQUID_WS_URL).await {
388 Ok((ws, _)) => ws,
389 Err(e) => {
390 yield Err(format!("ws connect failed: {e} — reconnecting in 5s"));
391 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
392 continue;
393 }
394 };
395 let (mut sink, mut stream) = ws.split();
396 if let Err(e) = sink.send(Message::Text(subscription.to_string().into())).await {
397 yield Err(format!("ws subscribe failed: {e} — reconnecting in 5s"));
398 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
399 continue;
400 }
401 let mut ping_interval = tokio::time::interval_at(
402 tokio::time::Instant::now() + std::time::Duration::from_secs(50),
403 std::time::Duration::from_secs(50),
404 );
405 let should_reconnect;
406 loop {
407 tokio::select! {
408 _ = ping_interval.tick() => {
409 if let Err(e) = sink.send(Message::Text(r#"{"method":"ping"}"#.to_string().into())).await {
410 yield Err(format!("ws ping failed: {e} — reconnecting in 5s"));
411 should_reconnect = true;
412 break;
413 }
414 }
415 msg = stream.next() => {
416 match msg {
417 None => {
418 yield Err("ws stream ended — reconnecting in 5s".to_string());
419 should_reconnect = true;
420 break;
421 }
422 Some(Err(e)) => {
423 yield Err(format!("ws error: {e} — reconnecting in 5s"));
424 should_reconnect = true;
425 break;
426 }
427 Some(Ok(Message::Ping(data))) => { let _ = sink.send(Message::Pong(data)).await; }
428 Some(Ok(Message::Close(_))) => {
429 yield Err("websocket closed — reconnecting in 5s".to_string());
430 should_reconnect = true;
431 break;
432 }
433 Some(Ok(Message::Text(text))) => {
434 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
435 yield Err(format!("unexpected ws message: {text}"));
436 continue;
437 };
438 match env.channel.as_str() {
439 "pong" | "subscriptionResponse" => {}
440 _ => {
441 for item in parse(env) {
442 yield Ok(item);
443 }
444 }
445 }
446 }
447 Some(Ok(_)) => {}
448 }
449 }
450 }
451 }
452 if should_reconnect {
453 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
454 }
455 }
456 })
457}
458
459#[allow(async_fn_in_trait)]
462impl guilder_abstraction::TestServer for HyperliquidClient {
463 async fn ping(&self) -> Result<bool, String> {
465 self.client
466 .post(HYPERLIQUID_INFO_URL)
467 .json(&serde_json::json!({"type": "allMids"}))
468 .send()
469 .await
470 .map(|r| r.status().is_success())
471 .map_err(|e| e.to_string())
472 }
473
474 async fn get_server_time(&self) -> Result<i64, String> {
476 Ok(std::time::SystemTime::now()
477 .duration_since(std::time::UNIX_EPOCH)
478 .map(|d| d.as_millis() as i64)
479 .unwrap_or(0))
480 }
481}
482
483#[allow(async_fn_in_trait)]
484impl guilder_abstraction::GetMarketData for HyperliquidClient {
485 async fn get_symbol(&self) -> Result<Vec<String>, String> {
487 let resp = self.client
488 .post(HYPERLIQUID_INFO_URL)
489 .json(&serde_json::json!({"type": "meta"}))
490 .send()
491 .await
492 .map_err(|e| e.to_string())?;
493 parse_response::<MetaResponse>(resp).await
494 .map(|r| r.universe.into_iter().map(|a| a.name).collect())
495 }
496
497 async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
499 let resp = self.client
500 .post(HYPERLIQUID_INFO_URL)
501 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
502 .send()
503 .await
504 .map_err(|e| e.to_string())?;
505 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
506 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
507 meta.universe.iter()
508 .position(|a| a.name == symbol)
509 .and_then(|i| ctxs.get(i))
510 .and_then(|ctx| parse_decimal(&ctx.open_interest))
511 .ok_or_else(|| format!("symbol {} not found", symbol))
512 }
513
514 async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
516 let resp = self.client
517 .post(HYPERLIQUID_INFO_URL)
518 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
519 .send()
520 .await
521 .map_err(|e| e.to_string())?;
522 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
523 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
524 let idx = meta.universe.iter()
525 .position(|a| a.name == symbol)
526 .ok_or_else(|| format!("symbol {} not found", symbol))?;
527 let ctx = ctxs.get(idx).ok_or_else(|| format!("symbol {} not found", symbol))?;
528 Ok(AssetContext {
529 symbol,
530 open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
531 funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
532 mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
533 day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
534 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
535 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
536 premium: ctx.premium.as_deref().and_then(parse_decimal),
537 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
538 })
539 }
540
541 async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
544 let resp = self.client
545 .post(HYPERLIQUID_INFO_URL)
546 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
547 .send()
548 .await
549 .map_err(|e| e.to_string())?;
550 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
551 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
552 let mut result = Vec::with_capacity(meta.universe.len());
553 for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
554 let Some(open_interest) = parse_decimal(&ctx.open_interest) else { continue };
555 let Some(funding_rate) = parse_decimal(&ctx.funding) else { continue };
556 let Some(mark_price) = parse_decimal(&ctx.mark_px) else { continue };
557 let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else { continue };
558 result.push(AssetContext {
559 symbol: asset.name.clone(),
560 open_interest,
561 funding_rate,
562 mark_price,
563 day_volume,
564 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
565 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
566 premium: ctx.premium.as_deref().and_then(parse_decimal),
567 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
568 });
569 }
570 Ok(result)
571 }
572
573 async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
576 let resp = self.client
577 .post(HYPERLIQUID_INFO_URL)
578 .json(&serde_json::json!({"type": "l2Book", "coin": symbol}))
579 .send()
580 .await
581 .map_err(|e| e.to_string())?;
582 let book: Option<WsBook> = parse_response(resp).await?;
583 let book = match book {
584 Some(b) => b,
585 None => return Ok(vec![]),
586 };
587 let mut levels = Vec::new();
588 for level in book.levels.first().into_iter().flatten() {
589 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
590 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
591 }
592 }
593 for level in book.levels.get(1).into_iter().flatten() {
594 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
595 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
596 }
597 }
598 Ok(levels)
599 }
600
601 async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
603 let resp = self.client
604 .post(HYPERLIQUID_INFO_URL)
605 .json(&serde_json::json!({"type": "allMids"}))
606 .send()
607 .await
608 .map_err(|e| e.to_string())?;
609 parse_response::<HashMap<String, String>>(resp).await?
610 .get(&symbol)
611 .and_then(|s| parse_decimal(s))
612 .ok_or_else(|| format!("symbol {} not found", symbol))
613 }
614
615 async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
618 let resp = self.client
619 .post(HYPERLIQUID_INFO_URL)
620 .json(&serde_json::json!({"type": "predictedFundings"}))
621 .send()
622 .await
623 .map_err(|e| e.to_string())?;
624 let data: PredictedFundingsResponse = parse_response(resp).await?;
625 let mut result = Vec::new();
626 for (symbol, venues) in data {
627 for (venue, entry) in venues {
628 let Some(entry) = entry else { continue };
629 if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
630 result.push(PredictedFunding {
631 symbol: symbol.clone(),
632 venue,
633 funding_rate,
634 next_funding_time_ms: entry.next_funding_time,
635 });
636 }
637 }
638 }
639 Ok(result)
640 }
641}
642
643#[allow(async_fn_in_trait)]
644impl guilder_abstraction::ManageOrder for HyperliquidClient {
645 async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
648 let asset_idx = self.get_asset_index(&symbol).await?;
649 let is_buy = matches!(side, OrderSide::Buy);
650
651 let tif_str = match time_in_force {
652 TimeInForce::Gtc => "Gtc",
653 TimeInForce::Ioc => "Ioc",
654 TimeInForce::Fok => "Fok",
655 };
656 let order_type_val = match order_type {
658 OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
659 OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
660 };
661
662 let action = serde_json::json!({
663 "type": "order",
664 "orders": [{
665 "a": asset_idx,
666 "b": is_buy,
667 "p": price.to_string(),
668 "s": volume.to_string(),
669 "r": false,
670 "t": order_type_val
671 }],
672 "grouping": "na"
673 });
674
675 let resp = self.submit_signed_action(action, None).await?;
676 let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
677 .as_i64()
678 .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
679 .ok_or_else(|| format!("unexpected response: {}", resp))?;
680
681 let timestamp_ms = std::time::SystemTime::now()
682 .duration_since(std::time::UNIX_EPOCH)
683 .unwrap()
684 .as_millis() as i64;
685
686 Ok(OrderPlacement { order_id: oid, symbol, side, price, quantity: volume, timestamp_ms })
687 }
688
689 async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
692 let user = self.require_user_address()?;
693
694 let resp = self.client
695 .post(HYPERLIQUID_INFO_URL)
696 .json(&serde_json::json!({"type": "openOrders", "user": user}))
697 .send()
698 .await
699 .map_err(|e| e.to_string())?;
700 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
701 let order = orders.iter()
702 .find(|o| o.oid == cloid)
703 .ok_or_else(|| format!("order {} not found", cloid))?;
704
705 let asset_idx = self.get_asset_index(&order.coin).await?;
706 let is_buy = order.side == "B";
707
708 let action = serde_json::json!({
709 "type": "batchModify",
710 "modifies": [{
711 "oid": cloid,
712 "order": {
713 "a": asset_idx,
714 "b": is_buy,
715 "p": price.to_string(),
716 "s": volume.to_string(),
717 "r": false,
718 "t": {"limit": {"tif": "Gtc"}}
719 }
720 }]
721 });
722
723 self.submit_signed_action(action, None).await?;
724 Ok(cloid)
725 }
726
727 async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
730 let user = self.require_user_address()?;
731
732 let resp = self.client
733 .post(HYPERLIQUID_INFO_URL)
734 .json(&serde_json::json!({"type": "openOrders", "user": user}))
735 .send()
736 .await
737 .map_err(|e| e.to_string())?;
738 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
739 let order = orders.iter()
740 .find(|o| o.oid == cloid)
741 .ok_or_else(|| format!("order {} not found", cloid))?;
742
743 let asset_idx = self.get_asset_index(&order.coin).await?;
744 let action = serde_json::json!({
745 "type": "cancel",
746 "cancels": [{"a": asset_idx, "o": cloid}]
747 });
748
749 self.submit_signed_action(action, None).await?;
750 Ok(cloid)
751 }
752
753 async fn cancel_all_order(&self) -> Result<bool, String> {
756 let user = self.require_user_address()?;
757
758 let resp = self.client
759 .post(HYPERLIQUID_INFO_URL)
760 .json(&serde_json::json!({"type": "openOrders", "user": user}))
761 .send()
762 .await
763 .map_err(|e| e.to_string())?;
764 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
765 if orders.is_empty() {
766 return Ok(true);
767 }
768
769 let meta_resp = self.client
770 .post(HYPERLIQUID_INFO_URL)
771 .json(&serde_json::json!({"type": "meta"}))
772 .send()
773 .await
774 .map_err(|e| e.to_string())?;
775 let meta: MetaResponse = parse_response(meta_resp).await?;
776
777 let cancels: Vec<Value> = orders.iter()
778 .filter_map(|o| {
779 let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
780 Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
781 })
782 .collect();
783
784 let action = serde_json::json!({"type": "cancel", "cancels": cancels});
785 self.submit_signed_action(action, None).await?;
786 Ok(true)
787 }
788}
789
790#[allow(async_fn_in_trait)]
791impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
792 fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
793 let sub = serde_json::json!({
794 "method": "subscribe",
795 "subscription": {"type": "l2Book", "coin": symbol}
796 });
797 ws_subscribe(sub, |env| {
798 if env.channel != "l2Book" { return vec![]; }
799 let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { return vec![]; };
800 let mut items = Vec::new();
801 for level in book.levels.first().into_iter().flatten() {
802 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
803 items.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
804 }
805 }
806 for level in book.levels.get(1).into_iter().flatten() {
807 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
808 items.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
809 }
810 }
811 items
812 })
813 }
814
815 fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
816 let sub = serde_json::json!({
817 "method": "subscribe",
818 "subscription": {"type": "activeAssetCtx", "coin": symbol}
819 });
820 ws_subscribe(sub, |env| {
821 if env.channel != "activeAssetCtx" { return vec![]; }
822 let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { return vec![]; };
823 let ctx = &update.ctx;
824 let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
825 parse_decimal(&ctx.open_interest),
826 parse_decimal(&ctx.funding),
827 parse_decimal(&ctx.mark_px),
828 parse_decimal(&ctx.day_ntl_vlm),
829 ) else { return vec![]; };
830 vec![AssetContext {
831 symbol: update.coin,
832 open_interest,
833 funding_rate,
834 mark_price,
835 day_volume,
836 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
837 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
838 premium: ctx.premium.as_deref().and_then(parse_decimal),
839 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
840 }]
841 })
842 }
843
844 fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
845 let sub = serde_json::json!({
846 "method": "subscribe",
847 "subscription": {"type": "userEvents", "user": user}
848 });
849 ws_subscribe(sub, |env| {
850 if env.channel != "userEvents" { return vec![]; }
851 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
852 let Some(liq) = event.liquidation else { return vec![]; };
853 let (Some(notional_position), Some(account_value)) = (
854 parse_decimal(&liq.liquidated_ntl_pos),
855 parse_decimal(&liq.liquidated_account_value),
856 ) else { return vec![]; };
857 vec![Liquidation {
858 symbol: String::new(),
859 side: OrderSide::Sell,
860 liquidated_user: liq.liquidated_user,
861 notional_position,
862 account_value,
863 }]
864 })
865 }
866
867 fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
868 let sub = serde_json::json!({
869 "method": "subscribe",
870 "subscription": {"type": "trades", "coin": symbol}
871 });
872 ws_subscribe(sub, |env| {
873 if env.channel != "trades" { return vec![]; }
874 let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { return vec![]; };
875 trades.into_iter().filter_map(|trade| {
876 let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
877 let price = parse_decimal(&trade.px)?;
878 let volume = parse_decimal(&trade.sz)?;
879 Some(Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid })
880 }).collect()
881 })
882 }
883}
884
885#[allow(async_fn_in_trait)]
886impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
887 async fn get_positions(&self) -> Result<Vec<Position>, String> {
890 let user = self.require_user_address()?;
891 let resp = self.client
892 .post(HYPERLIQUID_INFO_URL)
893 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
894 .send()
895 .await
896 .map_err(|e| e.to_string())?;
897 let state: ClearinghouseStateResponse = parse_response(resp).await?;
898
899 Ok(state.asset_positions.into_iter()
900 .filter_map(|ap| {
901 let p = ap.position;
902 let size = parse_decimal(&p.szi)?;
903 if size.is_zero() { return None; }
904 let entry_price = p.entry_px.as_deref().and_then(parse_decimal).unwrap_or_default();
905 let side = if size > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell };
906 Some(Position { symbol: p.coin, side, size: size.abs(), entry_price })
907 })
908 .collect())
909 }
910
911 async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
914 let user = self.require_user_address()?;
915 let resp = self.client
916 .post(HYPERLIQUID_INFO_URL)
917 .json(&serde_json::json!({"type": "openOrders", "user": user}))
918 .send()
919 .await
920 .map_err(|e| e.to_string())?;
921 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
922
923 Ok(orders.into_iter()
924 .filter_map(|o| {
925 let price = parse_decimal(&o.limit_px)?;
926 let quantity = parse_decimal(&o.orig_sz)?;
927 let remaining = parse_decimal(&o.sz)?;
928 let filled_quantity = quantity - remaining;
929 let side = if o.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
930 Some(OpenOrder { order_id: o.oid, symbol: o.coin, side, price, quantity, filled_quantity })
931 })
932 .collect())
933 }
934
935 async fn get_collateral(&self) -> Result<Decimal, String> {
937 let user = self.require_user_address()?;
938 let resp = self.client
939 .post(HYPERLIQUID_INFO_URL)
940 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
941 .send()
942 .await
943 .map_err(|e| e.to_string())?;
944 let state: ClearinghouseStateResponse = parse_response(resp).await?;
945 parse_decimal(&state.margin_summary.account_value)
946 .ok_or_else(|| "invalid account value".to_string())
947 }
948}
949
950#[allow(async_fn_in_trait)]
951impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
952 fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
953 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
954 let sub = serde_json::json!({
955 "method": "subscribe",
956 "subscription": {"type": "userEvents", "user": format!("{:#x}", addr)}
957 });
958 ws_subscribe(sub, |env| {
959 if env.channel != "userEvents" { return vec![]; }
960 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
961 event.fills.unwrap_or_default().into_iter().filter_map(|fill| {
962 let side = if fill.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
963 let price = parse_decimal(&fill.px)?;
964 let quantity = parse_decimal(&fill.sz)?;
965 let fee_usd = parse_decimal(&fill.fee)?;
966 Some(UserFill { order_id: fill.oid, symbol: fill.coin, side, price, quantity, fee_usd, timestamp_ms: fill.time })
967 }).collect()
968 })
969 }
970
971 fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
972 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
973 let sub = serde_json::json!({
974 "method": "subscribe",
975 "subscription": {"type": "orderUpdates", "user": format!("{:#x}", addr)}
976 });
977 ws_subscribe(sub, |env| {
978 if env.channel != "orderUpdates" { return vec![]; }
979 let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else { return vec![]; };
980 updates.into_iter().map(|upd| {
981 let status = match upd.status.as_str() {
982 "open" => OrderStatus::Placed,
983 "filled" => OrderStatus::Filled,
984 "canceled" | "cancelled" => OrderStatus::Cancelled,
985 _ => OrderStatus::PartiallyFilled,
986 };
987 let side = if upd.order.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
988 OrderUpdate {
989 order_id: upd.order.oid,
990 symbol: upd.order.coin,
991 status,
992 side: Some(side),
993 price: parse_decimal(&upd.order.limit_px),
994 quantity: parse_decimal(&upd.order.orig_sz),
995 remaining_quantity: parse_decimal(&upd.order.sz),
996 timestamp_ms: upd.status_timestamp,
997 }
998 }).collect()
999 })
1000 }
1001
1002 fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1003 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1004 let sub = serde_json::json!({
1005 "method": "subscribe",
1006 "subscription": {"type": "userEvents", "user": format!("{:#x}", addr)}
1007 });
1008 ws_subscribe(sub, |env| {
1009 if env.channel != "userEvents" { return vec![]; }
1010 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
1011 let Some(funding) = event.funding else { return vec![]; };
1012 let Some(amount_usd) = parse_decimal(&funding.usdc) else { return vec![]; };
1013 vec![FundingPayment { symbol: funding.coin, amount_usd, timestamp_ms: funding.time }]
1014 })
1015 }
1016
1017 fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1018 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1019 let sub = serde_json::json!({
1020 "method": "subscribe",
1021 "subscription": {"type": "userNonFundingLedgerUpdates", "user": format!("{:#x}", addr)}
1022 });
1023 ws_subscribe(sub, |env| {
1024 if env.channel != "userNonFundingLedgerUpdates" { return vec![]; }
1025 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { return vec![]; };
1026 ledger.updates.into_iter().filter_map(|e| {
1027 if e.delta.kind != "deposit" { return None; }
1028 let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1029 Some(Deposit { asset: "USDC".to_string(), amount_usd, timestamp_ms: e.time })
1030 }).collect()
1031 })
1032 }
1033
1034 fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1035 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1036 let sub = serde_json::json!({
1037 "method": "subscribe",
1038 "subscription": {"type": "userNonFundingLedgerUpdates", "user": format!("{:#x}", addr)}
1039 });
1040 ws_subscribe(sub, |env| {
1041 if env.channel != "userNonFundingLedgerUpdates" { return vec![]; }
1042 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { return vec![]; };
1043 ledger.updates.into_iter().filter_map(|e| {
1044 if e.delta.kind != "withdraw" { return None; }
1045 let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1046 Some(Withdrawal { asset: "USDC".to_string(), amount_usd, timestamp_ms: e.time })
1047 }).collect()
1048 })
1049 }
1050}