Repository Public
1// Venue access of the live execution engine: one full Hyperliquid adapter per network (testnet and mainnet are separate2// accounts with separate keys), the trading object bound to the PINNED asset table (the same table the signer uses), and the few3// venue reads the engine needs beyond the adapter's contract (order status by client id, API-wallet role, leverage setting).4//5// The read-only registry of venues.ts is untouched: paper trading and market data never see a trading object.6import { z } from 'zod';7import { createHlTrading, hlPinnedAssets, hlPinnedMismatch, type HlNetwork, type HlTrading } from '@spw/derivatives/live';8import { createHyperliquidAdapter, hlAccount, hlFrontendOrder, hlOrder, parseHl, VenueError, type Book, type HyperliquidAdapter, type Instrument, type Order, type Ticker } from '@spw/derivatives';9import { UserError } from '../config.ts';10import { venueFailure } from './data.ts';1112/** every L1 action carries `expiresAfter = nonce + this`: the venue itself refuses the signature after that time */13export const LIVE_ACTION_TTL_MS = 90_000;14const INSTRUMENTS_TTL_MS = 600_000;1516// answers of the info requests used here — shapes read live on testnet, 2026-11-11 (docs/v4/venues/hyperliquid.md, section 13)17const orderStatusAnswer = z.union([18 z.looseObject({ status: z.literal('unknownOid') }),19 z.looseObject({ status: z.literal('order'), order: z.looseObject({ order: hlFrontendOrder, status: z.string().max(60), statusTimestamp: z.number().int().nonnegative() }) }),20]);21const userRoleAnswer = z.looseObject({ role: z.string().max(40), data: z.looseObject({ user: z.string().optional() }).optional() });22const activeAssetDataAnswer = z.looseObject({ leverage: z.looseObject({ type: z.enum(['cross', 'isolated']), value: z.number().int().min(1).max(1000) }) });2324export interface LiveVenue {25 readonly network: HlNetwork;26 readonly adapter: HyperliquidAdapter;27 /** builder / submitter on the pinned table; `submit` reaches the mainnet exchange endpoint only when the server setting allows it.28 * `ttlMs`: venue expiry of the actions it builds and sends, after their nonce (default LIVE_ACTION_TTL_MS; mandate orders use a shorter one) */29 trading(ttlMs?: number): HlTrading;30 instrument(id: string): Promise<Instrument>;31 /** ticker and book read NOW (never a cached value): the context of a live order */32 market(inst: Instrument): Promise<{ ticker: Ticker; book: Book }>;33 /** refuses to trade when the venue's live `meta` no longer matches the pinned asset table */34 assertPinned(): Promise<void>;35 /** the venue's own record of an order, by client order id (cloid) or order id; null = the venue knows no such order */36 orderStatus(account: string, id: string): Promise<Order | null>;37 /** `agent` + the account it signs for, once an API wallet is approved; `missing` for an address the venue has never seen */38 userRole(address: string): Promise<{ role: string; user: string | null }>;39 /** leverage setting of one instrument on the account (the venue keeps one per asset) */40 leverageOf(account: string, symbol: string): Promise<{ leverage: string; marginMode: 'cross' | 'isolated' }>;41}4243export class LiveVenues {44 private readonly made = new Map<HlNetwork, LiveVenue>();45 private readonly o: { fetch?: typeof fetch; now: () => number; mainnetEnabled: () => boolean };46 constructor(o: { fetch?: typeof fetch; now?: () => number; mainnetEnabled: () => boolean }) { this.o = { ...o, now: o.now ?? Date.now }; }4748 get(network: string): LiveVenue {49 if (network !== 'mainnet' && network !== 'testnet') throw new UserError('bad_request', 'network must be mainnet or testnet', 400);50 let v = this.made.get(network);51 if (!v) { v = this.make(network); this.made.set(network, v); }52 return v;53 }54 close(): void { for (const v of this.made.values()) v.adapter.close(); }5556 private make(network: HlNetwork): LiveVenue {57 const { now, mainnetEnabled } = this.o;58 const adapter = createHyperliquidAdapter({ network, now, ...(this.o.fetch ? { fetch: this.o.fetch } : {}) });59 let instruments: { at: number; list: Instrument[] } | null = null;60 const wrap = async <T>(fn: () => Promise<T>): Promise<T> => { try { return await fn(); } catch (e) { throw venueFailure(e); } };61 return {62 network, adapter,63 trading: (ttlMs = LIVE_ACTION_TTL_MS) => createHlTrading({ network, assets: hlPinnedAssets(network), http: adapter.http, now, expiresAfterMs: ttlMs,64 // two locks on the mainnet exchange endpoint: the adapter's own flag, set only from the server setting65 allowMainnetExchange: network === 'mainnet' && mainnetEnabled() }),66 async instrument(id) {67 if (!instruments || now() - instruments.at > INSTRUMENTS_TTL_MS) instruments = { at: now(), list: await wrap(() => adapter.instruments()) };68 const inst = instruments.list.find((i) => i.id === id);69 if (!inst) throw new UserError('unknown_instrument', `unknown instrument '${String(id).slice(0, 80)}' on Hyperliquid ${network}`, 404);70 return inst;71 },72 async market(inst) {73 const [tickers, book] = await wrap(() => Promise.all([adapter.tickers(), adapter.book(inst.symbol)]));74 const ticker = tickers.find((t) => t.instrument === inst.id);75 if (!ticker) throw new UserError('venue_unavailable', `Hyperliquid publishes no ticker for ${inst.symbol}`, 503);76 return { ticker, book };77 },78 async assertPinned() {79 const why = hlPinnedMismatch(network, await wrap(() => adapter.assets()));80 if (why) throw new UserError('asset_table_mismatch', `live trading is suspended: the venue's asset table no longer matches the table pinned in the wallet and in the signer (${why}). Nothing was signed.`, 503);81 },82 async orderStatus(account, id) {83 const oid = /^0x[0-9a-f]{32}$/.test(id) ? id : /^(0|[1-9]\d{0,15})$/.test(id) ? Number(id) : null;84 if (oid === null) throw new VenueError('unsupported', 'hyperliquid: order id must be a client order id or a numeric order id', { venue: 'hyperliquid' });85 return wrap(async () => {86 const a = parseHl(orderStatusAnswer, await adapter.http.info({ type: 'orderStatus', user: hlAccount(account), oid }), 'orderStatus');87 return a.status === 'unknownOid' ? null : hlOrder(a.order.order, a.order.status, a.order.statusTimestamp);88 });89 },90 async userRole(address) {91 return wrap(async () => {92 const a = parseHl(userRoleAnswer, await adapter.http.info({ type: 'userRole', user: hlAccount(address) }), 'userRole');93 return { role: a.role, user: a.data?.user ? a.data.user.toLowerCase() : null };94 });95 },96 async leverageOf(account, symbol) {97 return wrap(async () => {98 const a = parseHl(activeAssetDataAnswer, await adapter.http.info({ type: 'activeAssetData', user: hlAccount(account), coin: symbol }), 'activeAssetData');99 return { leverage: String(a.leverage.value), marginMode: a.leverage.type };100 });101 },102 };103 }104}105