Skip to main content

df_hyperliquid_core/earn/vaults/
plan.rs

1//! From a vault move's typed values to exactly what is signed: the action,
2//! its nonce, the key it needs, the equity that should change once it lands,
3//! and when a deposit unlocks.
4
5use crate::account::signers::ActionKind;
6use crate::client::Network;
7use crate::earn::vaults::reads::{VaultDetails, VaultEquity};
8use crate::earn::vaults::transfer::{USD_DECIMALS, VaultTransfer, lockup_ms};
9use crate::utils::{Address, BadUnits, Dec, cut_units};
10
11/// Who may sign an action: `Main` is the account's own key only; `Either` also
12/// takes an agent the account approved.
13pub use crate::account::signers::Signers as Key;
14
15/// The vault actions.
16const KINDS: [ActionKind; 1] = [ActionKind::VaultTransfer];
17
18/// The key each vault action needs, by its wire `type`.
19#[must_use]
20pub fn key(kind: &str) -> Option<Key> {
21    ActionKind::from_wire(kind).filter(|kind| KINDS.contains(kind)).map(ActionKind::signers)
22}
23
24/// A figure a read back checks.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum Balance {
27    /// The account's equity in a vault: its `userVaultEquities` row. The perp
28    /// USDC on the other side moves by exactly the amount.
29    Vault(Address),
30}
31
32/// How the equity moves once the action lands. The vault's profit and loss
33/// moves it too, so a read back shows the direction, by about the amount.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Change {
36    Rises(Dec),
37    Falls(Dec),
38}
39
40/// The equity an action changes, and what reading it back should show.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Effect {
43    pub balance: Balance,
44    pub change: Change,
45}
46
47impl Effect {
48    /// Whether `after`, read back, shows the action landed against `before`.
49    #[must_use]
50    pub fn landed(&self, before: Dec, after: Dec) -> bool {
51        match self.change {
52            Change::Rises(_) => after > before,
53            Change::Falls(_) => after < before,
54        }
55    }
56}
57
58/// Exactly what is signed, and what to check once it lands.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Plan<A> {
61    pub network: Network,
62    pub action: A,
63    pub nonce: u64,
64    pub key: Key,
65    pub effect: Effect,
66    /// When a deposit may first be withdrawn, milliseconds.
67    pub free_at: Option<u64>,
68}
69
70/// Why a move is not signed.
71#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
72pub enum Refused {
73    #[error("{0} takes no deposits")]
74    NoDeposits(Address),
75    #[error("locked until {until} ms")]
76    Locked { until: u64 },
77    #[error("{amount} USDC is more than the {held} there is")]
78    MoreThan { amount: Dec, held: Dec },
79    #[error(transparent)]
80    Units(#[from] BadUnits),
81}
82
83/// Everything outside the amounts that a plan is made from.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Ctx {
86    pub network: Network,
87    pub nonce: u64,
88    /// Now, milliseconds: a lockup is measured from it.
89    pub now_ms: u64,
90}
91
92impl Ctx {
93    const fn plan(&self, action: VaultTransfer, change: Change, free_at: Option<u64>) -> Plan<VaultTransfer> {
94        let effect = Effect { balance: Balance::Vault(action.vault()), change };
95        Plan { network: self.network, action, nonce: self.nonce, key: VaultTransfer::KEY, effect, free_at }
96    }
97
98    /// USDC from the perp balance into `vault`, locked for its lockup.
99    pub fn deposit(&self, vault: &VaultDetails, usdc: Dec) -> Result<Plan<VaultTransfer>, Refused> {
100        let action = VaultTransfer::deposit(vault.vault_address, usdc)?;
101        if !vault.takes_deposits() {
102            return Err(Refused::NoDeposits(vault.vault_address));
103        }
104        let free_at = self.now_ms.saturating_add(lockup_ms(self.network, vault.vault_address));
105        Ok(self.plan(action, Change::Rises(usdc), Some(free_at)))
106    }
107
108    /// `usdc` out of the vault `equity` is in, once its lockup has passed.
109    pub fn withdraw(&self, equity: &VaultEquity, usdc: Dec) -> Result<Plan<VaultTransfer>, Refused> {
110        let action = VaultTransfer::withdraw(equity.vault_address, usdc)?;
111        unlocked(equity, self.now_ms)?;
112        if usdc > equity.equity {
113            return Err(Refused::MoreThan { amount: usdc, held: equity.equity });
114        }
115        Ok(self.plan(action, Change::Falls(usdc), None))
116    }
117
118    /// The whole equity, cut to millionths so it never asks for more.
119    pub fn withdraw_all(&self, equity: &VaultEquity) -> Result<Plan<VaultTransfer>, Refused> {
120        unlocked(equity, self.now_ms)?;
121        let usd = cut_units(equity.equity, USD_DECIMALS)?;
122        let action = VaultTransfer::from_micros(equity.vault_address, false, usd);
123        let amount = equity.withdrawable(self.now_ms);
124        Ok(self.plan(action, Change::Falls(amount), None))
125    }
126}
127
128fn unlocked(equity: &VaultEquity, now_ms: u64) -> Result<(), Refused> {
129    match equity.is_locked(now_ms) {
130        true => Err(Refused::Locked { until: equity.locked_until_timestamp }),
131        false => Ok(()),
132    }
133}
134
135#[cfg(test)]
136#[path = "tests/plan.rs"]
137mod tests;