SPB Git

Repository Public

canonical.ts 12.6 KB 204 lines typescript

1// Canonical objects of controlled live trading and their hashes. The owner's passkey signs the SHA-256 of these bytes, and the2// isolated signer recomputes the same bytes from the same fields: what the screen shows is what the passkey commits to.3//4//  - VenueAuthorization: the owner turns live trading on for ONE venue account. It names the trading ("agent") key, the limits the5//    signer will enforce, the expiry, and the hash of the jurisdiction acknowledgement that was displayed.6//  - VenueOrder / VenueLeverage: one order or one leverage change, exactly as the venue will receive it.7//8// Serialization: a domain tag, then (name, value) pairs in an order fixed by this code, each length-prefixed. Never JSON key order.9// Pure: no network, no clock, no key.10import { sha256 } from '@noble/hashes/sha2.js';11import { z } from 'zod';12import { D, type DecStr } from '../decimal.ts';13import { DecStrSchema, OrderSideSchema, TifSchema } from '../models.ts';1415export const VENUE_AUTH_MAX_DAYS = 90;16/** an order or leverage approval is valid at most this long (nonce → expiry) */17export const VENUE_ACTION_MAX_TTL_MS = 120_000;18/** hard ceilings of the signer-side venue policy (the owner chooses below them) */19export const VENUE_POLICY_BOUNDS = { maxLeverage: 20, maxOrdersPerDay: 500, minPriceBandBps: 10, maxPriceBandBps: 2000, maxInstruments: 8 } as const;2021const enc = new TextEncoder();22const ADDRESS = /^0x[0-9a-f]{40}$/;23const PosDec = DecStrSchema.refine((x) => !x.startsWith('-') && /[1-9]/.test(x), 'must be positive');24const canon = (x: DecStr): DecStr => D.str(D.from(x));2526function encode(domain: string, fields: [string, string][]): Uint8Array {27  const parts: Uint8Array[] = [enc.encode(domain), new Uint8Array([0])];28  for (const [k, v] of fields) {29    const kb = enc.encode(k), vb = enc.encode(v), head = new Uint8Array(6);30    new DataView(head.buffer).setUint16(0, kb.length);31    new DataView(head.buffer).setUint32(2, vb.length);32    parts.push(head, kb, vb);33  }34  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));35  let at = 0;36  for (const p of parts) { out.set(p, at); at += p.length; }37  return out;38}39export const hex = (b: Uint8Array): string => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');4041// ---------------------------------------------------------------------------------------------------------------------------42// Signer-side venue policy: the limits the signer itself enforces (or, for the two it cannot see, that the API must respect)4344export const VenuePolicySchema = z.strictObject({45  /** instrument ids (`hyperliquid:BTC`); each must be in the signer's pinned asset table */46  allowedInstruments: z.array(z.string().regex(/^hyperliquid:[A-Za-z0-9]{1,15}$/)).min(1).max(VENUE_POLICY_BOUNDS.maxInstruments),47  /** highest leverage SETTING the signer will sign for an instrument (integer, as the venue takes it) */48  maxLeverage: z.number().int().min(1).max(VENUE_POLICY_BOUNDS.maxLeverage),49  /** size × limit/worst price of one order, in the venue's collateral (USDC) — enforced by the signer on every order */50  maxOrderNotional: PosDec,51  /** resulting position per instrument and resulting gross exposure. The signer has NO view of positions: it only refuses an52   *  order whose own notional exceeds them; the rest is enforced by the API's risk engine against the venue's real state. */53  maxPositionNotional: PosDec,54  maxGrossExposure: PosDec,55  /** orders signed per UTC day (cancels are not counted: stopping is always possible) */56  maxOrdersPerDay: z.number().int().min(1).max(VENUE_POLICY_BOUNDS.maxOrdersPerDay),57  /** every price of an order (limit/worst price and trigger price) must be within this band of the signer's OWN mid for the asset */58  maxPriceDeviationBps: z.number().int().min(VENUE_POLICY_BOUNDS.minPriceBandBps).max(VENUE_POLICY_BOUNDS.maxPriceBandBps),59});60export type VenuePolicy = z.infer<typeof VenuePolicySchema>;6162export const VenueAuthorizationSchema = z.strictObject({63  v: z.literal(1),64  authorizationId: z.uuid(),65  /** the owner key (signer key id) this authorization belongs to */66  keyId: z.string().min(8).max(80),67  venue: z.literal('hyperliquid'),68  network: z.enum(['mainnet', 'testnet']),69  /** the venue account = the owner's own EVM address (lower case) */70  account: z.string().regex(ADDRESS),71  /** the dedicated trading key (Hyperliquid API wallet) derived by the signer for this venue and network (lower case) */72  agentAddress: z.string().regex(ADDRESS),73  /** name under which the venue registers the API wallet; approving the same name again replaces it */74  agentName: z.string().regex(/^[A-Za-z0-9_-]{1,16}$/),75  /** MANDATE_LIVE is part of the schema so that it can be refused by name; only MANUAL_LIVE is accepted */76  mode: z.enum(['MANUAL_LIVE', 'MANDATE_LIVE']),77  policy: VenuePolicySchema,78  /** unix seconds */79  createdAt: z.number().int().positive(), expiresAt: z.number().int().positive(),80  /** nonce (unix ms) of the venue's approveAgent action that this authorization lets the main key sign — exactly one */81  approveNonce: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),82  /** SHA-256 of the jurisdiction acknowledgement text that was shown to the owner */83  ackSha256: z.string().regex(/^[0-9a-f]{64}$/),84});85export type VenueAuthorization = z.infer<typeof VenueAuthorizationSchema>;8687export function venueAuthorizationBytes(a: VenueAuthorization): Uint8Array {88  const x = VenueAuthorizationSchema.parse(a), p = x.policy;89  return encode('SPW-VENUE-AUTH-v1', [90    ['v', '1'], ['authorizationId', x.authorizationId], ['keyId', x.keyId], ['venue', x.venue], ['network', x.network], ['account', x.account],91    ['agentAddress', x.agentAddress], ['agentName', x.agentName], ['mode', x.mode],92    ['policy.allowedInstruments', [...p.allowedInstruments].sort().join(',')], ['policy.maxLeverage', String(p.maxLeverage)],93    ['policy.maxOrderNotional', canon(p.maxOrderNotional)], ['policy.maxPositionNotional', canon(p.maxPositionNotional)], ['policy.maxGrossExposure', canon(p.maxGrossExposure)],94    ['policy.maxOrdersPerDay', String(p.maxOrdersPerDay)], ['policy.maxPriceDeviationBps', String(p.maxPriceDeviationBps)],95    ['createdAt', String(x.createdAt)], ['expiresAt', String(x.expiresAt)], ['approveNonce', String(x.approveNonce)], ['ackSha256', x.ackSha256],96  ]);97}98export const venueAuthorizationHash = (a: VenueAuthorization): Uint8Array => sha256(venueAuthorizationBytes(a));99100/** the agent name as the venue receives it: the venue itself drops the API wallet at the authorization's expiry */101export const venueAgentWireName = (a: Pick<VenueAuthorization, 'agentName' | 'expiresAt'>): string => `${a.agentName} valid_until ${a.expiresAt * 1000}`;102103/** Structural validity at approval time. The signer additionally checks the key id, both addresses, the pinned asset table and the acknowledgement hash. */104export function validateVenueAuthorization(a: VenueAuthorization, nowSec: number): void {105  if (a.mode !== 'MANUAL_LIVE') throw new Error('MANDATE_LIVE (agents trading derivatives without a passkey per order) is not part of this delivery: only MANUAL_LIVE can be authorized');106  if (a.account === a.agentAddress) throw new Error('the trading key must differ from the account key');107  if (new Set(a.policy.allowedInstruments).size !== a.policy.allowedInstruments.length) throw new Error('duplicate instrument');108  const order = D.from(a.policy.maxOrderNotional), position = D.from(a.policy.maxPositionNotional), gross = D.from(a.policy.maxGrossExposure);109  if (D.gt(order, position)) throw new Error('order limit above the position limit');110  if (D.gt(position, gross)) throw new Error('position limit above the gross exposure limit');111  if (a.expiresAt <= nowSec) throw new Error('authorization already expired');112  if (a.createdAt > nowSec + 120 || a.createdAt < nowSec - 600) throw new Error('authorization creation time is not current');113  if (a.expiresAt - a.createdAt > VENUE_AUTH_MAX_DAYS * 86400) throw new Error(`an authorization lasts at most ${VENUE_AUTH_MAX_DAYS} days`);114  if (Math.abs(a.approveNonce - a.createdAt * 1000) > 600_000) throw new Error('approval nonce is not current');115  if (String(a.expiresAt * 1000).length !== 13) throw new Error('expiry cannot be expressed to the venue');116}117118// ---------------------------------------------------------------------------------------------------------------------------119// One order — the fields the approval screen shows, in the venue's own normal form (what `describe` reads back from the payload)120121export const VENUE_ORDER_TYPES = ['limit', 'stop_market', 'stop_limit', 'take_profit_market', 'take_profit_limit'] as const;122const head = {123  v: z.literal(1),124  authorizationId: z.uuid(),125  venue: z.literal('hyperliquid'),126  network: z.enum(['mainnet', 'testnet']),127  account: z.string().regex(ADDRESS),128  /** venue nonce (unix ms) and the time after which the venue itself refuses the signed action (unix ms) */129  nonce: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),130  expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),131};132export const VenueOrderSchema = z.strictObject({133  ...head,134  kind: z.literal('order'),135  instrument: z.string().regex(/^hyperliquid:[A-Za-z0-9]{1,15}$/),136  side: OrderSideSchema,137  /** a market order is an immediate-or-cancel LIMIT at its worst acceptable price: it appears here as `limit` + `ioc` */138  type: z.enum(VENUE_ORDER_TYPES),139  size: PosDec,140  /** limit price, or worst acceptable price */141  price: PosDec,142  triggerPrice: PosDec.nullable(),143  reduceOnly: z.boolean(),144  tif: TifSchema,145  /** the venue's client order id (128-bit hex), derived from the Idempotency-Key */146  clientId: z.string().regex(/^0x[0-9a-f]{32}$/),147});148export type VenueOrder = z.infer<typeof VenueOrderSchema>;149150export function venueOrderBytes(o: VenueOrder): Uint8Array {151  const x = VenueOrderSchema.parse(o);152  return encode('SPW-VENUE-ORDER-v1', [153    ['v', '1'], ['authorizationId', x.authorizationId], ['venue', x.venue], ['network', x.network], ['account', x.account], ['kind', x.kind],154    ['instrument', x.instrument], ['side', x.side], ['type', x.type], ['size', canon(x.size)], ['price', canon(x.price)],155    ['triggerPrice', x.triggerPrice === null ? '' : canon(x.triggerPrice)], ['reduceOnly', x.reduceOnly ? '1' : '0'], ['tif', x.tif], ['clientId', x.clientId],156    ['nonce', String(x.nonce)], ['expiresAt', String(x.expiresAt)],157  ]);158}159export const venueOrderHash = (o: VenueOrder): Uint8Array => sha256(venueOrderBytes(o));160161export const VenueLeverageSchema = z.strictObject({162  ...head,163  kind: z.literal('leverage'),164  instrument: z.string().regex(/^hyperliquid:[A-Za-z0-9]{1,15}$/),165  leverage: z.number().int().min(1).max(1000),166  marginMode: z.enum(['cross', 'isolated']),167});168export type VenueLeverage = z.infer<typeof VenueLeverageSchema>;169170export function venueLeverageBytes(l: VenueLeverage): Uint8Array {171  const x = VenueLeverageSchema.parse(l);172  return encode('SPW-VENUE-LEVERAGE-v1', [173    ['v', '1'], ['authorizationId', x.authorizationId], ['venue', x.venue], ['network', x.network], ['account', x.account], ['kind', x.kind],174    ['instrument', x.instrument], ['leverage', String(x.leverage)], ['marginMode', x.marginMode], ['nonce', String(x.nonce)], ['expiresAt', String(x.expiresAt)],175  ]);176}177export const venueLeverageHash = (l: VenueLeverage): Uint8Array => sha256(venueLeverageBytes(l));178179/** validity window shared by orders and leverage changes: the venue expiry is after the nonce and at most two minutes later */180export function venueWindowError(x: { nonce: number; expiresAt: number }, nowMs: number): string | null {181  if (x.expiresAt <= x.nonce) return 'expiry is not after the nonce';182  if (x.expiresAt - x.nonce > VENUE_ACTION_MAX_TTL_MS) return `validity window longer than ${VENUE_ACTION_MAX_TTL_MS / 1000} s`;183  if (x.expiresAt <= nowMs) return 'approval window expired';184  if (x.nonce > nowMs + 60_000) return 'nonce is dated in the future';185  if (x.nonce < nowMs - VENUE_ACTION_MAX_TTL_MS) return 'nonce is stale';186  return null;187}188189/** notional the signer bounds: size × the highest price the order names (limit/worst price, or trigger price when higher) */190export function venueOrderNotional(o: Pick<VenueOrder, 'size' | 'price' | 'triggerPrice'>): DecStr {191  const price = o.triggerPrice === null ? D.from(o.price) : D.max(D.from(o.price), D.from(o.triggerPrice));192  return D.str(D.mul(D.from(o.size), price));193}194195/** |price − mid| / mid ≤ bps / 10000, exactly (cross-multiplied) */196export function withinBand(price: DecStr, mid: DecStr, bps: number): boolean {197  const p = D.from(price), m = D.from(mid);198  if (!D.isPos(p) || !D.isPos(m)) return false;199  return D.lte(D.mul(D.abs(D.sub(p, m)), D.from(10000n)), D.mul(D.from(BigInt(bps)), m));200}201202/** the serialization above, for the other canonical objects of live trading (mandate.ts) */203export const canonicalEncode = encode;204
Files main
Menu

Theme