knowledge/hl/accounts.md
vregistry-c914171 · 38.5 KB
# Hyperliquid — account and address types
A reference to account entities on Hyperliquid (HL): the master wallet, agent/API wallet, sub-accounts, vaults, builder address, collateral modes (manual / Unified Account / legacy dex abstraction, portfolio margin), permissions, and address formats. Facts were verified against SDK `@nktkas/hyperliquid` 0.27.x and `viem`; items verified live have a date, and items taken from documentation are marked.
## TL;DR
1. **The agent signs; the master trades and is queried.** The agent (API wallet) private key signs exchange actions, but `user` in every info request is the address of the **account** that holds the funds (master or sub-account). The agent address is useless for reading state: it has no positions.
2. **An agent cannot withdraw funds** and cannot sign `approveBuilderFee`. Use a separate agent for automation; it does not need the master account's private key.
3. **`User or API Wallet 0x… does not exist`** means that the agent is not authorized (Authorize was not clicked, or the key expired or was revoked). The error is not transient: **every** request fails, so retries are useless. The only remedy is a new API wallet followed by Authorize.
4. **`extraAgents` shows only NAMED agents.** An unnamed agent created through `approveAgent` without a name can trade but does not appear in the list. The definitive test is the first signed request (for example, `updateLeverage`).
5. **Sub-account:** orders are signed by an agent of the **master** account, and the request includes `vaultAddress` = the sub-account address. In the SDK this is `defaultVaultAddress`. A separate key for every sub-account is unnecessary. Verified live 2026-09.
6. **`userRole`** (weight 60) returns `missing | user | agent | subAccount | vault` and `master`. It is the best preflight request: it catches the common mistake of providing an API wallet address instead of an account address.
7. **The collateral mode** (`userState.abstraction`) is available **only in WS `webData3`**: `disabled` (manual), `unifiedAccount`, `dexAbstractionEnabled`. REST `userDexAbstraction` returns `false` for `unifiedAccount`, so it does not detect the most common mode. HL may silently migrate an account to Unified Account.
8. **To trade through an agent on a HIP-3 dex (`xyz:`)**, call `agentEnableDexAbstraction()` once. The response `Abstraction transition not allowed` means that the account is already unified; treat it as success.
9. **On Unified Account**, the perp leg is a reserve (`hold`/`spotHold`) inside spot. `usdSend` and `usdClassTransfer` are disabled there (`Action disabled when unified account is active`); use `sendAsset` instead. Capital is always calculated as follows: `Σ perp accountValue (all dexes) + (spot total − hold)`.
10. **One bot — one account (or sub-account).** Two bots on one address fight over positions, and a separate server or IP does not fix that. Revoking an agent does not close positions, and deleting the key from your own database does not revoke it on HL.
---
## 1. Account and address types: summary table
| Entity | What it is | Funds/positions | Can sign | How to trade | `userRole.role` |
|---|---|---|---|---|---|
| Master account | An EVM address that deposited on HL | Its own | Everything, including withdrawals, `approveAgent`, `approveBuilderFee` | With its own key or through an agent | `user` |
| Agent / API wallet | A separate EVM key approved by the master | **None** (the address is empty) | Trading actions on behalf of the master: orders, cancellations, leverage, `agentEnableDexAbstraction`. **Cannot** withdraw or sign `approveBuilderFee` | Sign with the agent; `user` = master | `agent` (`data.user` identifies the owner) |
| Sub-account | A separate user under the master | Its own: isolated margin, positions, liquidation, and **its own address-based rate limit** | Has no key of its own; actions are signed by the master's agent/key | Master's agent + `vaultAddress` = sub-account address | `subAccount` (`data.master`) |
| Vault | An account with depositors | Its own | Through the owner (leader) | `vaultAddress` = vault address | `vault` (with master) |
| Builder address | A regular address for which users approved a builder fee | A regular account | Like a regular account | Does not trade for users itself: its address is passed in the builder field of orders | `user` |
| Unknown address | Has never deposited or traded | — | — | — | `missing` |
---
## 2. Agent / API wallet
### 2.1. Creation and properties
- In the HL UI, an agent is created **under the master account**: More → API (page `app.hyperliquid.xyz/API`) → Generate API Wallet → specify a **name** and **lifetime (up to 180 days)** → **Authorize** (signed by the master). The private key is shown **once**.
- The private-key format is `0x` + 64 hex characters (32 bytes): `/^0x[0-9a-fA-F]{64}$/`.
- Programmatically, an agent is approved by the `approveAgent` exchange action signed by the master. Omitting the name creates an **unnamed** agent (the snippet and EIP-712 fields appear later in this section).
- **Agent limit:** one unnamed and three named agents per account, plus two more for each sub-account. When several bot instances run on one account, each needs its own API wallet, and this limit caps the number of instances.
- **An agent cannot withdraw funds.** For automation it is a less privileged key than the master key; do not give the automation the master key.
- If the key accidentally belongs to the account itself (key address == account address), trading will work, but this is unsafe. Warn the user and ask for an API wallet.
- The agent address is derived from the private key (`viem`, `privateKeyToAccount`). The key itself does not reveal the master address, so the account address is always passed as a **separate** parameter.
```ts
import { privateKeyToAccount } from 'viem/accounts';
const agent = privateKeyToAccount(AGENT_PRIVKEY as `0x${string}`);
const agentAddress = agent.address.toLowerCase(); // viem returns checksum form—normalize it
const accountAddress = '0xYOUR_ADDRESS'.toLowerCase(); // ACCOUNT address, not the API wallet
```
**Programmatic approval: `approveAgent`.**
> According to the HL documentation and SDK d.ts; not verified live programmatically. Verify before use.
```ts
import { ExchangeClient, HttpTransport } from '@nktkas/hyperliquid';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
const transport = new HttpTransport();
// Signed by the MASTER wallet (not the agent). Prefer a browser wallet over putting the master key in a script.
const exMaster = new ExchangeClient({ wallet: masterWallet, transport });
const agentPk = generatePrivateKey(); // show and save ONCE
const agentAddress = privateKeyToAccount(agentPk).address.toLowerCase();
await exMaster.approveAgent({ agentAddress, agentName: 'bot-1' }); // no name creates an unnamed agent, invisible in extraAgents
```
- EIP-712 type `HyperliquidTransaction:ApproveAgent`: fields `hyperliquidChain`, `agentAddress`, `agentName`, `nonce`. The domain is `HyperliquidSignTransaction`, as for `approveBuilderFee` (see sdk-and-api.md §8.2).
- According to the documentation, lifetime is encoded as a suffix in the name: `agentName = 'bot-1 valid_until 1790000000000'` (ms). Check: `extraAgents` should return `validUntil` with this value (§2.4).
- According to the documentation, another `approveAgent` with the same name replaces the previous agent with that name (not verified; see §11).
- After approval, a signed `updateLeverage` test is mandatory (§2.4): the absence of the `does not exist` error is the only reliable confirmation.
### 2.2. Revocation and lifetime
- Revoke on `app.hyperliquid.xyz/API`. The user can revoke an agent directly.
- **Revoke does not close open positions.** It only stops acceptance of new signed actions. Emergency stop ≠ flat: positions must be closed separately, before revocation or manually through the UI.
- **Deleting a local copy of the key does not revoke it on HL.** If the key leaked, it remains valid until revoke or expiration of `validUntil`. To actually disable access, revoke API wallets in the HL account.
- An expired `validUntil` produces the same error as an unauthorized agent (see 2.3).
### 2.3. “agent not authorized” error
The exchange's exact response form:
```json
{ "status": "err", "response": "User or API Wallet 0x… does not exist." }
```
The SDK throws `ApiRequestError: User or API Wallet 0x… does not exist`.
- **Causes:** the key was generated, but the master did not sign the Authorize/Confirm step; the key expired (`validUntil` is in the past); the agent was revoked.
- The private key is still valid: viem accepts it and derives an address. But HL does not know that address, and **every** user request fails—`updateLeverage`, placement, cancellation.
- **The error is not transient.** Retries are useless. The only remedy is: new API wallet → mandatory Authorize with the master's signature → replace the key in the bot.
- The address in the message varies by user, so match a stable substring:
```ts
const AGENT_NOT_EXISTS = /User or API Wallet .* does not exist/i;
// broader variant for the first signed request during preflight:
const NOT_APPROVED = /does not exist|not approved|unauthorized/i;
```
- Classify it as **“dead key,”** not as a rejection of a particular order. Otherwise the bot keeps sending orders while an open position remains unmanaged: this key can no longer cancel or close it, and the wind-down procedure will not work with the key either. Close the position with a new key or manually in the UI.
### 2.4. Authorization check without a trade: `extraAgents`
```json
POST https://api.hyperliquid.xyz/info
{ "type": "extraAgents", "user": "0xMASTER_ADDRESS" }
→ [ { "address": "0x…", "name": "…", "validUntil": 1790000000000 } ]
```
- `validUntil` is in **milliseconds**. `0` or `null` means no expiration.
- Weight **20**: this is considered a “heavy” info request.
- An agent is live if its `address` (compare lowercase) appears in the list **and** `!validUntil || validUntil > Date.now()`.
- `user` is the **master** (the owner that approved the agent). For a sub-account, this is the master account, not the sub-account address.
- **Only named agents are shown.** An unnamed agent (`approveAgent` without a name) can trade but does not appear in the list. Therefore, absence from `extraAgents` is a **warning, not a block**. The first signed request provides the truth (`updateLeverage` is convenient because it is idempotent).
- Agents created through the UI are named (the UI requires a name) and appear in the list. Verified by a smoke test: a working key gives `authorized: true`, while a key without Authorize gives `not_listed`.
Classification useful immediately after connecting a key, before the first trade:
| reason | Meaning | Action |
|---|---|---|
| `ok` | Present in the list and not expired | Continue |
| `not_listed` | The key was generated, but Authorize was not signed (or the agent is unnamed) | Ask for Authorize; test with a signed request |
| `expired` | `validUntil <= now` | Create a new API wallet |
| `query_failed` | Network or 5xx | Do not hard-block; the order path will determine the outcome |
```ts
import { InfoClient, HttpTransport } from '@nktkas/hyperliquid';
const info = new InfoClient({ transport: new HttpTransport() });
async function verifyAgent(master: string, agentAddress: string) {
try {
const agents = await info.extraAgents({ user: master as `0x${string}` });
const hit = (Array.isArray(agents) ? agents : [])
.find((a: any) => String(a?.address).toLowerCase() === agentAddress.toLowerCase());
if (!hit) return { authorized: false, reason: 'not_listed' as const }; // or an unnamed agent!
const vu = Number((hit as any).validUntil);
if (vu && vu <= Date.now()) return { authorized: false, reason: 'expired' as const, validUntil: vu };
return { authorized: true, reason: 'ok' as const, validUntil: vu || null };
} catch {
return { authorized: false, reason: 'query_failed' as const };
}
}
```
### 2.5. Agent↔account binding as a bot invariant (fail-closed)
The agent key and account address are **two independent inputs**. If the address contains a typo (or names another account belonging to the same person), the bot **reads** account B, while every signed order **executes** on account A, where this agent was approved. A bot reading the wrong account always sees a flat position and repeats entries, while positions accumulate on A without protection: the bot cannot see, protect, or close them.
Rules:
- Check the binding (`extraAgents(account)` contains `agentAddress`) **before** the first submission.
- **Fail closed until the first positive response.** Trading is disabled until the exchange has confirmed the pair at least once during this process. An unavailable response is not evidence of validity.
- Set the recheck frequency explicitly, accounting for the possibility that API wallets can be revoked after the initial binding.
- A definite mismatch (the exchange responded but the agent is absent from the list) changes the account state to “unmanaged”: trading stops, an explicit reason (which agent and which account) is logged, and an alert is raised.
- Changing the key invalidates the previous check result. Never write a private key to logs.
---
## 3. Sub-accounts
- A sub-account is a **separate user**: its own funds, margin, positions, liquidation, and **its own address-based rate limit** (a fresh sub-account has a budget of 10,000 requests). Create it in the UI (Sub-Accounts → Create) or through the API.
- **Trading:** actions are signed by an agent of the **master** account (or by the master key), and the request adds `vaultAddress` = the sub-account address, just as for a vault. The agent must be approved **on the master account**, and `extraAgents` checks also query the master.
- **State reads** are ordinary info requests with `user` = the sub-account address (`clearinghouseState`, `spotClearinghouseState`, `frontendOpenOrders`…).
- Verified live (2026-09): orders, `updateLeverage`, and fills on a sub-account work through the master's agent with `vaultAddress`.
- A sub-account can also be in unified mode (see 5.4).
SDK (`@nktkas/hyperliquid`, confirmed from the README and SDK code):
| Method | Purpose |
|---|---|
| `exchange.createSubAccount({ name })` | Create a sub-account; returns its address |
| `exchange.subAccountTransfer({ subAccountUser, isDeposit, usd })` | Transfer perp USDC between master and sub-account |
| `exchange.subAccountSpotTransfer(...)` | Transfer spot tokens between master and sub-account |
| `exchange.subAccountModify(...)` | Modify a sub-account (name) |
| `info.subAccounts({ user })` | List the master's sub-accounts |
| `order` / `cancel` / `updateLeverage` with `vaultAddress` | Trade on behalf of the sub-account |
```ts
import { ExchangeClient, HttpTransport } from '@nktkas/hyperliquid';
import { privateKeyToAccount } from 'viem/accounts';
const transport = new HttpTransport({ isTestnet: false, timeout: 10_000 });
const agent = privateKeyToAccount(AGENT_PRIVKEY as `0x${string}`); // MASTER account's agent
// Master account: do not pass vaultAddress
const exMain = new ExchangeClient({ wallet: agent, transport });
// Sub-account: every client action will include vaultAddress
const exSub = new ExchangeClient({
wallet: agent,
transport,
defaultVaultAddress: '0xSUBACCOUNT_ADDRESS',
});
```
Sub-account pitfalls:
- **`reserveRequestWeight` does not carry `vaultAddress`.** Purchased address-based capacity (0.0005 USDC per request) is credited to the master account or agent account, not the sub-account. Disable purchases when trading through a sub-account.
- Master and sub-account must differ (`master !== account`).
- One master and one agent can serve N sub-accounts: the master's agent signs for any sub-account through `vaultAddress`; the API supports this.
---
## 4. `userRole` — “what kind of address is this?” preflight
```json
POST /info { "type": "userRole", "user": "0x…" }
→ { "role": "missing" | "user" | "agent" | "subAccount" | "vault", "data": { "user"?: "0x…", "master"?: "0x…" } }
```
- IP weight **60**. Read it once at startup.
- `agent` → `data.user` is the account on which this API wallet was approved. `subAccount` → `data.master` is the master account.
- `missing` means the exchange does not know the address (it has never deposited or traded). Usually this is a typo.
Preflight logic:
```ts
type Role = 'missing' | 'user' | 'agent' | 'subAccount' | 'vault';
async function preflight(account: string, configuredMaster?: string) {
const r = await fetch('https://api.hyperliquid.xyz/info', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'userRole', user: account }),
}).then((x) => x.json()) as { role: Role; data?: { user?: string; master?: string } };
let vaultAddress: string | null = null;
let owner = account; // account on which the agent must be approved
if (r.role === 'agent')
throw new Error(`${account} is an API wallet address, not an account address (account: ${r.data?.user})`);
if (r.role === 'missing')
throw new Error(`${account} is unknown to the exchange—check the address`);
if (r.role === 'subAccount' || r.role === 'vault') {
vaultAddress = account;
owner = (r.data?.master ?? configuredMaster ?? '').toLowerCase();
if (!owner) throw new Error('subaccount/vault, but the master account is unknown');
if (configuredMaster && configuredMaster.toLowerCase() !== owner)
throw new Error('configured master does not match the master reported by the exchange');
}
// then: extraAgents(owner) is a warning; updateLeverage is the definitive test
return { vaultAddress, owner };
}
```
---
## 5. Collateral modes: manual / Unified Account / dex abstraction / portfolio margin
### 5.1. Modes
| `userState.abstraction` (webData3) | Name | Collateral across dexes | Is `agentEnableDexAbstraction` required? |
|---|---|---|---|
| `disabled` | manual / isolated dexes (legacy) | Each dex (main, xyz, …) has separate collateral; USDC is transferred separately | Yes, the agent can call it itself |
| `unifiedAccount` | Unified Account (**default for new accounts**) | Shared automatically across all dexes and spot | No, the agent inherits access. The call returns `Abstraction transition not allowed` |
| `dexAbstractionEnabled` | legacy dex abstraction (enabled by the master through app.hyperliquid.xyz) | Shared | No, the agent inherits it |
A separate marker is `portfolioMarginEnabled: true` in `spotClearinghouseState` (portfolio margin). It changes the semantics of `hold`; see 5.5.
**HL may migrate an account to Unified Account without any action by the owner.** If a specific mode matters to the account, the mode must be **monitored**.
### 5.2. How to determine the mode
| Source | What it provides | Suitable? |
|---|---|---|
| WS `webData3` → `data.userState.abstraction` | Exact string `disabled` / `unifiedAccount` / `dexAbstractionEnabled` | **Yes, the only source of the string** |
| REST `{"type":"userDexAbstraction","user":…}` (weight 2) | `boolean \| null`: `true` only for legacy `dexAbstractionEnabled` | **Not for unified:** returns `false` (verified 2026-06-15 on several unified accounts) |
| REST `webData2` (`userState` / `clearinghouseState`) | It has no `abstraction`, `dexAbstractionEnabled`, `accountType`, or `isVault` keys | No |
Consequence: a mode watchdog based on REST `userDexAbstraction` stays silent during a manual → `unifiedAccount` transition.
```ts
import WebSocket from 'ws';
function readAbstractionMode(user: string, timeoutMs = 6000): Promise<string | null> {
return new Promise((resolve) => {
let done = false;
const ws = new WebSocket('wss://api.hyperliquid.xyz/ws');
const finish = (v: string | null) => { if (done) return; done = true; try { ws.close(); } catch {} resolve(v); };
ws.on('open', () => ws.send(JSON.stringify({ method: 'subscribe', subscription: { type: 'webData3', user } })));
ws.on('message', (raw) => {
let msg: any; try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.channel === 'webData3') {
const a = msg?.data?.userState?.abstraction;
finish(typeof a === 'string' ? a : null); // 'disabled' = manual
}
});
ws.on('error', () => finish(null));
setTimeout(() => finish(null), timeoutMs);
});
}
```
To monitor the mode, periodically read the string through a short-lived socket and compare it with the previous value. `null` (timeout) does not count as a mode change.
### 5.3. `agentEnableDexAbstraction` — agent access to HIP-3 dexes
- No parameters: `await exchange.agentEnableDexAbstraction()`. Available in `ExchangeClient` SDK ≥ 0.27. Signed by the **agent**, not the master.
- Required for an agent to place orders on a HIP-3 / builder dex (`xyz:` and similar) for an account in isolated-dex mode. Without it, orders on `xyz:` are rejected. A successful call moves the account to abstraction mode. It is unnecessary for the main perp dex.
- Call it lazily before the first HIP-3 order (or at startup if the configuration contains a market with a dex prefix), once per process. Success can be persisted to avoid sending the call again after a restart.
- Idempotent: safe to retry on 5xx and network errors, like `updateLeverage`.
- **`Abstraction transition not allowed`** (regex `/transition\s+not\s+allowed/i`) means the master is already in `unifiedAccount` or `dexAbstractionEnabled`. This is **success**: the agent inherits access and HIP-3 orders will work.
- Any other error is real. **Do not submit** the order; record the enable error in the order status, or HL will reject it with an unclear reason. For **closing** orders, still configure a retry: the closing path must not contain an unretired failure point.
- Concurrent HIP-3 signals must wait for one shared Promise so that enable is sent exactly once.
```ts
let dexAbstractionReady = false;
let pending: Promise<void> | null = null;
async function ensureDexAbstraction(exchange: ExchangeClient): Promise<void> {
if (dexAbstractionReady) return;
if (pending) return pending;
pending = (async () => {
try {
await exchange.agentEnableDexAbstraction();
} catch (err) {
if (!/transition\s+not\s+allowed/i.test(String(err))) throw err; // real error
// already unifiedAccount / dexAbstractionEnabled—access is inherited
}
dexAbstractionReady = true;
})().finally(() => { pending = null; });
return pending;
}
```
### 5.4. Unified Account: how funds are represented
- **The perp leg is a reserve inside spot.** The `hold` (or `spotHold`) field in `spotClearinghouseState` mirrors perp margin. Moving funds from perps to spot **does not change `total`** stablecoins by even a cent; only the free balance (`total − hold`) changes.
- When the perp position closes to 0, `spot total` is bit-for-bit unchanged, `hold` goes to 0, free spot increases by the same amount, and capital does not change by a cent. This is not a withdrawal.
- **`clearinghouseState` for a unified account may return zero for a live account:** in the observation, it did so for most reads.
- **HL changes the representation convention on the fly** (observed 2026-08-25/26, within hours):
- (a) spot separate: perp `accountValue` shows only position margin, and the rest of the capital is in free spot;
- (b) spot moved into perps as cross collateral: perp `accountValue` ≈ all capital, and free spot is near zero.
- The result of the capital formula matches the UI under both conventions.
- **Invariant capital formula:** `equity = Σ perp accountValue (across all dexes) + (spot total − hold)`. For portfolio margin, use `spotHold` instead of `hold`. The formula was verified live under both conventions.
- **Do not add all spot to perp equity**: the reserved part is already included in `accountValue`.
- `hold` increases with **every** placed order, so free spot fluctuates noticeably within a minute. For **balance display**, `total` is more truthful; for **sizing**, use `total − hold` (the conservative side).
- Free spot USDC actually serves as collateral for perps. Verified 2026-09-14 on a unified account: a post-only order was **accepted with a zero perp leg and USDC only in spot**; no transfer was needed.
- Internal spot ↔ perps transfers (`accountClassTransfer` in the ledger) on a unified account are neither deposits nor withdrawals. Do not count them as cash flow in PnL and drawdown.
- **Withdrawal/drain guards that measure only one perp leg produce false positives on a unified account:** the perp balance “falls by 100%,” so the guard treats it as a withdrawal or drain even though the funds merely moved to spot. In addition, while `clearinghouseState` returns zero, such a guard is effectively asleep.
**Actions disabled on a unified account:**
| Action | Result | Replacement |
|---|---|---|
| `usdSend` | `Action disabled when unified account is active` | `sendAsset` |
| `usdClassTransfer` (spot ↔ perp) | Same | Not needed: the pool is shared |
Code that sweeps Spot → Perps through `usdClassTransfer` must disable itself after catching this error.
```ts
// USDC token id comes from spotMeta
const USDC_TOKEN = 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054';
await exchange.sendAsset({
destination: '0xRECIPIENT_ADDRESS',
sourceDex: 'spot', // funds received through HL Send are in the spot pocket
destinationDex: 'spot',
token: USDC_TOKEN,
amount: '10',
});
```
### 5.5. Portfolio margin (`portfolioMarginEnabled: true`)
- All spot USDC serves as collateral for perps. Borrowing capacity is available (fields `borrowed`, `ltv`). USDC can be supplied to Earn (borrow/lend supply): it is then subtracted from `spotHold` but remains in `total`.
- **HL redefines `hold`:** it becomes net “reserve − available borrowing capacity” and **turns negative**. The actual reserve moves to the new **`spotHold`** field.
- Observation 2026-08-29: for USDC, `spotHold == total` bit-for-bit, while `hold` was strongly negative. For a token with `total = 0`, `hold ≈ −999999.99`, `spotHold = 0`.
- `spotHold` mirrors perp equity: it matches `Σ perp accountValue` within fractions of a percent, just as `hold` does on an ordinary unified account.
- Conclusion: **a PM account has no free spot**; all USDC is pledged and already included in `accountValue`.
- A standard spot parser with the invariants `hold ≥ 0` and `hold ≤ total` breaks on these accounts. Use a separate branch: `free = total − spotHold`.
- Part of a PM account's capital may be in Earn and spot bids and can move abruptly for reasons unrelated to trading. `perp accountValue`, `spot total`, and `spot free` are three independently changing values. The risk metric must use the one in which trading actually occurs.
### 5.6. HIP-3 dexes and collateral
- `xyz:` is a separate exchange inside HL (HIP-3): it has its own market list, prices, and **margin account**. `clearinghouseState` and `frontendOpenOrders` **without `dex` return only the main perp dex**. Each builder dex needs a separate request: `{"type":"clearinghouseState","user":A,"dex":"xyz"}`. An account may look flat on main while holding everything on xyz.
- In **isolated** mode (`disabled`), USDC for HIP-3 markets must be transferred to the dex separately. Equity on the main account does not support orders on xyz. Calculate exposure and equity caps per dex.
- With **abstraction enabled** (unified or legacy), main USDC automatically backs HIP-3 orders. From the HIP-3 docs: *“For USDC HIP-3 positions, collateral comes from your USDC (Perps) available balance.”* Risk is then calculated across one pool:
```
marginRatio = (main.marginUsed + xyz.marginUsed) / (main.accountValue + xyz.accountValue + freeStables)
```
- Cross-check: the sum of `accountValue` across main and xyz matched `portfolio.perpDay.accountValue`. The only difference was price movement during the requests.
- For HIP-3 orders, field `a` contains a composite id: `100000 + dexIndex * 10000 + assetIndex`. See the orders file for details.
---
## 6. Vaults
- `{"type":"vaultDetails","vaultAddress":…}` returns `null` for a regular account. This is how to check whether an address is a vault.
- `userRole` for a vault gives `role: "vault"` and its master (owner).
- Trading on behalf of a vault uses `vaultAddress`, as for a sub-account.
- `{"type":"subAccounts","user":master}` returns the master's sub-accounts. This is useful in the same set of “what kind of account is this?” checks.
- **HLP is structured as a parent with child vaults** (read-only, 2026-09-23): `vaultDetails` for HLP returns `name: "Hyperliquidity Provider (HLP)"` and `relationship: { type: "parent", data: { childAddresses: [...] } }`—7 addresses. Orders and positions reside on child addresses: the first one had open orders and positions. By response shape (values were not retained): `cloid` was `null` on every order, `tif` was a string on every order, every `children` array was empty, and none of the `openOrders` entries had a `reduceOnly` key—there were no trigger, TP/SL, or reduce-only orders. This is a convenient public source for testing parsers against the response shapes of a “live” account (orders, positions, funding).
- `{"type":"vaultSummaries"}` returned `[]` on mainnet 2026-09-23: `/info` cannot provide a vault list this way.
---
## 7. Builder account and builder fee
- There is no special account type. A builder is an ordinary EVM address for which a user approved a fee through **`approveBuilderFee`**. The user's orders, including those signed by the user's agent, then pass this address in the builder field, and the exchange credits the builder fee. One builder address can serve every user of a service.
- **Only the user's master wallet can sign `approveBuilderFee`; an agent cannot.** Approval is therefore a separate step performed by the user in their wallet: the bot cannot do it with the agent key.
- `approveBuilderFee` is **authorization** (a maximum rate), not a charge.
- The currently approved maximum is read with the `maxBuilderFee` info request (by master and builder). Approval changes rarely, so the result can be cached by master address.
- The collateral mode of a builder address, like that of any account, can change without owner action (see 5.1–5.2). Monitor it if it matters.
---
## 8. Address formats
| Item | Format | Regex |
|---|---|---|
| Address (account, agent, sub-account, vault, builder) | `0x` + 40 hex characters, 42 characters total, **full** | lowercase storage: `/^0x[0-9a-f]{40}$/`; human input (checksum allowed): `/^0x[0-9a-fA-F]{40}$/` |
| Agent private key | `0x` + 64 hex characters | `/^0x[0-9a-fA-F]{64}$/` |
| Spot token id | `NAME:0x` + 32 hex characters | for example, `USDC:0x6d1e7cde53ba9467b783cb7c530ce054` |
- HL accepts any casing for an address in the `user` field, including lowercase.
- **Normalize to lowercase immediately at ingress:** CLI arguments, database reads, WS subscriptions, cache and kv keys, and comparisons with `extraAgents`. Mixing case breaks deduplication: one address gets two records and the cache misses.
- `viem` `privateKeyToAccount(...).address` returns **checksum** form. Convert it to lowercase before comparison.
- Logs and reports often abbreviate addresses (first and last characters separated by an ellipsis), but the API requires the full address. An abbreviated address cannot even be used as a key.
- A common input error is putting the API wallet address in the “account address” field. `userRole` returns `agent`: produce an understandable error rather than “account is empty.”
---
## 9. Architectural rules
- **One bot — one account** (its own address + its own agent), or at least one sub-account per strategy. Two bots that each reconcile `clearinghouseState` against their own target state will close and reduce each other's positions. This is a conflict **at the account level**: a separate server or IP does not help.
- Read the **account's current position** from `clearinghouseState` (`szi`, `positionValue`) rather than reconstructing it from the fill feed: `userFills` is incomplete (for example, it excludes TWAP slices).
- **Manual trades on a bot account.** A bot that reconciles account positions treats a manual position as its own; keep manual trading on a separate account.
- **Stop ≠ flat.** Disabling trading and revoking an agent do not close positions. The emergency procedure must explicitly decide what to do with positions.
---
## 10. Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| Every order fails with `User or API Wallet 0x… does not exist`; a position is stranded | The agent is unauthorized, expired, or revoked. The key is valid locally, but HL does not know it | Classify as “dead key,” do not retry, notify the user, create a new API wallet + Authorize |
| The bot reads an empty account and repeatedly enters | The account address ≠ the account on which the agent was approved: it reads B but executes on A | Fail-closed binding check through `extraAgents` before trading and periodically |
| Preflight rejects a working agent | `extraAgents` does not show unnamed agents | `extraAgents` is only a warning; a signed `updateLeverage` is authoritative |
| The “account address” field contains an API wallet address; the bot sees an empty account | An agent address holds no positions | `userRole` → `agent` → understandable error with the master address from `data.user` |
| Orders on `xyz:` are rejected | The agent lacks `agentEnableDexAbstraction` on an isolated account | Call it before the first HIP-3 order |
| `agentEnableDexAbstraction` “fails,” and the bot reports that “HIP-3 will not work” | `Abstraction transition not allowed` means the account is already unified and orders will work | Match `/transition\s+not\s+allowed/i` and treat it as success |
| The mode watchdog stays silent during migration to Unified Account | REST `userDexAbstraction` returns `false` for `unifiedAccount` | Read `userState.abstraction` from WS `webData3` |
| A “100% withdrawal/drain” guard fires even though no funds were withdrawn | Unified: the perp leg is a reserve inside spot and moved to spot | `equity = Σ perp accountValue + (spot total − hold)`; measure capital, not one leg |
| Equity is doubled | All spot was added to perps even though `hold` already mirrors perp margin | Add only `total − hold` |
| The spot parser throws on `hold < 0` | Portfolio margin: `hold` = reserve − borrowing capacity; the real reserve is in `spotHold` | Branch on `portfolioMarginEnabled`: `free = total − spotHold` |
| `usdSend` / `usdClassTransfer` → `Action disabled when unified account is active` | Legacy transfers are disabled on a unified account | `sendAsset({ sourceDex, destinationDex, token, amount })`; disable spot → perp sweep |
| HIP-3 positions are “invisible,” and the account appears flat | `clearinghouseState` without `dex` returns only main | Make a separate request with `dex: "xyz"` for each dex |
| Purchased request capacity did not help the sub-account | `reserveRequestWeight` without `vaultAddress` credits weight to the master account | Do not purchase it on a sub-account; use the sub-account's own limit |
| Two bots close each other's positions | Both reconcile one account to their own target | Separate account or sub-account per bot. Different IPs do not help |
| “We deleted the key, so access is disabled” | Deleting it from your own database does not revoke the agent on HL | Revoke on `app.hyperliquid.xyz/API` |
| “We revoked the agent, so positions are closed” | Revoke only blocks new actions | Close positions before revocation or manually |
| One address creates two records in the cache or database | Checksum and lowercase forms are mixed | Normalize to lowercase at ingress |
| `/health` is green while positions are unmanaged | A cycle without a key or confirmed agent was counted as successful | Cycle success requires a confirmed agent binding wherever execution is mandatory |
---
## 11. Open questions / not verified
- **`agentEnableDexAbstraction` through `vaultAddress` on a sub-account** has not been verified by a smoke test. `updateLeverage` through `vaultAddress` on a sub-account was verified live 2026-09-14; this later fact closed the earlier “not verified” item.
- **`agentEnableDexAbstraction` on a fresh isolated (`disabled`) account** has not been verified with a live key; the “transition not allowed” response on an already unified account was observed live. If HIP-3 orders are rejected, begin diagnosis here.
- **Lifetime of an agent created through the UI.** The UI allows up to 180 days, but what happens at expiration—whether the error is the same or different and whether reapproval is needed—has not been observed live.
- **Semantics of `validUntil = 0` in `extraAgents`.** Both interpretations occur: `0` as expired and as no expiration. This guide uses “`0`/`null` = no expiration” because it is less disruptive; otherwise a non-expiring agent would be falsely marked `expired`. `0` has not been observed live.
- **Weight of `extraAgents`.** It is counted both as a light request and as 20 in different sources. This guide uses **20**.
- **Relationship between `portfolioMarginEnabled` and `abstraction = unifiedAccount`.** Whether a PM account is always unified and whether unified without PM (`hold ≥ 0`) exists has not been checked systematically. In practice: branch on `portfolioMarginEnabled` + formula with `hold`/`spotHold`.
- **Isolated mode vs shared collateral for HIP-3.** “USDC for xyz must be transferred separately” applies to `disabled`; “main USDC backs xyz” applies to unified and abstraction. This has not been compared across accounts in different modes. Read the mode before sizing HIP-3.
- **Units of the `usd` field in `subAccountTransfer`** have not been verified live. Check the documentation and SDK before the first transfer.
- **Exact error message when the agent limit is exceeded** (1 unnamed + 3 named + 2 per sub-account) has not been observed.
- **Repeated `approveAgent`:** whether a new unnamed agent replaces the previous unnamed one and a new agent with the same name replaces the previous named one (as stated in the documentation) has not been verified live. The `valid_until` suffix in `agentName` has not been verified either.
- **Published accrued builder-fee data may lag** (not verified; for daily-dump lag, see fees.md §4.3).
- **`clearinghouseState` for a unified account returns zero** in most reads—an observation on one account. It is unknown how general this behavior is. Calculate capital with the formula that includes spot.
---
Knowledge snapshot: 2026-09; dates of individual checks appear in the text. The HL API changes—recheck limits and response shapes.
---
<!-- license-footer -->
_© markpaper contributors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Hyperliquid knowledge base” and include links to the original and the license._