Skip to main content

df_hyperliquid_core/transfer/send/
plan.rs

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