1use std::str::FromStr;
2
3use alloy::primitives::{Address, FixedBytes};
4use anyhow::{anyhow, Context, Result};
5use rust_decimal::Decimal;
6use serde::{Deserialize, Deserializer, Serialize};
7
8#[derive(Debug, Serialize)]
9pub struct HyperliquidLedgerRequest {
10 #[serde(rename = "type")]
11 pub request_type: &'static str,
12 pub user: String,
13 #[serde(rename = "startTime")]
14 pub start_time: i64,
15}
16
17impl HyperliquidLedgerRequest {
18 pub fn user_non_funding_ledger_updates(user: String, start_time: i64) -> Self {
19 Self {
20 request_type: "userNonFundingLedgerUpdates",
21 user,
22 start_time,
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
28pub struct LedgerUpdate {
29 pub time: u64,
30 pub hash: FixedBytes<32>,
31 pub evm_tx_hash: Option<String>,
32 pub delta: LedgerDelta,
33}
34
35impl LedgerUpdate {
36 pub fn writer_evm_tx_hash(&self) -> Option<&str> {
37 self.evm_tx_hash
38 .as_deref()
39 .and_then(non_empty_str)
40 .or_else(|| self.delta.evm_tx_hash.as_deref().and_then(non_empty_str))
41 }
42
43 pub fn transfer(&self) -> Result<Option<TokenTransfer>> {
44 let Some(destination) = self.delta.destination.as_deref() else {
45 return Ok(None);
46 };
47 let destination = Address::from_str(destination).with_context(|| {
48 format!(
49 "invalid destination '{}' in HyperCore event {}",
50 destination, self.hash
51 )
52 })?;
53
54 let (sender, token, amount) = match self.delta.delta_type.as_str() {
55 "internalTransfer" => {
56 let sender = self
57 .delta
58 .user
59 .as_deref()
60 .map(Address::from_str)
61 .transpose()
62 .with_context(|| format!("invalid user in internalTransfer {}", self.hash))?;
63 let Some(sender) = sender else {
64 return Ok(None);
65 };
66 let raw = self.delta.usdc.as_deref().ok_or_else(|| {
67 anyhow!(
68 "internalTransfer {} missing required 'usdc' field",
69 self.hash
70 )
71 })?;
72 let amount = Decimal::from_str(raw).with_context(|| {
73 format!(
74 "invalid transfer amount '{}' in internalTransfer {}",
75 raw, self.hash
76 )
77 })?;
78 (sender, "USDC".to_string(), amount)
79 }
80 "send" => {
81 let sender = self
82 .delta
83 .user
84 .as_deref()
85 .map(Address::from_str)
86 .transpose()
87 .with_context(|| format!("invalid user in send {}", self.hash))?;
88 let Some(sender) = sender else {
89 return Ok(None);
90 };
91 let token = self.delta.token.as_deref().unwrap_or("").to_string();
92 let raw =
93 self.delta.amount.as_deref().ok_or_else(|| {
94 anyhow!("send {} missing required 'amount' field", self.hash)
95 })?;
96 let amount = Decimal::from_str(raw)
97 .with_context(|| format!("invalid amount '{}' in send {}", raw, self.hash))?;
98 (sender, token, amount)
99 }
100 _ => return Ok(None),
101 };
102
103 Ok(Some(TokenTransfer {
104 time: self.time,
105 event_hash: self.hash,
106 writer_evm_tx_hash: self.writer_evm_tx_hash().map(str::to_string),
107 sender,
108 destination,
109 token,
110 amount,
111 }))
112 }
113}
114
115impl<'de> Deserialize<'de> for LedgerUpdate {
116 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
117 where
118 D: Deserializer<'de>,
119 {
120 let raw = RawLedgerUpdate::deserialize(deserializer)?;
121 let hash = parse_event_hash(&raw.hash).map_err(serde::de::Error::custom)?;
122 Ok(Self {
123 time: raw.time,
124 hash,
125 evm_tx_hash: raw.evm_tx_hash,
126 delta: raw.delta,
127 })
128 }
129}
130
131#[derive(Debug, Clone, Deserialize)]
132#[serde(rename_all = "camelCase")]
133struct RawLedgerUpdate {
134 time: u64,
135 hash: String,
136 #[serde(default, alias = "evm_tx_hash")]
137 evm_tx_hash: Option<String>,
138 delta: LedgerDelta,
139}
140
141#[derive(Debug, Clone, Deserialize)]
142#[serde(rename_all = "camelCase")]
143#[allow(dead_code)]
144pub struct LedgerDelta {
145 #[serde(rename = "type")]
146 pub delta_type: String,
147 #[serde(default)]
148 pub usdc: Option<String>,
149 #[serde(default)]
150 pub amount: Option<String>,
151 #[serde(default)]
152 pub token: Option<String>,
153 #[serde(default)]
154 pub destination: Option<String>,
155 #[serde(default)]
156 pub user: Option<String>,
157 #[serde(default, alias = "evm_tx_hash")]
158 pub evm_tx_hash: Option<String>,
159}
160
161#[derive(Debug, Clone)]
162pub struct TokenTransfer {
163 pub time: u64,
164 pub event_hash: FixedBytes<32>,
165 pub writer_evm_tx_hash: Option<String>,
166 pub sender: Address,
167 pub destination: Address,
168 pub token: String,
169 pub amount: Decimal,
170}
171
172#[derive(Debug, Deserialize)]
173pub struct WsLedgerEnvelope {
174 #[serde(default)]
175 pub channel: Option<String>,
176 #[serde(default)]
177 pub data: Option<Vec<LedgerUpdate>>,
178}
179
180pub fn parse_event_hash(event_hash: &str) -> Result<FixedBytes<32>> {
181 let source_event_hash = FixedBytes::<32>::from_str(event_hash)
182 .with_context(|| format!("invalid HyperCore cash ledger event hash: {event_hash}"))?;
183 if source_event_hash.is_zero() {
184 anyhow::bail!("HyperCore cash ledger event hash must be nonzero");
185 }
186 Ok(source_event_hash)
187}
188
189fn non_empty_str(value: &str) -> Option<&str> {
190 let trimmed = value.trim();
191 if trimmed.is_empty() {
192 None
193 } else {
194 Some(trimmed)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn real_send_fixture() -> serde_json::Value {
203 serde_json::json!({
204 "time": 1778789705303_u64,
205 "hash": "0x3790cd9fc3cac2c7390a043b67187102057f00855ecde199db5978f282ce9cb1",
206 "delta": {
207 "type": "send",
208 "user": "0x301cd221cf81ef94cd276042aacfbcd0e3795589",
209 "destination": "0xa1e2e61e0f9021e4e72a822e4ec0bf735afdebdf",
210 "sourceDex": "spot",
211 "destinationDex": "spot",
212 "token": "USDC",
213 "amount": "1.0",
214 "usdcValue": "1.0",
215 "fee": "1.0",
216 "nativeTokenFee": "0.0",
217 "nonce": 1778789686339_u64,
218 "feeToken": "USDC"
219 }
220 })
221 }
222
223 fn real_internal_transfer_fixture() -> serde_json::Value {
224 serde_json::json!({
225 "time": 1710245329284_u64,
226 "hash": "0x27bb0996075790365f590408011f7b0201fc004e9cf81a12ad03c4847b64c5cd",
227 "delta": {
228 "type": "internalTransfer",
229 "usdc": "1.0",
230 "user": "0xc272fa7d73e8ed66e65a6281570d3788bea5e7a4",
231 "destination": "0x0000000000000000000000000000000000000000",
232 "fee": "0.0"
233 }
234 })
235 }
236
237 #[test]
238 fn http_send_parses_usdc_from_amount_field() {
239 let update: LedgerUpdate =
240 serde_json::from_value(real_send_fixture()).expect("real send fixture must parse");
241
242 assert_eq!(update.delta.delta_type, "send");
243 assert_eq!(update.delta.token.as_deref(), Some("USDC"));
244 assert_eq!(update.delta.amount.as_deref(), Some("1.0"));
245 assert!(update.delta.usdc.is_none());
246 }
247
248 #[test]
249 fn http_send_ignores_non_usdc_token() {
250 let mut fixture = real_send_fixture();
251 fixture["delta"]["token"] = serde_json::json!("HYPE");
252 fixture["delta"]["amount"] = serde_json::json!("5.0");
253
254 let update: LedgerUpdate = serde_json::from_value(fixture).expect("fixture must parse");
255
256 assert_eq!(update.delta.delta_type, "send");
257 assert_eq!(update.delta.token.as_deref(), Some("HYPE"));
258 }
259
260 #[test]
261 fn http_internal_transfer_parses_usdc_from_usdc_field() {
262 let update: LedgerUpdate = serde_json::from_value(real_internal_transfer_fixture())
263 .expect("real internalTransfer fixture must parse");
264
265 assert_eq!(update.delta.delta_type, "internalTransfer");
266 assert_eq!(update.delta.usdc.as_deref(), Some("1.0"));
267 assert!(update.delta.amount.is_none());
268 }
269
270 #[test]
271 fn http_ignores_deposit_delta_type() {
272 let update: LedgerUpdate = serde_json::from_value(serde_json::json!({
273 "time": 1778500000000_u64,
274 "hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
275 "delta": { "type": "deposit", "usdc": "50.25" }
276 }))
277 .expect("valid update");
278
279 assert_eq!(update.delta.delta_type, "deposit");
280 }
281
282 #[test]
283 fn http_ignores_withdraw_delta_type() {
284 let update: LedgerUpdate = serde_json::from_value(serde_json::json!({
285 "time": 1778500000000_u64,
286 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
287 "delta": { "type": "withdraw", "usdc": "50.25" }
288 }))
289 .expect("valid update");
290
291 assert_eq!(update.delta.delta_type, "withdraw");
292 }
293
294 #[test]
295 fn rejects_short_hashes_at_decode_boundary() {
296 let error = serde_json::from_value::<LedgerUpdate>(serde_json::json!({
297 "time": 1778500000000_u64,
298 "hash": "0xabc",
299 "delta": { "type": "deposit", "usdc": "50.25" }
300 }))
301 .expect_err("short hash must fail decode");
302
303 assert!(
304 error.to_string().contains("invalid HyperCore"),
305 "unexpected error: {error}"
306 );
307 }
308
309 #[test]
310 fn ws_send_event_parses_correctly() {
311 let json = serde_json::json!({
312 "channel": "allUserNonFundingLedgerEvents",
313 "data": [real_send_fixture()]
314 });
315
316 let envelope: WsLedgerEnvelope = serde_json::from_value(json).expect("valid envelope");
317 let events = envelope.data.expect("should have data");
318 assert_eq!(events.len(), 1);
319 assert_eq!(events[0].delta.delta_type, "send");
320 assert_eq!(events[0].delta.token.as_deref(), Some("USDC"));
321 assert_eq!(events[0].delta.amount.as_deref(), Some("1.0"));
322 assert_eq!(
323 events[0].delta.user.as_deref(),
324 Some("0x301cd221cf81ef94cd276042aacfbcd0e3795589")
325 );
326 assert_eq!(
327 events[0].delta.destination.as_deref(),
328 Some("0xa1e2e61e0f9021e4e72a822e4ec0bf735afdebdf")
329 );
330 }
331
332 #[test]
333 fn ws_internal_transfer_event_parses_correctly() {
334 let json = serde_json::json!({
335 "channel": "allUserNonFundingLedgerEvents",
336 "data": [real_internal_transfer_fixture()]
337 });
338
339 let envelope: WsLedgerEnvelope = serde_json::from_value(json).expect("valid envelope");
340 let events = envelope.data.expect("should have data");
341 assert_eq!(events.len(), 1);
342 assert_eq!(events[0].delta.delta_type, "internalTransfer");
343 assert_eq!(events[0].delta.usdc.as_deref(), Some("1.0"));
344 assert!(events[0].delta.amount.is_none());
345 assert_eq!(
346 events[0].delta.user.as_deref(),
347 Some("0xc272fa7d73e8ed66e65a6281570d3788bea5e7a4")
348 );
349 }
350
351 #[test]
352 fn ws_envelope_ignores_non_data_messages() {
353 let json = serde_json::json!({
354 "method": "subscribe",
355 "subscription": { "type": "allUserNonFundingLedgerEvents" }
356 });
357
358 let envelope: WsLedgerEnvelope = serde_json::from_value(json).expect("valid envelope");
359 assert!(envelope.channel.is_none());
360 assert!(envelope.data.is_none());
361 }
362}