Skip to content
markpaper

src/orders/agent.ts

v0.3.0 · 5.2 KB

Download file
// Pre-trade check that the signing agent (API wallet) is approved on the account.
// Kept local on purpose (a minimal `extraAgents` read) so the orders module does not depend on the
// account module.

import type { InfoRequester } from '../transport/types.js';
import { HlOrderError, describeValue, messageOf, normalizeAddress } from './errors.js';
import { infoOptions, type OrdersInfoOptions } from './mids.js';

/** `extraAgents` is a heavy /info request. */
export const EXTRA_AGENTS_WEIGHT = 20;

export type AgentStatus =
  /** Listed and not expired. */
  | 'active'
  /** Listed with `validUntil <= now`: create a new API wallet. */
  | 'expired'
  /**
   * Not in the list: Authorize was never signed, or the agent is UNNAMED (unnamed agents can trade
   * but are never listed). Prove it with a signed idempotent action such as `updateLeverage`.
   */
  | 'not_listed';

export interface AgentCheck {
  readonly status: AgentStatus;
  /** Lowercase agent address. */
  readonly agent: `0x${string}`;
  /** Lowercase master address. */
  readonly master: `0x${string}`;
  /** Expiry in ms; `null` when none (HL `null` or `0`). */
  readonly validUntil: number | null;
  readonly name: string | null;
}

export interface AgentCheckOptions extends OrdersInfoOptions {
  /** Clock in ms (tests, or a server-derived time). Default `Date.now()`. */
  now?: number;
}

function parseValidUntil(value: unknown): number | null {
  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)) throw new HlOrderError('INVALID_RESPONSE', `extraAgents.validUntil is ${describeValue(value)}`);
  // `0` and `null` mean "no expiry": implementations disagreed and "no expiry" is the less breaking
  // reading (otherwise a non-expiring agent is falsely reported expired). A literal 0 was never seen live.
  return n === 0 ? null : n;
}

/**
 * Reads `{ type: 'extraAgents', user: master }` (weight 20) and reports the state of `agentAddress`.
 *
 * `master` is the account the agent was approved on; for a sub-account that is its MAIN account.
 * `validUntil` is in ms. Throws on a transport failure or an answer that is not an array.
 */
export async function checkAgent(
  info: InfoRequester,
  master: string,
  agentAddress: string,
  opts: AgentCheckOptions = {},
): Promise<AgentCheck> {
  const m = normalizeAddress(master, 'master');
  const agent = normalizeAddress(agentAddress, 'agentAddress');
  const raw = await info<unknown>({ type: 'extraAgents', user: m }, infoOptions(opts, EXTRA_AGENTS_WEIGHT));
  if (!Array.isArray(raw)) throw new HlOrderError('INVALID_RESPONSE', `extraAgents answer is ${describeValue(raw)}, not an array`);
  const now = opts.now ?? Date.now();
  for (const item of raw) {
    const entry = item as { address?: unknown; name?: unknown; validUntil?: unknown } | null;
    if (typeof entry?.address !== 'string' || entry.address.toLowerCase() !== agent) continue;
    const validUntil = parseValidUntil(entry.validUntil);
    return {
      status: validUntil === null || validUntil > now ? 'active' : 'expired',
      agent,
      master: m,
      validUntil,
      name: typeof entry.name === 'string' ? entry.name : null,
    };
  }
  return { status: 'not_listed', agent, master: m, validUntil: null, name: null };
}

export interface AssertAgentOptions extends AgentCheckOptions {
  /**
   * Accept an agent missing from `extraAgents` (returns the `not_listed` check instead of throwing).
   * Set it only for UNNAMED agents, and confirm them with a signed idempotent action before trading.
   * Default `false`.
   */
  allowUnlisted?: boolean;
}

/**
 * Throws unless `agentAddress` is an approved, unexpired agent of `master` (accounts.md §2.4, §2.5).
 *
 * Why: the agent key and the account address are independent inputs. With a typo in the account a
 * bot READS account B while every signed order EXECUTES on account A; B always looks flat, so the bot
 * re-enters at full size every tick. Stay fail-closed until the first positive check, re-check
 * periodically (API wallets get revoked days later; for example every 30 min) and keep last-known-good
 * on a transient `AGENT_QUERY_FAILED`.
 *
 * @throws HlOrderError `AGENT_NOT_LISTED`, `AGENT_EXPIRED`, `AGENT_QUERY_FAILED` (cause attached),
 * `INVALID_RESPONSE`, `INVALID_ARGUMENT`.
 */
export async function assertAgentActive(
  info: InfoRequester,
  master: string,
  agentAddress: string,
  opts: AssertAgentOptions = {},
): Promise<AgentCheck> {
  let check: AgentCheck;
  try {
    check = await checkAgent(info, master, agentAddress, opts);
  } catch (err) {
    if (err instanceof HlOrderError) throw err;
    throw new HlOrderError('AGENT_QUERY_FAILED', `extraAgents read failed: ${messageOf(err)}`, { cause: err });
  }
  if (check.status === 'expired') {
    throw new HlOrderError('AGENT_EXPIRED', `agent ${check.agent} of ${check.master} expired at ${String(check.validUntil)}`);
  }
  if (check.status === 'not_listed' && !opts.allowUnlisted) {
    throw new HlOrderError(
      'AGENT_NOT_LISTED',
      `agent ${check.agent} is NOT an approved API wallet of ${check.master} (unnamed agents are not listed): refusing to trade`,
    );
  }
  return check;
}
All files