SPB Git

Repository Public

routes.ts 27.7 KB 298 lines typescript

1// Trading agents (V3): lifecycle, vaults, mandates. The engine that trades is in engine.ts; nothing here signs anything2// except through the signer's mandate endpoints.3import { getWithdrawal, listWithdrawals, requestWithdrawal } from './withdrawals.ts'; // v84import { randomUUID } from 'node:crypto';5import type { FastifyInstance } from 'fastify';6import { generateAuthenticationOptions } from '@simplewebauthn/server';7import { hexToBigInt, hexToNumber } from 'viem';8import { z } from 'zod';9import { NETWORKS, cashLegs, CASH_SYMBOLS, formatUnits, getAsset, getNetwork, hasSignerPrice, IntentSchema, MANDATE_NETWORKS, mandateHash, MandateSchema, MAX_AGENT_SYMBOLS, parseUnits, resolveAgentAssets, toB64u, toHex, tradableSymbols, validateMandate, vaultKeyId, type AgentAsset, type Mandate, type NetworkDef } from '@spw/core';10import { listCreds } from '../auth.ts';11import { adapterFor } from '../chains/index.ts';12import { rpcAny, rpcQuorum } from '../chains/rpc.ts';13import { config, UserError } from '../config.ts';14import { audit, sql } from '../db.ts';15import { getSeries } from '../market/series.ts';16import { listQuality } from '../market/store.ts';17import { FEE_WARN_SHARE, feeShare, FLOOR_ORDERS, gasReserve, RESERVE_ORDERS, type GasReserve } from './gas.ts';18import { signer } from '../signer-client.ts';19// >>> v6 perp agents: the shared routes delegate to perp-routes.ts when the agent's kind is 'perp'20import { assertPerpApprovable, createPerpAgent, isPerp, perpDetail, perpMode, perpView, spotOnly } from './perp-routes.ts';21// <<< v6 perp agents22import { paperBookView } from './paper-view.ts'; // v8 hardening: read-only view of the paper book2324export interface AgentRow {25  id: string; name: string; mission: string; state: string; mode: string; network: string; universe: string[]; interval: string;26  /** cash leg symbol (USDC unless the owner chose USDT) and the registry asset chosen for each market symbol; null/empty on agents created before V5 */27  cash: string | null; assets: Record<string, string> | null;28  risk: Risk; vault_index: number; vault_address: string; capital_usdc: string; strategy_version_id: string | null; mandate_id: string | null; mandate: Mandate | null;29  last_bar: Date | null; stop_reason: string | null; created_at: Date; updated_at: Date;30  /** v6: 'spot' (default) or 'perp'; a perp agent has no vault and no mandate, its paper account is `deriv_account_id` (perp-engine.ts) */31  kind?: string; deriv_account_id?: string | null; perp?: unknown;32}33const Risk = z.strictObject({34  /** share of the vault that may be invested in non-USDC assets, percent */35  maxExposurePct: z.number().min(1).max(100).default(50),36  /** the engine stops the agent when equity falls this far below the allocated capital, percent (can be overshot by a gap) */37  maxLossPct: z.number().min(1).max(100).default(5),38  maxOrderUsd: z.number().min(1).max(100_000).default(25),39  maxDailyUsd: z.number().min(1).max(1_000_000).default(100),40  maxOrdersPerDay: z.number().int().min(1).max(200).default(12),41  mandateDays: z.number().int().min(1).max(90).default(30),42  slippageBps: z.number().int().min(5).max(300).default(50),43});44export type Risk = z.infer<typeof Risk>;45const Create = z.strictObject({ name: z.string().min(2).max(40), mission: z.string().max(400).default(''), network: z.string().min(2).max(24).default('base'),46  /** market symbols (BTC, ETH, LINK…), 1 to 12 */47  universe: z.array(z.string().regex(/^[A-Z0-9]{2,10}$/)).min(1).max(MAX_AGENT_SYMBOLS),48  /** optional: the registry asset to hold for a symbol ('native' or a token address); default = the stated rule of tradableSymbols */49  assets: z.record(z.string(), z.string().max(80)).default({}), cash: z.enum(['USDC', 'USDT']).optional(), interval: z.enum(['1h', '1d']).default('1h'),50  capitalUsdc: z.string().regex(/^\d{1,7}(\.\d{1,6})?$/), risk: Risk.default(Risk.parse({})) });5152/** The cash leg symbol of an agent. */53export const cashOf = (a: Pick<AgentRow, 'cash'>): string => a.cash ?? 'USDC';54/** The market symbols an agent trades (an agent created before V5 may list its cash leg in the universe). */55export const agentSymbols = (a: Pick<AgentRow, 'universe'>): string[] => a.universe.filter((s) => !CASH_SYMBOLS.includes(s));56export type AgentAssetRef = Pick<AgentRow, 'network' | 'universe'> & Partial<Pick<AgentRow, 'cash' | 'assets'>>;57/** Assets an agent holds on its network: the cash leg first, then ONE registry asset per market symbol (the representation58 *  recorded at creation; the default rule for agents created before V5). `symbol` is the market symbol (the cash symbol for the cash leg). */59export function agentAssets(a: AgentAssetRef, n: NetworkDef = getNetwork(a.network)): AgentAsset[] {60  try { const r = resolveAgentAssets(n, agentSymbols(a), { cash: a.cash ?? null, assets: a.assets ?? null }); return [r.cash, ...r.assets]; }61  catch (e) { throw new UserError('unsupported_asset', (e as Error).message, 422); }62}63const load = async (id: string): Promise<AgentRow> => {64  if (!z.uuid().safeParse(id).success) throw new UserError('not_found', 'agent not found', 404);65  const [a] = await sql<AgentRow[]>`select * from agents where id = ${id}`;66  if (!a) throw new UserError('not_found', 'agent not found', 404);67  return a;68};69export interface VaultBalance extends AgentAsset { amount: bigint; role: 'cash' | 'asset' | 'gas' }70export async function vaultBalances(a: AgentRow): Promise<VaultBalance[]> {71  const n = getNetwork(a.network), b = await adapterFor(a.network).balances(a.vault_address);72  const list: VaultBalance[] = agentAssets(a, n).map((x) => ({ ...x, amount: b[x.id] ?? 0n, role: x.kind === 'cash' ? 'cash' : 'asset' }));73  // the native coin pays the fees: shown even when the agent does not trade it (never part of the strategy's equity then)74  if (!list.some((x) => x.id === 'native')) list.push({ symbol: n.native.symbol, id: 'native', decimals: n.native.decimals, assetSymbol: n.native.symbol, name: n.native.name, kind: 'native', issuer: null, amount: b.native ?? 0n, role: 'gas' });75  return list;76}77const view = (a: AgentRow) => ({ id: a.id, name: a.name, mission: a.mission, state: a.state, mode: a.mode, network: a.network, networkName: getNetwork(a.network).name, universe: a.universe, cash: cashOf(a), interval: a.interval, risk: a.risk,78  holdings: (() => { try { return agentAssets(a).map((x) => ({ symbol: x.symbol, asset: x.id, assetSymbol: x.assetSymbol, name: x.name, kind: x.kind, issuer: x.issuer })); } catch { return null; } })(),79  vaultAddress: a.vault_address, vaultIndex: a.vault_index, capitalUsdc: formatUnits(BigInt(a.capital_usdc), 6), strategyVersionId: a.strategy_version_id, mandateId: a.mandate_id,80  mandate: a.mandate ? { expiresAt: a.mandate.expiresAt, label: a.mandate.label, strategySha256: a.mandate.strategySha256, maxOrdersPerDay: a.mandate.maxOrdersPerDay } : null,81  lastBar: a.last_bar, stopReason: a.stop_reason, createdAt: a.created_at, updatedAt: a.updated_at });8283/** Build the mandate from the agent's risk settings: USD caps become per-asset base-unit caps at current prices. */84export function mandateFor(a: AgentRow, keyId: string, strategySha256: string, prices: Record<string, number>, now: number): Mandate {85  const assets = agentAssets(a);86  // the mandate schema holds at most 12 assets, cash leg included (a signer limit that V5 does not raise)87  if (assets.length > 12) throw new UserError('too_many_assets', `a mandate covers at most 12 assets: the cash leg and 11 symbols; this agent has ${assets.length - 1} symbols (paper trading only)`, 422);88  const blind = assets.filter((x) => x.kind !== 'cash' && !hasSignerPrice(x.symbol)).map((x) => x.symbol);89  if (blind.length) throw new UserError('no_signer_price', `the signer prices an asset only from Coinbase and Kraken together; ${blind.join(', ')} ${blind.length > 1 ? 'are' : 'is'} not listed on both, so every order would be refused (paper trading only)`, 422);90  const cap = (usd: number, x: AgentAsset) => { const p = x.kind === 'cash' ? 1 : prices[x.symbol]; if (!p || !(p > 0)) throw new UserError('no_price', `no market price for ${x.symbol}; cannot size the mandate`, 503);91    return parseUnits((usd / p).toFixed(Math.min(x.decimals, 8)), x.decimals).toString(); };92  const m: Mandate = { v: 1, mandateId: randomUUID(), keyId, vaultIndex: a.vault_index, vault: a.vault_address, network: a.network, provider: 'kyberswap', assets: assets.map((x) => x.id),93    maxOrder: Object.fromEntries(assets.map((x) => [x.id, cap(a.risk.maxOrderUsd, x)])), maxDaily: Object.fromEntries(assets.map((x) => [x.id, cap(a.risk.maxDailyUsd, x)])),94    maxOrdersPerDay: a.risk.maxOrdersPerDay, maxSlippageBps: Math.min(500, Math.max(150, a.risk.slippageBps + 80)), createdAt: now, expiresAt: now + a.risk.mandateDays * 86400, strategySha256, label: a.name.slice(0, 60) };95  validateMandate(MandateSchema.parse(m), now);96  return m;97}9899export interface AgentDeps { prices: () => Promise<Record<string, { usd: number }> | null> }100/** USD price of market symbols: the wallet's price feed, else the last stored candle close (symbols the feed does not carry). */101export async function symbolPrices(symbols: string[], feed: Record<string, { usd: number }> | null): Promise<Record<string, number>> {102  const out: Record<string, number> = {};103  for (const s of new Set(symbols)) {104    const p = feed?.[s]?.usd;105    if (p !== undefined && p > 0) { out[s] = p; continue; }106    try { const bar = (await getSeries(s, '1h', { limit: 1 })).bars.at(-1); if (bar && Date.now() - bar.ts < 6 * 3_600_000) out[s] = bar.close; } catch { /* no price: the caller decides */ }107  }108  return out;109}110/** An agent may go LIVE only with the gas reserve of its network in the vault. Returns the reserve and the balance. */111export async function requireGasReserve(a: AgentRow, feeCeiling: string | undefined, reserve: (n: NetworkDef, feeCeiling: string | undefined) => Promise<GasReserve> = gasReserve): Promise<{ reserve: bigint; held: bigint }> {112  const n = getNetwork(a.network);113  let r: GasReserve;114  try { r = await reserve(n, feeCeiling); } catch (e) { throw new UserError('no_gas_estimate', `the gas reserve on ${n.name} cannot be computed (${(e as Error).message.slice(0, 100)}); live trading is not started`, 503); }115  const held = (await adapterFor(a.network).balances(a.vault_address)).native ?? 0n;116  if (held < r.reserve) throw new UserError('insufficient_gas', `the vault holds ${formatUnits(held, n.native.decimals)} ${n.native.symbol}; live trading on ${n.name} needs a gas reserve of ${formatUnits(r.reserve, n.native.decimals)} ${n.native.symbol} (${RESERVE_ORDERS} orders at recent gas prices, at least ${FLOOR_ORDERS} at the signer's fee ceiling). Send it to the vault first.`, 409);117  return { reserve: r.reserve, held };118}119120export function agentRoutes(app: FastifyInstance, deps: AgentDeps): void {121  // what an agent can trade, network by network: derived from the token registry and the static table of listings122  app.get('/api/v1/agents/networks', async () => {123    const feed = await deps.prices().catch(() => null), st = await signer.status().catch(() => null);124    const quality = await listQuality().catch(() => []);125    const networks = await Promise.all(MANDATE_NETWORKS.map(async (id) => {126      const n = getNetwork(id), cash = cashLegs(n), symbols = tradableSymbols(n);127      let gas: { reserve: string; perOrder: string; perOrderUsd: number | null; gasPriceGwei: number } | null = null;128      try { const r = await gasReserve(n, st?.policy.maxFee[id]); const usd = (await symbolPrices([n.native.symbol], feed))[n.native.symbol];129        gas = { reserve: formatUnits(r.reserve, n.native.decimals), perOrder: formatUnits(r.perOrder, n.native.decimals), perOrderUsd: usd === undefined ? null : Number(formatUnits(r.perOrder, n.native.decimals)) * usd, gasPriceGwei: Number(r.gasPrice) / 1e9 }; } catch { /* shown as unknown */ }130      return { id, name: n.name, native: n.native.symbol, enabled: cash.length > 0 && symbols.length > 0, cash: cash.map((c) => ({ symbol: c.symbol, asset: c.id, name: c.name })), defaultCash: cash.some((c) => c.symbol === 'USDC') ? 'USDC' : null,131        gas, feeWarning: id === 'ethereum' || (gas?.perOrderUsd ?? 0) > 1,132        symbols: symbols.map((t) => ({ symbol: t.symbol, sources: t.sources, live: t.live, defaultAsset: t.defaultId,133          data: Object.fromEntries(['1h', '1d'].map((iv) => { const q = quality.find((x) => x.symbol === t.symbol && x.interval === iv); return [iv, q ? { status: q.status, fresh: q.fresh, lastTs: q.last_ts } : null]; })),134          representations: t.representations.map((r) => ({ asset: r.id, assetSymbol: r.assetSymbol, name: r.name, kind: r.kind, issuer: r.issuer })) })) };135    }));136    return { networks, maxSymbols: MAX_AGENT_SYMBOLS, maxLiveSymbols: 11, feeWarnShare: FEE_WARN_SHARE };137  });138139  app.get('/api/v1/agents', async () => ({ agents: (await sql<AgentRow[]>`select * from agents order by created_at desc`).map((a) => (isPerp(a) ? perpView(a) : view(a))) })); // v6: perp agents have their own view140141  app.post('/api/v1/agents', async (req) => {142    if ((req.body as { kind?: unknown } | null)?.kind === 'perp') return createPerpAgent(req.body); // v6: a perp agent (paper account, no vault, no signer call)143    const b = Create.parse(req.body);144    if (!MANDATE_NETWORKS.includes(b.network)) throw new UserError('unsupported_network', `agents trade on ${MANDATE_NETWORKS.map((x) => getNetwork(x).name).join(', ')}`, 422);145    const n = getNetwork(b.network);146    // every symbol must have market data and exactly one registry asset on this network; the choice is recorded with the agent147    let resolved: AgentAsset[];148    try { const r = resolveAgentAssets(n, b.universe, { cash: b.cash ?? null, assets: b.assets }); resolved = [r.cash, ...r.assets]; } catch (e) { throw new UserError('unsupported_asset', (e as Error).message, 422); }149    const cash = resolved[0]!, chosen = Object.fromEntries(resolved.slice(1).map((x) => [x.symbol, x.id]));150    const st = await signer.status(true);151    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);152    const id = randomUUID();153    const [row] = await sql.begin(async (tx) => {154      await tx`select pg_advisory_xact_lock(7707002)`;155      // one vault index per agent, whatever its network: the same index would be the same address on every EVM network156      const rows = await tx<{ next: number }[]>`select coalesce(max(vault_index), 0) + 1 as next from agents`;157      const next = rows[0]!.next;158      const { address } = await signer.vaultAddress(next); // derived by the signer; the API never chooses it159      return tx<AgentRow[]>`insert into agents (id, name, mission, network, universe, cash, assets, interval, risk, vault_index, vault_address, capital_usdc)160        values (${id}, ${b.name}, ${b.mission}, ${n.id}, ${tx.json(b.universe)}, ${cash.symbol}, ${tx.json(chosen)}, ${b.interval}, ${tx.json(b.risk as never)}, ${next}, ${address}, ${parseUnits(b.capitalUsdc, 6).toString()}) returning *`;161    });162    await audit('agent.created', 'owner', { id, name: b.name, network: n.id, vault: row!.vault_address, vaultIndex: row!.vault_index, universe: b.universe, cash: cash.symbol, assets: chosen });163    // warnings never block: network fees against the order size, symbols that can only be paper traded164    const warnings: { code: string; message: string }[] = [];165    try { const r = await gasReserve(n, st.policy.maxFee[n.id]); const usd = (await symbolPrices([n.native.symbol], await deps.prices().catch(() => null)))[n.native.symbol];166      if (usd !== undefined) { const share = feeShare(r.perOrder, n.native.decimals, usd, b.risk.maxOrderUsd);167        if (share > FEE_WARN_SHARE) warnings.push({ code: 'fees', message: `on ${n.name} one order costs about ${(Number(formatUnits(r.perOrder, n.native.decimals)) * usd).toFixed(2)} USD in network fees: ${(100 * share).toFixed(1)} % of this agent's largest order (${b.risk.maxOrderUsd} USD). Small orders are eaten by fees.` }); } } catch { /* no estimate: no warning */ }168    const paperOnly = resolved.slice(1).filter((x) => !hasSignerPrice(x.symbol)).map((x) => x.symbol);169    if (paperOnly.length) warnings.push({ code: 'paper_only', message: `${paperOnly.join(', ')}: not listed on both Coinbase and Kraken, so the signer cannot price it; this agent can be paper traded but not go live` });170    if (resolved.length > 12) warnings.push({ code: 'paper_only', message: 'a mandate covers at most 12 assets (the cash leg and 11 symbols): with 12 symbols this agent can be paper traded but not go live' });171    return { ...view(row!), warnings };172  });173174  app.get('/api/v1/agents/:id', async (req) => {175    const a = await load((req.params as { id: string }).id);176    if (isPerp(a)) return perpDetail(a); // v6177    const balances = await vaultBalances(a).catch(() => null);178    const px = await symbolPrices((balances ?? []).filter((x) => x.kind !== 'cash').map((x) => x.symbol), await deps.prices().catch(() => null));179    const usd = (x: VaultBalance) => { const p = x.kind === 'cash' ? 1 : px[x.symbol]; return p === undefined ? null : Number(formatUnits(x.amount, x.decimals)) * p; };180    const versions = await sql`select id, name, version, code_sha256, status, notes, created_at, approved_at, report->'metrics' as metrics, report->'robustness' as robustness, report->'benchmarks' as benchmarks, report->'config' as config from strategy_versions where agent_id = ${a.id} order by created_at desc`;181    const orders = await sql`select id, ts, mode, from_symbol, to_symbol, amount_in, expected_out, min_out, received, state, reason, txid from agent_orders where agent_id = ${a.id} order by ts desc limit 60`;182    const equity = await sql`select ts, mode, equity_usd from agent_equity where agent_id = ${a.id} order by ts desc limit 500`;183    const log = await sql`select ts, kind, title, body from research_log where agent_id = ${a.id} order by ts desc limit 40`;184    const st = await signer.status().catch(() => null);185    const sm = st?.mandates?.find((m) => m.mandate.mandateId === a.mandate_id) ?? null;186    return { ...view(a),187      vault: balances ? balances.map((x) => ({ symbol: x.symbol, assetSymbol: x.assetSymbol, name: x.name, kind: x.kind, issuer: x.issuer, role: x.role, asset: x.id, amount: x.amount.toString(), display: formatUnits(x.amount, x.decimals), usd: usd(x) })) : null,188      mandateStatus: sm ? { active: !sm.revoked && !sm.expired, revoked: sm.revoked, expired: sm.expired, expiresAt: sm.mandate.expiresAt, today: sm.today, maxOrder: sm.mandate.maxOrder, maxDaily: sm.mandate.maxDaily, maxOrdersPerDay: sm.mandate.maxOrdersPerDay } : null,189      // v8 hardening: the SIMULATED positions (paper book), read-only and separate from the real vault above; null when the agent never ran on paper190      paper: await paperBookView(a.id, cashOf(a), agentSymbols(a), async (s) => symbolPrices(s, await deps.prices().catch(() => null))).catch(() => null),191      versions, orders, equity: equity.reverse(), log };192  });193194  // paper trading needs no mandate: it signs nothing195  app.post('/api/v1/agents/:id/mode', async (req) => {196    const a = await load((req.params as { id: string }).id);197    const b = z.strictObject({ mode: z.enum(['PAPER', 'PAUSED', 'STOPPED', 'RESEARCH']) }).parse(req.body);198    if (isPerp(a)) return perpMode(a, b.mode); // v6199    if (b.mode === 'PAPER' && !a.strategy_version_id) throw new UserError('no_strategy', 'approve a strategy version first', 409);200    if (a.state === 'LIVE' && b.mode === 'PAPER') throw new UserError('bad_state', 'revoke the mandate before returning to paper trading', 409);201    const state = b.mode === 'PAPER' ? 'PAPER_TRADING' : b.mode;202    const [r] = await sql<AgentRow[]>`update agents set state = ${state}, mode = ${b.mode === 'PAPER' ? 'PAPER' : a.mode}, stop_reason = ${b.mode === 'STOPPED' ? 'stopped by the owner' : null}, updated_at = now() where id = ${a.id} returning *`;203    await audit('agent.mode', 'owner', { id: a.id, state });204    return view(r!);205  });206207  // strategy versions: registered by the research side (path + hash), approved by the owner208  app.post('/api/v1/agents/:id/strategies/:sid/approve', async (req) => {209    const { id, sid } = req.params as { id: string; sid: string };210    const a = await load(id);211    const [v] = await sql`select * from strategy_versions where id = ${z.uuid().parse(sid)} and agent_id = ${a.id}`;212    if (!v) throw new UserError('not_found', 'strategy version not found', 404);213    if (!v.report) throw new UserError('no_report', 'this version has no backtest report: it cannot be approved', 409);214    // v6: kinds never cross — a perp agent approves only a contract-2 version with a valid version-2 report, a spot agent never one215    if (isPerp(a)) assertPerpApprovable(a, v as never); else if ((v.meta as { kind?: string } | null)?.kind === 'perp') throw new UserError('wrong_kind', 'a perpetual-futures strategy cannot run on a spot agent', 409);216    if (a.state === 'LIVE') throw new UserError('bad_state', 'the code of a live agent cannot change: revoke the mandate first, then approve the new version and a new mandate', 409);217    await sql`update strategy_versions set status = 'retired' where agent_id = ${a.id} and status = 'approved'`;218    await sql`update strategy_versions set status = 'approved', approved_at = now() where id = ${v.id}`;219    const [r] = await sql<AgentRow[]>`update agents set strategy_version_id = ${v.id}, state = case when state in ('CREATED','RESEARCHING','BACKTESTING','AWAITING_APPROVAL') then 'AWAITING_APPROVAL' else state end, updated_at = now() where id = ${a.id} returning *`;220    await audit('strategy.approved', 'owner', { agent: a.id, version: v.id, sha256: v.code_sha256 });221    if (isPerp(r!)) return perpView(r!); // v6222    return view(r!);223  });224225  // mandate: prepared from the agent's limits, approved with a passkey whose challenge is the mandate hash226  app.post('/api/v1/agents/:id/mandate/prepare', async (req) => {227    const a = await load((req.params as { id: string }).id); spotOnly(a); // v6: vault and mandate routes are for spot agents228    if (!a.strategy_version_id) throw new UserError('no_strategy', 'approve a strategy version first', 409);229    if (a.state === 'LIVE') throw new UserError('bad_state', 'a mandate is already active; revoke it to change limits', 409);230    const [v] = await sql`select code_sha256 from strategy_versions where id = ${a.strategy_version_id}`;231    const st = await signer.status(true);232    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);233    const px = await symbolPrices(agentSymbols(a), await deps.prices().catch(() => null));234    const mandate = mandateFor(a, st.keyId, v!.code_sha256, px, Math.floor(Date.now() / 1000));235    const gas = await requireGasReserve(a, st.policy.maxFee[a.network]); // no reserve, no live trading236    const hash = mandateHash(mandate);237    await sql`update agents set mandate = ${sql.json(mandate as never)}, updated_at = now() where id = ${a.id}`;238    const creds = await listCreds();239    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 120_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });240    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');241    const n = getNetwork(a.network);242    const human = agentAssets(a, n).map((x) => ({ symbol: x.assetSymbol === x.symbol ? x.symbol : `${x.assetSymbol} (${x.symbol})`, tracks: x.symbol, assetSymbol: x.assetSymbol, kind: x.kind, issuer: x.issuer, maxOrder: formatUnits(BigInt(mandate.maxOrder[x.id]!), x.decimals), maxDaily: formatUnits(BigInt(mandate.maxDaily[x.id]!), x.decimals) }));243    return { mandate, hash: toHex(hash), limits: human, options, network: n.id, networkName: n.name, gasReserve: { required: formatUnits(gas.reserve, n.native.decimals), held: formatUnits(gas.held, n.native.decimals), symbol: n.native.symbol } };244  });245  app.post('/api/v1/agents/:id/mandate/approve', async (req) => {246    const a = await load((req.params as { id: string }).id); spotOnly(a); // v6: vault and mandate routes are for spot agents247    const b = z.object({ assertion: z.record(z.string(), z.unknown()) }).parse(req.body);248    if (!a.mandate) throw new UserError('bad_state', 'prepare the mandate first', 409);249    await requireGasReserve(a, (await signer.status(true)).policy.maxFee[a.network]);250    const r = await signer.mandateApprove(a.mandate, b.assertion); // only the signer judges the passkey and stores the limits251    const [row] = await sql<AgentRow[]>`update agents set mandate_id = ${r.mandateId}, state = 'LIVE', mode = 'LIVE', stop_reason = null, updated_at = now() where id = ${a.id} returning *`;252    await audit('mandate.approved', 'owner', { agent: a.id, mandateId: r.mandateId, hash: r.hash });253    return view(row!);254  });255  // stopping never needs a passkey256  app.post('/api/v1/agents/:id/revoke', async (req) => {257    const a = await load((req.params as { id: string }).id); spotOnly(a); // v6: vault and mandate routes are for spot agents258    if (a.mandate_id) await signer.mandateRevoke(a.mandate_id);259    const [row] = await sql<AgentRow[]>`update agents set state = 'STOPPED', stop_reason = 'mandate revoked by the owner', updated_at = now() where id = ${a.id} returning *`;260    await audit('mandate.revoked', 'owner', { agent: a.id, mandateId: a.mandate_id });261    return view(row!);262  });263264  // what the vault's address holds on every EVM network (the same address exists on all of them)265  app.get('/api/v1/agents/:id/elsewhere', async (req) => {266    const a = await load((req.params as { id: string }).id); spotOnly(a); // v6: vault and mandate routes are for spot agents267    const nets = Object.values(NETWORKS).filter((n) => n.family === 'evm' && n.id !== a.network);268    const found = (await Promise.all(nets.map(async (n) => { try { const b = await adapterFor(n.id).balances(a.vault_address);269      const held = [n.native, ...n.tokens].filter((x) => (b[x.id] ?? 0n) > 0n);270      if (!held.length) return [];271      // display only: what one token withdrawal would cost right now (the arithmetic of the withdraw route below), so the page can say what is missing272      let need: bigint | null = null;273      try { const block = await rpcAny<{ baseFeePerGas?: `0x${string}` }>(n.rpc, 'eth_getBlockByNumber', ['latest', false]); let tip = 1_000_000n; try { tip = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_maxPriorityFeePerGas', [])); } catch { /* floor */ }274        need = 90_000n * (hexToBigInt(block.baseFeePerGas ?? '0x0') * 2n + tip); } catch { /* unknown: the page falls back to hasGas */ }275      return held.map((x) => ({ network: n.id, networkName: n.name, asset: x.id, symbol: x.symbol, display: formatUnits(b[x.id]!, x.decimals), gas: n.native.symbol, hasGas: (b.native ?? 0n) > 0n,276        gasHeld: formatUnits(b.native ?? 0n, n.native.decimals), gasNeed: need === null ? null : formatUnits(need, n.native.decimals), gasEnough: need === null ? null : (b.native ?? 0n) >= need })); } catch { return []; } }))).flat();277    return { address: a.vault_address, holdings: found };278  });279280  // bring funds back: a vault can only pay the owner's own address (enforced by the signer)281  // v8: a tracked operation (withdrawals.ts) — the answer carries its real state; `ok` is true only once it was broadcast282  app.post('/api/v1/agents/:id/withdraw', async (req) => {283    const a = await load((req.params as { id: string }).id); spotOnly(a); // v6: vault and mandate routes are for spot agents284    const b = z.strictObject({ asset: z.string().max(80), network: z.string().max(24).optional(), idempotencyKey: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/).optional() }).parse(req.body);285    return requestWithdrawal(a, b);286  });287  app.get('/api/v1/agents/:id/withdrawals', async (req) => {288    const a = await load((req.params as { id: string }).id);289    return { withdrawals: await listWithdrawals(a.id), broadcast: config.broadcast };290  });291  app.get('/api/v1/agents/:id/withdrawals/:wid', async (req) => {292    const p = req.params as { id: string; wid: string }, a = await load(p.id);293    const w = await getWithdrawal(a.id, z.uuid().parse(p.wid));294    if (!w) throw new UserError('not_found', 'withdrawal not found', 404);295    return w;296  });297}298
Files main
Menu

Theme