Skip to content
markpaper

src/account/agents.ts

v0.3.0 · 7.4 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';

/**
 * Agent (API wallet) limits per the knowledge base: 1 unnamed + 3 named agents per account, plus 2 more
 * per sub-account. Each running bot instance on one account needs its own API wallet, so these limits cap
 * the number of instances. The exact error text when exceeding them was never observed.
 */
export const AGENT_LIMITS = Object.freeze({
  unnamedPerAccount: 1,
  namedPerAccount: 3,
  extraPerSubAccount: 2,
});

export interface AgentInfo {
  /** Lowercased agent address. */
  address: Address;
  name: string;
  /** Expiry in ms since epoch; `null` when HL reports none (or 0). */
  validUntil: number | null;
  /** `isAgentActive` evaluated at fetch time (or at `opts.now`). */
  active: boolean;
}

/**
 * Whether an agent entry has not expired: `!validUntil || validUntil > now`.
 *
 * `validUntil` is in milliseconds. `0` and `null` are treated as "no expiry": implementations disagreed
 * (one treated 0 as expired) and "no expiry" is the less breaking choice — otherwise a non-expiring agent
 * would be falsely reported as expired. A literal 0 was never observed live.
 *
 * `now` defaults to the local clock, which can drift from the exchange clock (tens of seconds were seen);
 * pass a server-derived time when a boundary within that drift matters.
 */
export function isAgentActive(agent: { validUntil?: number | null }, now: number = Date.now()): boolean {
  const vu = agent.validUntil;
  return vu === undefined || vu === null || vu === 0 || vu > now;
}

function parseValidUntil(value: unknown): number | null | undefined {
  if (value === undefined || value === null) return null;
  const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
  if (!Number.isFinite(n)) return undefined;
  return n === 0 ? null : n;
}

/**
 * Lists agents approved on an account via `{type:"extraAgents", user}` (weight 20).
 *
 * - `user` is the MASTER account the agent was approved on — for a sub-account, query its master.
 * - Only NAMED agents are listed. An unnamed agent (approveAgent without a name) can trade but never
 *   appears here, so absence is a warning, not proof. The definitive check is the first signed action
 *   (for example an idempotent `updateLeverage`): `User or API Wallet 0x... does not exist` means dead key.
 * - Entries with a malformed address are skipped (they can never match a key).
 *
 * @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE`; transport errors are rethrown.
 */
export async function fetchAgents(
  info: InfoRequester,
  user: string,
  opts: AccountCallOptions & { now?: number } = {},
): Promise<AgentInfo[]> {
  const address = normalizeAddress(user, 'user');
  const raw = await info<unknown>({ type: 'extraAgents', user: address }, callOptions(opts));
  if (!Array.isArray(raw)) {
    throw new HlAccountError('INVALID_RESPONSE', `extraAgents answer is not an array (got ${describe(raw)})`);
  }
  const now = opts.now ?? Date.now();
  const out: AgentInfo[] = [];
  for (const item of raw) {
    if (!isObject(item) || !isAddress(item.address)) continue;
    const validUntil = parseValidUntil(item.validUntil);
    if (validUntil === undefined) {
      throw new HlAccountError('INVALID_RESPONSE', `extraAgents.validUntil is not a number (got ${describe(item.validUntil)})`);
    }
    const agent = {
      address: item.address.toLowerCase() as Address,
      name: typeof item.name === 'string' ? item.name : '',
      validUntil,
    };
    out.push({ ...agent, active: isAgentActive(agent, now) });
  }
  return out;
}

export type AgentApprovalReason = 'ok' | 'not_listed' | 'expired' | 'query_failed';

export interface AgentApproval {
  /** Listed and not expired. */
  approved: boolean;
  /**
   * - `ok` — listed, not expired: proceed.
   * - `not_listed` — Authorize was not signed, OR the agent is unnamed: ask to authorize, then verify with a signed action.
   * - `expired` — `validUntil <= now`: create a new API wallet.
   * - `query_failed` — network/5xx: do not hard-block, the order path will surface a dead key.
   */
  reason: AgentApprovalReason;
  validUntil: number | null;
  /** Error message when `query_failed`. */
  error?: string;
}

/**
 * Checks that `agentAddress` is an approved, unexpired agent of `master` without placing a trade.
 *
 * The agent key and the account address are two independent inputs. With a typo in the account, an engine
 * READS account B while every signed order EXECUTES on account A (where the agent is approved): B always
 * looks flat, so it re-enters at full size every tick. Engines should stay fail-closed until the first
 * positive check, re-check periodically (API wallets get revoked days later) and keep last-known-good on
 * transient `query_failed`.
 */
export async function checkAgentApproval(
  info: InfoRequester,
  master: string,
  agentAddress: string,
  opts: AccountCallOptions & { now?: number } = {},
): Promise<AgentApproval> {
  const agent = normalizeAddress(agentAddress, 'agentAddress');
  let agents: AgentInfo[];
  try {
    agents = await fetchAgents(info, master, opts);
  } catch (err) {
    if (err instanceof HlAccountError && err.code === 'INVALID_ARGUMENT') throw err;
    return { approved: false, reason: 'query_failed', validUntil: null, error: err instanceof Error ? err.message : String(err) };
  }
  const hit = agents.find((a) => a.address === agent);
  if (!hit) return { approved: false, reason: 'not_listed', validUntil: null };
  if (!hit.active) return { approved: false, reason: 'expired', validUntil: hit.validUntil };
  return { approved: true, reason: 'ok', validUntil: hit.validUntil };
}

function messageOf(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  if (isObject(err) && typeof err.response === 'string') return err.response;
  return String(err);
}

/**
 * `User or API Wallet 0x... does not exist` — the agent is not authorized, expired or revoked.
 * Not transient: every request fails, retries are useless. Classify as "key is dead", not as a rejected
 * order; otherwise an engine keeps sending orders while an open position stays unmanaged. A deauthorized
 * key can neither cancel nor close, so a wind-down signed with it does nothing: stop the engine.
 */
export function isAgentNotFoundError(err: unknown): boolean {
  return /User or API Wallet .* does not exist/i.test(messageOf(err));
}

/**
 * `Abstraction transition not allowed` from `agentEnableDexAbstraction` means the account is already in
 * Unified Account / dex abstraction mode: HIP-3 access is inherited. Treat it as SUCCESS.
 */
export function isAbstractionTransitionNotAllowedError(err: unknown): boolean {
  return /transition\s+not\s+allowed/i.test(messageOf(err));
}

/**
 * `Action disabled when unified account is active` — `usdSend` / `usdClassTransfer` are disabled on a
 * Unified Account. Use `sendAsset` instead; a spot→perp sweep is unnecessary there and should switch
 * itself off on this error.
 */
export function isUnifiedAccountActionDisabledError(err: unknown): boolean {
  return /Action disabled when unified account is active/i.test(messageOf(err));
}
All files