Repository Public
1// Derivative MANDATE (MANDATE_LIVE): the canonical object the owner approves ONCE with a passkey, after which an approved2// automated strategy may place orders without a passkey per order — inside limits that the isolated signer stores and enforces.3//4// A mandate EXTENDS a venue authorization (canonical.ts): same venue, network, account and trading key. It can only be narrower5// than the authorization on every limit, and it dies with it. Pure: no network, no clock, no key.6//7// What is new compared with the authorization: position and gross-exposure limits that the SIGNER enforces (worstCaseExposure8// below, fed by the signer's own read of the venue account and its own prices), an hourly order rate, a daily turnover, a9// floor on the account value, and the identity of the strategy (code hash + agent id, for the audit trail).10import { sha256 } from '@noble/hashes/sha2.js';11import { z } from 'zod';12import { D, type Dec, type DecStr } from '../decimal.ts';13import { DecStrSchema } from '../models.ts';14import { canonicalEncode, VENUE_POLICY_BOUNDS, type VenueAuthorization } from './canonical.ts';1516export const VENUE_MANDATE_MAX_DAYS = 30;17/** after a mandate is revoked or expires, cancels and reduce-only orders are still signed this long (never new exposure) */18export const VENUE_MANDATE_WIND_DOWN_S = 86_400;19/** hard ceilings of a mandate, on top of the authorization's own limits */20export const VENUE_MANDATE_BOUNDS = { maxDays: VENUE_MANDATE_MAX_DAYS, maxOrdersPerHour: 60, maxLeverageChangesPerDay: 20, maxInstruments: VENUE_POLICY_BOUNDS.maxInstruments } as const;21/** a signed order stays in the signer's exposure ledger until its venue expiry plus this (the venue's info endpoint may lag) */22export const VENUE_PENDING_GRACE_MS = 10_000;23/** the venue's own timestamp on the account state the signer read may differ from the signer's clock by at most this */24export const VENUE_READ_MAX_AGE_MS = 30_000;2526const ADDRESS = /^0x[0-9a-f]{40}$/;27const PosDec = DecStrSchema.refine((x) => !x.startsWith('-') && /[1-9]/.test(x), 'must be positive');28const canon = (x: DecStr): DecStr => D.str(D.from(x));2930export const VenueMandateInstrumentSchema = z.strictObject({31 instrument: z.string().regex(/^hyperliquid:[A-Za-z0-9]{1,15}$/),32 /** highest leverage SETTING of this instrument under which the signer signs an order that can add exposure, and the highest it sets */33 maxLeverage: z.number().int().min(1).max(VENUE_POLICY_BOUNDS.maxLeverage),34 /** worst-case position on this instrument, in the venue's collateral, at the signer's own price — enforced by the signer */35 maxPositionNotional: PosDec,36});37export const VenueMandateSchema = z.strictObject({38 v: z.literal(1),39 mandateId: z.uuid(),40 /** the venue authorization this mandate extends; revoking either stops the mandate */41 authorizationId: z.uuid(),42 keyId: z.string().min(8).max(80),43 venue: z.literal('hyperliquid'),44 network: z.enum(['mainnet', 'testnet']),45 account: z.string().regex(ADDRESS),46 agentAddress: z.string().regex(ADDRESS),47 /** audit: which strategy code and which agent the owner approved (the signer cannot verify what code runs; it records it) */48 strategySha256: z.string().regex(/^[0-9a-f]{64}$/),49 agentId: z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/),50 instruments: z.array(VenueMandateInstrumentSchema).min(1).max(VENUE_MANDATE_BOUNDS.maxInstruments),51 /** size × highest named price of ONE order (reduce-only orders included) */52 maxOrderNotional: PosDec,53 /** worst-case sum over all instruments of the account — enforced by the signer */54 maxGrossExposure: PosDec,55 /** orders that can add exposure, per UTC day and per rolling hour (reduce-only orders and cancels are never counted) */56 maxOrdersPerDay: z.number().int().min(1).max(VENUE_POLICY_BOUNDS.maxOrdersPerDay),57 maxOrdersPerHour: z.number().int().min(1).max(VENUE_MANDATE_BOUNDS.maxOrdersPerHour),58 /** sum of the notionals of the orders that can add exposure, per UTC day */59 maxDailyTurnover: PosDec,60 /** loss limit: when the venue reports an account value below this, the signer signs only reduce-only orders and cancels */61 floorEquity: PosDec,62 maxPriceDeviationBps: z.number().int().min(VENUE_POLICY_BOUNDS.minPriceBandBps).max(VENUE_POLICY_BOUNDS.maxPriceBandBps),63 /** unix seconds */64 createdAt: z.number().int().positive(), expiresAt: z.number().int().positive(),65 /** SHA-256 of the jurisdiction acknowledgement text shown again when the mandate was approved */66 ackSha256: z.string().regex(/^[0-9a-f]{64}$/),67});68export type VenueMandate = z.infer<typeof VenueMandateSchema>;69export type VenueMandateInstrument = z.infer<typeof VenueMandateInstrumentSchema>;7071export function venueMandateBytes(m: VenueMandate): Uint8Array {72 const x = VenueMandateSchema.parse(m), inst = [...x.instruments].sort((a, b) => (a.instrument < b.instrument ? -1 : a.instrument > b.instrument ? 1 : 0));73 return canonicalEncode('SPW-VENUE-MANDATE-v1', [74 ['v', '1'], ['mandateId', x.mandateId], ['authorizationId', x.authorizationId], ['keyId', x.keyId], ['venue', x.venue], ['network', x.network], ['account', x.account],75 ['agentAddress', x.agentAddress], ['strategySha256', x.strategySha256], ['agentId', x.agentId],76 ['instruments', inst.map((i) => `${i.instrument}|${i.maxLeverage}|${canon(i.maxPositionNotional)}`).join(',')],77 ['maxOrderNotional', canon(x.maxOrderNotional)], ['maxGrossExposure', canon(x.maxGrossExposure)], ['maxOrdersPerDay', String(x.maxOrdersPerDay)], ['maxOrdersPerHour', String(x.maxOrdersPerHour)],78 ['maxDailyTurnover', canon(x.maxDailyTurnover)], ['floorEquity', canon(x.floorEquity)], ['maxPriceDeviationBps', String(x.maxPriceDeviationBps)],79 ['createdAt', String(x.createdAt)], ['expiresAt', String(x.expiresAt)], ['ackSha256', x.ackSha256],80 ]);81}82export const venueMandateHash = (m: VenueMandate): Uint8Array => sha256(venueMandateBytes(m));8384/**85 * Validity of a mandate against the authorization it extends, at approval time. Throws with the first reason. The signer86 * additionally checks that the authorization is its own stored one (unrevoked), the pinned asset table and the acknowledgement hash.87 */88export function validateVenueMandate(m: VenueMandate, a: VenueAuthorization, nowSec: number): void {89 if (a.mode !== 'MANUAL_LIVE') throw new Error('a mandate extends a MANUAL_LIVE authorization');90 if (m.authorizationId !== a.authorizationId || m.keyId !== a.keyId || m.venue !== a.venue || m.network !== a.network || m.account !== a.account || m.agentAddress !== a.agentAddress)91 throw new Error('the mandate does not name the venue, network, account and trading key of its authorization');92 if (nowSec >= a.expiresAt) throw new Error('the authorization has expired');93 const p = a.policy, gt = (x: DecStr, y: DecStr) => D.gt(D.from(x), D.from(y));94 if (new Set(m.instruments.map((i) => i.instrument)).size !== m.instruments.length) throw new Error('duplicate instrument');95 for (const i of m.instruments) {96 if (!p.allowedInstruments.includes(i.instrument)) throw new Error(`${i.instrument} is not allowed by the authorization`);97 if (i.maxLeverage > p.maxLeverage) throw new Error(`${i.instrument}: leverage ${i.maxLeverage} is above the authorization's ${p.maxLeverage}`);98 if (gt(i.maxPositionNotional, p.maxPositionNotional)) throw new Error(`${i.instrument}: position limit above the authorization's ${canon(p.maxPositionNotional)}`);99 if (gt(i.maxPositionNotional, m.maxGrossExposure)) throw new Error(`${i.instrument}: position limit above the mandate's gross exposure limit`);100 }101 if (gt(m.maxOrderNotional, p.maxOrderNotional)) throw new Error(`order limit above the authorization's ${canon(p.maxOrderNotional)}`);102 if (!m.instruments.some((i) => !gt(m.maxOrderNotional, i.maxPositionNotional))) throw new Error('order limit above every position limit');103 if (gt(m.maxGrossExposure, p.maxGrossExposure)) throw new Error(`gross exposure limit above the authorization's ${canon(p.maxGrossExposure)}`);104 if (m.maxOrdersPerDay > p.maxOrdersPerDay) throw new Error(`orders per day above the authorization's ${p.maxOrdersPerDay}`);105 if (m.maxOrdersPerHour > m.maxOrdersPerDay) throw new Error('orders per hour above orders per day');106 if (m.maxPriceDeviationBps > p.maxPriceDeviationBps) throw new Error(`price band wider than the authorization's ${p.maxPriceDeviationBps} bps`);107 if (gt(m.maxOrderNotional, m.maxDailyTurnover)) throw new Error('order limit above the daily turnover limit');108 if (m.expiresAt <= nowSec) throw new Error('mandate already expired');109 if (m.createdAt > nowSec + 120 || m.createdAt < nowSec - 600) throw new Error('mandate creation time is not current');110 if (m.expiresAt - m.createdAt > VENUE_MANDATE_MAX_DAYS * 86400) throw new Error(`a mandate lasts at most ${VENUE_MANDATE_MAX_DAYS} days`);111 if (m.expiresAt > a.expiresAt) throw new Error('the mandate would outlive its authorization');112}113114export type VenueMandatePhase = 'active' | 'wind_down' | 'ended';115/**116 * `active`: everything the mandate allows. `wind_down` (from the revocation or the expiry, for VENUE_MANDATE_WIND_DOWN_S):117 * cancels and reduce-only orders only. `ended`: nothing. The authorization's own revocation or expiry ends everything at once118 * and is checked separately.119 */120export function venueMandatePhase(m: Pick<VenueMandate, 'expiresAt'>, revokedAtSec: number | null, nowSec: number): { phase: VenueMandatePhase; endedAt: number | null; windDownUntil: number | null } {121 const end = revokedAtSec === null ? m.expiresAt : Math.min(revokedAtSec, m.expiresAt);122 if (nowSec < end) return { phase: 'active', endedAt: null, windDownUntil: null };123 const until = end + VENUE_MANDATE_WIND_DOWN_S;124 return { phase: nowSec < until ? 'wind_down' : 'ended', endedAt: end, windDownUntil: until };125}126127// ---------------------------------------------------------------------------------------------------------------------------128// Worst-case exposure — the bound the signer enforces before it signs an order that can add exposure.129//130// Inputs: the account's positions (signed sizes, from the signer's own read of the venue), a price per instrument (the signer's131// own), and every order that could still add exposure: the non-reduce-only orders resting on the venue, the orders the signer132// signed whose venue expiry (+ grace) has not passed, and the candidate. Reduce-only orders are left out: the venue never lets133// one increase a position or open the opposite one.134//135// Per instrument: if every buy fills and no sell does, the position is at most max(size, 0) + Σ buys; the other way round,136// max(−size, 0) + Σ sells. Whatever subset fills, |position| ≤ the larger of the two. Each order is valued at the higher of137// its own price and the instrument's price (a sell can fill above its limit), the position at the instrument's price.138// The bound over-counts on purpose (an order just filled is counted as position AND as pending until it leaves the ledger).139140export interface ExposurePosition { instrument: string; /** signed size: positive long, negative short */ size: DecStr }141export interface ExposureOrder { instrument: string; side: 'buy' | 'sell'; size: DecStr; /** highest price the order names */ price: DecStr; reduceOnly: boolean }142export interface WorstCaseExposure { perInstrument: Record<string, DecStr>; gross: DecStr }143144export function worstCaseExposure(positions: readonly ExposurePosition[], orders: readonly ExposureOrder[], prices: Readonly<Record<string, DecStr>>): WorstCaseExposure {145 const long = new Map<string, Dec>(), short = new Map<string, Dec>();146 const add = (m: Map<string, Dec>, k: string, v: Dec) => m.set(k, D.add(m.get(k) ?? D.zero, v));147 const priceOf = (inst: string): Dec | null => { const p = Object.hasOwn(prices, inst) ? D.from(prices[inst]!) : null; if (p !== null && !D.isPos(p)) throw new Error(`bad price for ${inst}`); return p; };148 const seen = new Set<string>();149 for (const p of positions) {150 if (seen.has(p.instrument)) throw new Error(`two positions on ${p.instrument}`);151 seen.add(p.instrument);152 const s = D.from(p.size);153 if (D.isZero(s)) continue;154 const px = priceOf(p.instrument);155 if (px === null) throw new Error(`no price for the position on ${p.instrument}`);156 add(D.isPos(s) ? long : short, p.instrument, D.mul(D.abs(s), px));157 }158 for (const o of orders) {159 if (o.reduceOnly) continue;160 const size = D.from(o.size), own = D.from(o.price);161 if (D.isNeg(size) || !D.isPos(own)) throw new Error(`bad order on ${o.instrument}`);162 const px = priceOf(o.instrument);163 add(o.side === 'buy' ? long : short, o.instrument, D.mul(size, px === null ? own : D.max(own, px)));164 }165 const perInstrument: Record<string, DecStr> = {};166 let gross = D.zero;167 for (const k of new Set([...long.keys(), ...short.keys()])) {168 const w = D.max(long.get(k) ?? D.zero, short.get(k) ?? D.zero);169 perInstrument[k] = D.str(w); gross = D.add(gross, w);170 }171 return { perInstrument, gross: D.str(gross) };172}173