Skip to main content

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