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 impact_pxs: Option<Vec<String>>,
117}
118
119#[derive(Deserialize)]
120#[serde(rename_all = "camelCase")]
121struct ClearinghouseStateResponse {
122 margin_summary: MarginSummary,
123 asset_positions: Vec<AssetPosition>,
124}
125
126#[derive(Deserialize)]
127#[serde(rename_all = "camelCase")]
128struct MarginSummary {
129 account_value: String,
130}
131
132#[derive(Deserialize)]
133struct AssetPosition {
134 position: PositionDetail,
135}
136
137#[derive(Deserialize)]
138#[serde(rename_all = "camelCase")]
139struct PositionDetail {
140 coin: String,
141 szi: String,
143 entry_px: Option<String>,
144}
145
146#[derive(Deserialize)]
147#[serde(rename_all = "camelCase")]
148struct RestOpenOrder {
149 coin: String,
150 side: String,
151 limit_px: String,
152 sz: String,
153 oid: i64,
154 orig_sz: String,
155}
156
157type PredictedFundingsResponse = Vec<(String, Vec<(String, 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::<MetaAndAssetCtxsResponse>(resp).await?;
418 meta.universe.iter()
419 .position(|a| a.name == symbol)
420 .and_then(|i| ctxs.get(i))
421 .and_then(|ctx| parse_decimal(&ctx.open_interest))
422 .ok_or_else(|| format!("symbol {} not found", symbol))
423 }
424
425 async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
427 let resp = self.client
428 .post(HYPERLIQUID_INFO_URL)
429 .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
430 .send()
431 .await
432 .map_err(|e| e.to_string())?;
433 let (meta, ctxs) = parse_response::<MetaAndAssetCtxsResponse>(resp).await?;
434 let idx = meta.universe.iter()
435 .position(|a| a.name == symbol)
436 .ok_or_else(|| format!("symbol {} not found", symbol))?;
437 let ctx = ctxs.get(idx).ok_or_else(|| format!("symbol {} not found", symbol))?;
438 Ok(AssetContext {
439 symbol,
440 open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
441 funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
442 mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
443 day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
444 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
445 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
446 premium: ctx.premium.as_deref().and_then(parse_decimal),
447 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
448 })
449 }
450
451 async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
453 let resp = self.client
454 .post(HYPERLIQUID_INFO_URL)
455 .json(&serde_json::json!({"type": "predictedFundings"}))
456 .send()
457 .await
458 .map_err(|e| e.to_string())?;
459 let data: PredictedFundingsResponse = parse_response(resp).await?;
460 let mut result = Vec::new();
461 for (symbol, venues) in data {
462 for (venue, entry) in venues {
463 if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
464 result.push(PredictedFunding {
465 symbol: symbol.clone(),
466 venue,
467 funding_rate,
468 next_funding_time_ms: entry.next_funding_time,
469 });
470 }
471 }
472 }
473 Ok(result)
474 }
475
476 async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
479 let resp = self.client
480 .post(HYPERLIQUID_INFO_URL)
481 .json(&serde_json::json!({"type": "l2Book", "coin": symbol}))
482 .send()
483 .await
484 .map_err(|e| e.to_string())?;
485 let book: WsBook = parse_response(resp).await?;
486 let mut levels = Vec::new();
487 for level in book.levels.first().into_iter().flatten() {
488 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
489 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
490 }
491 }
492 for level in book.levels.get(1).into_iter().flatten() {
493 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
494 levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
495 }
496 }
497 Ok(levels)
498 }
499
500 async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
502 let resp = self.client
503 .post(HYPERLIQUID_INFO_URL)
504 .json(&serde_json::json!({"type": "allMids"}))
505 .send()
506 .await
507 .map_err(|e| e.to_string())?;
508 parse_response::<HashMap<String, String>>(resp).await?
509 .get(&symbol)
510 .and_then(|s| parse_decimal(s))
511 .ok_or_else(|| format!("symbol {} not found", symbol))
512 }
513}
514
515#[allow(async_fn_in_trait)]
516impl guilder_abstraction::ManageOrder for HyperliquidClient {
517 async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
520 let asset_idx = self.get_asset_index(&symbol).await?;
521 let is_buy = matches!(side, OrderSide::Buy);
522
523 let tif_str = match time_in_force {
524 TimeInForce::Gtc => "Gtc",
525 TimeInForce::Ioc => "Ioc",
526 TimeInForce::Fok => "Fok",
527 };
528 let order_type_val = match order_type {
530 OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
531 OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
532 };
533
534 let action = serde_json::json!({
535 "type": "order",
536 "orders": [{
537 "a": asset_idx,
538 "b": is_buy,
539 "p": price.to_string(),
540 "s": volume.to_string(),
541 "r": false,
542 "t": order_type_val
543 }],
544 "grouping": "na"
545 });
546
547 let resp = self.submit_signed_action(action, None).await?;
548 let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
549 .as_i64()
550 .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
551 .ok_or_else(|| format!("unexpected response: {}", resp))?;
552
553 let timestamp_ms = std::time::SystemTime::now()
554 .duration_since(std::time::UNIX_EPOCH)
555 .unwrap()
556 .as_millis() as i64;
557
558 Ok(OrderPlacement { order_id: oid, symbol, side, price, quantity: volume, timestamp_ms })
559 }
560
561 async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
564 let user = self.require_user_address()?;
565
566 let resp = self.client
567 .post(HYPERLIQUID_INFO_URL)
568 .json(&serde_json::json!({"type": "openOrders", "user": user}))
569 .send()
570 .await
571 .map_err(|e| e.to_string())?;
572 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
573 let order = orders.iter()
574 .find(|o| o.oid == cloid)
575 .ok_or_else(|| format!("order {} not found", cloid))?;
576
577 let asset_idx = self.get_asset_index(&order.coin).await?;
578 let is_buy = order.side == "B";
579
580 let action = serde_json::json!({
581 "type": "batchModify",
582 "modifies": [{
583 "oid": cloid,
584 "order": {
585 "a": asset_idx,
586 "b": is_buy,
587 "p": price.to_string(),
588 "s": volume.to_string(),
589 "r": false,
590 "t": {"limit": {"tif": "Gtc"}}
591 }
592 }]
593 });
594
595 self.submit_signed_action(action, None).await?;
596 Ok(cloid)
597 }
598
599 async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
602 let user = self.require_user_address()?;
603
604 let resp = self.client
605 .post(HYPERLIQUID_INFO_URL)
606 .json(&serde_json::json!({"type": "openOrders", "user": user}))
607 .send()
608 .await
609 .map_err(|e| e.to_string())?;
610 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
611 let order = orders.iter()
612 .find(|o| o.oid == cloid)
613 .ok_or_else(|| format!("order {} not found", cloid))?;
614
615 let asset_idx = self.get_asset_index(&order.coin).await?;
616 let action = serde_json::json!({
617 "type": "cancel",
618 "cancels": [{"a": asset_idx, "o": cloid}]
619 });
620
621 self.submit_signed_action(action, None).await?;
622 Ok(cloid)
623 }
624
625 async fn cancel_all_order(&self) -> Result<bool, String> {
628 let user = self.require_user_address()?;
629
630 let resp = self.client
631 .post(HYPERLIQUID_INFO_URL)
632 .json(&serde_json::json!({"type": "openOrders", "user": user}))
633 .send()
634 .await
635 .map_err(|e| e.to_string())?;
636 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
637 if orders.is_empty() {
638 return Ok(true);
639 }
640
641 let meta_resp = self.client
642 .post(HYPERLIQUID_INFO_URL)
643 .json(&serde_json::json!({"type": "meta"}))
644 .send()
645 .await
646 .map_err(|e| e.to_string())?;
647 let meta: MetaResponse = parse_response(meta_resp).await?;
648
649 let cancels: Vec<Value> = orders.iter()
650 .filter_map(|o| {
651 let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
652 Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
653 })
654 .collect();
655
656 let action = serde_json::json!({"type": "cancel", "cancels": cancels});
657 self.submit_signed_action(action, None).await?;
658 Ok(true)
659 }
660}
661
662#[allow(async_fn_in_trait)]
663impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
664 fn subscribe_l2_update(&self, symbol: String) -> BoxStream<L2Update> {
668 Box::pin(async_stream::stream! {
669 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
670 let sub = serde_json::json!({
671 "method": "subscribe",
672 "subscription": {"type": "l2Book", "coin": symbol}
673 });
674 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
675
676 while let Some(Ok(Message::Text(text))) = ws.next().await {
677 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
678 if env.channel != "l2Book" { continue; }
679 let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { continue; };
680
681 for level in book.levels.first().into_iter().flatten() {
682 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
683 yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time };
684 }
685 }
686 for level in book.levels.get(1).into_iter().flatten() {
687 if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
688 yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time };
689 }
690 }
691 }
692 })
693 }
694
695 fn subscribe_asset_context(&self, symbol: String) -> BoxStream<AssetContext> {
697 Box::pin(async_stream::stream! {
698 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
699 let sub = serde_json::json!({
700 "method": "subscribe",
701 "subscription": {"type": "activeAssetCtx", "coin": symbol}
702 });
703 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
704
705 while let Some(Ok(Message::Text(text))) = ws.next().await {
706 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
707 if env.channel != "activeAssetCtx" { continue; }
708 let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { continue; };
709 let ctx = &update.ctx;
710 if let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
711 parse_decimal(&ctx.open_interest),
712 parse_decimal(&ctx.funding),
713 parse_decimal(&ctx.mark_px),
714 parse_decimal(&ctx.day_ntl_vlm),
715 ) {
716 yield AssetContext {
717 symbol: update.coin,
718 open_interest,
719 funding_rate,
720 mark_price,
721 day_volume,
722 mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
723 oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
724 premium: ctx.premium.as_deref().and_then(parse_decimal),
725 prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
726 };
727 }
728 }
729 })
730 }
731
732 fn subscribe_liquidation(&self, user: String) -> BoxStream<Liquidation> {
735 Box::pin(async_stream::stream! {
736 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
737 let sub = serde_json::json!({
738 "method": "subscribe",
739 "subscription": {"type": "userEvents", "user": user}
740 });
741 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
742
743 while let Some(Ok(Message::Text(text))) = ws.next().await {
744 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
745 if env.channel != "userEvents" { continue; }
746 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
747 let Some(liq) = event.liquidation else { continue; };
748 if let (Some(notional_position), Some(account_value)) = (
749 parse_decimal(&liq.liquidated_ntl_pos),
750 parse_decimal(&liq.liquidated_account_value),
751 ) {
752 yield Liquidation {
753 symbol: String::new(),
754 side: OrderSide::Sell,
755 liquidated_user: liq.liquidated_user,
756 notional_position,
757 account_value,
758 };
759 }
760 }
761 })
762 }
763
764 fn subscribe_fill(&self, symbol: String) -> BoxStream<Fill> {
766 Box::pin(async_stream::stream! {
767 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
768 let sub = serde_json::json!({
769 "method": "subscribe",
770 "subscription": {"type": "trades", "coin": symbol}
771 });
772 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
773
774 while let Some(Ok(Message::Text(text))) = ws.next().await {
775 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
776 if env.channel != "trades" { continue; }
777 let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { continue; };
778
779 for trade in trades {
780 let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
781 if let (Some(price), Some(volume)) = (parse_decimal(&trade.px), parse_decimal(&trade.sz)) {
782 yield Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid };
783 }
784 }
785 }
786 })
787 }
788}
789
790#[allow(async_fn_in_trait)]
791impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
792 async fn get_positions(&self) -> Result<Vec<Position>, String> {
795 let user = self.require_user_address()?;
796 let resp = self.client
797 .post(HYPERLIQUID_INFO_URL)
798 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
799 .send()
800 .await
801 .map_err(|e| e.to_string())?;
802 let state: ClearinghouseStateResponse = parse_response(resp).await?;
803
804 Ok(state.asset_positions.into_iter()
805 .filter_map(|ap| {
806 let p = ap.position;
807 let size = parse_decimal(&p.szi)?;
808 if size.is_zero() { return None; }
809 let entry_price = p.entry_px.as_deref().and_then(parse_decimal).unwrap_or_default();
810 let side = if size > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell };
811 Some(Position { symbol: p.coin, side, size: size.abs(), entry_price })
812 })
813 .collect())
814 }
815
816 async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
819 let user = self.require_user_address()?;
820 let resp = self.client
821 .post(HYPERLIQUID_INFO_URL)
822 .json(&serde_json::json!({"type": "openOrders", "user": user}))
823 .send()
824 .await
825 .map_err(|e| e.to_string())?;
826 let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
827
828 Ok(orders.into_iter()
829 .filter_map(|o| {
830 let price = parse_decimal(&o.limit_px)?;
831 let quantity = parse_decimal(&o.orig_sz)?;
832 let remaining = parse_decimal(&o.sz)?;
833 let filled_quantity = quantity - remaining;
834 let side = if o.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
835 Some(OpenOrder { order_id: o.oid, symbol: o.coin, side, price, quantity, filled_quantity })
836 })
837 .collect())
838 }
839
840 async fn get_collateral(&self) -> Result<Decimal, String> {
842 let user = self.require_user_address()?;
843 let resp = self.client
844 .post(HYPERLIQUID_INFO_URL)
845 .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
846 .send()
847 .await
848 .map_err(|e| e.to_string())?;
849 let state: ClearinghouseStateResponse = parse_response(resp).await?;
850 parse_decimal(&state.margin_summary.account_value)
851 .ok_or_else(|| "invalid account value".to_string())
852 }
853}
854
855#[allow(async_fn_in_trait)]
856impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
857 fn subscribe_user_fills(&self) -> BoxStream<UserFill> {
860 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
861 let user = format!("{:#x}", addr);
862 Box::pin(async_stream::stream! {
863 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
864 let sub = serde_json::json!({
865 "method": "subscribe",
866 "subscription": {"type": "userEvents", "user": user}
867 });
868 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
869
870 while let Some(Ok(Message::Text(text))) = ws.next().await {
871 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
872 if env.channel != "userEvents" { continue; }
873 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
874 for fill in event.fills.unwrap_or_default() {
875 let side = if fill.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
876 if let (Some(price), Some(quantity), Some(fee_usd)) = (
877 parse_decimal(&fill.px),
878 parse_decimal(&fill.sz),
879 parse_decimal(&fill.fee),
880 ) {
881 yield UserFill { order_id: fill.oid, symbol: fill.coin, side, price, quantity, fee_usd, timestamp_ms: fill.time };
882 }
883 }
884 }
885 })
886 }
887
888 fn subscribe_order_updates(&self) -> BoxStream<OrderUpdate> {
891 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
892 let user = format!("{:#x}", addr);
893 Box::pin(async_stream::stream! {
894 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
895 let sub = serde_json::json!({
896 "method": "subscribe",
897 "subscription": {"type": "orderUpdates", "user": user}
898 });
899 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
900
901 while let Some(Ok(Message::Text(text))) = ws.next().await {
902 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
903 if env.channel != "orderUpdates" { continue; }
904 let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else { continue; };
905 for upd in updates {
906 let status = match upd.status.as_str() {
907 "open" => OrderStatus::Placed,
908 "filled" => OrderStatus::Filled,
909 "canceled" | "cancelled" => OrderStatus::Cancelled,
910 _ => OrderStatus::PartiallyFilled,
911 };
912 let side = if upd.order.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
913 yield OrderUpdate {
914 order_id: upd.order.oid,
915 symbol: upd.order.coin,
916 status,
917 side: Some(side),
918 price: parse_decimal(&upd.order.limit_px),
919 quantity: parse_decimal(&upd.order.orig_sz),
920 remaining_quantity: parse_decimal(&upd.order.sz),
921 timestamp_ms: upd.status_timestamp,
922 };
923 }
924 }
925 })
926 }
927
928 fn subscribe_funding_payments(&self) -> BoxStream<FundingPayment> {
931 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
932 let user = format!("{:#x}", addr);
933 Box::pin(async_stream::stream! {
934 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
935 let sub = serde_json::json!({
936 "method": "subscribe",
937 "subscription": {"type": "userEvents", "user": user}
938 });
939 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
940
941 while let Some(Ok(Message::Text(text))) = ws.next().await {
942 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
943 if env.channel != "userEvents" { continue; }
944 let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
945 let Some(funding) = event.funding else { continue; };
946 if let Some(amount_usd) = parse_decimal(&funding.usdc) {
947 yield FundingPayment { symbol: funding.coin, amount_usd, timestamp_ms: funding.time };
948 }
949 }
950 })
951 }
952
953 fn subscribe_deposits(&self) -> BoxStream<Deposit> {
954 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
955 let user = format!("{:#x}", addr);
956 Box::pin(async_stream::stream! {
957 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
958 let sub = serde_json::json!({
959 "method": "subscribe",
960 "subscription": {"type": "userNonFundingLedgerUpdates", "user": user}
961 });
962 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
963 while let Some(Ok(Message::Text(text))) = ws.next().await {
964 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
965 if env.channel != "userNonFundingLedgerUpdates" { continue; }
966 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { continue; };
967 for entry in ledger.updates {
968 if entry.delta.kind == "deposit" {
969 if let Some(amount_usd) = entry.delta.usdc.as_deref().and_then(parse_decimal) {
970 yield Deposit { asset: "USDC".to_string(), amount_usd, timestamp_ms: entry.time };
971 }
972 }
973 }
974 }
975 })
976 }
977
978 fn subscribe_withdrawals(&self) -> BoxStream<Withdrawal> {
979 let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
980 let user = format!("{:#x}", addr);
981 Box::pin(async_stream::stream! {
982 let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
983 let sub = serde_json::json!({
984 "method": "subscribe",
985 "subscription": {"type": "userNonFundingLedgerUpdates", "user": user}
986 });
987 if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
988 while let Some(Ok(Message::Text(text))) = ws.next().await {
989 let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
990 if env.channel != "userNonFundingLedgerUpdates" { continue; }
991 let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { continue; };
992 for entry in ledger.updates {
993 if entry.delta.kind == "withdraw" {
994 if let Some(amount_usd) = entry.delta.usdc.as_deref().and_then(parse_decimal) {
995 yield Withdrawal { asset: "USDC".to_string(), amount_usd, timestamp_ms: entry.time };
996 }
997 }
998 }
999 }
1000 })
1001 }
1002}