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