src/orders/agent.ts
v0.3.0 · 5.2 KB
// 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;
}