SPB Git

Repository Public

trading.test.ts 36.3 KB 435 lines typescript

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

Theme