1use crate::rate_limiter::{RestRateLimiter, AddressRateLimiter};
2use alloy_primitives::Address;
3use futures_util::{stream, StreamExt};
4use guilder_abstraction::{
5 self, AssetContext, BoxStream, Deposit, Fill, FundingPayment, L2Update, Liquidation, OpenOrder,
6 OrderPlacement, OrderSide, OrderStatus, OrderType, OrderUpdate, Position, PredictedFunding,
7 Side, TimeInForce, UserFill, Withdrawal,
8};
9use reqwest::Client;
10use rust_decimal::Decimal;
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::str::FromStr;
15use std::sync::Arc;
16const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
17const HYPERLIQUID_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
18
19async fn parse_response<T: for<'de> serde::Deserialize<'de>>(
20 resp: reqwest::Response,
21) -> Result<T, String> {
22 let text = resp.text().await.map_err(|e| e.to_string())?;
23 serde_json::from_str(&text).map_err(|e| format!("{e}: {text}"))
24}
25
26pub struct HyperliquidClient {
27 client: Client,
28 user_address: Option<Address>,
29 private_key: Option<String>,
30 rest_limiter: Arc<RestRateLimiter>,
31 address_limiter: Arc<AddressRateLimiter>,
32 ws_mux: crate::ws::WsMux,
33}
34
35impl Default for HyperliquidClient {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl HyperliquidClient {
42 pub fn new() -> Self {
43 HyperliquidClient {
44 client: Client::new(),
45 user_address: None,
46 private_key: None,
47 rest_limiter: Arc::new(RestRateLimiter::new()),
48 address_limiter: Arc::new(AddressRateLimiter::new()),
49 ws_mux: crate::ws::WsMux::new(),
50 }
51 }
52
53 pub fn with_auth(user_address: Address, private_key: String) -> Self {
54 HyperliquidClient {
55 client: Client::new(),
56 user_address: Some(user_address),
57 private_key: Some(private_key),
58 rest_limiter: Arc::new(RestRateLimiter::new()),
59 address_limiter: Arc::new(AddressRateLimiter::new()),
60 ws_mux: crate::ws::WsMux::new(),
61 }
62 }
63
64 pub fn with_budgets(mut self, rest_weight: u32, addr_budget: u64) -> Self {
67 self.rest_limiter = Arc::new(RestRateLimiter::new_with_budget(rest_weight));
68 self.address_limiter = Arc::new(AddressRateLimiter::new_with_budget(addr_budget));
69 self
70 }
71
72 async fn info_post(&self, body: Value, weight: u32) -> Result<reqwest::Response, String> {
74 self.rest_limiter.acquire_blocking(weight).await;
75 self.client
76 .post(HYPERLIQUID_INFO_URL)
77 .json(&body)
78 .send()
79 .await
80 .map_err(|e| e.to_string())
81 }
82
83 async fn exchange_post(&self, body: Value, weight: u32) -> Result<reqwest::Response, String> {
86 self.rest_limiter.acquire_blocking(weight).await;
87 self.client
88 .post(HYPERLIQUID_EXCHANGE_URL)
89 .json(&body)
90 .send()
91 .await
92 .map_err(|e| e.to_string())
93 }
94
95 fn require_user_address(&self) -> Result<String, String> {
96 self.user_address
97 .map(|a| format!("{:#x}", a))
98 .ok_or_else(|| "user address required: use HyperliquidClient::with_auth".to_string())
99 }
100
101 fn require_private_key(&self) -> Result<&str, String> {
102 self.private_key
103 .as_deref()
104 .ok_or_else(|| "private key required: use HyperliquidClient::with_auth".to_string())
105 }
106
107 async fn get_asset_index(&self, symbol: &str) -> Result<usize, String> {
108 let resp = self
110 .info_post(serde_json::json!({"type": "meta"}), 20)
111 .await?;
112 let meta: MetaResponse = parse_response(resp).await?;
113 meta.universe
114 .iter()
115 .position(|a| a.name == symbol)
116 .ok_or_else(|| format!("symbol {} not found", symbol))
117 }
118
119 async fn submit_signed_action(
120 &self,
121 action: Value,
122 vault_address: Option<&str>,
123 ) -> Result<Value, String> {
124 let private_key = self.require_private_key()?;
125 let nonce = std::time::SystemTime::now()
126 .duration_since(std::time::UNIX_EPOCH)
127 .unwrap()
128 .as_millis() as u64;
129
130 let (r, s, v) = sign_action(private_key, &action, vault_address, nonce)?;
131
132 let payload = serde_json::json!({
133 "action": action,
134 "nonce": nonce,
135 "signature": {"r": r, "s": s, "v": v},
136 "vaultAddress": null
137 });
138
139 let resp = self.exchange_post(payload, 1).await?;
141
142 let body: Value = parse_response(resp).await?;
143 if body["status"].as_str() == Some("err") {
144 return Err(body["response"]
145 .as_str()
146 .unwrap_or("unknown error")
147 .to_string());
148 }
149 Ok(body)
150 }
151}
152
153#[derive(Deserialize)]
156struct MetaResponse {
157 universe: Vec<AssetInfo>,
158}
159
160#[derive(Deserialize)]
161struct AssetInfo {
162 name: String,
163}
164
165type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
166
167#[derive(Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(dead_code)]
170struct RestAssetCtx {
171 open_interest: String,
172 funding: String,
173 mark_px: String,
174 day_ntl_vlm: String,
175 mid_px: Option<String>,
176 oracle_px: Option<String>,
177 premium: Option<String>,
178 prev_day_px: Option<String>,
179}
180
181#[derive(Deserialize)]
182#[serde(rename_all = "camelCase")]
183struct ClearinghouseStateResponse {
184 margin_summary: MarginSummary,
185 asset_positions: Vec<AssetPosition>,
186}
187
188#[derive(Deserialize)]
189#[serde(rename_all = "camelCase")]
190struct MarginSummary {
191 account_value: String,
192}
193
194#[derive(Deserialize)]
195struct AssetPosition {
196 position: PositionDetail,
197}
198
199#[derive(Deserialize)]
200#[serde(rename_all = "camelCase")]
201struct PositionDetail {
202 coin: String,
203 szi: String,
205 entry_px: Option<String>,
206}
207
208#[derive(Deserialize)]
209#[serde(rename_all = "camelCase")]
210struct RestOpenOrder {
211 coin: String,
212 side: String,
213 limit_px: String,
214 sz: String,
215 oid: i64,
216 orig_sz: String,
217}
218
219type PredictedFundingsResponse = Vec<(String, Vec<(String, Option<PredictedFundingEntry>)>)>;
222
223#[derive(Deserialize)]
224#[serde(rename_all = "camelCase")]
225struct PredictedFundingEntry {
226 funding_rate: String,
227 next_funding_time: i64,
228}
229
230#[derive(Deserialize)]
233struct WsEnvelope {
234 channel: String,
235 #[serde(default)]
236 data: Value,
237}
238
239#[derive(Deserialize)]
240struct WsBook {
241 coin: String,
242 levels: Vec<Vec<WsLevel>>,
243 time: i64,
244}
245
246#[derive(Deserialize)]
247struct WsLevel {
248 px: String,
249 sz: String,
250}
251
252#[derive(Deserialize)]
253#[serde(rename_all = "camelCase")]
254struct WsAssetCtx {
255 coin: String,
256 ctx: WsPerpsCtx,
257}
258
259#[derive(Deserialize)]
260#[serde(rename_all = "camelCase")]
261struct WsPerpsCtx {
262 open_interest: String,
263 funding: String,
264 mark_px: String,
265 day_ntl_vlm: String,
266 mid_px: Option<String>,
267 oracle_px: Option<String>,
268 premium: Option<String>,
269 prev_day_px: Option<String>,
270}
271
272#[derive(Deserialize)]
273struct WsUserEvent {
274 liquidation: Option<WsLiquidation>,
275 fills: Option<Vec<WsUserFill>>,
276 funding: Option<WsFunding>,
277}
278
279#[derive(Deserialize)]
280struct WsLiquidation {
281 liquidated_user: String,
282 liquidated_ntl_pos: String,
283 liquidated_account_value: String,
284}
285
286#[derive(Deserialize)]
287struct WsUserFill {
288 coin: String,
289 px: String,
290 sz: String,
291 side: String,
292 time: i64,
293 oid: i64,
294 fee: String,
295}
296
297#[derive(Deserialize)]
298struct WsFunding {
299 time: i64,
300 coin: String,
301 usdc: String,
302}
303
304#[derive(Deserialize)]
305struct WsTrade {
306 coin: String,
307 side: String,
308 px: String,
309 sz: String,
310 time: i64,
311 tid: i64,
312}
313
314#[derive(Deserialize)]
315struct WsOrderUpdate {
316 order: WsOrderInfo,
317 status: String,
318 #[serde(rename = "statusTimestamp")]
319 status_timestamp: i64,
320}
321
322#[derive(Deserialize)]
323#[serde(rename_all = "camelCase")]
324struct WsOrderInfo {
325 coin: String,
326 side: String,
327 limit_px: String,
328 sz: String,
329 oid: i64,
330 orig_sz: String,
331}
332
333#[derive(Deserialize)]
336struct WsLedgerUpdates {
337 updates: Vec<WsLedgerEntry>,
338}
339
340#[derive(Deserialize)]
341struct WsLedgerEntry {
342 time: i64,
343 delta: WsLedgerDelta,
344}
345
346#[derive(Deserialize)]
347struct WsLedgerDelta {
348 #[serde(rename = "type")]
349 kind: String,
350 usdc: Option<String>,
351}
352
353fn parse_decimal(s: &str) -> Option<Decimal> {
356 Decimal::from_str(s).ok()
357}
358
359fn keccak256(data: &[u8]) -> [u8; 32] {
360 use sha3::{Digest, Keccak256};
361 Keccak256::digest(data).into()
362}
363
364fn hyperliquid_domain_separator() -> [u8; 32] {
366 let type_hash = keccak256(
367 b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
368 );
369 let name_hash = keccak256(b"Exchange");
370 let version_hash = keccak256(b"1");
371 let mut chain_id = [0u8; 32];
372 chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
373 let verifying_contract = [0u8; 32];
374
375 let mut data = [0u8; 160];
376 data[..32].copy_from_slice(&type_hash);
377 data[32..64].copy_from_slice(&name_hash);
378 data[64..96].copy_from_slice(&version_hash);
379 data[96..128].copy_from_slice(&chain_id);
380 data[128..160].copy_from_slice(&verifying_contract);
381 keccak256(&data)
382}
383
384fn sign_action(
387 private_key: &str,
388 action: &Value,
389 vault_address: Option<&str>,
390 nonce: u64,
391) -> Result<(String, String, u8), String> {
392 use k256::ecdsa::SigningKey;
393
394 let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
396 let mut data = msgpack_bytes;
397 data.extend_from_slice(&nonce.to_be_bytes());
398 match vault_address {
399 None => data.push(0u8),
400 Some(addr) => {
401 data.push(1u8);
402 let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
403 .map_err(|e| format!("invalid vault address: {}", e))?;
404 data.extend_from_slice(&addr_bytes);
405 }
406 }
407 let connection_id = keccak256(&data);
408
409 let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
411 let source_hash = keccak256(b"a"); let mut struct_data = [0u8; 96];
413 struct_data[..32].copy_from_slice(&agent_type_hash);
414 struct_data[32..64].copy_from_slice(&source_hash);
415 struct_data[64..96].copy_from_slice(&connection_id);
416 let struct_hash = keccak256(&struct_data);
417
418 let domain_sep = hyperliquid_domain_separator();
420 let mut final_data = Vec::with_capacity(66);
421 final_data.extend_from_slice(b"\x19\x01");
422 final_data.extend_from_slice(&domain_sep);
423 final_data.extend_from_slice(&struct_hash);
424 let final_hash = keccak256(&final_data);
425
426 let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
428 .map_err(|e| format!("invalid private key: {}", e))?;
429 let signing_key =
430 SigningKey::from_bytes(key_bytes.as_slice().into()).map_err(|e| e.to_string())?;
431 let (sig, recovery_id) = signing_key
432 .sign_prehash_recoverable(&final_hash)
433 .map_err(|e| e.to_string())?;
434
435 let sig_bytes = sig.to_bytes();
436 let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
437 let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
438 let v = 27u8 + recovery_id.to_byte();
439
440 Ok((r, s, v))
441}
442
443#[allow(async_fn_in_trait)]
446impl guilder_abstraction::TestServer for HyperliquidClient {
447 async fn ping(&self) -> Result<bool, String> {
449 self.info_post(serde_json::json!({"type": "allMids"}), 2)
451 .await
452 .map(|r| r.status().is_success())
453 }
454
455 async fn get_server_time(&self) -> Result<i64, String> {
457 Ok(std::time::SystemTime::now()
458 .duration_since(std::time::UNIX_EPOCH)
459 .map(|d| d.as_millis() as i64)
460 .unwrap_or(0))
461 }
462}
463
464#[allow(async_fn_in_trait)]
465impl guilder_abstraction::GetMarketData for HyperliquidClient {
466 async fn get_symbol(&self) -> Result<Vec<String>, String> {
468 let resp = self
470 .info_post(serde_json::json!({"type": "meta"}), 20)
471 .await?;
472 parse_response::<MetaResponse>(resp)
473 .await
474 .map(|r| r.universe.into_iter().map(|a| a.name).collect())
475 }
476
477 async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
479 let resp = self
481 .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
482 .await?;
483 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
484 .await?
485 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
486 meta.universe
487 .iter()
488 .position(|a| a.name == symbol)
489 .and_then(|i| ctxs.get(i))
490 .and_then(|ctx| parse_decimal(&ctx.open_interest))
491 .ok_or_else(|| format!("symbol {} not found", symbol))
492 }
493
494 async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
496 let resp = self
498 .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
499 .await?;
500 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
501 .await?
502 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
503 let idx = meta
504 .universe
505 .iter()
506 .position(|a| a.name == symbol)
507 .ok_or_else(|| format!("symbol {} not found", symbol))?;
508 let ctx = ctxs
509 .get(idx)
510 .ok_or_else(|| format!("symbol {} not found", symbol))?;
511 Ok(AssetContext {
512 symbol,
513 open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
514 funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
515 mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
516 day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
517 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
518 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
519 premium: ctx.premium.as_deref().and_then(parse_decimal),
520 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
521 })
522 }
523
524 async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
527 let resp = self
529 .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
530 .await?;
531 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
532 .await?
533 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
534 let mut result = Vec::with_capacity(meta.universe.len());
535 for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
536 let Some(open_interest) = parse_decimal(&ctx.open_interest) else {
537 continue;
538 };
539 let Some(funding_rate) = parse_decimal(&ctx.funding) else {
540 continue;
541 };
542 let Some(mark_price) = parse_decimal(&ctx.mark_px) else {
543 continue;
544 };
545 let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else {
546 continue;
547 };
548 result.push(AssetContext {
549 symbol: asset.name.clone(),
550 open_interest,
551 funding_rate,
552 mark_price,
553 day_volume,
554 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
555 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
556 premium: ctx.premium.as_deref().and_then(parse_decimal),
557 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
558 });
559 }
560 Ok(result)
561 }
562
563 async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
566 let resp = self
568 .info_post(serde_json::json!({"type": "l2Book", "coin": symbol}), 2)
569 .await?;
570 let book: Option<WsBook> = parse_response(resp).await?;
571 let book = match book {
572 Some(b) => b,
573 None => return Ok(vec![]),
574 };
575 let mut levels = Vec::new();
576 for level in book.levels.first().into_iter().flatten() {
577 if let (Some(price), Some(volume)) =
578 (parse_decimal(&level.px), parse_decimal(&level.sz))
579 {
580 levels.push(L2Update {
581 symbol: book.coin.clone(),
582 price,
583 volume,
584 side: Side::Ask,
585 sequence: book.time,
586 });
587 }
588 }
589 for level in book.levels.get(1).into_iter().flatten() {
590 if let (Some(price), Some(volume)) =
591 (parse_decimal(&level.px), parse_decimal(&level.sz))
592 {
593 levels.push(L2Update {
594 symbol: book.coin.clone(),
595 price,
596 volume,
597 side: Side::Bid,
598 sequence: book.time,
599 });
600 }
601 }
602 Ok(levels)
603 }
604
605 async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
607 let resp = self
609 .info_post(serde_json::json!({"type": "allMids"}), 2)
610 .await?;
611 parse_response::<HashMap<String, String>>(resp)
612 .await?
613 .get(&symbol)
614 .and_then(|s| parse_decimal(s))
615 .ok_or_else(|| format!("symbol {} not found", symbol))
616 }
617
618 async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
621 let resp = self
623 .info_post(serde_json::json!({"type": "predictedFundings"}), 20)
624 .await?;
625 let data: PredictedFundingsResponse = parse_response(resp).await?;
626 let mut result = Vec::new();
627 for (symbol, venues) in data {
628 for (venue, entry) in venues {
629 let Some(entry) = entry else { continue };
630 if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
631 result.push(PredictedFunding {
632 symbol: symbol.clone(),
633 venue,
634 funding_rate,
635 next_funding_time_ms: entry.next_funding_time,
636 });
637 }
638 }
639 }
640 Ok(result)
641 }
642}
643
644#[allow(async_fn_in_trait)]
645impl guilder_abstraction::ManageOrder for HyperliquidClient {
646 async fn place_order(
649 &self,
650 symbol: String,
651 side: OrderSide,
652 price: Decimal,
653 volume: Decimal,
654 order_type: OrderType,
655 time_in_force: TimeInForce,
656 ) -> Result<OrderPlacement, String> {
657 let asset_idx = self.get_asset_index(&symbol).await?;
658 let is_buy = matches!(side, OrderSide::Buy);
659
660 let tif_str = match time_in_force {
661 TimeInForce::Gtc => "Gtc",
662 TimeInForce::Ioc => "Ioc",
663 TimeInForce::Fok => "Fok",
664 };
665 let order_type_val = match order_type {
667 OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
668 OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
669 };
670
671 let action = serde_json::json!({
672 "type": "order",
673 "orders": [{
674 "a": asset_idx,
675 "b": is_buy,
676 "p": price.to_string(),
677 "s": volume.to_string(),
678 "r": false,
679 "t": order_type_val
680 }],
681 "grouping": "na"
682 });
683
684 let resp = self.submit_signed_action(action, None).await?;
685 let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
686 .as_i64()
687 .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
688 .ok_or_else(|| format!("unexpected response: {}", resp))?;
689
690 let timestamp_ms = std::time::SystemTime::now()
691 .duration_since(std::time::UNIX_EPOCH)
692 .unwrap()
693 .as_millis() as i64;
694
695 Ok(OrderPlacement {
696 order_id: oid,
697 symbol,
698 side,
699 price,
700 quantity: volume,
701 timestamp_ms,
702 })
703 }
704
705 async fn change_order_by_cloid(
708 &self,
709 cloid: i64,
710 price: Decimal,
711 volume: Decimal,
712 ) -> Result<i64, String> {
713 let user = self.require_user_address()?;
714
715 let resp = self
717 .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
718 .await?;
719 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
720 let order = orders
721 .iter()
722 .find(|o| o.oid == cloid)
723 .ok_or_else(|| format!("order {} not found", cloid))?;
724
725 let asset_idx = self.get_asset_index(&order.coin).await?;
726 let is_buy = order.side == "B";
727
728 let action = serde_json::json!({
729 "type": "batchModify",
730 "modifies": [{
731 "oid": cloid,
732 "order": {
733 "a": asset_idx,
734 "b": is_buy,
735 "p": price.to_string(),
736 "s": volume.to_string(),
737 "r": false,
738 "t": {"limit": {"tif": "Gtc"}}
739 }
740 }]
741 });
742
743 self.submit_signed_action(action, None).await?;
744 Ok(cloid)
745 }
746
747 async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
750 let user = self.require_user_address()?;
751
752 let resp = self
754 .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
755 .await?;
756 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
757 let order = orders
758 .iter()
759 .find(|o| o.oid == cloid)
760 .ok_or_else(|| format!("order {} not found", cloid))?;
761
762 let asset_idx = self.get_asset_index(&order.coin).await?;
763 let action = serde_json::json!({
764 "type": "cancel",
765 "cancels": [{"a": asset_idx, "o": cloid}]
766 });
767
768 self.submit_signed_action(action, None).await?;
769 Ok(cloid)
770 }
771
772 async fn cancel_all_order(&self) -> Result<bool, String> {
775 let user = self.require_user_address()?;
776
777 let resp = self
779 .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
780 .await?;
781 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
782 if orders.is_empty() {
783 return Ok(true);
784 }
785
786 let meta_resp = self
788 .info_post(serde_json::json!({"type": "meta"}), 20)
789 .await?;
790 let meta: MetaResponse = parse_response(meta_resp).await?;
791
792 let cancels: Vec<Value> = orders
793 .iter()
794 .filter_map(|o| {
795 let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
796 Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
797 })
798 .collect();
799
800 let action = serde_json::json!({"type": "cancel", "cancels": cancels});
801 self.submit_signed_action(action, None).await?;
802 Ok(true)
803 }
804}
805
806#[allow(async_fn_in_trait)]
807impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
808 fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
809 let sub = serde_json::json!({
810 "method": "subscribe",
811 "subscription": {"type": "l2Book", "coin": symbol.clone()}
812 });
813 let key = crate::ws::SubKey {
814 channel: "l2Book".to_string(),
815 routing_key: symbol,
816 };
817 let stream = self.ws_mux.subscribe(key, sub);
818 Box::pin(async_stream::stream! {
819 for await msg in stream {
820 let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
821 continue;
822 };
823 if env.channel != "l2Book" {
824 continue;
825 }
826 let Ok(book) = serde_json::from_value::<WsBook>(env.data) else {
827 continue;
828 };
829 for level in book.levels.first().into_iter().flatten() {
830 if let (Some(price), Some(volume)) =
831 (parse_decimal(&level.px), parse_decimal(&level.sz))
832 {
833 yield Ok(L2Update {
834 symbol: book.coin.clone(),
835 price,
836 volume,
837 side: Side::Ask,
838 sequence: book.time,
839 });
840 }
841 }
842 for level in book.levels.get(1).into_iter().flatten() {
843 if let (Some(price), Some(volume)) =
844 (parse_decimal(&level.px), parse_decimal(&level.sz))
845 {
846 yield Ok(L2Update {
847 symbol: book.coin.clone(),
848 price,
849 volume,
850 side: Side::Bid,
851 sequence: book.time,
852 });
853 }
854 }
855 }
856 })
857 }
858
859 fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
860 let sub = serde_json::json!({
861 "method": "subscribe",
862 "subscription": {"type": "activeAssetCtx", "coin": symbol.clone()}
863 });
864 let key = crate::ws::SubKey {
865 channel: "activeAssetCtx".to_string(),
866 routing_key: symbol,
867 };
868 let stream = self.ws_mux.subscribe(key, sub);
869 Box::pin(async_stream::stream! {
870 for await msg in stream {
871 let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
872 continue;
873 };
874 if env.channel != "activeAssetCtx" {
875 continue;
876 }
877 let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else {
878 continue;
879 };
880 let ctx = &update.ctx;
881 let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
882 parse_decimal(&ctx.open_interest),
883 parse_decimal(&ctx.funding),
884 parse_decimal(&ctx.mark_px),
885 parse_decimal(&ctx.day_ntl_vlm),
886 ) else {
887 continue;
888 };
889 yield Ok(AssetContext {
890 symbol: update.coin,
891 open_interest,
892 funding_rate,
893 mark_price,
894 day_volume,
895 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
896 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
897 premium: ctx.premium.as_deref().and_then(parse_decimal),
898 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
899 });
900 }
901 })
902 }
903
904 fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
905 let sub = serde_json::json!({
906 "method": "subscribe",
907 "subscription": {"type": "userEvents", "user": user.clone()}
908 });
909 let key = crate::ws::SubKey {
910 channel: "userEvents".to_string(),
911 routing_key: user,
912 };
913 let raw_stream = self.ws_mux.subscribe(key, sub);
914 Box::pin(raw_stream.filter_map(|text| async move {
915 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
916 return None;
917 };
918 if env.channel != "userEvents" {
919 return None;
920 }
921 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
922 return None;
923 };
924 let Some(liq) = event.liquidation else {
925 return None;
926 };
927 let (Some(notional_position), Some(account_value)) = (
928 parse_decimal(&liq.liquidated_ntl_pos),
929 parse_decimal(&liq.liquidated_account_value),
930 ) else {
931 return None;
932 };
933 let item = Liquidation {
934 symbol: String::new(),
935 side: OrderSide::Sell,
936 liquidated_user: liq.liquidated_user,
937 notional_position,
938 account_value,
939 };
940 Some(stream::iter(vec![Ok(item)].into_iter()))
941 }).flatten())
942 }
943
944 fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
945 let sub = serde_json::json!({
946 "method": "subscribe",
947 "subscription": {"type": "trades", "coin": symbol.clone()}
948 });
949 let key = crate::ws::SubKey {
950 channel: "trades".to_string(),
951 routing_key: symbol,
952 };
953 let stream = self.ws_mux.subscribe(key, sub);
954 Box::pin(async_stream::stream! {
955 for await msg in stream {
956 let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
957 continue;
958 };
959 if env.channel != "trades" {
960 continue;
961 }
962 let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else {
963 continue;
964 };
965 for trade in trades {
966 let side = if trade.side == "B" {
967 OrderSide::Buy
968 } else {
969 OrderSide::Sell
970 };
971 let price = parse_decimal(&trade.px);
972 let volume = parse_decimal(&trade.sz);
973 if let (Some(price), Some(volume)) = (price, volume) {
974 yield Ok(Fill {
975 symbol: trade.coin,
976 price,
977 volume,
978 side,
979 timestamp_ms: trade.time,
980 trade_id: trade.tid,
981 });
982 }
983 }
984 }
985 })
986 }
987}
988
989#[allow(async_fn_in_trait)]
990impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
991 async fn get_positions(&self) -> Result<Vec<Position>, String> {
994 let user = self.require_user_address()?;
995 let resp = self
997 .info_post(
998 serde_json::json!({"type": "clearinghouseState", "user": user}),
999 2,
1000 )
1001 .await?;
1002 let state: ClearinghouseStateResponse = parse_response(resp).await?;
1003
1004 Ok(state
1005 .asset_positions
1006 .into_iter()
1007 .filter_map(|ap| {
1008 let p = ap.position;
1009 let size = parse_decimal(&p.szi)?;
1010 if size.is_zero() {
1011 return None;
1012 }
1013 let entry_price = p
1014 .entry_px
1015 .as_deref()
1016 .and_then(parse_decimal)
1017 .unwrap_or_default();
1018 let side = if size > Decimal::ZERO {
1019 OrderSide::Buy
1020 } else {
1021 OrderSide::Sell
1022 };
1023 Some(Position {
1024 symbol: p.coin,
1025 side,
1026 size: size.abs(),
1027 entry_price,
1028 })
1029 })
1030 .collect())
1031 }
1032
1033 async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
1036 let user = self.require_user_address()?;
1037 let resp = self
1039 .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
1040 .await?;
1041 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1042
1043 Ok(orders
1044 .into_iter()
1045 .filter_map(|o| {
1046 let price = parse_decimal(&o.limit_px)?;
1047 let quantity = parse_decimal(&o.orig_sz)?;
1048 let remaining = parse_decimal(&o.sz)?;
1049 let filled_quantity = quantity - remaining;
1050 let side = if o.side == "B" {
1051 OrderSide::Buy
1052 } else {
1053 OrderSide::Sell
1054 };
1055 Some(OpenOrder {
1056 order_id: o.oid,
1057 symbol: o.coin,
1058 side,
1059 price,
1060 quantity,
1061 filled_quantity,
1062 })
1063 })
1064 .collect())
1065 }
1066
1067 async fn get_collateral(&self) -> Result<Decimal, String> {
1069 let user = self.require_user_address()?;
1070 let resp = self
1072 .info_post(
1073 serde_json::json!({"type": "clearinghouseState", "user": user}),
1074 2,
1075 )
1076 .await?;
1077 let state: ClearinghouseStateResponse = parse_response(resp).await?;
1078 parse_decimal(&state.margin_summary.account_value)
1079 .ok_or_else(|| "invalid account value".to_string())
1080 }
1081}
1082
1083#[allow(async_fn_in_trait)]
1084impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
1085 fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
1086 let Some(addr) = self.user_address else {
1087 return Box::pin(stream::empty());
1088 };
1089 let addr_str = format!("{:#x}", addr);
1090 let sub = serde_json::json!({
1091 "method": "subscribe",
1092 "subscription": {"type": "userEvents", "user": addr_str.clone()}
1093 });
1094 let key = crate::ws::SubKey {
1095 channel: "userEvents".to_string(),
1096 routing_key: addr_str,
1097 };
1098 let raw_stream = self.ws_mux.subscribe(key, sub);
1099 Box::pin(raw_stream.filter_map(|text| async move {
1100 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1101 return None;
1102 };
1103 if env.channel != "userEvents" {
1104 return None;
1105 }
1106 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1107 return None;
1108 };
1109 let items: Vec<_> = event
1110 .fills
1111 .unwrap_or_default()
1112 .into_iter()
1113 .filter_map(|fill| {
1114 let side = if fill.side == "B" {
1115 OrderSide::Buy
1116 } else {
1117 OrderSide::Sell
1118 };
1119 let price = parse_decimal(&fill.px)?;
1120 let quantity = parse_decimal(&fill.sz)?;
1121 let fee_usd = parse_decimal(&fill.fee)?;
1122 Some(UserFill {
1123 order_id: fill.oid,
1124 symbol: fill.coin,
1125 side,
1126 price,
1127 quantity,
1128 fee_usd,
1129 timestamp_ms: fill.time,
1130 })
1131 })
1132 .collect();
1133 if items.is_empty() {
1134 None
1135 } else {
1136 Some(stream::iter(items.into_iter().map(Ok)))
1137 }
1138 }).flatten())
1139 }
1140
1141 fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
1142 let Some(addr) = self.user_address else {
1143 return Box::pin(stream::empty());
1144 };
1145 let addr_str = format!("{:#x}", addr);
1146 let sub = serde_json::json!({
1147 "method": "subscribe",
1148 "subscription": {"type": "orderUpdates", "user": addr_str.clone()}
1149 });
1150 let key = crate::ws::SubKey {
1151 channel: "orderUpdates".to_string(),
1152 routing_key: addr_str,
1153 };
1154 let raw_stream = self.ws_mux.subscribe(key, sub);
1155 Box::pin(raw_stream.filter_map(|text| async move {
1156 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1157 return None;
1158 };
1159 if env.channel != "orderUpdates" {
1160 return None;
1161 }
1162 let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else {
1163 return None;
1164 };
1165 let items: Vec<_> = updates
1166 .into_iter()
1167 .map(|upd| {
1168 let status = match upd.status.as_str() {
1169 "open" => OrderStatus::Placed,
1170 "filled" => OrderStatus::Filled,
1171 "canceled" | "cancelled" => OrderStatus::Cancelled,
1172 _ => OrderStatus::PartiallyFilled,
1173 };
1174 let side = if upd.order.side == "B" {
1175 OrderSide::Buy
1176 } else {
1177 OrderSide::Sell
1178 };
1179 OrderUpdate {
1180 order_id: upd.order.oid,
1181 symbol: upd.order.coin,
1182 status,
1183 side: Some(side),
1184 price: parse_decimal(&upd.order.limit_px),
1185 quantity: parse_decimal(&upd.order.orig_sz),
1186 remaining_quantity: parse_decimal(&upd.order.sz),
1187 timestamp_ms: upd.status_timestamp,
1188 }
1189 })
1190 .collect();
1191 if items.is_empty() {
1192 None
1193 } else {
1194 Some(stream::iter(items.into_iter().map(Ok)))
1195 }
1196 }).flatten())
1197 }
1198
1199 fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1200 let Some(addr) = self.user_address else {
1201 return Box::pin(stream::empty());
1202 };
1203 let addr_str = format!("{:#x}", addr);
1204 let sub = serde_json::json!({
1205 "method": "subscribe",
1206 "subscription": {"type": "userEvents", "user": addr_str.clone()}
1207 });
1208 let key = crate::ws::SubKey {
1209 channel: "userEvents".to_string(),
1210 routing_key: addr_str,
1211 };
1212 let raw_stream = self.ws_mux.subscribe(key, sub);
1213 Box::pin(raw_stream.filter_map(|text| async move {
1214 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1215 return None;
1216 };
1217 if env.channel != "userEvents" {
1218 return None;
1219 }
1220 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1221 return None;
1222 };
1223 let Some(funding) = event.funding else {
1224 return None;
1225 };
1226 let Some(amount_usd) = parse_decimal(&funding.usdc) else {
1227 return None;
1228 };
1229 let item = FundingPayment {
1230 symbol: funding.coin,
1231 amount_usd,
1232 timestamp_ms: funding.time,
1233 };
1234 Some(stream::iter(vec![Ok(item)].into_iter()))
1235 }).flatten())
1236 }
1237
1238 fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1239 let Some(addr) = self.user_address else {
1240 return Box::pin(stream::empty());
1241 };
1242 let addr_str = format!("{:#x}", addr);
1243 let sub = serde_json::json!({
1244 "method": "subscribe",
1245 "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1246 });
1247 let key = crate::ws::SubKey {
1248 channel: "userNonFundingLedgerUpdates".to_string(),
1249 routing_key: addr_str,
1250 };
1251 let raw_stream = self.ws_mux.subscribe(key, sub);
1252 Box::pin(raw_stream.filter_map(|text| async move {
1253 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1254 return None;
1255 };
1256 if env.channel != "userNonFundingLedgerUpdates" {
1257 return None;
1258 }
1259 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1260 return None;
1261 };
1262 let items: Vec<_> = ledger
1263 .updates
1264 .into_iter()
1265 .filter_map(|e| {
1266 if e.delta.kind != "deposit" {
1267 return None;
1268 }
1269 let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1270 Some(Deposit {
1271 asset: "USDC".to_string(),
1272 amount_usd,
1273 timestamp_ms: e.time,
1274 })
1275 })
1276 .collect();
1277 if items.is_empty() {
1278 None
1279 } else {
1280 Some(stream::iter(items.into_iter().map(Ok)))
1281 }
1282 }).flatten())
1283 }
1284
1285 fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1286 let Some(addr) = self.user_address else {
1287 return Box::pin(stream::empty());
1288 };
1289 let addr_str = format!("{:#x}", addr);
1290 let sub = serde_json::json!({
1291 "method": "subscribe",
1292 "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1293 });
1294 let key = crate::ws::SubKey {
1295 channel: "userNonFundingLedgerUpdates".to_string(),
1296 routing_key: addr_str,
1297 };
1298 let raw_stream = self.ws_mux.subscribe(key, sub);
1299 Box::pin(raw_stream.filter_map(|text| async move {
1300 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1301 return None;
1302 };
1303 if env.channel != "userNonFundingLedgerUpdates" {
1304 return None;
1305 }
1306 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1307 return None;
1308 };
1309 let items: Vec<_> = ledger
1310 .updates
1311 .into_iter()
1312 .filter_map(|e| {
1313 if e.delta.kind != "withdraw" {
1314 return None;
1315 }
1316 let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1317 Some(Withdrawal {
1318 asset: "USDC".to_string(),
1319 amount_usd,
1320 timestamp_ms: e.time,
1321 })
1322 })
1323 .collect();
1324 if items.is_empty() {
1325 None
1326 } else {
1327 Some(stream::iter(items.into_iter().map(Ok)))
1328 }
1329 }).flatten())
1330 }
1331}