Repository Public
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