use rust_decimal::Decimal;
use serde_json::Value;
use crate::error::{Error, Result};
use crate::types::{Deposit, DepositStatus, Timestamp, Withdrawal, WithdrawalStatus};
use super::parse::{self, RawAssetCtx, RawLedgerUpdate};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HyperliquidLedgerEntry {
pub kind: HyperliquidLedgerKind,
pub time: Timestamp,
pub hash: String,
pub asset: Option<String>,
pub amount: Option<Decimal>,
pub fee: Option<Decimal>,
pub counterparty: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HyperliquidLedgerKind {
Deposit,
Withdraw,
InternalTransfer,
SubAccountTransfer,
SpotTransfer,
AccountClassTransfer,
VaultDeposit,
VaultWithdraw,
VaultDistribution,
Liquidation,
Other(String),
}
impl HyperliquidLedgerKind {
fn from_name(name: &str) -> Self {
match name {
"deposit" => Self::Deposit,
"withdraw" => Self::Withdraw,
"internalTransfer" => Self::InternalTransfer,
"subAccountTransfer" => Self::SubAccountTransfer,
"spotTransfer" => Self::SpotTransfer,
"accountClassTransfer" => Self::AccountClassTransfer,
"vaultDeposit" => Self::VaultDeposit,
"vaultWithdraw" => Self::VaultWithdraw,
"vaultDistribution" => Self::VaultDistribution,
"liquidation" | "ledgerLiquidation" => Self::Liquidation,
other => Self::Other(other.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HyperliquidAssetContext {
pub mid_price: Option<Decimal>,
pub mark_price: Option<Decimal>,
pub oracle_price: Option<Decimal>,
pub funding_rate: Option<Decimal>,
pub open_interest: Option<Decimal>,
pub size_decimals: u32,
pub price_decimals: u32,
}
pub(crate) fn ledger_entries(raw: &[RawLedgerUpdate]) -> Result<Vec<HyperliquidLedgerEntry>> {
raw.iter().map(ledger_entry).collect()
}
fn ledger_entry(raw: &RawLedgerUpdate) -> Result<HyperliquidLedgerEntry> {
let name = kind_name(raw);
let (asset, amount_field) = match text(&raw.delta, "token") {
Some(token) => (token.to_ascii_uppercase(), "amount"),
None => (parse::SETTLE_ASSET.to_string(), "usdc"),
};
let amount = decimal_field(&raw.delta, amount_field)?.map(|value| value.abs());
Ok(HyperliquidLedgerEntry {
kind: HyperliquidLedgerKind::from_name(name),
time: parse::millis(raw.time, "time")?,
hash: raw.hash.clone(),
asset: amount.map(|_| asset),
amount,
fee: decimal_field(&raw.delta, "fee")?,
counterparty: text(&raw.delta, "destination").map(str::to_string),
})
}
pub(crate) fn deposits(raw: &[RawLedgerUpdate]) -> Result<Vec<(Deposit, i64)>> {
raw.iter()
.filter(|entry| kind_name(entry) == "deposit")
.map(|entry| {
let hash = entry.hash.clone();
Ok((
Deposit {
id: hash.clone(),
asset: parse::SETTLE_ASSET.to_string(),
network: None,
provider_network: None,
amount: required_decimal(&entry.delta, "usdc")?.abs(),
address: None,
memo: None,
status: DepositStatus::Unknown,
provider_status: "deposit".to_string(),
tx_id: Some(hash),
created_at: Some(parse::millis(entry.time, "time")?),
},
entry.time,
))
})
.collect()
}
pub(crate) fn withdrawals(raw: &[RawLedgerUpdate]) -> Result<Vec<(Withdrawal, i64)>> {
raw.iter()
.filter(|entry| kind_name(entry) == "withdraw")
.map(|entry| {
let hash = entry.hash.clone();
Ok((
Withdrawal {
id: hash.clone(),
asset: parse::SETTLE_ASSET.to_string(),
network: None,
provider_network: None,
amount: required_decimal(&entry.delta, "usdc")?.abs(),
fee: decimal_field(&entry.delta, "fee")?,
destination: None,
status: WithdrawalStatus::Unknown,
provider_status: "withdraw".to_string(),
tx_id: Some(hash),
created_at: Some(parse::millis(entry.time, "time")?),
},
entry.time,
))
})
.collect()
}
fn kind_name(raw: &RawLedgerUpdate) -> &str {
raw.delta
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
}
fn text<'a>(delta: &'a Value, field: &str) -> Option<&'a str> {
delta.get(field).and_then(Value::as_str)
}
fn required_decimal(delta: &Value, field: &'static str) -> Result<Decimal> {
decimal_field(delta, field)?
.ok_or_else(|| Error::decode(format!("Hyperliquid ledger delta has no `{field}`")))
}
fn decimal_field(delta: &Value, field: &'static str) -> Result<Option<Decimal>> {
match delta.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => parse::decimal(value, field).map(Some),
Some(Value::Number(value)) => parse::decimal(&value.to_string(), field).map(Some),
Some(other) => Err(Error::decode(format!(
"Hyperliquid ledger `{field}` is not a number: {other}"
))),
}
}
pub(crate) fn asset_context(
raw: &RawAssetCtx,
asset: &parse::Asset,
) -> Result<HyperliquidAssetContext> {
let optional = |value: Option<&str>, field: &'static str| {
value.map(|value| parse::decimal(value, field)).transpose()
};
Ok(HyperliquidAssetContext {
size_decimals: asset.size_decimals,
price_decimals: asset.price_decimals(),
mid_price: optional(raw.mid_px.as_deref(), "midPx")?,
mark_price: optional(raw.mark_px.as_deref(), "markPx")?,
oracle_price: optional(raw.oracle_px.as_deref(), "oraclePx")?,
funding_rate: optional(raw.funding.as_deref(), "funding")?,
open_interest: optional(raw.open_interest.as_deref(), "openInterest")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Exchange, Market, MarketStatus};
const LEDGER: &str = r#"[
{
"delta": {"type": "deposit", "usdc": "1000.0"},
"hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
"time": 1681222254710
},
{
"delta": {
"type": "withdraw",
"usdc": "250.0",
"nonce": 1681222254711,
"fee": "1.0"
},
"hash": "0x0000000000000000000000000000000000000000000000000000000000000002",
"time": 1681222354710
},
{
"delta": {
"type": "spotTransfer",
"token": "PURR",
"amount": "12.5",
"usdcValue": "3.75",
"user": "0x14791697260e4c9a71f18484c9f997b308e59325",
"destination": "0x0000000000000000000000000000000000000009",
"fee": "0.0"
},
"hash": "0x0000000000000000000000000000000000000000000000000000000000000003",
"time": 1681222454710
},
{
"delta": {
"type": "liquidation",
"accountValue": "12.0",
"leverage": 20.0,
"liquidatedPositions": [{"coin": "ETH", "szi": "-0.5"}]
},
"hash": "0x0000000000000000000000000000000000000000000000000000000000000004",
"time": 1681222554710
},
{
"delta": {"type": "somethingHyperliquidAddedLater", "usdc": "1.0"},
"hash": "0x0000000000000000000000000000000000000000000000000000000000000005",
"time": 1681222654710
}
]"#;
fn entries() -> Vec<HyperliquidLedgerEntry> {
let raw: Vec<RawLedgerUpdate> =
parse::json(LEDGER).expect("official ledger updates payload");
ledger_entries(&raw).expect("a page of entries")
}
#[test]
fn each_kind_of_cash_movement_keeps_its_own_meaning() {
let entries = entries();
assert_eq!(entries[0].kind, HyperliquidLedgerKind::Deposit);
assert_eq!(entries[0].amount, Some(Decimal::from(1_000)));
assert_eq!(entries[0].asset.as_deref(), Some("USDC"));
assert_eq!(entries[0].fee, None);
assert_eq!(entries[0].time, Timestamp::from_millis(1_681_222_254_710));
assert_eq!(entries[1].kind, HyperliquidLedgerKind::Withdraw);
assert_eq!(entries[1].amount, Some(Decimal::from(250)));
assert_eq!(entries[1].fee, Some(Decimal::ONE));
}
#[test]
fn bridge_events_map_without_inventing_network_destination_or_status() {
let raw: Vec<RawLedgerUpdate> = parse::json(LEDGER).expect("official ledger payload");
let deposits = deposits(&raw).expect("deposit history");
let withdrawals = withdrawals(&raw).expect("withdrawal history");
let deposit = &deposits[0].0;
assert_eq!(deposit.asset, "USDC");
assert_eq!(deposit.amount, Decimal::from(1_000));
assert_eq!(deposit.network, None);
assert_eq!(deposit.provider_network, None);
assert_eq!(deposit.address, None);
assert_eq!(deposit.status, DepositStatus::Unknown);
assert_eq!(deposit.provider_status, "deposit");
assert_eq!(deposit.tx_id.as_deref(), Some(deposit.id.as_str()));
let withdrawal = &withdrawals[0].0;
assert_eq!(withdrawal.asset, "USDC");
assert_eq!(withdrawal.amount, Decimal::from(250));
assert_eq!(withdrawal.fee, Some(Decimal::ONE));
assert_eq!(withdrawal.network, None);
assert_eq!(withdrawal.provider_network, None);
assert_eq!(withdrawal.destination, None);
assert_eq!(withdrawal.status, WithdrawalStatus::Unknown);
assert_eq!(withdrawal.provider_status, "withdraw");
assert_eq!(withdrawal.tx_id.as_deref(), Some(withdrawal.id.as_str()));
}
#[test]
fn numeric_ledger_amounts_are_read_without_float_round_trips() {
let raw: Vec<RawLedgerUpdate> = parse::json(
r#"[{
"delta": {"type": "deposit", "usdc": 0.125},
"hash": "0x01",
"time": 1681222254710
}]"#,
)
.expect("numeric ledger payload");
assert_eq!(
deposits(&raw).expect("deposit")[0].0.amount,
Decimal::new(125, 3)
);
}
#[test]
fn a_spot_transfer_names_its_token_rather_than_assuming_usdc() {
let entries = entries();
assert_eq!(entries[2].kind, HyperliquidLedgerKind::SpotTransfer);
assert_eq!(entries[2].asset.as_deref(), Some("PURR"));
assert_eq!(entries[2].amount, Some(Decimal::new(125, 1)));
assert_eq!(
entries[2].counterparty.as_deref(),
Some("0x0000000000000000000000000000000000000009")
);
}
#[test]
fn a_liquidation_has_no_single_amount_and_does_not_invent_one() {
let entries = entries();
assert_eq!(entries[3].kind, HyperliquidLedgerKind::Liquidation);
assert_eq!(entries[3].amount, None);
assert_eq!(entries[3].asset, None);
assert!(entries[3].hash.ends_with("04"));
}
#[test]
fn a_kind_this_release_does_not_know_arrives_named_rather_than_dropped() {
let entries = entries();
assert_eq!(
entries[4].kind,
HyperliquidLedgerKind::Other("somethingHyperliquidAddedLater".to_string())
);
assert_eq!(entries[4].amount, Some(Decimal::ONE));
}
#[test]
fn an_asset_context_carries_the_numbers_the_common_api_has_no_field_for() {
let body = r#"{
"dayNtlVlm": "1169046.29406",
"funding": "0.0000125",
"markPx": "14.3161",
"midPx": "14.314",
"openInterest": "688.11",
"oraclePx": "14.325",
"prevDayPx": "15.322"
}"#;
let raw: RawAssetCtx = parse::json(body).expect("official asset context payload");
let asset = parse::Asset {
market: Market::perpetual(Exchange::Hyperliquid, "HYPE", "USDC"),
native: "HYPE".to_string(),
asset_id: 0,
size_decimals: 2,
max_leverage: Some(3),
only_isolated: false,
status: MarketStatus::Active,
};
let context = asset_context(&raw, &asset).expect("a context");
assert_eq!(context.size_decimals, 2);
assert_eq!(context.price_decimals, 4);
assert_eq!(context.funding_rate, Some(Decimal::new(125, 7)));
assert_eq!(context.open_interest, Some(Decimal::new(68_811, 2)));
assert_eq!(context.oracle_price, Some(Decimal::new(14_325, 3)));
assert_eq!(context.mark_price, Some(Decimal::new(143_161, 4)));
assert_eq!(context.mid_price, Some(Decimal::new(14_314, 3)));
}
}