Repository Public
1// Hyperliquid trading: price/size rules, action building, describe() round trips, tampering, submit. No network.2import { describe, expect, it } from 'vitest';3import { VenueError, type Instrument, type OrderRequest, type SignRequest } from '../../src/index.ts';4import {5 createHlHttp, createHlNonceSource, createHlTrading, hlCloid, hlFormatPrice, hlFormatSize, hlNonceInWindow, hlQuantizePrice, hlSigFigs, hlTickAt, hlWire, l1SignPayload,6 buildUsdClassTransferTypedDataOnly, buildWithdraw3TypedDataOnly, recoverTypedDataSigner, type HlL1SignPayload, type HlUserSignPayload,7} from '../../src/venues/hyperliquid/index.ts';8import { privateKeyToAddress } from 'viem/accounts';9import { SDK_TEST_KEY, replay, signWith, universe, type Route } from './helpers.ts';1011const { assets, instruments } = universe('mainnet');12const BTC = instruments.get('BTC')!, ETH = instruments.get('ETH')!, DOGE = instruments.get('DOGE')!;13const NOW = 1_794_366_000_000;14const trading = createHlTrading({ network: 'mainnet', assets, now: () => NOW });15const order = (o: Partial<OrderRequest> = {}): OrderRequest => ({ instrument: BTC.id, side: 'buy', type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'order-1', ...o });16const rejected = (f: () => unknown): string => { try { f(); } catch (e) { if (e instanceof VenueError && e.code === 'rejected') return e.message; throw e; } throw new Error('not rejected'); };17const clone = <T>(x: T): T => structuredClone(x);18const payloadOf = (s: SignRequest) => s.payload as HlL1SignPayload;1920describe('price and size rules (tick and lot size page)', () => {21 it('documented price examples', () => {22 expect(hlFormatPrice('1234.5', 0)).toEqual({ ok: true, wire: '1234.5' }); // "1234.5 is valid"23 expect(hlFormatPrice('1234.56', 0).ok).toBe(false); // "1234.56 is not (too many significant figures)"24 expect(hlFormatPrice('0.001234', 0)).toEqual({ ok: true, wire: '0.001234' }); // "0.001234 is valid"25 expect(hlFormatPrice('0.0012345', 0).ok).toBe(false); // "more than 6 decimal places"26 expect(hlFormatPrice('0.01234', 1)).toEqual({ ok: true, wire: '0.01234' }); // szDecimals = 127 expect(hlFormatPrice('0.012345', 1).ok).toBe(false); // "more than 6 - szDecimals decimal places"28 expect(hlFormatPrice('123456', 0)).toEqual({ ok: true, wire: '123456' }); // "Integer prices are always allowed"29 expect(hlFormatPrice('12345.6', 0).ok).toBe(false); // "even though 12345.6 is not"30 });31 it('documented size example', () => {32 expect(hlFormatSize('1.001', 3)).toEqual({ ok: true, wire: '1.001' });33 expect(hlFormatSize('1.0001', 3).ok).toBe(false);34 });35 it('trailing zeros are removed for the wire, never counted against the request', () => {36 expect(hlFormatPrice('73000.0', 5)).toEqual({ ok: true, wire: '73000' });37 expect(hlFormatPrice('1670.10', 4)).toEqual({ ok: true, wire: '1670.1' });38 expect(hlFormatSize('0.00100', 5)).toEqual({ ok: true, wire: '0.001' });39 expect(hlWire('100.50')).toBe('100.5');40 expect(hlSigFigs('0.001234')).toBe(4);41 expect(hlSigFigs('1234.5')).toBe(5);42 expect(hlSigFigs('100')).toBe(3);43 });44 it('refuses zero, negatives, exponents and anything that is not a canonical decimal', () => {45 for (const bad of ['0', '0.0', '-1', '1e3', '01', '.5', '1.', ' 1', 'NaN', '']) { expect(hlFormatPrice(bad, 2).ok, bad).toBe(false); expect(hlFormatSize(bad, 2).ok, bad).toBe(false); }46 expect(hlFormatPrice(1 as never, 2).ok).toBe(false);47 });48 it('BTC (szDecimals 5): one decimal at most, five significant figures', () => {49 expect(hlFormatPrice('73699', 5).ok).toBe(true);50 expect(hlFormatPrice('73699.5', 5).ok).toBe(false); // six significant figures51 expect(hlFormatPrice('9999.5', 5).ok).toBe(true);52 expect(hlFormatPrice('9999.55', 5).ok).toBe(false);53 expect(hlFormatSize('0.00001', 5).ok).toBe(true);54 expect(hlFormatSize('0.000001', 5).ok).toBe(false);55 });56 it('the step at a price, and explicit rounding onto the grid', () => {57 expect(hlTickAt('73699', 5)).toBe('1');58 expect(hlTickAt('9999.5', 5)).toBe('0.1');59 expect(hlTickAt('1670.1', 4)).toBe('0.1');60 expect(hlTickAt('0.17567', 0)).toBe('0.00001');61 expect(hlTickAt('0.063173', 0)).toBe('0.000001');62 expect(hlTickAt('0.0000123', 0)).toBe('0.000001'); // capped by the 6-decimal rule63 expect(hlTickAt('123456', 0)).toBe('1');64 expect(hlQuantizePrice('73699.56', 5, 'down')).toBe('73699');65 expect(hlQuantizePrice('73699.56', 5, 'up')).toBe('73700');66 expect(hlQuantizePrice('99999.5', 5, 'up')).toBe('100000');67 expect(hlQuantizePrice('0.999999', 0, 'up')).toBe('1');68 for (const [p, d] of [['73699.56', 5], ['0.0631734', 0], ['1670.123', 4], ['99999.5', 5]] as const) for (const m of ['down', 'up'] as const) expect(hlFormatPrice(hlQuantizePrice(p, d, m), d).ok).toBe(true);69 });70});7172describe('buildOrder', () => {73 it('limit gtc / ioc / alo', () => {74 for (const [tif, wire] of [['gtc', 'Gtc'], ['ioc', 'Ioc'], ['alo', 'Alo']] as const) {75 const { action } = trading.buildOrder(order({ tif }), { instrument: BTC, nonce: NOW });76 expect(action).toEqual({ type: 'order', orders: [{ a: 0, b: true, p: '73000', s: '0.001', r: false, t: { limit: { tif: wire } }, c: hlCloid('order-1') }], grouping: 'na' });77 expect(Object.keys(action.orders[0]!)).toEqual(['a', 'b', 'p', 's', 'r', 't', 'c']);78 }79 });80 it('market = immediate-or-cancel limit at the request\'s worst price; the price is mandatory', () => {81 const { action } = trading.buildOrder(order({ type: 'market', side: 'sell', price: '70000', tif: 'ioc', reduceOnly: true }), { instrument: BTC, nonce: NOW });82 expect(action.orders[0]).toMatchObject({ b: false, p: '70000', r: true, t: { limit: { tif: 'Ioc' } } });83 expect(rejected(() => trading.buildOrder(order({ type: 'market', price: null, tif: 'ioc' }), { instrument: BTC, nonce: NOW }))).toMatch(/worst acceptable price/);84 expect(rejected(() => trading.buildOrder(order({ type: 'market', tif: 'gtc' }), { instrument: BTC, nonce: NOW }))).toMatch(/tif must be ioc/);85 });86 it('stop and take-profit orders, market and limit', () => {87 const t = (type: OrderRequest['type']) => trading.buildOrder(order({ type, side: 'sell', price: '69000', triggerPrice: '70000', reduceOnly: true }), { instrument: BTC, nonce: NOW }).action.orders[0]!.t;88 expect(t('stop_market')).toEqual({ trigger: { isMarket: true, triggerPx: '70000', tpsl: 'sl' } });89 expect(t('stop_limit')).toEqual({ trigger: { isMarket: false, triggerPx: '70000', tpsl: 'sl' } });90 expect(t('take_profit_market')).toEqual({ trigger: { isMarket: true, triggerPx: '70000', tpsl: 'tp' } });91 expect(t('take_profit_limit')).toEqual({ trigger: { isMarket: false, triggerPx: '70000', tpsl: 'tp' } });92 expect(Object.keys((t('stop_market') as { trigger: object }).trigger)).toEqual(['isMarket', 'triggerPx', 'tpsl']); // SDK order93 expect(rejected(() => trading.buildOrder(order({ type: 'stop_market', triggerPrice: null }), { instrument: BTC, nonce: NOW }))).toMatch(/triggerPrice is required/);94 expect(rejected(() => trading.buildOrder(order({ type: 'stop_market', triggerPrice: '70000', tif: 'ioc' }), { instrument: BTC, nonce: NOW }))).toMatch(/tif must be gtc/);95 expect(rejected(() => trading.buildOrder(order({ triggerPrice: '70000' }), { instrument: BTC, nonce: NOW }))).toMatch(/no trigger price/);96 });97 it('off-tick and off-lot requests are refused, never rounded', () => {98 const off = (o: Partial<OrderRequest>, i: Instrument = BTC) => { try { trading.buildOrder(order({ instrument: i.id, ...o }), { instrument: i, nonce: NOW }); } catch (e) { return (e as VenueError).detail; } return null; };99 expect(off({ price: '73000.5' })).toMatchObject({ offTick: true }); // 6 significant figures100 expect(off({ price: '7300.55' })).toMatchObject({ offTick: true }); // 2 decimals, BTC allows 1101 expect(off({ size: '0.000001' })).toMatchObject({ offLot: true });102 expect(off({ type: 'stop_limit', triggerPrice: '70000.55' })).toMatchObject({ offTick: true });103 expect(off({ price: '0.12345', size: '100' }, DOGE)).toBeNull(); // DOGE szDecimals 0: 6 decimals allowed, 5 significant figures104 expect(off({ price: '0.012345', size: '1000' }, DOGE)).toBeNull();105 expect(off({ price: '0.123456', size: '100' }, DOGE)).toMatchObject({ offTick: true });106 expect(off({ price: '0.0123456', size: '100' }, DOGE)).toMatchObject({ offTick: true });107 expect(off({ size: '10.5' }, DOGE)).toMatchObject({ offLot: true });108 expect(off({ price: '0' })).toMatchObject({ offTick: true });109 expect(off({ size: '0' })).toMatchObject({ offLot: true });110 });111 it('refuses malformed requests, foreign or delisted instruments, bad nonces', () => {112 const delisted = [...instruments.values()].find((i) => i.status === 'halted')!;113 expect(rejected(() => trading.buildOrder(order({ instrument: delisted.id, size: '1', price: '1' }), { instrument: delisted, nonce: NOW }))).toMatch(/delisted/);114 expect(rejected(() => trading.buildOrder(order({ instrument: ETH.id }), { instrument: BTC, nonce: NOW }))).toMatch(/differ/);115 expect(rejected(() => trading.buildOrder(order(), { instrument: { ...BTC, venue: 'dydx' }, nonce: NOW }))).toMatch(/not a Hyperliquid instrument/);116 expect(rejected(() => trading.buildOrder(order(), { instrument: { ...BTC, lotSize: '0.001' }, nonce: NOW }))).toMatch(/lot size differs/);117 expect(rejected(() => trading.buildOrder(order({ instrument: 'hyperliquid:NOPE' }), { instrument: { ...BTC, symbol: 'NOPE', id: 'hyperliquid:NOPE' }, nonce: NOW }))).toMatch(/not in the mainnet asset table/);118 expect(rejected(() => trading.buildOrder(order({ size: 0.001 as never }), { instrument: BTC, nonce: NOW }))).toMatch(/malformed/);119 expect(rejected(() => trading.buildOrder(order({ side: 'long' as never }), { instrument: BTC, nonce: NOW }))).toMatch(/malformed/);120 expect(rejected(() => trading.buildOrder(order({ clientId: '' }), { instrument: BTC, nonce: NOW }))).toMatch(/clientId/);121 for (const nonce of [0, -1, 1.5, NaN]) expect(rejected(() => trading.buildOrder(order(), { instrument: BTC, nonce }))).toMatch(/nonce/);122 });123 it('cloid: deterministic, 128 bits, distinct per clientId, a cloid passes through', () => {124 const a = hlCloid('order-1');125 expect(a).toMatch(/^0x[0-9a-f]{32}$/);126 expect(hlCloid('order-1')).toBe(a);127 expect(hlCloid('order-2')).not.toBe(a);128 expect(hlCloid(a)).toBe(a);129 expect(hlCloid('0x' + 'AB'.repeat(16))).not.toBe('0x' + 'AB'.repeat(16)); // upper case is not a cloid: hashed like any id130 });131 it('the sign request carries the phantom agent of the action', () => {132 const { action, sign } = trading.buildOrder(order(), { instrument: BTC, nonce: NOW });133 expect(sign).toMatchObject({ venue: 'hyperliquid', network: 'mainnet', kind: 'hl_l1' });134 const p = payloadOf(sign);135 expect(p).toMatchObject({ scheme: 'l1', nonce: NOW, vaultAddress: null, expiresAfter: null });136 expect(p.typedData).toEqual(l1SignPayload(action as never, { network: 'mainnet', nonce: NOW }).typedData);137 expect(p.typedData.message.source).toBe('a');138 expect(JSON.parse(JSON.stringify(sign))).toEqual(sign); // plain JSON: it can travel to the signer unchanged139 });140 it('expiresAfter, when configured, is nonce + the configured delay and is part of the hash', () => {141 const t = createHlTrading({ network: 'testnet', assets: universe('testnet').assets, expiresAfterMs: 60_000 });142 const btc = universe('testnet').instruments.get('BTC')!;143 const { sign } = t.buildOrder(order(), { instrument: btc, nonce: NOW });144 expect(payloadOf(sign).expiresAfter).toBe(NOW + 60_000);145 expect(payloadOf(sign).typedData.message.source).toBe('b');146 expect(t.describe(sign).detail.expiresAfter).toBe(NOW + 60_000);147 });148});149150describe('describe: build → describe gives back the request', () => {151 const cases: [string, Instrument, Partial<OrderRequest>][] = [152 ['limit gtc buy', BTC, {}],153 ['limit alo sell', BTC, { side: 'sell', tif: 'alo', price: '75000' }],154 ['limit ioc reduce-only', ETH, { instrument: ETH.id, tif: 'ioc', reduceOnly: true, size: '0.0147', price: '1670.1' }],155 ['market sell', BTC, { type: 'market', side: 'sell', tif: 'ioc', price: '70000' }],156 ['stop market', BTC, { type: 'stop_market', side: 'sell', price: '69000', triggerPrice: '70000', reduceOnly: true }],157 ['stop limit', BTC, { type: 'stop_limit', side: 'buy', price: '76000', triggerPrice: '75500' }],158 ['take profit market', ETH, { instrument: ETH.id, type: 'take_profit_market', side: 'sell', size: '1', price: '2300', triggerPrice: '2500', reduceOnly: true }],159 ['take profit limit', DOGE, { instrument: DOGE.id, type: 'take_profit_limit', side: 'sell', size: '190', price: '0.07448', triggerPrice: '0.076', reduceOnly: true }],160 ['trailing zeros in the request', BTC, { size: '0.00100', price: '73000.0' }],161 ];162 it.each(cases)('%s', (_n, instrument, o) => {163 const req = order(o), { sign } = trading.buildOrder(req, { instrument, nonce: NOW }), d = trading.describe(sign);164 expect(d.kind).toBe('order');165 expect(d.orders).toHaveLength(1);166 expect(d.orders![0]).toEqual(trading.normalizeRequest(req, instrument));167 const got = d.orders![0]!;168 // field by field against the request: only the venue-imposed normalisations differ169 expect(got.instrument).toBe(req.instrument);170 expect(got.side).toBe(req.side);171 expect(got.reduceOnly).toBe(req.reduceOnly);172 expect(got.size).toBe(hlWire(req.size));173 expect(got.price).toBe(hlWire(req.price!));174 expect(got.triggerPrice).toBe(req.triggerPrice === null ? null : hlWire(req.triggerPrice));175 expect(got.type).toBe(req.type === 'market' ? 'limit' : req.type); // a market order IS an IOC limit on this venue176 expect(got.tif).toBe(req.tif);177 expect(got.clientId).toBe(hlCloid(req.clientId));178 expect(d.detail).toMatchObject({ nonce: NOW, vaultAddress: null, expiresAfter: null, grouping: 'na' });179 });180 it('describe survives a JSON round trip of the sign request (key order is preserved)', () => {181 const { sign } = trading.buildOrder(order(), { instrument: BTC, nonce: NOW });182 expect(trading.describe(JSON.parse(JSON.stringify(sign)))).toEqual(trading.describe(sign));183 });184 it('cancel by order id and by client id', () => {185 const a = trading.buildCancel({ instrument: BTC, venueOrderId: '601262932710', nonce: NOW });186 expect(a.action).toEqual({ type: 'cancel', cancels: [{ a: 0, o: 601262932710 }] });187 expect(trading.describe(a.sign)).toMatchObject({ kind: 'cancel', detail: { cancels: [{ instrument: 'hyperliquid:BTC', venueOrderId: '601262932710' }], nonce: NOW } });188 const b = trading.buildCancel({ instrument: ETH, clientId: 'order-1', nonce: NOW });189 expect(b.action).toEqual({ type: 'cancelByCloid', cancels: [{ asset: 1, cloid: hlCloid('order-1') }] });190 expect(trading.describe(b.sign)).toMatchObject({ kind: 'cancel', detail: { cancels: [{ instrument: 'hyperliquid:ETH', clientId: hlCloid('order-1') }] } });191 expect(trading.describe(a.sign).orders).toBeUndefined();192 expect(rejected(() => trading.buildCancel({ instrument: BTC, nonce: NOW }))).toMatch(/exactly one/);193 expect(rejected(() => trading.buildCancel({ instrument: BTC, venueOrderId: '1', clientId: 'x', nonce: NOW }))).toMatch(/exactly one/);194 for (const bad of ['abc', '-1', '1.5', '99999999999999999999', '01']) expect(rejected(() => trading.buildCancel({ instrument: BTC, venueOrderId: bad, nonce: NOW }))).toMatch(/numeric order id/);195 });196 it('leverage change', () => {197 const a = trading.buildUpdateLeverage({ instrument: ETH, leverage: 5, marginMode: 'isolated', nonce: NOW });198 expect(a.action).toEqual({ type: 'updateLeverage', asset: 1, isCross: false, leverage: 5 });199 expect(trading.describe(a.sign)).toMatchObject({ kind: 'leverage', detail: { instrument: 'hyperliquid:ETH', leverage: '5', marginMode: 'isolated' } });200 expect(trading.describe(trading.buildUpdateLeverage({ instrument: BTC, leverage: 3, marginMode: 'cross', nonce: NOW }).sign).detail.marginMode).toBe('cross');201 expect(rejected(() => trading.buildUpdateLeverage({ instrument: ETH, leverage: Number(ETH.maxLeverage) + 1, marginMode: 'cross', nonce: NOW }))).toMatch(/between 1 and/);202 expect(rejected(() => trading.buildUpdateLeverage({ instrument: ETH, leverage: 2.5, marginMode: 'cross', nonce: NOW }))).toMatch(/integer/);203 const iso = [...instruments.values()].find((i) => i.marginModes.length === 1)!;204 expect(rejected(() => trading.buildUpdateLeverage({ instrument: iso, leverage: 1, marginMode: 'cross', nonce: NOW }))).toMatch(/isolated-only/);205 });206 it('agent approval: address, name, expiry — and the typed data is signed by the ACCOUNT key', async () => {207 const agent = '0x5e9ee1089755c3435139848e47e6635505d5a13a';208 const a = trading.buildApproveAgent({ agentAddress: agent.toUpperCase().replace('0X', '0x'), agentName: 'spw-trade valid_until 1800000000000', nonce: NOW });209 expect(a.sign.kind).toBe('hl_user');210 expect(a.action).toEqual({ type: 'approveAgent', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', agentAddress: agent, agentName: 'spw-trade valid_until 1800000000000', nonce: NOW });211 expect(trading.describe(a.sign)).toEqual({ kind: 'approve_agent', detail: { agentAddress: agent, agentName: 'spw-trade', validUntil: 1800000000000, nonce: NOW, signatureChainId: '0x66eee', hyperliquidChain: 'Mainnet' } });212 const unnamed = trading.buildApproveAgent({ agentAddress: agent, agentName: null, nonce: NOW });213 expect('agentName' in unnamed.action).toBe(false);214 expect(trading.describe(unnamed.sign).detail).toMatchObject({ agentAddress: agent, agentName: null, validUntil: null });215 const td = (a.sign.payload as HlUserSignPayload).typedData;216 expect(td.domain).toEqual({ name: 'HyperliquidSignTransaction', version: '1', chainId: 0x66eee, verifyingContract: '0x0000000000000000000000000000000000000000' });217 expect(td.primaryType).toBe('HyperliquidTransaction:ApproveAgent');218 expect(await recoverTypedDataSigner(td, await signWith(SDK_TEST_KEY, td))).toBe(privateKeyToAddress(SDK_TEST_KEY).toLowerCase());219 expect(rejected(() => trading.buildApproveAgent({ agentAddress: '0x12', agentName: null, nonce: NOW }))).toMatch(/approveAgent/);220 });221});222223describe('describe: anything else is refused', () => {224 const built = () => trading.buildOrder(order(), { instrument: BTC, nonce: NOW }).sign;225 /** recomputes the typed data after tampering, as an attacker who controls the API would */226 const rehash = (s: SignRequest): SignRequest => { const p = payloadOf(s); p.typedData = l1SignPayload(p.action as never, { network: s.network, nonce: p.nonce, vaultAddress: p.vaultAddress, expiresAfter: p.expiresAfter }).typedData; return s; };227 const tamper = (f: (p: any, s: any) => void, fix = true): SignRequest => { const s = clone(built()); f(s.payload, s); return fix ? rehash(s) : s; };228229 it('a tampered action no longer matches its typed data', () => {230 for (const f of [(p: any) => { p.action.orders[0].s = '1'; }, (p: any) => { p.action.orders[0].b = false; }, (p: any) => { p.action.orders[0].p = '1'; }, (p: any) => { p.action.orders[0].a = 1; },231 (p: any) => { p.nonce += 1; }, (p: any) => { p.vaultAddress = `0x${'1'.repeat(40)}`; }, (p: any) => { p.expiresAfter = NOW; }, (p: any) => { p.action.orders[0].r = true; }])232 expect(rejected(() => trading.describe(tamper(f, false)))).toMatch(/does not match the action/);233 });234 it('tampered typed data is refused', () => {235 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.message.connectionId = `0x${'00'.repeat(32)}`; }, false)))).toMatch(/does not match/);236 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.message.source = 'b'; }, false)))).toMatch(/does not match/);237 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.domain.chainId = 1; }, false)))).toMatch(/does not match/);238 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.domain.name = 'HyperliquidSignTransaction'; }, false)))).toMatch(/does not match/);239 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.primaryType = 'Other'; }, false)))).toMatch(/does not match/);240 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.types.Agent.push({ name: 'x', type: 'string' }); }, false)))).toMatch(/does not match/);241 expect(rejected(() => trading.describe(tamper((p) => { p.typedData.extra = 1; }, false)))).toMatch(/sign payload/);242 });243 it('unexpected extra fields are refused even when the hash is consistent', () => {244 expect(rejected(() => trading.describe(tamper((p) => { p.action.builder = { b: `0x${'1'.repeat(40)}`, f: 10 }; })))).toMatch(/order action/);245 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].x = 1; })))).toMatch(/order action/);246 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].t.limit.extra = true; })))).toMatch(/order action/);247 expect(rejected(() => trading.describe(tamper((p) => { p.action.grouping = 'normalTpsl'; })))).toMatch(/order action/);248 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders = []; })))).toMatch(/order action/);249 expect(rejected(() => trading.describe(tamper((p) => { p.extra = true; })))).toMatch(/sign payload/);250 const cancel = clone(trading.buildCancel({ instrument: BTC, venueOrderId: '5', nonce: NOW }).sign);251 (cancel.payload as any).action.f = true;252 expect(rejected(() => trading.describe(rehash(cancel)))).toMatch(/cancel action/);253 const lev = clone(trading.buildUpdateLeverage({ instrument: BTC, leverage: 2, marginMode: 'cross', nonce: NOW }).sign);254 (lev.payload as any).action.extra = 1;255 expect(rejected(() => trading.describe(rehash(lev)))).toMatch(/updateLeverage action/);256 });257 it('values the builder could not have produced are refused', () => {258 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].p = '73000.55'; })))).toMatch(/price .* is not valid/);259 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].p = '73000.0'; })))).toMatch(/order action/); // trailing zero: not the wire form260 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].s = '0.0000001'; })))).toMatch(/size .* is not valid/);261 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].a = 99999; })))).toMatch(/not in the mainnet asset table/);262 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].a = 10000; })))).toMatch(/not in the mainnet asset table/); // spot asset id263 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].c = '0x12'; })))).toMatch(/order action/);264 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].t = { limit: { tif: 'Fok' } }; })))).toMatch(/order action/);265 expect(rejected(() => trading.describe(tamper((p) => { const o = p.action.orders[0]; p.action.orders[0] = { b: o.b, a: o.a, p: o.p, s: o.s, r: o.r, t: o.t, c: o.c }; })))).toMatch(/canonical order/);266 expect(rejected(() => trading.describe(tamper((p) => { p.action = { grouping: 'na', type: 'order', orders: p.action.orders }; })))).toMatch(/canonical order/);267 expect(rejected(() => trading.describe(tamper((p) => { p.action.orders[0].s = 0.001; }, false)))).toMatch(/cannot be hashed/); // a float cannot even be encoded268 const lev = clone(trading.buildUpdateLeverage({ instrument: ETH, leverage: 2, marginMode: 'cross', nonce: NOW }).sign);269 (lev.payload as any).action.leverage = 500;270 expect(rejected(() => trading.describe(rehash(lev)))).toMatch(/above the maximum/);271 });272 it('a consistent hash of an action this module does not build is `other` — the signer refuses it', () => {273 for (const action of [274 { type: 'agentSendAsset', destination: `0x${'1'.repeat(40)}`, sourceDex: '', destinationDex: 'spot', token: 'USDC', amount: '100', fromSubAccount: '', nonce: NOW },275 { type: 'vaultTransfer', vaultAddress: `0x${'1'.repeat(40)}`, isDeposit: true, usd: 1000000 },276 { type: 'subAccountTransfer', subAccountUser: `0x${'1'.repeat(40)}`, isDeposit: true, usd: 10 },277 { type: 'cDeposit2', wei: 100000000 }, { type: 'scheduleCancel' }, { type: 'noop' }, { type: 'twapOrder', twap: { a: 0, b: true, s: '1', r: false, m: 5, t: false } },278 { type: 'batchModify', modifies: [] }, { type: 'updateIsolatedMargin', asset: 0, isBuy: true, ntli: 1000000 },279 ]) {280 const s = tamper((p) => { p.action = action; });281 expect(trading.describe(s)).toMatchObject({ kind: 'other', detail: { type: action.type } });282 expect(trading.describe(s).orders).toBeUndefined();283 }284 });285 it('wrong venue, wrong network, unknown kind, malformed payload', () => {286 expect(rejected(() => trading.describe({ ...built(), venue: 'dydx' }))).toMatch(/not for Hyperliquid/);287 expect(rejected(() => trading.describe({ ...built(), network: 'testnet' }))).toMatch(/is for testnet/);288 expect(rejected(() => trading.describe({ ...built(), kind: 'raw' }))).toMatch(/unknown sign request kind/);289 expect(rejected(() => trading.describe({ ...built(), payload: null }))).toMatch(/sign payload/);290 expect(rejected(() => trading.describe({ ...built(), payload: { scheme: 'l1' } }))).toMatch(/sign payload/);291 expect(rejected(() => trading.describe(null as never))).toMatch(/not for Hyperliquid/);292 // a testnet action presented to a mainnet describer: its phantom agent says source 'b'293 const testnet = createHlTrading({ network: 'testnet', assets }).buildOrder(order(), { instrument: BTC, nonce: NOW }).sign;294 expect(rejected(() => trading.describe({ ...testnet, network: 'mainnet' }))).toMatch(/does not match/);295 });296 it('user-signed: a tampered approval is refused; transfers and withdrawals are `other`', () => {297 const agent = '0x5e9ee1089755c3435139848e47e6635505d5a13a', other = `0x${'9'.repeat(40)}`;298 const mk = () => clone(trading.buildApproveAgent({ agentAddress: agent, agentName: 'spw-trade', nonce: NOW }).sign);299 const t = (f: (p: any) => void) => { const s = mk(); f(s.payload); return s; };300 expect(rejected(() => trading.describe(t((p) => { p.action.agentAddress = other; })))).toMatch(/does not match/); // shown one agent, signing another301 expect(rejected(() => trading.describe(t((p) => { p.typedData.message.agentAddress = other; })))).toMatch(/does not match/);302 expect(rejected(() => trading.describe(t((p) => { p.action.agentName = 'x'; })))).toMatch(/does not match/);303 expect(rejected(() => trading.describe(t((p) => { p.action.nonce += 1; })))).toMatch(/does not match/);304 expect(rejected(() => trading.describe(t((p) => { p.action.hyperliquidChain = 'Testnet'; })))).toMatch(/is for Testnet/);305 expect(rejected(() => trading.describe(t((p) => { p.typedData.message.hyperliquidChain = 'Testnet'; })))).toMatch(/does not match/);306 expect(rejected(() => trading.describe(t((p) => { p.typedData.primaryType = 'HyperliquidTransaction:Withdraw'; })))).toMatch(/does not match/);307 expect(rejected(() => trading.describe(t((p) => { p.action.extra = 1; })))).toMatch(/approveAgent action/);308 expect(rejected(() => trading.describe(t((p) => { p.action.agentAddress = agent.toUpperCase().replace('0X', '0x'); })))).toMatch(/approveAgent action/);309 const w = buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: other, amount: '100', time: NOW });310 expect(trading.describe({ venue: 'hyperliquid', network: 'mainnet', kind: 'hl_user', payload: { scheme: 'user', action: w.action, typedData: w.typedData } })).toEqual({ kind: 'other', detail: { type: 'withdraw3', primaryType: 'HyperliquidTransaction:Withdraw' } });311 const u = buildUsdClassTransferTypedDataOnly({ network: 'mainnet', amount: '100', toPerp: false, nonce: NOW });312 expect(trading.describe({ venue: 'hyperliquid', network: 'mainnet', kind: 'hl_user', payload: { scheme: 'user', action: u.action, typedData: u.typedData } }).kind).toBe('other');313 // a withdrawal dressed up as an approval: the typed data is what gets signed, and it does not match314 const s = mk();315 (s.payload as any).typedData = w.typedData;316 expect(rejected(() => trading.describe(s))).toMatch(/does not match/);317 });318});319320describe('nonces', () => {321 it('window (T − 2 days, T + 1 day), exclusive', () => {322 expect(hlNonceInWindow(NOW, NOW)).toBe(true);323 expect(hlNonceInWindow(NOW - 2 * 86_400_000 + 1, NOW)).toBe(true);324 expect(hlNonceInWindow(NOW - 2 * 86_400_000, NOW)).toBe(false);325 expect(hlNonceInWindow(NOW + 86_400_000 - 1, NOW)).toBe(true);326 expect(hlNonceInWindow(NOW + 86_400_000, NOW)).toBe(false);327 expect(hlNonceInWindow(1.5, NOW)).toBe(false);328 });329 it('the nonce source is strictly increasing, even inside one millisecond or when the clock steps back', () => {330 let t = NOW;331 const n = createHlNonceSource(() => t);332 expect([n.next(), n.next(), n.next()]).toEqual([NOW, NOW + 1, NOW + 2]);333 t = NOW - 5000;334 expect(n.next()).toBe(NOW + 3);335 t = NOW + 10;336 expect(n.next()).toBe(NOW + 10);337 });338});339340describe('submit', () => {341 const tAssets = universe('testnet'), tBtc = tAssets.instruments.get('BTC')!;342 const setup = (route: Route, o: { network?: 'mainnet' | 'testnet'; allowMainnetExchange?: boolean; now?: number } = {}) => {343 const network = o.network ?? 'testnet', r = replay({ exchange: route });344 const http = createHlHttp({ network, fetch: r.fetchImpl, now: () => o.now ?? NOW });345 const t = createHlTrading({ network, assets: network === 'testnet' ? tAssets.assets : assets, http, now: () => o.now ?? NOW, allowMainnetExchange: o.allowMainnetExchange });346 return { t, calls: r.calls, http };347 };348 const sig = { r: `0x${'11'.repeat(32)}`, s: `0x${'22'.repeat(32)}`, v: 27 };349 const failure = async (p: Promise<unknown>): Promise<VenueError> => { try { await p; } catch (e) { if (e instanceof VenueError) return e; throw e; } throw new Error('no error'); };350351 it('resting order: body as the SDK posts it, order id returned', async () => {352 const { t, calls, http } = setup({ body: { status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 77738308 } }] } } } });353 const { action } = t.buildOrder(order(), { instrument: tBtc, nonce: NOW });354 const out = await t.submit(action, sig, NOW);355 expect(out).toMatchObject({ accepted: true, venueOrderIds: ['77738308'], error: null });356 expect(calls[0]!.url).toBe('https://api.hyperliquid-testnet.xyz/exchange');357 expect(calls[0]!.body).toEqual({ action, nonce: NOW, signature: sig, vaultAddress: null, expiresAfter: null });358 expect(Object.keys(calls[0]!.body.action.orders[0])).toEqual(['a', 'b', 'p', 's', 'r', 't', 'c']);359 expect(http.budget.used()).toBe(1);360 });361 it('filled order and a 65-byte hex signature', async () => {362 const { t, calls } = setup({ body: { status: 'ok', response: { type: 'order', data: { statuses: [{ filled: { totalSz: '0.02', avgPx: '1891.4', oid: 77747314 } }] } } } });363 const { action } = t.buildOrder(order({ tif: 'ioc' }), { instrument: tBtc, nonce: NOW });364 const out = await t.submit(action, `0x${'11'.repeat(32)}${'22'.repeat(32)}1c`, NOW);365 expect(out.venueOrderIds).toEqual(['77747314']);366 expect(calls[0]!.body.signature).toEqual({ ...sig, v: 28 });367 });368 it('per-order error → VenueError rejected with the venue\'s words', async () => {369 const { t } = setup({ body: { status: 'ok', response: { type: 'order', data: { statuses: [{ error: 'Order must have minimum value of $10.' }] } } } });370 const e = await failure(t.submit(t.buildOrder(order(), { instrument: tBtc, nonce: NOW }).action, sig, NOW));371 expect(e.code).toBe('rejected');372 expect(e.message).toContain('Order must have minimum value of $10.');373 expect((e.detail as Record<string, any>).venueOrderIds).toEqual([]);374 });375 it('whole-request error (status err) → rejected', async () => {376 const { t } = setup({ body: { status: 'err', response: 'User or API Wallet 0x1234567890123456789012345678901234567890 does not exist.' } });377 const e = await failure(t.submit(t.buildOrder(order(), { instrument: tBtc, nonce: NOW }).action, sig, NOW));378 expect(e.code).toBe('rejected');379 expect(e.message).toContain('does not exist');380 });381 it('cancel: success, and the documented cancel error', async () => {382 const ok = setup({ body: { status: 'ok', response: { type: 'cancel', data: { statuses: ['success'] } } } });383 const c = ok.t.buildCancel({ instrument: tBtc, venueOrderId: '5', nonce: NOW });384 expect(await ok.t.submit(c.action, sig, NOW)).toMatchObject({ accepted: true, venueOrderIds: [] });385 const ko = setup({ body: { status: 'ok', response: { type: 'cancel', data: { statuses: [{ error: 'Order was never placed, already canceled, or filled.' }] } } } });386 expect((await failure(ko.t.submit(c.action, sig, NOW))).message).toContain('never placed');387 });388 it('leverage change and agent approval answer with type default', async () => {389 const { t, calls } = setup({ body: { status: 'ok', response: { type: 'default' } } });390 expect((await t.submit(t.buildUpdateLeverage({ instrument: tBtc, leverage: 3, marginMode: 'cross', nonce: NOW }).action, sig, NOW)).accepted).toBe(true);391 const a = t.buildApproveAgent({ agentAddress: `0x${'5'.repeat(40)}`, agentName: null, nonce: NOW });392 expect((await t.submit(a.action, sig, NOW)).accepted).toBe(true);393 expect(calls[1]!.body.action).toEqual({ type: 'approveAgent', hyperliquidChain: 'Testnet', signatureChainId: '0x66eee', agentAddress: `0x${'5'.repeat(40)}`, nonce: NOW });394 expect((await failure(t.submit(a.action, sig, NOW + 1))).message).toMatch(/nonce of the action and of the request differ/);395 });396 it('trigger order waiting for its trigger: accepted, no order id yet', async () => {397 const { t } = setup({ body: { status: 'ok', response: { type: 'order', data: { statuses: ['waitingForTrigger'] } } } });398 const { action } = t.buildOrder(order({ type: 'stop_market', side: 'sell', price: '69000', triggerPrice: '70000', reduceOnly: true }), { instrument: tBtc, nonce: NOW });399 expect(await t.submit(action, sig, NOW)).toMatchObject({ accepted: true, venueOrderIds: [] });400 });401 it('an answer that cannot be read is not a rejection: bad_answer (outcome unknown)', async () => {402 for (const body of [{ status: 'ok' }, { status: 'maybe', response: {} }, 'null', { status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 'x' } }] } } }]) {403 const { t } = setup({ body });404 expect((await failure(t.submit(t.buildOrder(order(), { instrument: tBtc, nonce: NOW }).action, sig, NOW))).code).toBe('bad_answer');405 }406 });407 it('HTTP 429 → rate_limited; 502 → unavailable; 422 → rejected', async () => {408 const act = (t: ReturnType<typeof setup>['t']) => t.buildOrder(order(), { instrument: tBtc, nonce: NOW }).action;409 const a = setup({ status: 429, body: '' }), b = setup({ status: 502, body: 'Bad Gateway' }), c = setup({ status: 422, body: 'Failed to deserialize the JSON body into the target type' });410 expect((await failure(a.t.submit(act(a.t), sig, NOW))).code).toBe('rate_limited');411 expect((await failure(b.t.submit(act(b.t), sig, NOW))).code).toBe('unavailable');412 expect((await failure(c.t.submit(act(c.t), sig, NOW))).code).toBe('rejected');413 });414 it('nothing is sent when the request is not submittable', async () => {415 const { t, calls } = setup({ body: {} });416 const { action } = t.buildOrder(order(), { instrument: tBtc, nonce: NOW });417 expect((await failure(t.submit(action, sig, NOW - 3 * 86_400_000))).message).toMatch(/outside the venue's window/);418 expect((await failure(t.submit(action, { r: '0x1', s: '0x2', v: 5 }, NOW))).code).toBe('rejected');419 expect((await failure(t.submit({ type: 'withdraw3' }, sig, NOW))).code).toBe('unsupported');420 expect((await failure(t.submit({ type: 'usdClassTransfer' }, sig, NOW))).code).toBe('unsupported');421 expect((await failure(t.submit({ ...action, builder: { b: '0x1', f: 1 } }, sig, NOW))).code).toBe('rejected');422 expect((await failure(t.submit(null, sig, NOW))).code).toBe('unsupported');423 expect((await failure(createHlTrading({ network: 'testnet', assets: tAssets.assets }).submit(action, sig, NOW))).code).toBe('unsupported');424 expect(calls.length).toBe(0);425 });426 it('the mainnet exchange endpoint is closed unless explicitly allowed', async () => {427 const closed = setup({ body: {} }, { network: 'mainnet' });428 const { action } = closed.t.buildOrder(order(), { instrument: BTC, nonce: NOW });429 const e = await failure(closed.t.submit(action, sig, NOW));430 expect(e.code).toBe('unsupported');431 expect(e.message).toMatch(/mainnet exchange endpoint is disabled/);432 expect(closed.calls.length).toBe(0);433 });434});435