Skip to main content

df_hyperliquid_core/transfer/
plan.rs

1//! From a move's typed values to exactly what is signed: the action, its nonce, the key it needs,
2//! and the balance that should change once it lands. An agent's signature on a user-signed action
3//! recovers to the agent, which holds nothing, so [`Key`] refuses it before signing.
4
5use crate::account::signers::ActionKind;
6use crate::client::{Network, USDC_INDEX};
7use crate::transfer::actions::{
8    AgentSendAsset, PERP_DEX, SPOT_DEX, SendAsset, SpotOut, UsdClassTransfer, UsdSend, Withdraw3,
9};
10use crate::transfer::amount::Amount;
11use crate::transfer::check::is_system_address;
12use crate::transfer::keeping::Keeping;
13use crate::transfer::token::SpotToken;
14use crate::utils::{Address, Dec};
15
16/// USDC on the perp side counts in millionths.
17pub const USD_DECIMALS: u32 = 6;
18
19/// Who may sign an action: `Main` is the account's own key only; `Either` also takes an agent the
20/// account approved.
21pub use crate::account::signers::Signers as Key;
22
23/// The transfer actions.
24const KINDS: [ActionKind; 6] = [
25    ActionKind::UsdClassTransfer,
26    ActionKind::UsdSend,
27    ActionKind::SpotSend,
28    ActionKind::SendAsset,
29    ActionKind::Withdraw3,
30    ActionKind::AgentSendAsset,
31];
32
33/// The key a transfer action needs, by its wire `type`.
34#[must_use]
35pub fn key(kind: &str) -> Option<Key> {
36    ActionKind::from_wire(kind).filter(|kind| KINDS.contains(kind)).map(ActionKind::signers)
37}
38
39/// A figure a read back checks.
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub enum Balance {
42    /// What a perp dex lets move out, `clearinghouseState`'s `withdrawable`;
43    /// `""` is the main dex.
44    Perp(String),
45    /// A spot token's `total` in `spotClearinghouseState`, by index. A pooled
46    /// account's USDC is `Spot(0)`.
47    Spot(u32),
48}
49
50/// How the figure moves once the action lands.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Change {
53    /// Up by exactly this much.
54    Up(Dec),
55    /// Down by exactly this much: a withdrawal's fee comes out of the amount.
56    Down(Dec),
57    /// Down by this much and whatever the venue takes on top: a fresh
58    /// recipient's activation fee, or HYPE for a spot send.
59    DownAtLeast(Dec),
60}
61
62/// The balance an action changes, and what reading it back should show.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Effect {
65    pub balance: Balance,
66    pub change: Change,
67}
68
69impl Effect {
70    /// Whether `after`, read back, shows the action landed against `before`,
71    /// read when it was planned.
72    #[must_use]
73    pub fn landed(&self, before: Dec, after: Dec) -> bool {
74        match self.change {
75            Change::Up(amount) => before.checked_add(amount) == Some(after),
76            Change::Down(amount) => before.checked_sub(amount) == Some(after),
77            Change::DownAtLeast(amount) => before.checked_sub(amount).is_some_and(|most| after <= most),
78        }
79    }
80}
81
82/// Exactly what is signed, and what to check once it lands.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Plan<A> {
85    pub network: Network,
86    pub action: A,
87    /// What it is signed with; a user-signed action carries it too.
88    pub nonce: u64,
89    pub key: Key,
90    pub effect: Effect,
91}
92
93/// Why a move is not signed.
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95pub enum Refused {
96    #[error("a pooled account holds USDC in one balance, so there is nothing to move between {0}")]
97    OneBalance(&'static str),
98    #[error("the two balances are the same one")]
99    SameDex,
100    #[error("that is this account; a send goes somewhere else")]
101    ToSelf,
102    #[error("{0} is a system address: a send there crosses to HyperEVM, which `to_evm` plans")]
103    SystemAddress(Address),
104    #[error("{0} has no token on HyperEVM to cross to")]
105    NotLinked(String),
106    /// No vector covers USDC crossing to HyperEVM, so it is refused.
107    #[error("USDC does not cross to HyperEVM here")]
108    Usdc,
109    #[error("{amount} has more than {places} decimal places")]
110    TooFine { amount: Dec, places: u32 },
111}
112
113/// Everything outside the amounts that a plan is made from.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Ctx {
116    pub network: Network,
117    /// The account that moves the money.
118    pub account: Address,
119    pub keeping: Keeping,
120    pub nonce: u64,
121}
122
123impl Ctx {
124    /// Where USDC leaving the perp side is read back: spot USDC for a pooled
125    /// account, the main dex's `withdrawable` otherwise.
126    #[must_use]
127    pub fn usdc(&self) -> Balance {
128        match self.keeping.pools() {
129            true => Balance::Spot(USDC_INDEX),
130            false => Balance::Perp(PERP_DEX.to_owned()),
131        }
132    }
133
134    const fn plan<A>(&self, action: A, key: Key, effect: Effect) -> Plan<A> {
135        Plan { network: self.network, action, nonce: self.nonce, key, effect }
136    }
137
138    /// `usdClassTransfer`: USDC between the account's own perp and spot. A
139    /// pooled account has one balance and nothing to move.
140    pub fn class_transfer(&self, amount: Amount, to_perp: bool) -> Result<Plan<UsdClassTransfer>, Refused> {
141        if self.keeping.pools() {
142            return Err(Refused::OneBalance("perps and spot"));
143        }
144        let value = places(&amount, USD_DECIMALS)?;
145        let change = match to_perp {
146            true => Change::Up(value),
147            false => Change::Down(value),
148        };
149        let effect = Effect { balance: Balance::Perp(PERP_DEX.to_owned()), change };
150        Ok(self.plan(UsdClassTransfer::new(amount, to_perp, self.nonce), UsdClassTransfer::KEY, effect))
151    }
152
153    /// `usdSend`: USDC to another account.
154    pub fn usd_send(&self, to: Address, amount: Amount) -> Result<Plan<UsdSend>, Refused> {
155        self.recipient(to)?;
156        let value = places(&amount, USD_DECIMALS)?;
157        let effect = Effect { balance: self.usdc(), change: Change::DownAtLeast(value) };
158        Ok(self.plan(UsdSend::new(to, amount, self.nonce), UsdSend::KEY, effect))
159    }
160
161    /// A spot token to another account, by the action the account takes.
162    pub fn send_token(&self, to: Address, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
163        self.recipient(to)?;
164        let value = places(&amount, token.wei_decimals)?;
165        let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
166        let action = SpotOut::new(to, &token.wire(), amount, self.keeping, self.nonce);
167        Ok(self.plan(action, SpotOut::KEY, effect))
168    }
169
170    /// Core to HyperEVM: the token to its system address, arriving at the same address on HyperEVM.
171    /// The way back is [`crate::transfer::link::Linked::to_core`].
172    pub fn to_evm(&self, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
173        if token.index == USDC_INDEX {
174            return Err(Refused::Usdc);
175        }
176        let most = token.crossing_places(self.network).ok_or_else(|| Refused::NotLinked(token.name.clone()))?;
177        let value = places(&amount, most)?;
178        let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
179        let action = SpotOut::new(token.system_address(self.network), &token.wire(), amount, self.keeping, self.nonce);
180        Ok(self.plan(action, SpotOut::KEY, effect))
181    }
182
183    /// `sendAsset` to the account itself, from one of its balances to another
184    /// (`""` the main dex, `spot`, or a HIP-3 dex by name), signed by its own
185    /// key. A pooled account has one balance and nothing to move.
186    pub fn between(&self, from: &str, to: &str, token: &SpotToken, amount: Amount) -> Result<Plan<SendAsset>, Refused> {
187        let effect = self.between_effect(from, to, token, &amount)?;
188        let action = SendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
189        Ok(self.plan(action, SendAsset::KEY, effect))
190    }
191
192    /// [`Ctx::between`] as `agentSendAsset`, which an agent signs.
193    pub fn agent_between(
194        &self,
195        from: &str,
196        to: &str,
197        token: &SpotToken,
198        amount: Amount,
199    ) -> Result<Plan<AgentSendAsset>, Refused> {
200        let effect = self.between_effect(from, to, token, &amount)?;
201        let action = AgentSendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
202        Ok(self.plan(action, AgentSendAsset::KEY, effect))
203    }
204
205    /// `withdraw3`: USDC out to `to` on Arbitrum. The venue's fee comes out of
206    /// the amount, so the balance falls by exactly it.
207    pub fn withdraw(&self, to: Address, amount: Amount) -> Result<Plan<Withdraw3>, Refused> {
208        if is_system_address(to) {
209            return Err(Refused::SystemAddress(to));
210        }
211        let value = places(&amount, USD_DECIMALS)?;
212        let effect = Effect { balance: self.usdc(), change: Change::Down(value) };
213        Ok(self.plan(Withdraw3::new(to, amount, self.nonce), Withdraw3::KEY, effect))
214    }
215
216    fn recipient(&self, to: Address) -> Result<(), Refused> {
217        match (to == self.account, is_system_address(to)) {
218            (true, _) => Err(Refused::ToSelf),
219            (false, true) => Err(Refused::SystemAddress(to)),
220            (false, false) => Ok(()),
221        }
222    }
223
224    /// The balance a move between two of the account's own leaves, by exactly
225    /// the amount. One side is always a perp dex, which counts in millionths.
226    fn between_effect(&self, from: &str, to: &str, token: &SpotToken, amount: &Amount) -> Result<Effect, Refused> {
227        if self.keeping.pools() {
228            return Err(Refused::OneBalance("dexes"));
229        }
230        if from == to {
231            return Err(Refused::SameDex);
232        }
233        let value = places(amount, token.wei_decimals.min(USD_DECIMALS))?;
234        let balance = match from {
235            SPOT_DEX => Balance::Spot(token.index),
236            dex => Balance::Perp(dex.to_owned()),
237        };
238        Ok(Effect { balance, change: Change::Down(value) })
239    }
240}
241
242/// The amount's value, refused when it has more than `most` places.
243fn places(amount: &Amount, most: u32) -> Result<Dec, Refused> {
244    let value = amount.value();
245    match value.places() > most {
246        true => Err(Refused::TooFine { amount: value, places: most }),
247        false => Ok(value),
248    }
249}
250
251#[cfg(test)]
252#[path = "tests/plan.rs"]
253mod tests;