src/account/agents.ts
v0.3.0 · 7.4 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';
/**
* 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));
}