Skip to main content

df_hyperliquid_core/earn/vaults/
reads.rs

1//! `vaultDetails`, `userVaultEquities` and `leadingVaults`. `vaultDetails`
2//! writes a few figures as bare JSON floats (`apr`, `maxWithdrawable`); they
3//! are read as the digits sent ([`Figure`]), never through an f64. Its
4//! `portfolio` history is left unread.
5
6use crate::client::{ApiError, Transport, info, info_request, user_request};
7use crate::utils::{Address, Dec, Figure, Json, Round, USDC_DECIMALS};
8use serde::Deserialize;
9
10/// `{"type":"vaultDetails","vaultAddress":..,"user"?:..}`: with `user`, the
11/// answer carries that follower's state.
12#[must_use]
13pub fn vault_details_request(vault: Address, user: Option<Address>) -> String {
14    let mut fields = vec![("vaultAddress", Json::String(vault.to_hex()))];
15    if let Some(user) = user {
16        fields.push(("user", Json::String(user.to_hex())));
17    }
18    info_request("vaultDetails", &fields)
19}
20
21#[must_use]
22pub fn user_vault_equities_request(user: Address) -> String {
23    user_request("userVaultEquities", user)
24}
25
26#[must_use]
27pub fn leading_vaults_request(user: Address) -> String {
28    user_request("leadingVaults", user)
29}
30
31/// `None` for an address that is no vault: the venue answers `null`.
32pub async fn vault_details(
33    transport: &impl Transport,
34    vault: Address,
35    user: Option<Address>,
36) -> Result<Option<VaultDetails>, ApiError> {
37    info(transport, vault_details_request(vault, user)).await
38}
39
40/// Every vault `user` has equity in, with when each unlocks.
41pub async fn user_vault_equities(transport: &impl Transport, user: Address) -> Result<Vec<VaultEquity>, ApiError> {
42    info(transport, user_vault_equities_request(user)).await
43}
44
45/// The vaults `user` leads.
46pub async fn leading_vaults(transport: &impl Transport, user: Address) -> Result<Vec<LeadingVault>, ApiError> {
47    info(transport, leading_vaults_request(user)).await
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct VaultDetails {
53    pub name: String,
54    pub vault_address: Address,
55    pub leader: Address,
56    #[serde(default)]
57    pub description: String,
58    /// Yearly, as a fraction.
59    #[serde(default)]
60    pub apr: Option<Figure>,
61    /// The asking follower's state, when `user` was named and follows.
62    #[serde(default)]
63    pub follower_state: Option<Follower>,
64    /// The leader's share of the vault.
65    #[serde(default)]
66    pub leader_fraction: Option<Figure>,
67    /// The leader's cut of followers' profit.
68    #[serde(default)]
69    pub leader_commission: Option<Figure>,
70    #[serde(default)]
71    pub followers: Vec<Follower>,
72    #[serde(default)]
73    pub max_distributable: Option<Figure>,
74    /// For the asking follower: what may leave now, in USDC.
75    #[serde(default)]
76    pub max_withdrawable: Option<Figure>,
77    #[serde(default)]
78    pub is_closed: bool,
79    /// Whether it leads strategy vaults (HLP), is one, or neither.
80    #[serde(default)]
81    pub relationship: Relationship,
82    #[serde(default)]
83    pub allow_deposits: bool,
84    #[serde(default)]
85    pub always_close_on_withdraw: bool,
86}
87
88/// `{"type":"parent","data":{"childAddresses":[..]}}`, `{"type":"child"}` or
89/// `{"type":"normal"}`.
90#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
91#[serde(tag = "type", content = "data", rename_all = "camelCase")]
92pub enum Relationship {
93    /// A vault anyone may follow, led by a person.
94    #[default]
95    Normal,
96    /// Leads the strategy vaults it lists (HLP).
97    #[serde(rename_all = "camelCase")]
98    Parent { child_addresses: Vec<Address> },
99    /// One of a parent's strategies, led by the parent itself.
100    Child,
101}
102
103impl VaultDetails {
104    /// Whether a deposit would be taken: open, and taking deposits.
105    #[must_use]
106    pub const fn takes_deposits(&self) -> bool {
107        self.allow_deposits && !self.is_closed
108    }
109
110    /// What the asking follower may take out at `now_ms`, in USDC cut to
111    /// millionths: its `maxWithdrawable` once its lockup has passed, nothing
112    /// before, and nothing when no follower was asked about.
113    #[must_use]
114    pub fn withdrawable_now(&self, now_ms: u64) -> Dec {
115        match &self.follower_state {
116            Some(follower) if !follower.is_locked(now_ms) => self
117                .max_withdrawable
118                .as_ref()
119                .and_then(Figure::to_dec)
120                .map_or(Dec::ZERO, |most| most.round(USDC_DECIMALS, Round::Down).max(Dec::ZERO)),
121            _ => Dec::ZERO,
122        }
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct Follower {
129    /// `None` for the leader's own row, which the venue writes as `"user":"Leader"`.
130    #[serde(deserialize_with = "follower_user")]
131    pub user: Option<Address>,
132    pub vault_equity: Dec,
133    #[serde(default)]
134    pub pnl: Option<Dec>,
135    #[serde(default)]
136    pub all_time_pnl: Option<Dec>,
137    #[serde(default)]
138    pub days_following: Option<u64>,
139    #[serde(default)]
140    pub vault_entry_time: Option<u64>,
141    /// Milliseconds; a withdrawal before it is refused.
142    #[serde(default)]
143    pub lockup_until: Option<u64>,
144}
145
146impl Follower {
147    /// Whether a withdrawal at `now_ms` would be refused for the lockup.
148    #[must_use]
149    pub fn is_locked(&self, now_ms: u64) -> bool {
150        self.lockup_until.is_some_and(|until| now_ms < until)
151    }
152}
153
154/// A follower's `user`: an address, or `Leader` for the leader's own stake.
155fn follower_user<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Address>, D::Error> {
156    let text = String::deserialize(deserializer)?;
157    if text == "Leader" {
158        return Ok(None);
159    }
160    text.parse().map(Some).map_err(serde::de::Error::custom)
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct VaultEquity {
166    pub vault_address: Address,
167    pub equity: Dec,
168    /// Milliseconds.
169    pub locked_until_timestamp: u64,
170}
171
172impl VaultEquity {
173    #[must_use]
174    pub const fn is_locked(&self, now_ms: u64) -> bool {
175        now_ms < self.locked_until_timestamp
176    }
177
178    /// What may leave at `now_ms`, in USDC: the equity cut to millionths once
179    /// the lockup has passed, nothing before.
180    #[must_use]
181    pub fn withdrawable(&self, now_ms: u64) -> Dec {
182        match self.is_locked(now_ms) {
183            true => Dec::ZERO,
184            false => self.equity.round(USDC_DECIMALS, Round::Down).max(Dec::ZERO),
185        }
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
190pub struct LeadingVault {
191    pub address: Address,
192    #[serde(default)]
193    pub name: String,
194}
195
196#[cfg(test)]
197#[path = "tests/details.rs"]
198mod tests;