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