Skip to main content

hypercore_rs/
cash_ledger.rs

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}