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 data: Value,
173}
174
175#[derive(Deserialize)]
176struct WsBook {
177 coin: String,
178 levels: Vec<Vec<WsLevel>>,
179 time: i64,
180}
181
182#[derive(Deserialize)]
183struct WsLevel {
184 px: String,
185 sz: String,
186}
187
188#[derive(Deserialize)]
189#[serde(rename_all = "camelCase")]
190struct WsAssetCtx {
191 coin: String,
192 ctx: WsPerpsCtx,
193}
194
195#[derive(Deserialize)]
196#[serde(rename_all = "camelCase")]
197struct WsPerpsCtx {
198 open_interest: String,
199 funding: String,
200 mark_px: String,
201 day_ntl_vlm: String,
202 mid_px: Option<String>,
203 oracle_px: Option<String>,
204 premium: Option<String>,
205 prev_day_px: Option<String>,
206}
207
208#[derive(Deserialize)]
209struct WsUserEvent {
210 liquidation: Option<WsLiquidation>,
211 fills: Option<Vec<WsUserFill>>,
212 funding: Option<WsFunding>,
213}
214
215#[derive(Deserialize)]
216struct WsLiquidation {
217 liquidated_user: String,
218 liquidated_ntl_pos: String,
219 liquidated_account_value: String,
220}
221
222#[derive(Deserialize)]
223struct WsUserFill {
224 coin: String,
225 px: String,
226 sz: String,
227 side: String,
228 time: i64,
229 oid: i64,
230 fee: String,
231}
232
233#[derive(Deserialize)]
234struct WsFunding {
235 time: i64,
236 coin: String,
237 usdc: String,
238}
239
240#[derive(Deserialize)]
241struct WsTrade {
242 coin: String,
243 side: String,
244 px: String,
245 sz: String,
246 time: i64,
247 tid: i64,
248}
249
250#[derive(Deserialize)]
251struct WsOrderUpdate {
252 order: WsOrderInfo,
253 status: String,
254 #[serde(rename = "statusTimestamp")]
255 status_timestamp: i64,
256}
257
258#[derive(Deserialize)]
259#[serde(rename_all = "camelCase")]
260struct WsOrderInfo {
261 coin: String,
262 side: String,
263 limit_px: String,
264 sz: String,
265 oid: i64,
266 orig_sz: String,
267}
268
269#[derive(Deserialize)]
272struct WsLedgerUpdates {
273 updates: Vec<WsLedgerEntry>,
274}
275
276#[derive(Deserialize)]
277struct WsLedgerEntry {
278 time: i64,
279 delta: WsLedgerDelta,
280}
281
282#[derive(Deserialize)]
283struct WsLedgerDelta {
284 #[serde(rename = "type")]
285 kind: String,
286 usdc: Option<String>,
287}
288
289fn parse_decimal(s: &str) -> Option<Decimal> {
292 Decimal::from_str(s).ok()
293}
294
295fn keccak256(data: &[u8]) -> [u8; 32] {
296 use sha3::{Digest, Keccak256};
297 Keccak256::digest(data).into()
298}
299
300fn hyperliquid_domain_separator() -> [u8; 32] {
302 let type_hash = keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
303 let name_hash = keccak256(b"Exchange");
304 let version_hash = keccak256(b"1");
305 let mut chain_id = [0u8; 32];
306 chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
307 let verifying_contract = [0u8; 32];
308
309 let mut data = [0u8; 160];
310 data[..32].copy_from_slice(&type_hash);
311 data[32..64].copy_from_slice(&name_hash);
312 data[64..96].copy_from_slice(&version_hash);
313 data[96..128].copy_from_slice(&chain_id);
314 data[128..160].copy_from_slice(&verifying_contract);
315 keccak256(&data)
316}
317
318fn sign_action(private_key: &str, action: &Value, vault_address: Option<&str>, nonce: u64) -> Result<(String, String, u8), String> {
321 use k256::ecdsa::SigningKey;
322
323 let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
325 let mut data = msgpack_bytes;
326 data.extend_from_slice(&nonce.to_be_bytes());
327 match vault_address {
328 None => data.push(0u8),
329 Some(addr) => {
330 data.push(1u8);
331 let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
332 .map_err(|e| format!("invalid vault address: {}", e))?;
333 data.extend_from_slice(&addr_bytes);
334 }
335 }
336 let connection_id = keccak256(&data);
337
338 let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
340 let source_hash = keccak256(b"a"); let mut struct_data = [0u8; 96];
342 struct_data[..32].copy_from_slice(&agent_type_hash);
343 struct_data[32..64].copy_from_slice(&source_hash);
344 struct_data[64..96].copy_from_slice(&connection_id);
345 let struct_hash = keccak256(&struct_data);
346
347 let domain_sep = hyperliquid_domain_separator();
349 let mut final_data = Vec::with_capacity(66);
350 final_data.extend_from_slice(b"\x19\x01");
351 final_data.extend_from_slice(&domain_sep);
352 final_data.extend_from_slice(&struct_hash);
353 let final_hash = keccak256(&final_data);
354
355 let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
357 .map_err(|e| format!("invalid private key: {}", e))?;
358 let signing_key = SigningKey::from_bytes(key_bytes.as_slice().into())
359 .map_err(|e| e.to_string())?;
360 let (sig, recovery_id) = signing_key.sign_prehash_recoverable(&final_hash)
361 .map_err(|e| e.to_string())?;
362
363 let sig_bytes = sig.to_bytes();
364 let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
365 let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
366 let v = 27u8 + recovery_id.to_byte();
367
368 Ok((r, s, v))
369}
370
371#[allow(async_fn_in_trait)]
374impl guilder_abstraction::TestServer for HyperliquidClient {
375 async fn ping(&self) -> Result<bool, String> {
377 self.client
378 .post(HYPERLIQUID_INFO_URL)
379 .json(&serde_json::json!({"type": "allMids"}))
380 .send()
381 .await
382 .map(|r| r.status().is_success())
383 .map_err(|e| e.to_string())
384 }
385
386 async fn get_server_time(&self) -> Result<i64, String> {
388 Ok(std::time::SystemTime::now()
389 .duration_since(std::time::UNIX_EPOCH)
390 .map(|d| d.as_millis() as i64)
391 .unwrap_or(0))
392 }
393}
394
395#[allow(async_fn_in_trait)]
396impl guilder_abstraction::GetMarketData for HyperliquidClient {
397 async fn get_symbol(&self) -> Result<Vec<String>, String> {
399 let resp = self.client
400 .post(HYPERLIQUID_INFO_URL)
401 .json(&serde_json::json!({"type": "meta"}))
402 .send()
403 .await
404 .map_err(|e| e.to_string())?;
405 parse_response::<MetaResponse>(resp).await
406 .map(|r| r.universe.into_iter().map(|a| a.name).collect())
407 }
408
409 async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
411 let resp = self.client
412 .post(HYPERLIQUID_INFO_URL)
413 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
414 .send()
415 .await
416 .map_err(|e| e.to_string())?;
417 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
418 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
419 meta.universe.iter()
420 .position(|a| a.name == symbol)
421 .and_then(|i| ctxs.get(i))
422 .and_then(|ctx| parse_decimal(&ctx.open_interest))
423 .ok_or_else(|| format!("symbol {} not found", symbol))
424 }
425
426 async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
428 let resp = self.client
429 .post(HYPERLIQUID_INFO_URL)
430 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
431 .send()
432 .await
433 .map_err(|e| e.to_string())?;
434 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
435 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
436 let idx = meta.universe.iter()
437 .position(|a| a.name == symbol)
438 .ok_or_else(|| format!("symbol {} not found", symbol))?;
439 let ctx = ctxs.get(idx).ok_or_else(|| format!("symbol {} not found", symbol))?;
440 Ok(AssetContext {
441 symbol,
442 open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
443 funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
444 mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
445 day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
446 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
447 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
448 premium: ctx.premium.as_deref().and_then(parse_decimal),
449 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
450 })
451 }
452
453 async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
456 let resp = self.client
457 .post(HYPERLIQUID_INFO_URL)
458 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
459 .send()
460 .await
461 .map_err(|e| e.to_string())?;
462 let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
463 .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
464 let mut result = Vec::with_capacity(meta.universe.len());
465 for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
466 let Some(open_interest) = parse_decimal(&ctx.open_interest) else { continue };
467 let Some(funding_rate) = parse_decimal(&ctx.funding) else { continue };
468 let Some(mark_price) = parse_decimal(&ctx.mark_px) else { continue };
469 let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else { continue };
470 result.push(AssetContext {
471 symbol: asset.name.clone(),
472 open_interest,
473 funding_rate,
474 mark_price,
475 day_volume,
476 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
477 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
478 premium: ctx.premium.as_deref().and_then(parse_decimal),
479 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
480 });
481 }
482 Ok(result)
483 }
484
485 async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
488 let resp = self.client
489 .post(HYPERLIQUID_INFO_URL)
490 .json(&serde_json::json!({"type": "l2Book", "coin": symbol}))
491 .send()
492 .await
493 .map_err(|e| e.to_string())?;
494 let book: WsBook = parse_response(resp).await?;
495 let mut levels = Vec::new();
496 for level in book.levels.first().into_iter().flatten() {
497 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
498 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
499 }
500 }
501 for level in book.levels.get(1).into_iter().flatten() {
502 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
503 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
504 }
505 }
506 Ok(levels)
507 }
508
509 async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
511 let resp = self.client
512 .post(HYPERLIQUID_INFO_URL)
513 .json(&serde_json::json!({"type": "allMids"}))
514 .send()
515 .await
516 .map_err(|e| e.to_string())?;
517 parse_response::<HashMap<String, String>>(resp).await?
518 .get(&symbol)
519 .and_then(|s| parse_decimal(s))
520 .ok_or_else(|| format!("symbol {} not found", symbol))
521 }
522
523 async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
526 let resp = self.client
527 .post(HYPERLIQUID_INFO_URL)
528 .json(&serde_json::json!({"type": "predictedFundings"}))
529 .send()
530 .await
531 .map_err(|e| e.to_string())?;
532 let data: PredictedFundingsResponse = parse_response(resp).await?;
533 let mut result = Vec::new();
534 for (symbol, venues) in data {
535 for (venue, entry) in venues {
536 let Some(entry) = entry else { continue };
537 if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
538 result.push(PredictedFunding {
539 symbol: symbol.clone(),
540 venue,
541 funding_rate,
542 next_funding_time_ms: entry.next_funding_time,
543 });
544 }
545 }
546 }
547 Ok(result)
548 }
549}
550
551#[allow(async_fn_in_trait)]
552impl guilder_abstraction::ManageOrder for HyperliquidClient {
553 async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
556 let asset_idx = self.get_asset_index(&symbol).await?;
557 let is_buy = matches!(side, OrderSide::Buy);
558
559 let tif_str = match time_in_force {
560 TimeInForce::Gtc => "Gtc",
561 TimeInForce::Ioc => "Ioc",
562 TimeInForce::Fok => "Fok",
563 };
564 let order_type_val = match order_type {
566 OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
567 OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
568 };
569
570 let action = serde_json::json!({
571 "type": "order",
572 "orders": [{
573 "a": asset_idx,
574 "b": is_buy,
575 "p": price.to_string(),
576 "s": volume.to_string(),
577 "r": false,
578 "t": order_type_val
579 }],
580 "grouping": "na"
581 });
582
583 let resp = self.submit_signed_action(action, None).await?;
584 let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
585 .as_i64()
586 .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
587 .ok_or_else(|| format!("unexpected response: {}", resp))?;
588
589 let timestamp_ms = std::time::SystemTime::now()
590 .duration_since(std::time::UNIX_EPOCH)
591 .unwrap()
592 .as_millis() as i64;
593
594 Ok(OrderPlacement { order_id: oid, symbol, side, price, quantity: volume, timestamp_ms })
595 }
596
597 async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
600 let user = self.require_user_address()?;
601
602 let resp = self.client
603 .post(HYPERLIQUID_INFO_URL)
604 .json(&serde_json::json!({"type": "openOrders", "user": user}))
605 .send()
606 .await
607 .map_err(|e| e.to_string())?;
608 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
609 let order = orders.iter()
610 .find(|o| o.oid == cloid)
611 .ok_or_else(|| format!("order {} not found", cloid))?;
612
613 let asset_idx = self.get_asset_index(&order.coin).await?;
614 let is_buy = order.side == "B";
615
616 let action = serde_json::json!({
617 "type": "batchModify",
618 "modifies": [{
619 "oid": cloid,
620 "order": {
621 "a": asset_idx,
622 "b": is_buy,
623 "p": price.to_string(),
624 "s": volume.to_string(),
625 "r": false,
626 "t": {"limit": {"tif": "Gtc"}}
627 }
628 }]
629 });
630
631 self.submit_signed_action(action, None).await?;
632 Ok(cloid)
633 }
634
635 async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
638 let user = self.require_user_address()?;
639
640 let resp = self.client
641 .post(HYPERLIQUID_INFO_URL)
642 .json(&serde_json::json!({"type": "openOrders", "user": user}))
643 .send()
644 .await
645 .map_err(|e| e.to_string())?;
646 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
647 let order = orders.iter()
648 .find(|o| o.oid == cloid)
649 .ok_or_else(|| format!("order {} not found", cloid))?;
650
651 let asset_idx = self.get_asset_index(&order.coin).await?;
652 let action = serde_json::json!({
653 "type": "cancel",
654 "cancels": [{"a": asset_idx, "o": cloid}]
655 });
656
657 self.submit_signed_action(action, None).await?;
658 Ok(cloid)
659 }
660
661 async fn cancel_all_order(&self) -> Result<bool, String> {
664 let user = self.require_user_address()?;
665
666 let resp = self.client
667 .post(HYPERLIQUID_INFO_URL)
668 .json(&serde_json::json!({"type": "openOrders", "user": user}))
669 .send()
670 .await
671 .map_err(|e| e.to_string())?;
672 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
673 if orders.is_empty() {
674 return Ok(true);
675 }
676
677 let meta_resp = self.client
678 .post(HYPERLIQUID_INFO_URL)
679 .json(&serde_json::json!({"type": "meta"}))
680 .send()
681 .await
682 .map_err(|e| e.to_string())?;
683 let meta: MetaResponse = parse_response(meta_resp).await?;
684
685 let cancels: Vec<Value> = orders.iter()
686 .filter_map(|o| {
687 let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
688 Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
689 })
690 .collect();
691
692 let action = serde_json::json!({"type": "cancel", "cancels": cancels});
693 self.submit_signed_action(action, None).await?;
694 Ok(true)
695 }
696}
697
698#[allow(async_fn_in_trait)]
699impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
700 fn subscribe_l2_update(&self, symbol: String) -> BoxStream<L2Update> {
704 Box::pin(async_stream::stream! {
705 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
706 let sub = serde_json::json!({
707 "method": "subscribe",
708 "subscription": {"type": "l2Book", "coin": symbol}
709 });
710 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
711
712 while let Some(Ok(Message::Text(text))) = ws.next().await {
713 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
714 if env.channel != "l2Book" { continue; }
715 let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { continue; };
716
717 for level in book.levels.first().into_iter().flatten() {
718 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
719 yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time };
720 }
721 }
722 for level in book.levels.get(1).into_iter().flatten() {
723 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
724 yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time };
725 }
726 }
727 }
728 })
729 }
730
731 fn subscribe_asset_context(&self, symbol: String) -> BoxStream<AssetContext> {
733 Box::pin(async_stream::stream! {
734 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
735 let sub = serde_json::json!({
736 "method": "subscribe",
737 "subscription": {"type": "activeAssetCtx", "coin": symbol}
738 });
739 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
740
741 while let Some(Ok(Message::Text(text))) = ws.next().await {
742 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
743 if env.channel != "activeAssetCtx" { continue; }
744 let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { continue; };
745 let ctx = &update.ctx;
746 if let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
747 parse_decimal(&ctx.open_interest),
748 parse_decimal(&ctx.funding),
749 parse_decimal(&ctx.mark_px),
750 parse_decimal(&ctx.day_ntl_vlm),
751 ) {
752 yield AssetContext {
753 symbol: update.coin,
754 open_interest,
755 funding_rate,
756 mark_price,
757 day_volume,
758 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
759 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
760 premium: ctx.premium.as_deref().and_then(parse_decimal),
761 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
762 };
763 }
764 }
765 })
766 }
767
768 fn subscribe_liquidation(&self, user: String) -> BoxStream<Liquidation> {
771 Box::pin(async_stream::stream! {
772 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
773 let sub = serde_json::json!({
774 "method": "subscribe",
775 "subscription": {"type": "userEvents", "user": user}
776 });
777 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
778
779 while let Some(Ok(Message::Text(text))) = ws.next().await {
780 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
781 if env.channel != "userEvents" { continue; }
782 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
783 let Some(liq) = event.liquidation else { continue; };
784 if let (Some(notional_position), Some(account_value)) = (
785 parse_decimal(&liq.liquidated_ntl_pos),
786 parse_decimal(&liq.liquidated_account_value),
787 ) {
788 yield Liquidation {
789 symbol: String::new(),
790 side: OrderSide::Sell,
791 liquidated_user: liq.liquidated_user,
792 notional_position,
793 account_value,
794 };
795 }
796 }
797 })
798 }
799
800 fn subscribe_fill(&self, symbol: String) -> BoxStream<Fill> {
802 Box::pin(async_stream::stream! {
803 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
804 let sub = serde_json::json!({
805 "method": "subscribe",
806 "subscription": {"type": "trades", "coin": symbol}
807 });
808 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
809
810 while let Some(Ok(Message::Text(text))) = ws.next().await {
811 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
812 if env.channel != "trades" { continue; }
813 let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { continue; };
814
815 for trade in trades {
816 let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
817 if let (Some(price), Some(volume)) = (parse_decimal(&trade.px), parse_decimal(&trade.sz)) {
818 yield Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid };
819 }
820 }
821 }
822 })
823 }
824}
825
826#[allow(async_fn_in_trait)]
827impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
828 async fn get_positions(&self) -> Result<Vec<Position>, String> {
831 let user = self.require_user_address()?;
832 let resp = self.client
833 .post(HYPERLIQUID_INFO_URL)
834 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
835 .send()
836 .await
837 .map_err(|e| e.to_string())?;
838 let state: ClearinghouseStateResponse = parse_response(resp).await?;
839
840 Ok(state.asset_positions.into_iter()
841 .filter_map(|ap| {
842 let p = ap.position;
843 let size = parse_decimal(&p.szi)?;
844 if size.is_zero() { return None; }
845 let entry_price = p.entry_px.as_deref().and_then(parse_decimal).unwrap_or_default();
846 let side = if size > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell };
847 Some(Position { symbol: p.coin, side, size: size.abs(), entry_price })
848 })
849 .collect())
850 }
851
852 async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
855 let user = self.require_user_address()?;
856 let resp = self.client
857 .post(HYPERLIQUID_INFO_URL)
858 .json(&serde_json::json!({"type": "openOrders", "user": user}))
859 .send()
860 .await
861 .map_err(|e| e.to_string())?;
862 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
863
864 Ok(orders.into_iter()
865 .filter_map(|o| {
866 let price = parse_decimal(&o.limit_px)?;
867 let quantity = parse_decimal(&o.orig_sz)?;
868 let remaining = parse_decimal(&o.sz)?;
869 let filled_quantity = quantity - remaining;
870 let side = if o.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
871 Some(OpenOrder { order_id: o.oid, symbol: o.coin, side, price, quantity, filled_quantity })
872 })
873 .collect())
874 }
875
876 async fn get_collateral(&self) -> Result<Decimal, String> {
878 let user = self.require_user_address()?;
879 let resp = self.client
880 .post(HYPERLIQUID_INFO_URL)
881 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
882 .send()
883 .await
884 .map_err(|e| e.to_string())?;
885 let state: ClearinghouseStateResponse = parse_response(resp).await?;
886 parse_decimal(&state.margin_summary.account_value)
887 .ok_or_else(|| "invalid account value".to_string())
888 }
889}
890
891#[allow(async_fn_in_trait)]
892impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
893 fn subscribe_user_fills(&self) -> BoxStream<UserFill> {
896 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
897 let user = format!("{:#x}", addr);
898 Box::pin(async_stream::stream! {
899 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
900 let sub = serde_json::json!({
901 "method": "subscribe",
902 "subscription": {"type": "userEvents", "user": user}
903 });
904 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
905
906 while let Some(Ok(Message::Text(text))) = ws.next().await {
907 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
908 if env.channel != "userEvents" { continue; }
909 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
910 for fill in event.fills.unwrap_or_default() {
911 let side = if fill.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
912 if let (Some(price), Some(quantity), Some(fee_usd)) = (
913 parse_decimal(&fill.px),
914 parse_decimal(&fill.sz),
915 parse_decimal(&fill.fee),
916 ) {
917 yield UserFill { order_id: fill.oid, symbol: fill.coin, side, price, quantity, fee_usd, timestamp_ms: fill.time };
918 }
919 }
920 }
921 })
922 }
923
924 fn subscribe_order_updates(&self) -> BoxStream<OrderUpdate> {
927 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
928 let user = format!("{:#x}", addr);
929 Box::pin(async_stream::stream! {
930 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
931 let sub = serde_json::json!({
932 "method": "subscribe",
933 "subscription": {"type": "orderUpdates", "user": user}
934 });
935 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
936
937 while let Some(Ok(Message::Text(text))) = ws.next().await {
938 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
939 if env.channel != "orderUpdates" { continue; }
940 let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else { continue; };
941 for upd in updates {
942 let status = match upd.status.as_str() {
943 "open" => OrderStatus::Placed,
944 "filled" => OrderStatus::Filled,
945 "canceled" | "cancelled" => OrderStatus::Cancelled,
946 _ => OrderStatus::PartiallyFilled,
947 };
948 let side = if upd.order.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
949 yield OrderUpdate {
950 order_id: upd.order.oid,
951 symbol: upd.order.coin,
952 status,
953 side: Some(side),
954 price: parse_decimal(&upd.order.limit_px),
955 quantity: parse_decimal(&upd.order.orig_sz),
956 remaining_quantity: parse_decimal(&upd.order.sz),
957 timestamp_ms: upd.status_timestamp,
958 };
959 }
960 }
961 })
962 }
963
964 fn subscribe_funding_payments(&self) -> BoxStream<FundingPayment> {
967 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
968 let user = format!("{:#x}", addr);
969 Box::pin(async_stream::stream! {
970 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
971 let sub = serde_json::json!({
972 "method": "subscribe",
973 "subscription": {"type": "userEvents", "user": user}
974 });
975 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
976
977 while let Some(Ok(Message::Text(text))) = ws.next().await {
978 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
979 if env.channel != "userEvents" { continue; }
980 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
981 let Some(funding) = event.funding else { continue; };
982 if let Some(amount_usd) = parse_decimal(&funding.usdc) {
983 yield FundingPayment { symbol: funding.coin, amount_usd, timestamp_ms: funding.time };
984 }
985 }
986 })
987 }
988
989 fn subscribe_deposits(&self) -> BoxStream<Deposit> {
990 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
991 let user = format!("{:#x}", addr);
992 Box::pin(async_stream::stream! {
993 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
994 let sub = serde_json::json!({
995 "method": "subscribe",
996 "subscription": {"type": "userNonFundingLedgerUpdates", "user": user}
997 });
998 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
999 while let Some(Ok(Message::Text(text))) = ws.next().await {
1000 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
1001 if env.channel != "userNonFundingLedgerUpdates" { continue; }
1002 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { continue; };
1003 for entry in ledger.updates {
1004 if entry.delta.kind == "deposit" {
1005 if let Some(amount_usd) = entry.delta.usdc.as_deref().and_then(parse_decimal) {
1006 yield Deposit { asset: "USDC".to_string(), amount_usd, timestamp_ms: entry.time };
1007 }
1008 }
1009 }
1010 }
1011 })
1012 }
1013
1014 fn subscribe_withdrawals(&self) -> BoxStream<Withdrawal> {
1015 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1016 let user = format!("{:#x}", addr);
1017 Box::pin(async_stream::stream! {
1018 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
1019 let sub = serde_json::json!({
1020 "method": "subscribe",
1021 "subscription": {"type": "userNonFundingLedgerUpdates", "user": user}
1022 });
1023 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
1024 while let Some(Ok(Message::Text(text))) = ws.next().await {
1025 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
1026 if env.channel != "userNonFundingLedgerUpdates" { continue; }
1027 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { continue; };
1028 for entry in ledger.updates {
1029 if entry.delta.kind == "withdraw" {
1030 if let Some(amount_usd) = entry.delta.usdc.as_deref().and_then(parse_decimal) {
1031 yield Withdrawal { asset: "USDC".to_string(), amount_usd, timestamp_ms: entry.time };
1032 }
1033 }
1034 }
1035 }
1036 })
1037 }
1038}