Skip to content
markpaper

src/account/roles.ts

v0.3.0 · 9.3 KB

Download file
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;
}
All files