Skip to main content

df_hyperliquid_core/account/
sub_account.rs

1//! Subaccounts: `createSubAccount`, `subAccountTransfer` (perp USDC), `subAccountSpotTransfer` (any
2//! spot token) and the `subAccounts` read. A subaccount is a user of its own with no key: it is read
3//! at its own address, and the master's agent trades its perps with it as `vaultAddress`.
4
5use std::fmt;
6
7use crate::account::signers::ActionKind;
8use crate::account::state::{PerpState, SpotState};
9use crate::client::{ApiError, Transport, info, user_request};
10use crate::utils::sign::{L1Action, Map};
11use crate::utils::{Address, AssetKind, BadUnits, Dec, exact_units};
12use serde::Deserialize;
13
14/// Whose account an action or a read is about: the master's own, or one of
15/// its subaccounts.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct AccountRef {
18    /// The account whose key, or whose agent, signs.
19    pub owner: Address,
20    /// The subaccount acted for, if any.
21    pub sub: Option<Address>,
22}
23
24/// What a subaccount does not do, and why.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, thiserror::Error)]
26pub enum SubRefusal {
27    #[error("a subaccount places no spot orders here")]
28    Spot,
29    #[error("a subaccount places no HIP-4 orders and splits or merges nothing here")]
30    Outcome,
31    /// An order-like action that names no market cannot be told apart.
32    #[error("a subaccount's order names its market, since its spot and HIP-4 orders are refused")]
33    MarketUnknown,
34    #[error("a subaccount moves nothing between its own ledgers here")]
35    LedgerMove,
36    #[error("a subaccount approves nothing: its master's agent signs for it")]
37    Approval,
38    #[error("a subaccount schedules no cancel here")]
39    ScheduleCancel,
40    /// Opening a subaccount, and moving money in and out of one, is the
41    /// master's own business.
42    #[error("only the master opens subaccounts and moves money in and out of them")]
43    MastersOwn,
44    /// User-signed: it would need the subaccount's own key, which there is not.
45    #[error("a subaccount has no key of its own to sign this")]
46    NoKey,
47}
48
49impl AccountRef {
50    #[must_use]
51    pub const fn own(owner: Address) -> AccountRef {
52        AccountRef { owner, sub: None }
53    }
54
55    #[must_use]
56    pub const fn sub(owner: Address, sub: Address) -> AccountRef {
57        AccountRef { owner, sub: Some(sub) }
58    }
59
60    #[must_use]
61    pub const fn is_sub(&self) -> bool {
62        self.sub.is_some()
63    }
64
65    /// Where its states, mode, fees and limits are read: the subaccount's
66    /// own address for one.
67    #[must_use]
68    pub fn read_at(&self) -> Address {
69        self.sub.unwrap_or(self.owner)
70    }
71
72    /// The `vaultAddress` an L1 action names: the subaccount it acts for.
73    #[must_use]
74    pub const fn vault_address(&self) -> Option<Address> {
75        self.sub
76    }
77
78    /// The account whose key, or whose agent, signs.
79    #[must_use]
80    pub const fn signs_for(&self) -> Address {
81        self.owner
82    }
83
84    /// Why this account does not do `kind`, or `None` when it may. An
85    /// order-like action (`order`, `modify`, `batchModify`, `twapOrder`) names
86    /// its `market`. The master's own account refuses nothing here.
87    #[must_use]
88    pub fn refuses(&self, kind: ActionKind, market: Option<AssetKind>) -> Option<SubRefusal> {
89        self.sub?;
90        match kind {
91            ActionKind::Order | ActionKind::Modify | ActionKind::BatchModify | ActionKind::TwapOrder => match market {
92                Some(AssetKind::Perp | AssetKind::Hip3Perp) => None,
93                Some(AssetKind::Spot) => Some(SubRefusal::Spot),
94                Some(AssetKind::Outcome) => Some(SubRefusal::Outcome),
95                None => Some(SubRefusal::MarketUnknown),
96            },
97            ActionKind::UserOutcome => Some(SubRefusal::Outcome),
98            ActionKind::UsdClassTransfer | ActionKind::AgentSendAsset => Some(SubRefusal::LedgerMove),
99            ActionKind::ApproveAgent | ActionKind::ApproveBuilderFee => Some(SubRefusal::Approval),
100            ActionKind::ScheduleCancel => Some(SubRefusal::ScheduleCancel),
101            ActionKind::CreateSubAccount | ActionKind::SubAccountTransfer | ActionKind::SubAccountSpotTransfer => {
102                Some(SubRefusal::MastersOwn)
103            }
104            // The docs: `userSetAbstraction`'s user "can be a sub-account of the user".
105            ActionKind::UserSetAbstraction => None,
106            user_signed if user_signed.is_user_signed() => Some(SubRefusal::NoKey),
107            _ => None,
108        }
109    }
110}
111
112impl fmt::Display for AccountRef {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self.sub {
115            Some(sub) => write!(f, "{sub} (a subaccount of {})", self.owner),
116            None => write!(f, "{}", self.owner),
117        }
118    }
119}
120
121/// `createSubAccount`: `{type, name}`.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CreateSubAccount {
124    name: String,
125}
126
127impl CreateSubAccount {
128    #[must_use]
129    pub fn new(name: &str) -> CreateSubAccount {
130        CreateSubAccount { name: name.to_owned() }
131    }
132}
133
134impl L1Action for CreateSubAccount {
135    fn action(&self) -> Map {
136        Map::new().put("type", "createSubAccount").put("name", self.name.clone())
137    }
138}
139
140/// `subAccountTransfer`: perp USDC between the master and a subaccount,
141/// `usd` in millionths.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct SubAccountTransfer {
144    sub_account: Address,
145    is_deposit: bool,
146    usd: u64,
147}
148
149impl SubAccountTransfer {
150    /// Into the subaccount, at most six places: refused rather than rounded
151    /// when finer.
152    pub fn deposit(sub_account: Address, usdc: Dec) -> Result<SubAccountTransfer, BadUnits> {
153        Ok(SubAccountTransfer { sub_account, is_deposit: true, usd: exact_units(usdc, 6)? })
154    }
155
156    /// Back to the master.
157    pub fn withdraw(sub_account: Address, usdc: Dec) -> Result<SubAccountTransfer, BadUnits> {
158        Ok(SubAccountTransfer { sub_account, is_deposit: false, usd: exact_units(usdc, 6)? })
159    }
160
161    /// From a count of millionths already made.
162    #[must_use]
163    pub const fn from_micros(sub_account: Address, is_deposit: bool, usd: u64) -> SubAccountTransfer {
164        SubAccountTransfer { sub_account, is_deposit, usd }
165    }
166}
167
168impl L1Action for SubAccountTransfer {
169    fn action(&self) -> Map {
170        Map::new()
171            .put("type", "subAccountTransfer")
172            .put("subAccountUser", self.sub_account.to_hex())
173            .put("isDeposit", self.is_deposit)
174            .put("usd", self.usd)
175    }
176}
177
178/// `subAccountSpotTransfer`: a spot token between the master and a
179/// subaccount. `token` is `NAME:0x<tokenId>`; the amount goes as a decimal
180/// with no trailing zeros.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct SubAccountSpotTransfer {
183    sub_account: Address,
184    is_deposit: bool,
185    token: String,
186    amount: Dec,
187}
188
189impl SubAccountSpotTransfer {
190    pub fn new(
191        sub_account: Address,
192        is_deposit: bool,
193        token: &str,
194        amount: Dec,
195    ) -> Result<SubAccountSpotTransfer, BadUnits> {
196        if !amount.is_positive() {
197            return Err(BadUnits::NotPositive(amount));
198        }
199        Ok(SubAccountSpotTransfer { sub_account, is_deposit, token: token.to_owned(), amount })
200    }
201}
202
203impl L1Action for SubAccountSpotTransfer {
204    fn action(&self) -> Map {
205        Map::new()
206            .put("type", "subAccountSpotTransfer")
207            .put("subAccountUser", self.sub_account.to_hex())
208            .put("isDeposit", self.is_deposit)
209            .put("token", self.token.clone())
210            .put("amount", self.amount.to_wire())
211    }
212}
213
214/// `{"type":"subAccounts","user":<master>}`.
215#[must_use]
216pub fn sub_accounts_request(master: Address) -> String {
217    user_request("subAccounts", master)
218}
219
220/// A master's subaccounts. The venue answers `null`, not `[]`, for none.
221pub async fn sub_accounts(transport: &impl Transport, master: Address) -> Result<Vec<SubAccount>, ApiError> {
222    let subs: Option<Vec<SubAccount>> = info(transport, sub_accounts_request(master)).await?;
223    Ok(subs.unwrap_or_default())
224}
225
226/// One subaccount with its states. A subaccount is a user in its own right,
227/// so its figures are never added to its master's.
228#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct SubAccount {
231    #[serde(default)]
232    pub name: String,
233    pub sub_account_user: Address,
234    pub master: Address,
235    #[serde(default)]
236    pub clearinghouse_state: Option<PerpState>,
237    #[serde(default)]
238    pub spot_state: Option<SpotState>,
239}
240
241impl SubAccount {
242    /// Acting for it, as its master.
243    #[must_use]
244    pub const fn account(&self) -> AccountRef {
245        AccountRef::sub(self.master, self.sub_account_user)
246    }
247
248    /// Its main dex's account value; nothing when it has no state.
249    #[must_use]
250    pub fn equity(&self) -> Dec {
251        self.clearinghouse_state.as_ref().map_or(Dec::ZERO, |state| state.margin_summary.account_value)
252    }
253
254    #[must_use]
255    pub fn notional(&self) -> Dec {
256        self.clearinghouse_state.as_ref().map_or(Dec::ZERO, |state| state.margin_summary.total_ntl_pos)
257    }
258
259    #[must_use]
260    pub fn withdrawable(&self) -> Dec {
261        self.clearinghouse_state.as_ref().map_or(Dec::ZERO, |state| state.withdrawable)
262    }
263
264    /// Positions held on its main dex, a flat leg left out.
265    #[must_use]
266    pub fn positions(&self) -> usize {
267        self.clearinghouse_state.as_ref().map_or(0, |state| state.positions().count())
268    }
269
270    /// Spot USDC's total.
271    #[must_use]
272    pub fn spot_usdc(&self) -> Dec {
273        self.spot_state.as_ref().and_then(SpotState::usdc).map_or(Dec::ZERO, |usdc| usdc.total)
274    }
275}
276
277#[cfg(test)]
278#[path = "tests/sub_account.rs"]
279mod tests;