SPB Git

Repository Public

mandate.ts 8.6 KB 109 lines typescript

1// Trading mandate: what an agent's vault may do without a passkey approval per order. Approved once by the owner (passkey over2// the canonical hash), stored and enforced by the isolated signer. Nothing outside the signer can widen it.3import { sha256 } from '@noble/hashes/sha2.js';4import { z } from 'zod';5import { baseUnits } from './amounts.ts';6import { getAsset, getNetwork } from './networks.ts';7import type { Intent } from './intent.ts';8import { KYBER_ENABLED } from './swap-evm.ts';9/** Agents trade on every network where swap execution is enabled (the router there was reviewed). One network per mandate. */10export const MANDATE_NETWORKS: readonly string[] = Object.keys(KYBER_ENABLED);1112const U = z.string().regex(/^(0|[1-9]\d{0,77})$/);13export const MAX_MANDATE_DAYS = 90;14export const MandateSchema = z.strictObject({15  v: z.literal(1),16  mandateId: z.uuid(),17  /** the owner key this mandate belongs to */18  keyId: z.string().min(8).max(80),19  /** HD account index of the agent vault (the owner's own wallet is account 0 and can never be mandated) */20  vaultIndex: z.number().int().min(1).max(100_000),21  vault: z.string().regex(/^0x[0-9a-fA-F]{40}$/),22  network: z.string().min(2).max(24),23  provider: z.enum(['kyberswap']),24  /** registry asset ids ('native' or token address) the vault may hold and trade */25  assets: z.array(z.string().min(1).max(80)).min(2).max(12),26  /** per asset id: largest input of a single order and total input per UTC day, base units. A missing asset cannot be sold. */27  maxOrder: z.record(z.string(), U), maxDaily: z.record(z.string(), U),28  maxOrdersPerDay: z.number().int().min(1).max(500),29  /** worst execution the signer will accept, measured against its own view of market prices: the guaranteed output must be30   *  worth at least (1 − this) of the input, in basis points */31  maxSlippageBps: z.number().int().min(10).max(500),32  createdAt: z.number().int().positive(), expiresAt: z.number().int().positive(),33  /** SHA-256 of the strategy code this mandate was approved for (audit trail; the engine refuses to run anything else) */34  strategySha256: z.string().regex(/^[0-9a-f]{64}$/),35  label: z.string().min(1).max(60),36});37export type Mandate = z.infer<typeof MandateSchema>;3839const enc = new TextEncoder();40/** Deterministic, length-prefixed, domain-separated serialization (asset lists and maps in sorted order). */41export function mandateBytes(m: Mandate): Uint8Array {42  const x = MandateSchema.parse(m);43  const f: [string, string][] = [['v', '1'], ['mandateId', x.mandateId], ['keyId', x.keyId], ['vaultIndex', String(x.vaultIndex)], ['vault', x.vault], ['network', x.network], ['provider', x.provider],44    ['assets', [...x.assets].sort().join(',')], ...Object.keys(x.maxOrder).sort().map((k) => [`maxOrder.${k}`, x.maxOrder[k]!] as [string, string]),45    ...Object.keys(x.maxDaily).sort().map((k) => [`maxDaily.${k}`, x.maxDaily[k]!] as [string, string]), ['maxOrdersPerDay', String(x.maxOrdersPerDay)], ['maxSlippageBps', String(x.maxSlippageBps)],46    ['createdAt', String(x.createdAt)], ['expiresAt', String(x.expiresAt)], ['strategySha256', x.strategySha256], ['label', x.label]];47  const parts: Uint8Array[] = [enc.encode('SPW-MANDATE-v1'), new Uint8Array([0])];48  for (const [k, v] of f) { const kb = enc.encode(k), vb = enc.encode(v), h = new Uint8Array(6); new DataView(h.buffer).setUint16(0, kb.length); new DataView(h.buffer).setUint32(2, vb.length); parts.push(h, kb, vb); }49  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; }50  return out;51}52export const mandateHash = (m: Mandate): Uint8Array => sha256(mandateBytes(m));53/** Key id carried by intents signed with a vault key, so an owner approval can never be replayed on a vault or vice versa. */54export const vaultKeyId = (ownerKeyId: string, index: number) => `${ownerKeyId}-v${index}`;5556/** Structural validity of a mandate at approval time (the signer also checks the vault address against its own derivation). */57export function validateMandate(m: Mandate, nowSec: number): void {58  const n = getNetwork(m.network);59  if (n.family !== 'evm' || !KYBER_ENABLED[m.network] || !MANDATE_NETWORKS.includes(m.network)) throw new Error(`mandates are not available on ${n.name}`);60  const seen = new Set<string>();61  for (const a of m.assets) { const d = getAsset(m.network, a); const k = d.id.toLowerCase(); if (seen.has(k)) throw new Error('duplicate asset'); seen.add(k); if (d.id !== a) throw new Error('asset id is not in registry form'); }62  for (const map of [m.maxOrder, m.maxDaily]) for (const k of Object.keys(map)) if (!m.assets.includes(k)) throw new Error('limit set for an asset outside the mandate');63  for (const a of m.assets) { const o = m.maxOrder[a], d = m.maxDaily[a]; if ((o === undefined) !== (d === undefined)) throw new Error('order and daily limits must be set together'); if (o !== undefined && baseUnits(o) > baseUnits(d!)) throw new Error('order limit above the daily limit'); }64  if (!Object.keys(m.maxOrder).length) throw new Error('a mandate needs at least one sellable asset');65  if (m.expiresAt <= nowSec) throw new Error('mandate already expired');66  if (m.createdAt > nowSec + 120 || m.createdAt < nowSec - 600) throw new Error('mandate creation time is not current');67  if (m.expiresAt - m.createdAt > MAX_MANDATE_DAYS * 86400) throw new Error(`a mandate lasts at most ${MAX_MANDATE_DAYS} days`);68}6970export interface MandateUsage { /** base units already sold today, per asset id */ sold: Record<string, string>; orders: number }71export type MandateVerdict = { ok: true } | { ok: false; reason: string };7273/** Does this order fit the mandate? Pure: the signer supplies today's usage and the clock. */74export function checkMandateOrder(m: Mandate, i: Intent, usage: MandateUsage, nowSec: number, opts: { halted: boolean; revoked: boolean; maxFee: string | undefined }): MandateVerdict {75  const deny = (reason: string): MandateVerdict => ({ ok: false, reason });76  if (opts.halted) return deny('emergency halt is active');77  if (opts.revoked) return deny('mandate revoked');78  if (nowSec >= m.expiresAt) return deny('mandate expired');79  if (i.keyId !== vaultKeyId(m.keyId, m.vaultIndex)) return deny('intent is not for this vault key');80  if (i.network !== m.network) return deny('network outside the mandate');81  if (!i.swap || i.swap.provider !== m.provider) return deny('only swaps through the mandated provider can be delegated');82  if (i.from.toLowerCase() !== m.vault.toLowerCase() || i.to.toLowerCase() !== m.vault.toLowerCase()) return deny('an order must spend from and deliver to the vault itself');83  if (!m.assets.includes(i.asset) || !m.assets.includes(i.swap.toAsset)) return deny('asset outside the mandate');84  if (i.expiresAt <= nowSec) return deny('order expired');85  if (i.createdAt > nowSec + 60 || i.expiresAt - i.createdAt > 300) return deny('order validity window is not acceptable');86  const amount = baseUnits(i.amount), perOrder = m.maxOrder[i.asset], perDay = m.maxDaily[i.asset];87  if (perOrder === undefined || perDay === undefined) return deny('this asset cannot be sold under the mandate');88  if (amount <= 0n || amount > baseUnits(perOrder)) return deny('order above the per-order limit');89  if (BigInt(usage.sold[i.asset] ?? '0') + amount > baseUnits(perDay)) return deny('daily volume limit reached');90  if (usage.orders >= m.maxOrdersPerDay) return deny('daily order count reached');91  if (opts.maxFee === undefined || baseUnits(i.maxFee) > baseUnits(opts.maxFee)) return deny('fee ceiling above policy');92  return { ok: true };93}9495/**96 * Price guard for a delegated order: the value the swap guarantees (minimum output at the signer's price) must be at least97 * (1 − maxSlippageBps) of the value given up. Prices are floats and are used for this comparison only, never for amounts.98 */99export function checkOrderPrice(m: Mandate, i: Intent, prices: Record<string, number>, outSymbol: string, outDecimals: number): MandateVerdict {100  const pIn = prices[i.symbol] ?? 0, pOut = prices[outSymbol] ?? 0;101  if (!(pIn > 0) || !(pOut > 0) || !i.swap) return { ok: false, reason: 'no trustworthy market price for this pair' };102  const unitsIn = Number(baseUnits(i.amount)) / 10 ** i.decimals, unitsOut = Number(baseUnits(i.swap.minOut)) / 10 ** outDecimals;103  const valueIn = unitsIn * pIn, valueOut = unitsOut * pOut;104  if (!(valueIn > 0) || !Number.isFinite(valueOut)) return { ok: false, reason: 'order value cannot be computed' };105  const floor = valueIn * (1 - m.maxSlippageBps / 10_000);106  if (valueOut < floor) return { ok: false, reason: `guaranteed output is worth ${(100 * valueOut / valueIn).toFixed(2)} % of the input at market price; the mandate requires at least ${(100 - m.maxSlippageBps / 100).toFixed(2)} %` };107  return { ok: true };108}109
Files main
Menu

Theme