Skip to main content

df_hyperliquid_core/earn/staking/
plan.rs

1//! From a staking move's typed values to exactly what is signed: the action,
2//! its nonce, the key it needs, the balance that should change once it lands,
3//! and when what it moved is free again.
4
5use crate::account::Signers;
6use crate::client::Network;
7use crate::earn::staking::actions::{CDeposit, CWithdraw, TokenDelegate};
8use crate::earn::staking::reads::{Delegation, DelegatorSummary, MAX_PENDING_WITHDRAWALS, locked_until, unstaking_ms};
9use crate::utils::{Address, BadUnits, Dec};
10
11/// A figure a read back checks.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Balance {
14    /// The staking balance free to delegate or withdraw:
15    /// `delegatorSummary`'s `undelegated`.
16    Undelegated,
17    /// What is delegated to one validator: its `delegations` row.
18    Delegated(Address),
19    /// On its way to spot: `delegatorSummary`'s `totalPendingWithdrawal`.
20    Pending,
21}
22
23/// How the figure moves once the action lands: by exactly the amount.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Change {
26    Up(Dec),
27    Down(Dec),
28}
29
30/// The balance an action changes, and what reading it back should show.
31///
32/// Every move here is read back on the staking balance: each one passes
33/// through it, and rewards do not (they are added to the delegations), so it
34/// moves by exactly the amount. The other side moves too: spot HYPE for a
35/// deposit, the delegation for a delegate, the queue for a withdrawal.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Effect {
38    pub balance: Balance,
39    pub change: Change,
40}
41
42impl Effect {
43    /// Whether `after`, read back, shows the action landed against `before`.
44    #[must_use]
45    pub fn landed(&self, before: Dec, after: Dec) -> bool {
46        match self.change {
47            Change::Up(amount) => before.checked_add(amount) == Some(after),
48            Change::Down(amount) => before.checked_sub(amount) == Some(after),
49        }
50    }
51}
52
53/// Exactly what is signed, and what to check once it lands.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Plan<A> {
56    pub network: Network,
57    pub action: A,
58    /// What it is signed with; the action carries it too.
59    pub nonce: u64,
60    pub key: Signers,
61    pub effect: Effect,
62    /// When what it moved is free again, milliseconds: a delegation's lock
63    /// ends, or a withdrawal reaches spot.
64    pub free_at: Option<u64>,
65}
66
67/// Why a move is not signed.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69pub enum Refused {
70    #[error("{amount} HYPE is more than the {held} there is")]
71    MoreThan { amount: Dec, held: Dec },
72    #[error("nothing is delegated to {0}")]
73    NoDelegation(Address),
74    #[error("that delegation is locked until {until} ms")]
75    Locked { until: u64 },
76    #[error("{MAX_PENDING_WITHDRAWALS} withdrawals already wait in the queue, the most there may be")]
77    QueueFull,
78    #[error(transparent)]
79    Units(#[from] BadUnits),
80}
81
82/// Everything outside the amounts that a plan is made from.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Ctx {
85    pub network: Network,
86    pub nonce: u64,
87    /// Now, milliseconds: a lock or a queue is measured from it.
88    pub now_ms: u64,
89}
90
91impl Ctx {
92    const fn plan<A>(&self, action: A, key: Signers, effect: Effect, free_at: Option<u64>) -> Plan<A> {
93        Plan { network: self.network, action, nonce: self.nonce, key, effect, free_at }
94    }
95
96    /// `cDeposit`: HYPE from spot into the staking balance.
97    pub fn stake(&self, hype: Dec) -> Result<Plan<CDeposit>, Refused> {
98        let action = CDeposit::new(hype, self.nonce)?;
99        let effect = Effect { balance: Balance::Undelegated, change: Change::Up(hype) };
100        Ok(self.plan(action, CDeposit::KEY, effect, None))
101    }
102
103    /// `cWithdraw`: HYPE from the staking balance toward spot, through the
104    /// network's queue, which holds at most five at once.
105    pub fn unstake(&self, hype: Dec, summary: &DelegatorSummary) -> Result<Plan<CWithdraw>, Refused> {
106        let action = CWithdraw::new(hype, self.nonce)?;
107        at_most(hype, summary.undelegated)?;
108        if summary.n_pending_withdrawals >= MAX_PENDING_WITHDRAWALS {
109            return Err(Refused::QueueFull);
110        }
111        let effect = Effect { balance: Balance::Undelegated, change: Change::Down(hype) };
112        let ready = self.now_ms.saturating_add(unstaking_ms(self.network));
113        Ok(self.plan(action, CWithdraw::KEY, effect, Some(ready)))
114    }
115
116    /// `tokenDelegate`: the staking balance to `validator`, locked for the
117    /// network's delegation lock.
118    pub fn delegate(
119        &self,
120        validator: Address,
121        hype: Dec,
122        summary: &DelegatorSummary,
123    ) -> Result<Plan<TokenDelegate>, Refused> {
124        let action = TokenDelegate::delegate(validator, hype, self.nonce)?;
125        at_most(hype, summary.undelegated)?;
126        let effect = Effect { balance: Balance::Undelegated, change: Change::Down(hype) };
127        Ok(self.plan(action, TokenDelegate::KEY, effect, Some(locked_until(self.network, self.now_ms))))
128    }
129
130    /// `tokenDelegate` back: from `validator` to the staking balance, once
131    /// its lock has passed.
132    pub fn undelegate(
133        &self,
134        validator: Address,
135        hype: Dec,
136        delegations: &[Delegation],
137    ) -> Result<Plan<TokenDelegate>, Refused> {
138        let action = TokenDelegate::undelegate(validator, hype, self.nonce)?;
139        let held = delegations
140            .iter()
141            .find(|delegation| delegation.validator == validator)
142            .ok_or(Refused::NoDelegation(validator))?;
143        if held.is_locked(self.now_ms) {
144            return Err(Refused::Locked { until: held.locked_until_timestamp });
145        }
146        at_most(hype, held.amount)?;
147        let effect = Effect { balance: Balance::Undelegated, change: Change::Up(hype) };
148        Ok(self.plan(action, TokenDelegate::KEY, effect, None))
149    }
150}
151
152fn at_most(amount: Dec, held: Dec) -> Result<(), Refused> {
153    match amount > held {
154        true => Err(Refused::MoreThan { amount, held }),
155        false => Ok(()),
156    }
157}
158
159#[cfg(test)]
160#[path = "tests/plan.rs"]
161mod tests;