SPB Git

Repository Public

trading.ts 26.2 KB 349 lines typescript

1// Hyperliquid trading: builds actions, re-derives what a sign request does (`describe`), and submits signed actions.2// It never holds a key. Action shapes and field ORDER follow the official Python SDK (hyperliquid/utils/signing.py and3// hyperliquid/exchange.py, master 2fdb18f) and https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint4import { keccak_256 } from '@noble/hashes/sha3.js';5import { z } from 'zod';6import type { DecStr } from '../../decimal.ts';7import { OrderRequestSchema, type Instrument, type OrderRequest } from '../../models.ts';8import { type SignRequest, type VenueTrading } from '../../venue.ts';9import { VenueError } from './error.ts';10import { hlFormatPrice, hlFormatSize, hlStep } from './format.ts';11import type { HlHttp } from './http.ts';12import { hlExchangeAnswer } from './schemas.ts';13import { buildApproveAgent, hlChainName, l1SignPayload, normalizeSignature, type HlApproveAgentAction, type HlNetwork, type HlSignature, type HlTypedData } from './signing.ts';1415const ID = 'hyperliquid' as const;16const err = (code: ConstructorParameters<typeof VenueError>[1], message: string, detail: Record<string, unknown> = {}) => new VenueError(ID, code, message, detail);17const reject = (message: string, detail: Record<string, unknown> = {}) => err('rejected', message, detail);1819/** one row of the venue's asset table (`meta.universe`): `asset` is the integer used in actions — it differs between networks */20export interface HlAsset { symbol: string; asset: number; szDecimals: number; maxLeverage: number; isolatedOnly: boolean; delisted: boolean }2122// --- wire types ----------------------------------------------------------------------------------------------------------------23export type HlTif = 'Gtc' | 'Ioc' | 'Alo';24export type HlOrderTypeWire = { limit: { tif: HlTif } } | { trigger: { isMarket: boolean; triggerPx: string; tpsl: 'tp' | 'sl' } };25/** key order is the SDK's `order_request_to_order_wire`: a, b, p, s, r, t, then c when present */26export interface HlOrderWire { a: number; b: boolean; p: string; s: string; r: boolean; t: HlOrderTypeWire; c?: string }27export interface HlOrderAction { type: 'order'; orders: HlOrderWire[]; grouping: 'na' }28export interface HlCancelAction { type: 'cancel'; cancels: { a: number; o: number }[] }29export interface HlCancelByCloidAction { type: 'cancelByCloid'; cancels: { asset: number; cloid: string }[] }30export interface HlUpdateLeverageAction { type: 'updateLeverage'; asset: number; isCross: boolean; leverage: number }31export type HlL1Action = HlOrderAction | HlCancelAction | HlCancelByCloidAction | HlUpdateLeverageAction;3233/** SignRequest.payload for kind 'hl_l1': everything needed to recompute the hash, plus the typed data that the key signs */34export interface HlL1SignPayload { scheme: 'l1'; action: unknown; nonce: number; vaultAddress: string | null; expiresAfter: number | null; typedData: HlTypedData }35/** SignRequest.payload for kind 'hl_user' (signed by the ACCOUNT key, e.g. approveAgent) */36export interface HlUserSignPayload { scheme: 'user'; action: unknown; typedData: HlTypedData }37export const HL_KIND_L1 = 'hl_l1', HL_KIND_USER = 'hl_user';3839// --- client order id -----------------------------------------------------------------------------------------------------------40const CLOID = /^0x[0-9a-f]{32}$/;41/**42 * "Client Order ID (cloid) is an optional 128 bit hex string". Derived deterministically from the request's `clientId`:43 * the first 16 bytes of keccak256("spw/hyperliquid/cloid/v1:" ‖ clientId). A `clientId` that already is a cloid44 * (0x + 32 lower-case hex digits, as read back from the venue) is used unchanged.45 */46export function hlCloid(clientId: string): string {47  if (typeof clientId !== 'string' || clientId.length < 1 || clientId.length > 128) throw reject('clientId must be 1–128 characters');48  if (CLOID.test(clientId)) return clientId;49  const h = keccak_256(new TextEncoder().encode(`spw/hyperliquid/cloid/v1:${clientId}`));50  return `0x${Array.from(h.subarray(0, 16), (x) => x.toString(16).padStart(2, '0')).join('')}`;51}5253// --- nonces ----------------------------------------------------------------------------------------------------------------------54// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets55//   "the 100 highest nonces are stored per address. Every new transaction must have nonce larger than the smallest nonce in this56//    set and also never have been used before."   "Nonces must be within (T - 2 days, T + 1 day)"   nonces are tracked per SIGNER.57export const HL_NONCE_PAST_MS = 2 * 86_400_000, HL_NONCE_FUTURE_MS = 86_400_000;58export function hlNonceInWindow(nonce: number, now: number): boolean {59  return Number.isSafeInteger(nonce) && nonce > now - HL_NONCE_PAST_MS && nonce < now + HL_NONCE_FUTURE_MS;60}61/** "fetch and increment an atomic counter … fast-forwarded to current unix milliseconds": unique, increasing, one per signer key */62export function createHlNonceSource(now: () => number = Date.now, last = 0): { next(): number } {63  let prev = last;64  return { next() { prev = Math.max(now(), prev + 1); return prev; } };65}6667// --- strict schemas of what `describe` accepts (any extra field is a refusal) ------------------------------------------------------68const uint = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);69const wireDec = z.string().regex(/^(0|[1-9]\d*)(\.\d*[1-9])?$/); // no trailing zeros: exactly what is hashed70const orderWire = z.strictObject({71  a: uint, b: z.boolean(), p: wireDec, s: wireDec, r: z.boolean(),72  t: z.union([73    z.strictObject({ limit: z.strictObject({ tif: z.enum(['Gtc', 'Ioc', 'Alo']) }) }),74    z.strictObject({ trigger: z.strictObject({ isMarket: z.boolean(), triggerPx: wireDec, tpsl: z.enum(['tp', 'sl']) }) }),75  ]),76  c: z.string().regex(CLOID).optional(),77});78const orderAction = z.strictObject({ type: z.literal('order'), orders: z.array(orderWire).min(1).max(40), grouping: z.literal('na') });79const cancelAction = z.strictObject({ type: z.literal('cancel'), cancels: z.array(z.strictObject({ a: uint, o: uint })).min(1).max(40) });80const cancelByCloidAction = z.strictObject({ type: z.literal('cancelByCloid'), cancels: z.array(z.strictObject({ asset: uint, cloid: z.string().regex(CLOID) })).min(1).max(40) });81const updateLeverageAction = z.strictObject({ type: z.literal('updateLeverage'), asset: uint, isCross: z.boolean(), leverage: z.number().int().min(1).max(1000) });82const approveAgentAction = z.strictObject({83  type: z.literal('approveAgent'), hyperliquidChain: z.enum(['Mainnet', 'Testnet']), signatureChainId: z.string().regex(/^0x[0-9a-f]{1,12}$/),84  agentAddress: z.string().regex(/^0x[0-9a-f]{40}$/), agentName: z.string().min(1).max(60).optional(), nonce: uint,85});86const typedData = z.strictObject({87  domain: z.strictObject({ name: z.string(), version: z.string(), chainId: z.number().int(), verifyingContract: z.string() }),88  types: z.record(z.string(), z.array(z.strictObject({ name: z.string(), type: z.string() }))),89  primaryType: z.string(), message: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),90});91const l1Payload = z.strictObject({ scheme: z.literal('l1'), action: z.looseObject({ type: z.string() }), nonce: uint, vaultAddress: z.string().regex(/^0x[0-9a-f]{40}$/).nullable(), expiresAfter: uint.nullable(), typedData });92const userPayload = z.strictObject({ scheme: z.literal('user'), action: z.looseObject({ type: z.string() }), typedData });9394/** canonical JSON (sorted keys) — used to compare typed data structurally */95const canon = (v: unknown): string => JSON.stringify(v, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, (x as Record<string, unknown>)[k]])) : x));96const sameKeys = (o: object, keys: string[]): boolean => { const k = Object.keys(o); return k.length === keys.length && k.every((x, i) => x === keys[i]); };9798// --- options -------------------------------------------------------------------------------------------------------------------99export interface HlTradingOptions {100  network: HlNetwork;101  /** the venue's asset table for THIS network (meta.universe). `describe` trusts it to name instruments: it must come from the venue. */102  assets: HlAsset[];103  /** needed by `submit` only */104  http?: HlHttp;105  now?: () => number;106  /** sub-account or vault traded on behalf of; null = the account itself (the only mode used by the wallet) */107  vaultAddress?: string | null;108  /** when set, every L1 action carries `expiresAfter = nonce + expiresAfterMs` (the venue refuses it after that time) */109  expiresAfterMs?: number | null;110  /** `submit` refuses the mainnet exchange endpoint unless this is true (live trading is enabled by the owner, not by the build) */111  allowMainnetExchange?: boolean;112}113114export interface HlDescription { kind: 'order' | 'cancel' | 'leverage' | 'approve_agent' | 'other'; orders?: OrderRequest[]; detail: Record<string, unknown> }115116export interface HlTrading extends VenueTrading {117  buildOrder(req: OrderRequest, ctx: { instrument: Instrument; nonce: number }): { action: HlOrderAction; sign: SignRequest };118  buildCancel(o: { instrument: Instrument; venueOrderId?: string; clientId?: string; nonce: number }): { action: HlCancelAction | HlCancelByCloidAction; sign: SignRequest };119  /** set the leverage and the margin mode of one instrument (L1 action, signed by the API wallet) */120  buildUpdateLeverage(o: { instrument: Instrument; leverage: number; marginMode: 'cross' | 'isolated'; nonce: number }): { action: HlUpdateLeverageAction; sign: SignRequest };121  /** approveAgent: authorizes `agentAddress` as API wallet. The sign request is for the ACCOUNT key (kind 'hl_user'). */122  buildApproveAgent(o: { agentAddress: string; agentName: string | null; nonce: number; signatureChainId?: string }): { action: HlApproveAgentAction; sign: SignRequest };123  describe(sign: SignRequest): HlDescription;124  /** what `describe` returns for an order built from `req` (market → IOC limit, wire decimals, clientId → cloid) */125  normalizeRequest(req: OrderRequest, instrument: Instrument): OrderRequest;126}127128export function createHlTrading(o: HlTradingOptions): HlTrading {129  const network = o.network, now = o.now ?? Date.now, vaultAddress = o.vaultAddress ? o.vaultAddress.toLowerCase() : null, expiresAfterMs = o.expiresAfterMs ?? null;130  if (vaultAddress !== null && !/^0x[0-9a-f]{40}$/.test(vaultAddress)) throw new Error('vaultAddress must be a 20-byte hex address');131  if (expiresAfterMs !== null && (!Number.isInteger(expiresAfterMs) || expiresAfterMs < 1000)) throw new Error('expiresAfterMs must be an integer of at least 1000');132  const bySymbol = new Map<string, HlAsset>(), byAsset = new Map<number, HlAsset>();133  for (const a of o.assets) {134    if (bySymbol.has(a.symbol) || byAsset.has(a.asset)) throw new Error(`asset table: duplicate entry ${a.symbol}/${a.asset}`);135    bySymbol.set(a.symbol, a); byAsset.set(a.asset, a);136  }137138  const assetOf = (instrument: Instrument): HlAsset => {139    if (instrument.venue !== ID || instrument.id !== `${ID}:${instrument.symbol}`) throw reject('instrument is not a Hyperliquid instrument');140    const a = bySymbol.get(instrument.symbol);141    if (!a) throw reject(`instrument ${instrument.symbol} is not in the ${network} asset table`);142    if (instrument.lotSize !== hlStep(a.szDecimals)) throw reject(`instrument ${instrument.symbol}: lot size differs from the asset table (stale instrument?)`);143    return a;144  };145  const nonceOk = (nonce: number): number => { if (!Number.isSafeInteger(nonce) || nonce <= 0) throw reject('nonce must be a positive integer (unix milliseconds)'); return nonce; };146  const price = (x: DecStr | null, a: HlAsset, what: string): string => {147    if (x === null) throw reject(`${what} is required`);148    const r = hlFormatPrice(x, a.szDecimals);149    if (!r.ok) throw reject(`${what}: ${r.reason}`, { offTick: true });150    return r.wire;151  };152  const size = (x: DecStr, a: HlAsset): string => {153    const r = hlFormatSize(x, a.szDecimals);154    if (!r.ok) throw reject(r.reason, { offLot: true });155    return r.wire;156  };157  const l1 = <A extends HlL1Action>(action: A, nonce: number): { action: A; sign: SignRequest } => {158    const expiresAfter = expiresAfterMs === null ? null : nonce + expiresAfterMs;159    const { typedData: td } = l1SignPayload(action as never, { network, nonce, vaultAddress, expiresAfter });160    const payload: HlL1SignPayload = { scheme: 'l1', action, nonce, vaultAddress, expiresAfter, typedData: td };161    return { action, sign: { venue: ID, network, kind: HL_KIND_L1, payload } };162  };163164  /** request → wire order (validated, never rounded) */165  const toWire = (input: OrderRequest, instrument: Instrument): HlOrderWire => {166    const parsed = OrderRequestSchema.safeParse(input);167    if (!parsed.success) throw reject(`malformed order request (${parsed.error.issues[0]?.path.join('.')} ${parsed.error.issues[0]?.message})`);168    const req = parsed.data, a = assetOf(instrument);169    if (req.instrument !== instrument.id) throw reject('order request and instrument differ');170    if (a.delisted || instrument.status === 'halted') throw reject(`${a.symbol} is delisted`);171    const s = size(req.size, a), p = price(req.price, a, req.type === 'market' ? 'price (worst acceptable price of a market order)' : 'price');172    let t: HlOrderTypeWire;173    if (req.type === 'limit' || req.type === 'market') {174      if (req.triggerPrice !== null) throw reject(`${req.type} orders take no trigger price`);175      // a market order is an aggressive limit: immediate-or-cancel, bounded by the request's worst price176      if (req.type === 'market' && req.tif !== 'ioc') throw reject('market orders are immediate-or-cancel: tif must be ioc');177      t = { limit: { tif: req.type === 'market' ? 'Ioc' : req.tif === 'gtc' ? 'Gtc' : req.tif === 'ioc' ? 'Ioc' : 'Alo' } };178    } else {179      if (req.tif !== 'gtc') throw reject('trigger orders have no time in force on Hyperliquid: tif must be gtc');180      const triggerPx = price(req.triggerPrice, a, 'triggerPrice');181      const isMarket = req.type === 'stop_market' || req.type === 'take_profit_market';182      t = { trigger: { isMarket, triggerPx, tpsl: req.type === 'stop_market' || req.type === 'stop_limit' ? 'sl' : 'tp' } };183    }184    return { a: a.asset, b: req.side === 'buy', p, s, r: req.reduceOnly, t, c: hlCloid(req.clientId) };185  };186187  /** wire order → normalized request; refuses anything the builder could not have produced */188  const fromWire = (w: z.infer<typeof orderWire>): OrderRequest => {189    const a = byAsset.get(w.a);190    if (!a) throw reject(`order on asset ${w.a}, which is not in the ${network} asset table`);191    const keys = ['a', 'b', 'p', 's', 'r', 't', ...(w.c !== undefined ? ['c'] : [])];192    if (!sameKeys(w, keys)) throw reject('order fields are not in the canonical order');193    const p = hlFormatPrice(w.p, a.szDecimals), s = hlFormatSize(w.s, a.szDecimals);194    if (!p.ok || p.wire !== w.p) throw reject(`order price ${w.p} is not valid for ${a.symbol}`);195    if (!s.ok || s.wire !== w.s) throw reject(`order size ${w.s} is not valid for ${a.symbol}`);196    const base = { instrument: `${ID}:${a.symbol}`, side: w.b ? ('buy' as const) : ('sell' as const), size: w.s, price: w.p, reduceOnly: w.r, clientId: w.c ?? '' };197    if ('limit' in w.t) {198        return { ...base, type: 'limit', triggerPrice: null, tif: w.t.limit.tif === 'Gtc' ? 'gtc' : w.t.limit.tif === 'Ioc' ? 'ioc' : 'alo' };199    }200    const g = w.t.trigger;201    if (!sameKeys(g, ['isMarket', 'triggerPx', 'tpsl'])) throw reject('trigger fields are not in the canonical order');202    const tp = hlFormatPrice(g.triggerPx, a.szDecimals);203    if (!tp.ok || tp.wire !== g.triggerPx) throw reject(`trigger price ${g.triggerPx} is not valid for ${a.symbol}`);204    const type = g.tpsl === 'sl' ? (g.isMarket ? 'stop_market' : 'stop_limit') : g.isMarket ? 'take_profit_market' : 'take_profit_limit';205    return { ...base, type, triggerPrice: g.triggerPx, tif: 'gtc' };206  };207208  const strict = <S extends z.ZodType>(schema: S, v: unknown, what: string): z.output<S> => {209    const r = schema.safeParse(v);210    if (!r.success) throw reject(`${what}: ${r.error.issues.slice(0, 2).map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`.slice(0, 240));211    return r.data;212  };213214  const describeL1 = (payload: unknown): HlDescription => {215    const p = strict(l1Payload, payload, 'sign payload');216    // zod rebuilds objects in schema order; the hash and the field-order checks must see the action AS GIVEN217    const raw = (payload as { action: Record<string, unknown> }).action;218    // 1. the typed data must be exactly the phantom agent of THIS action, nonce, vault and expiry, on THIS network219    let expected: HlTypedData;220    try { expected = l1SignPayload(raw as never, { network, nonce: p.nonce, vaultAddress: p.vaultAddress, expiresAfter: p.expiresAfter }).typedData; }221    catch (e) { throw reject(`action cannot be hashed (${(e as Error).message})`); }222    if (canon(expected) !== canon(p.typedData)) throw reject('typed data does not match the action (hash, network or domain differ)');223    const common = { nonce: p.nonce, vaultAddress: p.vaultAddress, expiresAfter: p.expiresAfter, connectionId: expected.message.connectionId };224    const list = (key: string): Record<string, unknown>[] => raw[key] as Record<string, unknown>[];225    // 2. what the action does226    switch (raw.type) {227      case 'order': {228        strict(orderAction, raw, 'order action');229        if (!sameKeys(raw, ['type', 'orders', 'grouping'])) throw reject('order action fields are not in the canonical order');230        return { kind: 'order', orders: list('orders').map((w) => fromWire(w as z.infer<typeof orderWire>)), detail: { ...common, grouping: 'na' } };231      }232      case 'cancel': {233        const a = strict(cancelAction, raw, 'cancel action');234        if (!sameKeys(raw, ['type', 'cancels']) || list('cancels').some((c) => !sameKeys(c, ['a', 'o']))) throw reject('cancel action fields are not in the canonical order');235        return { kind: 'cancel', detail: { ...common, cancels: a.cancels.map((c) => { const x = byAsset.get(c.a); if (!x) throw reject(`cancel on unknown asset ${c.a}`); return { instrument: `${ID}:${x.symbol}`, venueOrderId: String(c.o) }; }) } };236      }237      case 'cancelByCloid': {238        const a = strict(cancelByCloidAction, raw, 'cancelByCloid action');239        if (!sameKeys(raw, ['type', 'cancels']) || list('cancels').some((c) => !sameKeys(c, ['asset', 'cloid']))) throw reject('cancelByCloid action fields are not in the canonical order');240        return { kind: 'cancel', detail: { ...common, cancels: a.cancels.map((c) => { const x = byAsset.get(c.asset); if (!x) throw reject(`cancel on unknown asset ${c.asset}`); return { instrument: `${ID}:${x.symbol}`, clientId: c.cloid }; }) } };241      }242      case 'updateLeverage': {243        const a = strict(updateLeverageAction, raw, 'updateLeverage action');244        if (!sameKeys(raw, ['type', 'asset', 'isCross', 'leverage'])) throw reject('updateLeverage action fields are not in the canonical order');245        const x = byAsset.get(a.asset);246        if (!x) throw reject(`leverage change on unknown asset ${a.asset}`);247        if (a.leverage > x.maxLeverage) throw reject(`leverage ${a.leverage} above the maximum of ${x.symbol} (${x.maxLeverage})`);248        if (a.isCross && x.isolatedOnly) throw reject(`${x.symbol} is isolated-only`);249        return { kind: 'leverage', detail: { ...common, instrument: `${ID}:${x.symbol}`, leverage: String(a.leverage), marginMode: a.isCross ? 'cross' : 'isolated' } };250      }251      default:252        // a valid hash of something this module does not build (transfer between dexes, staking, sub-account, vault …): never sign253        return { kind: 'other', detail: { ...common, type: String(raw.type).slice(0, 60) } };254    }255  };256257  const describeUser = (payload: unknown): HlDescription => {258    const p = strict(userPayload, payload, 'sign payload');259    if (p.action.type !== 'approveAgent') return { kind: 'other', detail: { type: p.action.type.slice(0, 60), primaryType: p.typedData.primaryType.slice(0, 80) } };260    const a = strict(approveAgentAction, p.action, 'approveAgent action');261    if (a.hyperliquidChain !== hlChainName(network)) throw reject(`approveAgent is for ${a.hyperliquidChain}, this is ${network}`);262    let expected: HlTypedData;263    try { expected = buildApproveAgent({ network, agentAddress: a.agentAddress, agentName: a.agentName ?? null, nonce: a.nonce, signatureChainId: a.signatureChainId }).typedData; }264    catch (e) { throw reject(`approveAgent: ${(e as Error).message}`); }265    if (canon(expected) !== canon(p.typedData)) throw reject('typed data does not match the approveAgent action');266    const m = /^(.*?)(?: valid_until (\d{13}))?$/.exec(a.agentName ?? '')!;267    return { kind: 'approve_agent', detail: { agentAddress: a.agentAddress, agentName: a.agentName === undefined ? null : m[1], validUntil: m[2] ? Number(m[2]) : null, nonce: a.nonce, signatureChainId: a.signatureChainId, hyperliquidChain: a.hyperliquidChain } };268  };269270  return {271    buildOrder(req, ctx) {272      return l1({ type: 'order', orders: [toWire(req, ctx.instrument)], grouping: 'na' }, nonceOk(ctx.nonce));273    },274275    buildCancel(c) {276      const a = assetOf(c.instrument), nonce = nonceOk(c.nonce);277      if ((c.venueOrderId === undefined) === (c.clientId === undefined)) throw reject('cancel needs exactly one of venueOrderId and clientId');278      if (c.venueOrderId !== undefined) {279        if (!/^(0|[1-9]\d{0,15})$/.test(c.venueOrderId) || !Number.isSafeInteger(Number(c.venueOrderId))) throw reject('venueOrderId must be the venue\'s numeric order id');280        return l1({ type: 'cancel', cancels: [{ a: a.asset, o: Number(c.venueOrderId) }] }, nonce);281      }282      return l1({ type: 'cancelByCloid', cancels: [{ asset: a.asset, cloid: hlCloid(c.clientId!) }] }, nonce);283    },284285    buildUpdateLeverage(u) {286      const a = assetOf(u.instrument), nonce = nonceOk(u.nonce);287      if (!Number.isInteger(u.leverage) || u.leverage < 1 || u.leverage > a.maxLeverage) throw reject(`leverage must be an integer between 1 and ${a.maxLeverage} for ${a.symbol}`);288      if (u.marginMode !== 'cross' && u.marginMode !== 'isolated') throw reject('marginMode must be cross or isolated');289      if (u.marginMode === 'cross' && a.isolatedOnly) throw reject(`${a.symbol} is isolated-only`);290      return l1({ type: 'updateLeverage', asset: a.asset, isCross: u.marginMode === 'cross', leverage: u.leverage }, nonce);291    },292293    buildApproveAgent(g) {294      let built: ReturnType<typeof buildApproveAgent>;295      try { built = buildApproveAgent({ network, agentAddress: g.agentAddress, agentName: g.agentName, nonce: nonceOk(g.nonce), signatureChainId: g.signatureChainId }); }296      catch (e) { if (e instanceof VenueError) throw e; throw reject(`approveAgent: ${(e as Error).message}`); }297      const payload: HlUserSignPayload = { scheme: 'user', action: built.action, typedData: built.typedData };298      return { action: built.action, sign: { venue: ID, network, kind: HL_KIND_USER, payload } };299    },300301    normalizeRequest(req, instrument) {302      return fromWire(toWire(req, instrument) as z.infer<typeof orderWire>);303    },304305    describe(sign) {306      if (!sign || sign.venue !== ID) throw reject('sign request is not for Hyperliquid');307      if (sign.network !== network) throw reject(`sign request is for ${String(sign.network)}, this is ${network}`);308      if (sign.kind === HL_KIND_L1) return describeL1(sign.payload);309      if (sign.kind === HL_KIND_USER) return describeUser(sign.payload);310      throw reject(`unknown sign request kind '${String(sign.kind).slice(0, 40)}'`);311    },312313    async submit(action, signature, nonce) {314      if (!o.http) throw err('unsupported', 'submit needs an http client');315      if (o.http.network !== network) throw err('unsupported', 'http client is for another network');316      if (network === 'mainnet' && o.allowMainnetExchange !== true) throw err('unsupported', 'the mainnet exchange endpoint is disabled (allowMainnetExchange is not set)');317      const type = (action as { type?: unknown } | null)?.type;318      let batch = 1, isUser = false;319      if (type === 'order') batch = strict(orderAction, action, 'order action').orders.length;320      else if (type === 'cancel') batch = strict(cancelAction, action, 'cancel action').cancels.length;321      else if (type === 'cancelByCloid') batch = strict(cancelByCloidAction, action, 'cancelByCloid action').cancels.length;322      else if (type === 'updateLeverage') strict(updateLeverageAction, action, 'updateLeverage action');323      else if (type === 'approveAgent') { isUser = true; if (strict(approveAgentAction, action, 'approveAgent action').nonce !== nonce) throw reject('approveAgent: the nonce of the action and of the request differ'); }324      else throw err('unsupported', `action type '${String(type).slice(0, 40)}' is not submitted by this adapter`);325      if (!hlNonceInWindow(nonce, now())) throw reject('nonce is outside the venue\'s window (T − 2 days, T + 1 day)');326      let sig: HlSignature;327      try { sig = normalizeSignature(signature); } catch (e) { throw reject((e as Error).message); }328      // body as the SDK's `_post_action`; user-signed actions carry neither vault nor expiry329      const body = { action, nonce, signature: sig, vaultAddress: isUser ? null : vaultAddress, expiresAfter: isUser || expiresAfterMs === null ? null : nonce + expiresAfterMs };330      const raw = await o.http.exchange(body, batch);331      const parsed = hlExchangeAnswer.safeParse(raw);332      // an answer we cannot read is NOT a rejection: the order may exist. The caller must reconcile.333      if (!parsed.success) throw err('bad_answer', 'exchange: unexpected answer; the outcome is unknown until reconciled', { raw });334      const ans = parsed.data;335      if (ans.status === 'err') throw reject(ans.response.slice(0, 300), { raw });336      const ids: string[] = [], errors: string[] = [];337      for (const s of ans.response.data?.statuses ?? []) {338        if (typeof s === 'string') continue; // "success" (cancel), "waitingForFill" / "waitingForTrigger" (no order id yet)339        const x = s as { error?: string; resting?: { oid: number }; filled?: { oid: number } };340        if (typeof x.error === 'string') errors.push(x.error);341        else if (x.resting) ids.push(String(x.resting.oid));342        else if (x.filled) ids.push(String(x.filled.oid));343      }344      if (errors.length) throw reject(errors.join(' | ').slice(0, 300), { raw, venueOrderIds: ids, errors });345      return { accepted: true, venueOrderIds: ids, error: null, raw };346    },347  };348}349
Files main
Menu

Theme