SPB Git

Repository Public

hl-fake.ts 17.9 KB 256 lines typescript

1// Scripted fake of the Hyperliquid exchange + info endpoints, for the live execution tests. No network.2//3// Built from what was recorded and what is documented:4//  - asset tables and market contexts are the recorded fixtures (packages/derivatives/test/fixtures/hyperliquid/<network>-meta.json,5//    -metaAndAssetCtxs.json); the BTC ticker and book are scripted on top;6//  - like the venue, it RECOVERS THE SIGNER from the signature over its own hash of the payload: an action signed by a key that7//    is neither the account nor one of its approved API wallets is answered "User or API Wallet 0x… does not exist."8//    (recorded: testnet-exchange-proof.json), an account that never deposited "Must deposit before performing actions. User: 0x…";9//  - answer shapes: exchange `{status:"ok",response:{type,data:{statuses:[{resting:{oid}}|{filled:{totalSz,avgPx,oid}}|{error}|"success"]}}}`10//    (exchange-endpoint page), `orderStatus` / `userRole` / `activeAssetData` as read live on testnet on 2026-11-11.11// What the real venue does in cases this fake decides by itself (the status of a partly filled IOC order, the wording of an12// expired action) is NOT verified; see docs/v4/venues/hyperliquid.md, section 13.13import { readFileSync } from 'node:fs';14import { D, type DecStr } from '@spw/derivatives';15import { buildApproveAgent, l1SignPayload, recoverTypedDataSigner } from '../../../packages/derivatives/src/venues/hyperliquid/signing.ts';1617type Net = 'mainnet' | 'testnet';18const fixture = (net: Net, name: string): any => JSON.parse(readFileSync(new URL(`../../../packages/derivatives/test/fixtures/hyperliquid/${net}-${name}.json`, import.meta.url), 'utf8')).body;19const dec = (x: DecStr) => D.from(x);20const TAKER = '0.00045';2122interface FakeOrder { oid: number; coin: string; side: 'B' | 'A'; limitPx: string; origSz: string; sz: string; cloid: string | null; timestamp: number; status: string; statusTimestamp: number; reduceOnly: boolean; tif: string | null;23  isTrigger: boolean; triggerPx: string; orderType: string }24interface FakeUser { funded: boolean; usdc: DecStr; agents: Map<string, { name: string; validUntil: number | null }>; positions: Map<string, { szi: DecStr; entryPx: DecStr }>; leverage: Map<string, { type: 'cross' | 'isolated'; value: number }>;25  orders: FakeOrder[]; fills: any[]; funding: any[] }26export type ExchangeFault = 'lost_before' | 'lost_after' | 'garbage_after' | 'http502_before' | null;2728export class FakeHl {29  readonly net: Net;30  readonly host: string;31  readonly now: () => number;32  meta: any;33  ctxs: any[];34  /** scripted BTC market */35  mark = '70000'; oracle = '70000';36  bids: [string, string][] = [['69995', '1'], ['69990', '2']];37  asks: [string, string][] = [['70005', '1'], ['70010', '2']];38  users = new Map<string, FakeUser>();39  /** what happens to the NEXT exchange request (then back to normal) */40  fault: ExchangeFault = null;41  /** every request received, in order */42  calls: { path: string; type: string; body: any }[] = [];43  private oid = 1000;44  private tid = 5000;4546  constructor(net: Net, now: () => number) {47    this.net = net; this.now = now; this.host = net === 'mainnet' ? 'api.hyperliquid.xyz' : 'api.hyperliquid-testnet.xyz';48    this.meta = structuredClone(fixture(net, 'meta'));49    this.ctxs = structuredClone(fixture(net, 'metaAndAssetCtxs')[1]);50  }5152  user(address: string): FakeUser {53    const a = address.toLowerCase();54    let u = this.users.get(a);55    if (!u) { u = { funded: false, usdc: '0', agents: new Map(), positions: new Map(), leverage: new Map(), orders: [], fills: [], funding: [] }; this.users.set(a, u); }56    return u;57  }58  /** the account deposited: the venue now knows it */59  fund(address: string, usdc: DecStr): void { const u = this.user(address); u.funded = true; u.usdc = usdc; }60  exchangeCalls(type?: string): number { return this.calls.filter((c) => c.path === '/exchange' && (!type || c.type === type)).length; }61  setMid(mid: number): void { this.mark = String(mid); this.oracle = String(mid); this.bids = [[String(mid - 5), '1'], [String(mid - 10), '2']]; this.asks = [[String(mid + 5), '1'], [String(mid + 10), '2']]; }62  /** an order placed from somewhere else than the wallet (the venue's own interface, another API wallet) */63  foreignOrder(address: string, o: { coin?: string; side?: 'B' | 'A'; px: string; sz: string }): number {64    const oid = ++this.oid;65    this.user(address).orders.push({ oid, coin: o.coin ?? 'BTC', side: o.side ?? 'B', limitPx: o.px, origSz: o.sz, sz: o.sz, cloid: null, timestamp: this.now(), status: 'open', statusTimestamp: this.now(), reduceOnly: false, tif: 'Gtc',66      isTrigger: false, triggerPx: '0.0', orderType: 'Limit' });67    return oid;68  }69  removeOrder(address: string, oid: number): void { const u = this.user(address); u.orders = u.orders.filter((o) => o.oid !== oid); }70  /** the position changed without the wallet (liquidation, a trade from another interface) */71  forcePosition(address: string, coin: string, szi: DecStr, entryPx: DecStr = this.mark): void {72    const u = this.user(address);73    if (D.isZero(dec(szi))) u.positions.delete(coin); else u.positions.set(coin, { szi, entryPx });74  }75  ethMark(): string { return D.str(D.quantize(dec(this.ctxOf('ETH').markPx), dec('0.1'), 'down')); }76  openOrders(address: string): FakeOrder[] { return this.user(address).orders.filter((o) => o.status === 'open'); }7778  private assetIndex(coin: string): number { return this.meta.universe.findIndex((u: any) => u.name === coin); }79  private coinOf(asset: number): string | null { return this.meta.universe[asset]?.name ?? null; }80  private ctxOf(coin: string): any {81    const c = this.ctxs[this.assetIndex(coin)];82    return coin === 'BTC' ? { ...c, markPx: this.mark, oraclePx: this.oracle, midPx: D.str(D.div(D.add(dec(this.bids[0]![0]), dec(this.asks[0]![0])), dec('2'), 1, 'down')) } : c;83  }84  private levOf(u: FakeUser, coin: string) { return u.leverage.get(coin) ?? { type: 'cross' as const, value: 20 }; }85  private owner(signer: string): { address: string; user: FakeUser } | null {86    const direct = this.users.get(signer);87    if (direct?.funded) return { address: signer, user: direct };88    for (const [address, user] of this.users) {89      const ag = user.agents.get(signer);90      if (user.funded && ag && (ag.validUntil === null || ag.validUntil > this.now())) return { address, user };91    }92    return null;93  }9495  // ------------------------------------------------------------------ info9697  private info(b: any): unknown {98    const t = this.now();99    switch (b.type) {100      case 'meta': return this.meta;101      case 'metaAndAssetCtxs': return [this.meta, this.meta.universe.map((u: any) => this.ctxOf(u.name))];102      case 'l2Book': {103        const lv = (rows: [string, string][]) => rows.map(([px, sz]) => ({ px, sz, n: 1 }));104        if (b.coin === 'BTC') return { coin: 'BTC', time: t, levels: [lv(this.bids), lv(this.asks)] };105        // any other coin: one level each side of its recorded mark (nothing is ever matched against it)106        const mark = dec(this.ctxOf(b.coin).markPx), one = D.quantize(D.div(mark, dec('1000'), 6, 'down'), dec('0.1'), 'up');107        return { coin: b.coin, time: t, levels: [lv([[D.str(D.quantize(D.sub(mark, one), dec('0.1'), 'down')), '1']]), lv([[D.str(D.quantize(D.add(mark, one), dec('0.1'), 'up')), '1']])] };108      }109      case 'clearinghouseState': {110        const u = this.user(b.user);111        let upnl = D.zero, used = D.zero, ntl = D.zero;112        const assetPositions = [...u.positions].map(([coin, p]) => {113          const mark = dec(this.ctxOf(coin).markPx), szi = dec(p.szi), lev = this.levOf(u, coin);114          const value = D.mul(D.abs(szi), mark), pnl = D.mul(szi, D.sub(mark, dec(p.entryPx))), margin = D.div(value, dec(String(lev.value)), 6, 'up');115          upnl = D.add(upnl, pnl); used = D.add(used, margin); ntl = D.add(ntl, value);116          return { type: 'oneWay', position: { coin, szi: p.szi, leverage: lev, entryPx: p.entryPx, positionValue: D.str(value), unrealizedPnl: D.str(pnl), returnOnEquity: '0.0', liquidationPx: null, marginUsed: D.str(margin),117            maxLeverage: 40, cumFunding: { allTime: '0.0', sinceOpen: '0.0', sinceChange: '0.0' } } };118        });119        const value = D.add(dec(u.usdc), upnl), summary = { accountValue: D.str(value), totalNtlPos: D.str(ntl), totalRawUsd: u.usdc, totalMarginUsed: D.str(used) };120        const free = D.sub(value, used);121        return { marginSummary: summary, crossMarginSummary: summary, crossMaintenanceMarginUsed: D.str(D.div(used, dec('2'), 6, 'up')), withdrawable: D.isNeg(free) ? '0.0' : D.str(free), assetPositions, time: t };122      }123      case 'frontendOpenOrders': return this.openOrders(b.user).map((o) => this.wireOrder(o));124      case 'userFills': return [...this.user(b.user).fills].reverse();125      case 'userFillsByTime': return this.user(b.user).fills.filter((f) => f.time >= b.startTime);126      case 'userFunding': return this.user(b.user).funding.filter((f) => f.time >= b.startTime);127      case 'orderStatus': {128        const o = this.user(b.user).orders.find((x) => (typeof b.oid === 'number' ? x.oid === b.oid : x.cloid === b.oid));129        return o ? { status: 'order', order: { order: this.wireOrder(o), status: o.status, statusTimestamp: o.statusTimestamp } } : { status: 'unknownOid' };130      }131      case 'userRole': {132        const a = String(b.user).toLowerCase();133        for (const [address, user] of this.users) if (user.agents.has(a)) return { role: 'agent', data: { user: address } };134        return this.users.get(a)?.funded ? { role: 'user' } : { role: 'missing' };135      }136      case 'activeAssetData': { const u = this.user(b.user); return { user: b.user, coin: b.coin, leverage: this.levOf(u, b.coin), maxTradeSzs: ['0.0', '0.0'], availableToTrade: ['0.0', '0.0'], markPx: this.ctxOf(b.coin).markPx }; }137      default: throw new Error(`fake: no info answer for ${b.type}`);138    }139  }140  private wireOrder(o: FakeOrder) {141    return { coin: o.coin, side: o.side, limitPx: o.limitPx, sz: o.sz, oid: o.oid, timestamp: o.timestamp, triggerCondition: 'N/A', isTrigger: o.isTrigger, triggerPx: o.triggerPx, children: [], isPositionTpsl: false,142      reduceOnly: o.reduceOnly, orderType: o.orderType, origSz: o.origSz, tif: o.tif, cloid: o.cloid };143  }144145  // ------------------------------------------------------------------ exchange146147  private fill(u: FakeUser, o: FakeOrder, px: DecStr, sz: DecStr): void {148    const signed = o.side === 'B' ? dec(sz) : D.neg(dec(sz)), cur = u.positions.get(o.coin), before = cur ? dec(cur.szi) : D.zero, after = D.add(before, signed);149    let closedPnl = D.zero, entry = cur ? dec(cur.entryPx) : dec(px);150    const reducing = !D.isZero(before) && D.sign(before) !== D.sign(signed);151    if (reducing) {152      const closed = D.min(D.abs(before), dec(sz));153      closedPnl = D.mul(D.mul(closed, D.sub(dec(px), entry)), D.isPos(before) ? D.one : D.neg(D.one));154      if (D.sign(after) !== D.sign(before) && !D.isZero(after)) entry = dec(px);155    } else entry = D.isZero(before) ? dec(px) : D.div(D.add(D.mul(D.abs(before), entry), D.mul(dec(sz), dec(px))), D.abs(after), 6, 'half_even');156    if (D.isZero(after)) u.positions.delete(o.coin); else u.positions.set(o.coin, { szi: D.str(after), entryPx: D.str(entry) });157    const fee = D.div(D.mul(D.mul(dec(sz), dec(px)), dec(TAKER)), D.one, 6, 'up');158    u.usdc = D.str(D.sub(D.add(dec(u.usdc), closedPnl), fee));159    const tid = ++this.tid;160    u.fills.push({ coin: o.coin, px, sz, side: o.side, time: this.now(), startPosition: D.str(before), dir: reducing ? 'Close' : 'Open', closedPnl: D.str(closedPnl), hash: `0x${tid.toString(16).padStart(64, '0')}`, oid: o.oid, crossed: true,161      fee: D.str(fee), tid, cloid: o.cloid, feeToken: 'USDC', twapId: null });162    o.sz = D.str(D.sub(dec(o.sz), dec(sz)));163  }164165  private place(u: FakeUser, w: any): unknown {166    const coin = this.coinOf(w.a);167    if (coin !== 'BTC') return { error: `fake: only BTC has a book (asset ${w.a})` };168    if (!w.r && D.lt(D.mul(dec(w.p), dec(w.s)), dec('10'))) return { error: 'Order must have minimum value of $10.' };169    if (w.c && u.orders.some((o) => o.cloid === w.c)) return { error: 'fake: duplicate cloid' };170    const t = this.now(), buy = w.b === true, trigger = 'trigger' in w.t, tif = trigger ? null : w.t.limit.tif;171    const o: FakeOrder = { oid: ++this.oid, coin, side: buy ? 'B' : 'A', limitPx: w.p, origSz: w.s, sz: w.s, cloid: w.c ?? null, timestamp: t, status: 'open', statusTimestamp: t, reduceOnly: w.r, tif,172      isTrigger: trigger, triggerPx: trigger ? w.t.trigger.triggerPx : '0.0', orderType: trigger ? (w.t.trigger.tpsl === 'sl' ? (w.t.trigger.isMarket ? 'Stop Market' : 'Stop Limit') : w.t.trigger.isMarket ? 'Take Profit Market' : 'Take Profit Limit') : 'Limit' };173    if (trigger) { u.orders.push(o); return 'waitingForTrigger'; }174    const book = buy ? this.asks : this.bids, crosses = (px: string) => (buy ? D.lte(dec(px), dec(w.p)) : D.gte(dec(px), dec(w.p)));175    if (tif === 'Alo') {176      if (book[0] && crosses(book[0][0])) return { error: `Post only order would have immediately matched, bbo was ${this.bids[0]![0]}@${this.asks[0]![0]}.` };177      u.orders.push(o); return { resting: { oid: o.oid, ...(o.cloid ? { cloid: o.cloid } : {}) } };178    }179    let cost = D.zero, got = D.zero;180    for (const level of book) {181      if (!crosses(level[0]) || D.isZero(dec(o.sz))) break;182      const take = D.min(dec(o.sz), dec(level[1]));183      if (D.isZero(take)) continue;184      this.fill(u, o, level[0], D.str(take));185      level[1] = D.str(D.sub(dec(level[1]), take));186      cost = D.add(cost, D.mul(take, dec(level[0]))); got = D.add(got, take);187    }188    if (D.isZero(got)) {189      if (tif === 'Ioc') return { error: `Order could not immediately match against any resting orders. asset=${w.a}` };190      u.orders.push(o); return { resting: { oid: o.oid, ...(o.cloid ? { cloid: o.cloid } : {}) } };191    }192    u.orders.push(o);193    const whole = D.isZero(dec(o.sz));194    if (whole) o.status = 'filled'; else if (tif === 'Ioc') o.status = 'canceled';195    o.statusTimestamp = t;196    if (!whole && tif === 'Gtc') return { resting: { oid: o.oid, ...(o.cloid ? { cloid: o.cloid } : {}) } };197    return { filled: { totalSz: D.str(got), avgPx: D.str(D.div(cost, got, 6, 'half_even')), oid: o.oid, ...(o.cloid ? { cloid: o.cloid } : {}) } };198  }199200  private async exchange(b: any): Promise<unknown> {201    const a = b.action, err = (response: string) => ({ status: 'err', response });202    if (a.type === 'approveAgent') {203      const td = buildApproveAgent({ network: this.net, agentAddress: a.agentAddress, agentName: a.agentName ?? null, nonce: a.nonce, signatureChainId: a.signatureChainId }).typedData;204      const signer = await recoverTypedDataSigner(td, b.signature), u = this.users.get(signer);205      if (!u?.funded) return err(`Must deposit before performing actions. User: ${signer}`);206      const m = /^(.*?)(?: valid_until (\d{13}))?$/.exec(a.agentName ?? '')!;207      for (const [addr, ag] of u.agents) if (ag.name === m[1]) u.agents.delete(addr);          // same name: the previous API wallet is replaced208      u.agents.set(a.agentAddress, { name: m[1]!, validUntil: m[2] ? Number(m[2]) : null });209      return { status: 'ok', response: { type: 'default' } };210    }211    const td = l1SignPayload(a, { network: this.net, nonce: b.nonce, vaultAddress: b.vaultAddress ?? null, expiresAfter: b.expiresAfter ?? null }).typedData;212    const signer = await recoverTypedDataSigner(td, b.signature), own = this.owner(signer);213    if (!own) return err(`User or API Wallet ${signer} does not exist.`);214    const u = own.user;215    if (a.type === 'order') return { status: 'ok', response: { type: 'order', data: { statuses: a.orders.map((w: any) => this.place(u, w)) } } };216    if (a.type === 'cancel' || a.type === 'cancelByCloid') {217      const statuses = a.cancels.map((c: any) => {218        const o = u.orders.find((x) => (a.type === 'cancel' ? x.oid === c.o : x.cloid === c.cloid));219        if (!o || o.status !== 'open') return { error: 'Order was never placed, already canceled, or filled.' };220        o.status = 'canceled'; o.statusTimestamp = this.now();221        return 'success';222      });223      return { status: 'ok', response: { type: 'cancel', data: { statuses } } };224    }225    if (a.type === 'updateLeverage') { u.leverage.set(this.coinOf(a.asset)!, { type: a.isCross ? 'cross' : 'isolated', value: a.leverage }); return { status: 'ok', response: { type: 'default' } }; }226    return err(`fake: unsupported action ${a.type}`);227  }228229  /** the injected fetch of one network; any other host is a test failure */230  fetch = (async (url: string, init?: RequestInit): Promise<Response> => {231    const u = new URL(String(url)), body = JSON.parse(String(init?.body ?? '{}'));232    if (u.host !== this.host) throw new Error(`fake ${this.net}: request to ${u.host}`);233    const json = (x: unknown, status = 200) => new Response(JSON.stringify(x), { status, headers: { 'content-type': 'application/json' } });234    if (u.pathname === '/info') { this.calls.push({ path: '/info', type: body.type, body }); return json(this.info(body)); }235    if (u.pathname !== '/exchange') throw new Error(`fake: unknown path ${u.pathname}`);236    this.calls.push({ path: '/exchange', type: body.action?.type, body });237    const fault = this.fault; this.fault = null;238    const timeout = () => { const e = new Error('The operation was aborted due to timeout'); e.name = 'TimeoutError'; return e; };239    if (fault === 'lost_before') throw timeout();240    if (fault === 'http502_before') return new Response('bad gateway', { status: 502 });241    const answer = await this.exchange(body);242    if (fault === 'lost_after') throw timeout();243    if (fault === 'garbage_after') return new Response('<html>upstream error</html>', { status: 200 });244    return json(answer);245  }) as unknown as typeof fetch;246}247248/** one fetch for both networks: each host goes to its own fake */249export function routeFetch(fakes: FakeHl[]): typeof fetch {250  return (async (url: string, init?: RequestInit) => {251    const host = new URL(String(url)).host, f = fakes.find((x) => x.host === host);252    if (!f) throw new Error(`no fake venue for ${host}: the tests must never reach a real endpoint`);253    return f.fetch(url as never, init);254  }) as unknown as typeof fetch;255}256
Files main
Menu

Theme