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