Skip to main content

df_papertrade_core/transfer/
deposit.rs

1//! Deposits from the wallet's Hyperliquid balance to its deposit address, a per-wallet CREATE2
2//! proxy whose USDC the relayer's sweeper credits to the venue within a few blocks. The address
3//! is registered with the relayer and checked against the one derived on chain, then each
4//! Hyperliquid send is signed by the wallet and posted once. Make one deposit at a time, as a
5//! second in the same window can hide the first from the sweep.
6
7use df_hyperliquid_core::account::verify;
8use df_hyperliquid_core::client::evm::precompiles::core_user_exists;
9use df_hyperliquid_core::client::{ExchangeResponse, Http, Network, Request, TransportError};
10use df_hyperliquid_core::transfer::{Amount, SPOT_DEX, SendAsset, SpotSend, UsdClassTransfer, UserAction};
11use df_hyperliquid_core::utils::{Address, Dec};
12
13use crate::account::wallet::{Purpose, SignRequest, Wallet};
14use crate::client::{Call, Client, Error};
15use crate::utils::wire::{BadWire, address_of, field, parse};
16
17/// The fee that opens a new HyperCore account: 1 USDC, in 6-decimal units.
18pub const ACTIVATION_FEE_USDC: u64 = 1_000_000;
19/// The minimum deposit: 10 USDC, in 6-decimal units.
20pub const MIN_DEPOSIT_USDC: u64 = 10_000_000;
21
22/// Which Hyperliquid balance the USDC comes from.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Source {
25    Spot,
26    /// A standard account's perp balance, moved to spot first.
27    Perp,
28}
29
30/// How the Hyperliquid account holds its balances.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Mode {
33    /// Separate spot and perp balances.
34    Standard,
35    /// One pooled balance (unified, portfolio margin).
36    Pooled,
37}
38
39/// A planned deposit, ready for the wallet to sign.
40#[derive(Debug, Clone)]
41pub struct Deposit {
42    pub to: Address,
43    /// Whether the address has no HyperCore account yet.
44    pub opens_account: bool,
45    /// What opening the address's account takes from the source balance on top of the deposit:
46    /// [`ACTIVATION_FEE_USDC`] when it [`opens_account`](Deposit::opens_account), zero otherwise.
47    pub activation_fee: u64,
48    pub sends: Vec<Send>,
49}
50
51/// One Hyperliquid send of a deposit.
52#[derive(Debug, Clone)]
53pub enum Send {
54    /// `usdClassTransfer` from the perp balance to spot.
55    ToSpot(UsdClassTransfer),
56    /// `spotSend` to the deposit address.
57    Spot(SpotSend),
58    /// `sendAsset` from a pooled balance to the deposit address, spot to spot.
59    Pooled(SendAsset),
60}
61
62impl Send {
63    #[must_use]
64    pub fn action(&self) -> &dyn UserAction {
65        match self {
66            Send::ToSpot(action) => action,
67            Send::Spot(action) => action,
68            Send::Pooled(action) => action,
69        }
70    }
71
72    #[must_use]
73    pub const fn purpose(&self) -> Purpose {
74        match self {
75            Send::ToSpot(_) => Purpose::DepositToSpot,
76            Send::Spot(_) | Send::Pooled(_) => Purpose::Deposit,
77        }
78    }
79}
80
81/// `usdc` (6 decimals) as the amount a Hyperliquid send signs.
82fn amount(usdc: u64) -> Result<Amount, Error> {
83    let value = Dec::new(i128::from(usdc), 6).ok_or_else(|| Error::Decode("amount out of range".into()))?;
84    Amount::cut(value, 6).map_err(|e| Error::Invalid(e.to_string()))
85}
86
87/// The deposit address in the relayer's answer to `POST /funding/deposits/check`.
88fn checked_address(text: &str) -> Result<Address, BadWire> {
89    let json = parse(text, "deposit_check")?;
90    address_of(field(&json, "proxy")?, "proxy")
91}
92
93impl<H: Http + Clone> Client<H> {
94    /// Registers `owner`'s deposit address with the relayer's sweeper and returns it.
95    pub async fn register_deposit(&self, owner: Address) -> Result<Address, Error> {
96        let text = self.relayer().send(&Call::deposit_check(owner)).await?;
97        Ok(checked_address(&text)?)
98    }
99
100    /// Plans a deposit of `usdc` (6 decimals, at least 10 USDC) from `source`, after registering
101    /// the address and checking it against the one derived on chain. When the address has no
102    /// HyperCore account, its first send also pays [`ACTIVATION_FEE_USDC`] from the source
103    /// balance; a perp balance moves to spot first, fee included.
104    pub async fn plan_deposit(&self, owner: Address, usdc: u64, source: Source, mode: Mode) -> Result<Deposit, Error> {
105        if usdc < MIN_DEPOSIT_USDC {
106            return Err(Error::Invalid("a deposit brings in at least 10 USDC".into()));
107        }
108        let to = self.register_deposit(owner).await?;
109        if to != self.deposit_address(owner).await? {
110            return Err(Error::Decode("the relayer named a deposit address the factory does not derive".into()));
111        }
112        let exists = self.rpc().read(&core_user_exists(to)).await.map_err(Error::Precompile)?;
113        let opens_account = !exists;
114        let activation_fee = if opens_account { ACTIVATION_FEE_USDC } else { 0 };
115        let nonce = self.now_ms();
116        let network = Network::Mainnet;
117        let sends = match (mode, source) {
118            (Mode::Pooled, _) => {
119                vec![Send::Pooled(SendAsset::usdc(network, to, SPOT_DEX, SPOT_DEX, amount(usdc)?, nonce))]
120            }
121            (Mode::Standard, Source::Spot) => {
122                vec![Send::Spot(SpotSend::new(to, network.usdc().wire, amount(usdc)?, nonce))]
123            }
124            (Mode::Standard, Source::Perp) => {
125                vec![
126                    Send::ToSpot(UsdClassTransfer::new(amount(usdc.saturating_add(activation_fee))?, false, nonce)),
127                    Send::Spot(SpotSend::new(to, network.usdc().wire, amount(usdc)?, nonce + 1)),
128                ]
129            }
130        };
131        Ok(Deposit { to, opens_account, activation_fee, sends })
132    }
133
134    /// Has the wallet sign each send in order and posts it to Hyperliquid once. A failed send
135    /// leaves the later ones unsent; an HTTP status other than 2xx is
136    /// [`TransportError::Status`].
137    pub async fn send_deposit(&self, wallet: &impl Wallet, deposit: &Deposit) -> Result<(), Error> {
138        let owner = wallet.address();
139        let network = Network::Mainnet;
140        for send in &deposit.sends {
141            let action = send.action();
142            let request = SignRequest {
143                purpose: send.purpose(),
144                json: action.wallet_json(network),
145                digest: action.digest(network),
146            };
147            let signature = wallet.sign(&request).await?;
148            verify(&request.digest, &signature, owner).map_err(|e| Error::Signature(e.to_string()))?;
149            let answer = self
150                .http()
151                .send(Request::post_json(self.config().exchange.clone(), action.body(&signature, network)))
152                .await?;
153            if !answer.is_success() {
154                return Err(TransportError::Status { status: answer.status, body: answer.text() }.into());
155            }
156            match ExchangeResponse::parse(&answer.text()).map_err(|e| Error::Decode(e.to_string()))? {
157                ExchangeResponse::Ok(_) => {}
158                ExchangeResponse::Err(why) => return Err(Error::Exchange(why)),
159            }
160        }
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166#[path = "tests/deposit.rs"]
167mod tests;