src/account/roles.ts
v0.3.0 · 9.3 KB
import type { InfoRequester } from '../transport/types.js';
import { isAddress, normalizeAddress } from './address.js';
import type { Address } from './address.js';
import { callOptions } from './call.js';
import type { AccountCallOptions } from './call.js';
import { HlAccountError } from './errors.js';
import { describe, isObject } from './parse.js';
export type UserRole = 'missing' | 'user' | 'agent' | 'subAccount' | 'vault';
const ROLES: readonly UserRole[] = ['missing', 'user', 'agent', 'subAccount', 'vault'];
export interface UserRoleInfo {
role: UserRole;
/**
* The related account, lowercased:
* - `agent` → the account this API wallet trades for (`data.user`);
* - `subAccount` / `vault` → the master/owner (`data.master`);
* - otherwise `null`.
*/
master: Address | null;
}
/**
* Parses a `userRole` answer: `{ role, data?: { user?, master? } }`.
* @throws {HlAccountError} `INVALID_RESPONSE`.
*/
export function parseUserRole(raw: unknown): UserRoleInfo {
if (!isObject(raw) || typeof raw.role !== 'string' || !ROLES.includes(raw.role as UserRole)) {
throw new HlAccountError('INVALID_RESPONSE', `unexpected userRole answer ${describe(raw)}`);
}
const role = raw.role as UserRole;
const data = isObject(raw.data) ? raw.data : {};
const related = role === 'agent' ? data.user : role === 'subAccount' || role === 'vault' ? data.master : undefined;
return { role, master: isAddress(related) ? (related.toLowerCase() as Address) : null };
}
/**
* `{type:"userRole", user}` — what kind of address this is. Weight 60: read once at startup.
*
* The best preflight: it catches the common mistake of entering the API wallet address where the account
* address belongs (`agent` — an agent address holds no positions, so "$0" there is wrong input, not an
* empty account) and typos (`missing` — never deposited or traded).
*
* @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE`; transport errors are rethrown.
*/
export async function fetchUserRole(info: InfoRequester, user: string, opts?: AccountCallOptions): Promise<UserRoleInfo> {
const address = normalizeAddress(user, 'user');
return parseUserRole(await info<unknown>({ type: 'userRole', user: address }, callOptions(opts)));
}
export interface TradingAccountResolution {
/** Account to read state from (lowercased). */
account: Address;
/** Role reported by the exchange, or `null` when it was taken from config after a failed read. */
role: UserRole | null;
/** Account on which the agent must be approved (query `extraAgents` with this). */
owner: Address;
/** Pass as `vaultAddress` / SDK `defaultVaultAddress` when trading; `null` for a main account. */
vaultAddress: Address | null;
roleSource: 'exchange' | 'config';
}
/**
* Preflight that resolves how to trade an account (knowledge base, accounts §4):
* - `agent` → throws `ADDRESS_IS_AGENT` (the error names the real account from `data.user`);
* - `missing` → throws `UNKNOWN_ADDRESS`;
* - `subAccount` / `vault` → `vaultAddress = account`, `owner = master`; the configured master must match
* the exchange; sub-account orders are signed by the MASTER's agent with `vaultAddress`;
* - `user` → main account; a configured master contradicts the exchange and throws `MASTER_MISMATCH`
* (reading one account while executing on another is the classic silent-disaster setup);
* - master equal to the account is a config error.
*
* If `userRole` cannot be read (network) and a master is configured, the config is trusted: it is a
* sub-account (`roleSource: "config"`). Without a configured master the error is rethrown. A caller abort
* (`opts.signal` aborted) is always rethrown: it is not evidence about the account.
*
* SDK types describe `vault` without `data` (only `subAccount` carries `data.master`); the knowledge base
* says a vault reports its master. Both shapes are accepted; without either a master from the exchange or
* `configuredMaster` a vault resolves to `MASTER_UNKNOWN`.
*/
export async function resolveTradingAccount(
info: InfoRequester,
account: string,
opts: AccountCallOptions & { configuredMaster?: string } = {},
): Promise<TradingAccountResolution> {
const acct = normalizeAddress(account, 'account');
const configured = opts.configuredMaster === undefined ? undefined : normalizeAddress(opts.configuredMaster, 'configuredMaster');
if (configured === acct) {
throw new HlAccountError('MASTER_MISMATCH', 'configured master equals the account itself');
}
let roleInfo: UserRoleInfo;
try {
roleInfo = await fetchUserRole(info, acct, opts);
} catch (err) {
if (err instanceof HlAccountError) throw err;
if (configured && !opts.signal?.aborted) {
return { account: acct, role: null, owner: configured, vaultAddress: acct, roleSource: 'config' };
}
throw err;
}
switch (roleInfo.role) {
case 'agent':
throw new HlAccountError(
'ADDRESS_IS_AGENT',
`the account address is an API wallet, not an account${roleInfo.master ? ` (its account: ${roleInfo.master})` : ''}`,
);
case 'missing':
throw new HlAccountError('UNKNOWN_ADDRESS', 'the exchange does not know this address — check it for typos');
case 'subAccount':
case 'vault': {
const owner = roleInfo.master ?? configured;
if (!owner) throw new HlAccountError('MASTER_UNKNOWN', `${roleInfo.role} without a known master`);
if (configured && roleInfo.master && configured !== roleInfo.master) {
throw new HlAccountError('MASTER_MISMATCH', 'configured master differs from the master reported by the exchange');
}
if (owner === acct) throw new HlAccountError('MASTER_MISMATCH', 'master equals the account itself');
return { account: acct, role: roleInfo.role, owner, vaultAddress: acct, roleSource: 'exchange' };
}
case 'user':
if (configured) {
throw new HlAccountError('MASTER_MISMATCH', 'a master is configured but the exchange reports a main account');
}
return { account: acct, role: 'user', owner: acct, vaultAddress: null, roleSource: 'exchange' };
}
}
export interface SubAccountInfo {
name: string;
/** Lowercased sub-account address. */
address: Address;
/** Lowercased master address. */
master: Address;
/** Nested perp state if present; may cover only the MAIN dex. */
clearinghouseState: unknown;
/** Nested spot state if present. */
spotState: unknown;
}
/**
* Lists sub-accounts of a master via `{type:"subAccounts", user: master}`; `null` → `[]`.
*
* @experimental The answer shape is from the documentation and SDK types, not verified live.
* The nested `clearinghouseState` may cover only the main dex, so do NOT compute equity from it: call
* `fetchAccountSnapshot(info, sub.address)` per sub-account (HIP-3 dexes included). A master's equity
* does not include its sub-accounts; "owner's total" = master + Σ subs, printed as a breakdown.
*
* @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE`; transport errors are rethrown.
*/
export async function fetchSubAccounts(info: InfoRequester, master: string, opts?: AccountCallOptions): Promise<SubAccountInfo[]> {
const address = normalizeAddress(master, 'master');
const raw = await info<unknown>({ type: 'subAccounts', user: address }, callOptions(opts));
if (raw === null) return [];
if (!Array.isArray(raw)) {
throw new HlAccountError('INVALID_RESPONSE', `subAccounts answer is neither an array nor null (got ${describe(raw)})`);
}
return raw.map((item, i) => {
if (!isObject(item) || !isAddress(item.subAccountUser)) {
throw new HlAccountError('INVALID_RESPONSE', `subAccounts[${i}] has no valid subAccountUser`);
}
return {
name: typeof item.name === 'string' ? item.name : '',
address: item.subAccountUser.toLowerCase() as Address,
master: isAddress(item.master) ? (item.master.toLowerCase() as Address) : address,
clearinghouseState: item.clearinghouseState ?? null,
spotState: item.spotState ?? null,
};
});
}
/** Known values of the REST `userAbstraction` answer (per SDK types). */
export type UserAbstraction = 'unifiedAccount' | 'portfolioMargin' | 'disabled' | 'default';
/**
* Reads the account collateral mode via REST `{type:"userAbstraction", user}`.
*
* @experimental Not verified live. The knowledge base trusts only WS `webData3`
* `userState.abstraction` (`disabled` | `unifiedAccount` | `dexAbstractionEnabled`). Beware the older REST
* `userDexAbstraction`: it returns `false` for Unified Accounts, so a mode watchdog built on it stays
* silent when an account is switched to Unified. Cross-check this endpoint against `webData3` before a
* watchdog relies on it. HL may switch an account to Unified Account without an owner action.
* Unknown strings are returned as-is.
*
* @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE` when the answer is not a string.
*/
export async function fetchUserAbstraction(
info: InfoRequester,
user: string,
opts?: AccountCallOptions,
): Promise<UserAbstraction | (string & {})> {
const address = normalizeAddress(user, 'user');
const raw = await info<unknown>({ type: 'userAbstraction', user: address }, callOptions(opts));
if (typeof raw !== 'string' || raw === '') {
throw new HlAccountError('INVALID_RESPONSE', `userAbstraction answer is not a string (got ${describe(raw)})`);
}
return raw;
}