SPB Git

Repository Public

live-venue.ts 6.9 KB 105 lines typescript

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
Files main
Menu

Theme