Repository Public
1import { readFileSync } from 'node:fs';2import { dirname, resolve } from 'node:path';3import { fileURLToPath } from 'node:url';4import { describe, expect, it } from 'vitest';5import { ApiError } from '../src/api.ts';6import { buildLive, canonicalProblem, clock, closeProblem, defaultPolicyForm, inFlight, isUnfunded, liveError, newKey, policyFromForm, remainingMs, samePolicy, sha256Hex, toneOf, venueWords,7 type LiveConfig, type LiveDetail, type LiveOrder, type LiveOrderBody, type VenueOrder } from '../src/derivatives/live.ts';8import { applyLiveEvent, mergeOrder } from '../src/derivatives/liveStore.ts';910const BTC = { id: 'hyperliquid:BTC', venue: 'hyperliquid' as const, tickSize: '0.1', lotSize: '0.00001' };11const BOUNDS: LiveConfig['bounds'] = { maxLeverage: 20, maxOrdersPerDay: 500, minPriceBandBps: 10, maxPriceBandBps: 2000, maxInstruments: 8, maxAuthorizationDays: 90, orderApprovalTtlMs: 90_000 };12const PINNED = [{ instrument: 'hyperliquid:BTC', maxLeverage: 40 }, { instrument: 'hyperliquid:ETH', maxLeverage: 25 }, { instrument: 'hyperliquid:SOL', maxLeverage: 10 }];13const canonical = (o: Partial<VenueOrder> = {}): VenueOrder => ({ v: 1, authorizationId: '5f0c2c1e-8f1a-4d59-9a39-0d7a54a1b111', venue: 'hyperliquid', network: 'testnet', account: `0x${'ab'.repeat(20)}`, kind: 'order', instrument: 'hyperliquid:BTC', side: 'buy',14 type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: `0x${'0'.repeat(32)}`, nonce: 1_800_000_000_000, expiresAt: 1_800_000_090_000, ...o });15const form = { side: 'buy' as const, kind: 'limit' as const, size: '', price: '', trigger: '', reduceOnly: false, postOnly: false };1617describe('order form → live order body', () => {18 it('sends sizes and prices as the decimal strings that were typed, put on the grid — never through a float', () => {19 const { body } = buildLive({ ...form, size: '0,1', price: '73 000,5' }, BTC);20 expect(body).toEqual({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.1', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc' }); // 5 significant figures on Hyperliquid: 73000.5 → 7300021 // 0.1 + 0.2 in floats is 0.30000000000000004: a decimal string never is22 expect(buildLive({ ...form, size: '0.30000000000000004', price: '100' }, BTC).body!.size).toBe('0.3');23 expect(buildLive({ ...form, size: '0.123456789', price: '100' }, BTC).body!.size).toBe('0.12345'); // rounded DOWN to the lot24 expect(buildLive({ ...form, size: '12345678901234567890.123456', price: '1' }, BTC).body!.size).toBe('12345678901234567890.12345'); // beyond double precision25 expect(typeof buildLive({ ...form, size: '1', price: '2' }, BTC).body!.size).toBe('string');26 });27 it('a market order carries no price (the server derives the worst price from the venue\'s book) and no leverage', () => {28 const { body } = buildLive({ ...form, kind: 'market', size: '0.002' }, BTC);29 expect(body).toEqual({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'market', size: '0.002', price: null, triggerPrice: null, reduceOnly: false, tif: 'ioc', slippageBps: 50 });30 expect(body).not.toHaveProperty('leverage');31 expect(body).not.toHaveProperty('clientId');32 });33 it('a stop carries its trigger and a worst price inside the bound; post-only is alo', () => {34 const stop = buildLive({ ...form, side: 'sell', kind: 'stop', size: '0.01', trigger: '70000' }, BTC).body!;35 expect(stop.type).toBe('stop_market'); expect(stop.triggerPrice).toBe('70000'); expect(stop.price).toBe('69650'); // 50 bps below, on the grid36 expect(buildLive({ ...form, size: '0.01', price: '70000', postOnly: true }, BTC).body!.tif).toBe('alo');37 });38 it('says what is missing instead of building something', () => {39 expect(buildLive({ ...form, size: '0.01' }, BTC)).toEqual({ body: null, problem: null }); // nothing typed yet: no reproach40 expect(buildLive({ ...form, size: '0.01', price: 'abc' }, BTC).problem).toBe('fxErrPrice');41 expect(buildLive({ ...form, size: '0.000001', price: '70000' }, BTC).problem).toBe('fxErrSizeSmall');42 expect(buildLive({ ...form, size: 'x', price: '70000' }, BTC).problem).toBe('fxErrSize');43 expect(buildLive({ ...form, size: '1', price: '1' }, { ...BTC, lotSize: '0' }).problem).toBe('fxErrInstrument');44 });45});4647describe('what the passkey signs must be what was asked for', () => {48 const body: LiveOrderBody = { instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc' };49 it('accepts the canonical form of the same order', () => {50 expect(canonicalProblem(body, canonical())).toBeNull();51 expect(canonicalProblem({ ...body, size: '0.0010' }, canonical())).toBeNull(); // same decimal, other spelling52 // a market order is sent as a limit IOC at its worst price53 expect(canonicalProblem({ ...body, type: 'market', price: null, tif: 'ioc', slippageBps: 50 }, canonical({ type: 'limit', tif: 'ioc', price: '73365' }))).toBeNull();54 });55 it('names the first field that differs', () => {56 expect(canonicalProblem(body, null)).toBe('canonical');57 expect(canonicalProblem(body, canonical({ instrument: 'hyperliquid:ETH' }))).toBe('instrument');58 expect(canonicalProblem(body, canonical({ side: 'sell' }))).toBe('side');59 expect(canonicalProblem(body, canonical({ size: '0.01' }))).toBe('size');60 expect(canonicalProblem(body, canonical({ price: '73001' }))).toBe('price');61 expect(canonicalProblem(body, canonical({ reduceOnly: true }))).toBe('reduceOnly');62 expect(canonicalProblem(body, canonical({ tif: 'ioc' }))).toBe('tif');63 expect(canonicalProblem(body, canonical({ type: 'stop_market', triggerPrice: '70000' }))).toBe('type');64 expect(canonicalProblem(body, canonical({ triggerPrice: '70000' }))).toBe('triggerPrice');65 expect(canonicalProblem({ ...body, type: 'market', price: null }, canonical({ tif: 'gtc' }))).toBe('tif'); // a "market" order that would rest66 });67 it('a close is reduce-only, IOC, on the reducing side, and never larger than the position', () => {68 const pos = { instrument: 'hyperliquid:BTC', side: 'long' as const, size: '0.002' };69 const c = canonical({ side: 'sell', reduceOnly: true, tif: 'ioc', size: '0.002', price: '72000' });70 expect(closeProblem(pos, c)).toBeNull();71 expect(closeProblem(pos, { ...c, size: '0.001' })).toBeNull();72 expect(closeProblem(pos, { ...c, size: '0.003' })).toBe('size');73 expect(closeProblem(pos, { ...c, side: 'buy' })).toBe('side');74 expect(closeProblem(pos, { ...c, reduceOnly: false })).toBe('reduceOnly');75 expect(closeProblem(pos, { ...c, tif: 'gtc' })).toBe('type');76 expect(closeProblem({ ...pos, side: 'short' }, c)).toBe('side');77 expect(closeProblem(pos, null)).toBe('canonical');78 });79});8081describe('approval window', () => {82 it('stops three seconds before the server\'s expiry, and corrects the local clock', () => {83 expect(remainingMs(100_000, 10_000)).toBe(87_000);84 expect(remainingMs(100_000, 97_000)).toBe(0);85 expect(remainingMs(100_000, 99_999)).toBe(0);86 expect(remainingMs(null, 0)).toBe(0);87 expect(remainingMs(100_000, 30_000, 20_000)).toBe(87_000); // this clock runs 20 s ahead of the server's88 expect(clock(87_000)).toBe('1:27'); expect(clock(4_100)).toBe('0:05'); expect(clock(0)).toBe('0:00');89 });90 it('one key per review, in the shape the server accepts', () => {91 const a = newKey('order'), b = newKey('order');92 expect(a).not.toBe(b);93 expect(a).toMatch(/^[A-Za-z0-9._:-]{8,100}$/);94 });95});9697describe('authorization limits', () => {98 it('the defaults are valid, cautious, and differ by network only in the price band', () => {99 for (const n of ['testnet', 'mainnet'] as const) {100 const r = policyFromForm(defaultPolicyForm(n, PINNED), BOUNDS, PINNED);101 expect(r.ok).toBe(true);102 if (r.ok) { expect(r.policy.allowedInstruments).toEqual(['hyperliquid:BTC']); expect(r.policy.maxLeverage).toBe(2); expect(r.days).toBe(7); expect(r.policy.maxPriceDeviationBps).toBe(n === 'testnet' ? 1000 : 200); expect(r.policy.maxOrderNotional).toBe('50'); }103 }104 });105 it('refuses what the server would refuse, with the reason', () => {106 const f = defaultPolicyForm('testnet', PINNED), bad = (patch: Partial<typeof f>) => { const r = policyFromForm({ ...f, ...patch }, BOUNDS, PINNED); return r.ok ? null : r.problem; };107 expect(bad({ instruments: [] })).toBe('instruments');108 expect(bad({ instruments: ['hyperliquid:DOGE'] })).toBe('instruments'); // not pinned: cannot be allowed109 expect(bad({ maxLeverage: '0' })).toBe('leverage'); expect(bad({ maxLeverage: '21' })).toBe('leverage'); expect(bad({ maxLeverage: '2.5' })).toBe('leverage');110 expect(bad({ instruments: ['hyperliquid:SOL'], maxLeverage: '15' })).toBe('leverageVenue'); // above SOL's venue maximum111 expect(bad({ maxOrderNotional: '0' })).toBe('order'); expect(bad({ maxOrderNotional: '' })).toBe('order');112 expect(bad({ maxOrderNotional: '200' })).toBe('orderAbovePosition');113 expect(bad({ maxPositionNotional: '200' })).toBe('positionAboveGross');114 expect(bad({ maxOrdersPerDay: '501' })).toBe('ordersPerDay');115 expect(bad({ bandBps: '5' })).toBe('band'); expect(bad({ bandBps: '2001' })).toBe('band');116 expect(bad({ days: '91' })).toBe('days'); expect(bad({ days: '0' })).toBe('days');117 const ok = policyFromForm({ ...f, maxOrderNotional: '1 234,50', maxPositionNotional: '2000', maxGrossExposure: '3000' }, BOUNDS, PINNED);118 expect(ok.ok && ok.policy.maxOrderNotional).toBe('1234.5'); // French input → canonical decimal string119 });120 it('compares the limits the server prepared with those asked for', () => {121 const p = { allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], maxLeverage: 2, maxOrderNotional: '50', maxPositionNotional: '100', maxGrossExposure: '150', maxOrdersPerDay: 20, maxPriceDeviationBps: 1000 };122 expect(samePolicy(p, { ...p, allowedInstruments: ['hyperliquid:ETH', 'hyperliquid:BTC'], maxOrderNotional: '50.0' })).toBe(true);123 expect(samePolicy(p, { ...p, maxOrderNotional: '500' })).toBe(false);124 expect(samePolicy(p, { ...p, allowedInstruments: ['hyperliquid:BTC'] })).toBe(false);125 expect(samePolicy(p, { ...p, maxPriceDeviationBps: 2000 })).toBe(false);126 });127 it('hashes the acknowledgement text as displayed', async () => {128 expect(await sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');129 expect(await sha256Hex('é')).not.toBe(await sha256Hex('e'));130 });131});132133describe('errors: which sentence, and what may be offered next', () => {134 const api = (status: number, code: string, message = 'details from the server') => new ApiError(status, code, message);135 it('halted is two moments', () => {136 expect(liveError(api(422, 'halted'), 'propose')).toMatchObject({ key: 'halted', consumed: true, retry: false });137 expect(liveError(api(423, 'halted'), 'approve')).toMatchObject({ key: 'halted_423', consumed: true, retry: false });138 });139 it('a signer refusal or an expiry consumes the proposal; only a lost answer is sent again', () => {140 for (const c of ['signer_price', 'signer_no_price', 'signer_bad_approval', 'signer_unknown_credential', 'signer_policy', 'signer_mismatch', 'signer_expired', 'signer_revoked', 'signer_stale_nonce', 'signer_replay'])141 expect(liveError(api(422, c), 'approve')).toMatchObject({ key: c, consumed: true, retry: false });142 expect(liveError(api(422, 'signer_something_new'), 'approve')).toMatchObject({ key: 'signer_other', code: 'signer_something_new', consumed: true });143 expect(liveError(api(410, 'proposal_expired'), 'approve')).toMatchObject({ key: 'proposal_expired', consumed: true, retry: false });144 expect(liveError(api(503, 'signer_unreachable'), 'approve')).toMatchObject({ key: 'signer_unreachable', consumed: true, retry: false }); // the server marked the order "not signed"145 expect(liveError(api(0, 'offline', 'offline'), 'approve')).toMatchObject({ key: 'offline', consumed: false, retry: true });146 expect(liveError(api(500, 'error', 'error'), 'approve')).toMatchObject({ key: 'other', consumed: false, retry: true, detail: '' });147 });148 it('at proposal a 422 is stored under the key; a transport failure may be retried with the same key', () => {149 expect(liveError(api(422, 'price_deviation'), 'propose')).toMatchObject({ key: 'other', code: 'price_deviation', consumed: true, retry: false }); // a risk code: its sentence is `rk_price_deviation`150 expect(liveError(api(503, 'venue_unavailable'), 'propose')).toMatchObject({ key: 'venue_unavailable', consumed: false, retry: true });151 expect(liveError(api(503, 'asset_table_mismatch'), 'propose')).toMatchObject({ key: 'asset_table_mismatch', consumed: false, retry: false });152 expect(liveError(api(403, 'mainnet_disabled'), 'propose')).toMatchObject({ key: 'mainnet_disabled', status: 403, consumed: false, retry: false });153 });154 it('a dismissed passkey prompt is not an error of the server', () => {155 const e = Object.assign(new Error('The operation either timed out or was not allowed.'), { name: 'NotAllowedError' });156 expect(liveError(e, 'approve')).toMatchObject({ key: 'passkey_cancelled', consumed: true, retry: false });157 expect(liveError(new Error('boom'), 'approve')).toMatchObject({ key: 'passkey_failed', consumed: true });158 });159 it('extracts the venue\'s own words from the API message', () => {160 const m = 'the venue did not register the trading key: Must deposit before performing actions. User: 0xe06252f11d8beac14b7f8a026dd02febdba1400f — the authorization was revoked on the signer, nothing can trade. Fund the venue account first if the venue asks for a deposit, then prepare a new authorization.';161 expect(venueWords(m)).toBe('Must deposit before performing actions. User: 0xe06252f11d8beac14b7f8a026dd02febdba1400f');162 expect(isUnfunded(m)).toBe(true);163 expect(venueWords('something else entirely')).toBe('something else entirely');164 expect(isUnfunded('Order has invalid price.')).toBe(false);165 });166 it('every code has a French and an English sentence', () => {167 const src = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../src/i18n.ts'), 'utf8');168 const live = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../src/derivatives/live.ts'), 'utf8');169 const known = [...live.slice(live.indexOf('const KNOWN = new Set(['), live.indexOf(']);', live.indexOf('const KNOWN = new Set(['))).matchAll(/'([a-z_0-9]+)'/g)].map((m) => m[1]!);170 expect(known.length).toBeGreaterThan(40);171 const need = [...known.map((c) => `flE_${c}`), 'flE_halted_423', 'flE_signer_other', 'flE_other', 'flE_passkey_cancelled', 'flE_passkey_failed', 'flE_canonical_mismatch',172 ...['proposed', 'risk_checked', 'authorized', 'signed', 'submitted', 'unknown', 'open', 'partially_filled', 'filled', 'cancelled', 'rejected'].flatMap((s) => [`flSt_${s}`, `flOut_${s}`]), 'flOut_partially_filled_final',173 'flTif_gtc', 'flTif_ioc', 'flTif_alo', 'flLev_confirmed', 'flLev_rejected', 'flLev_unknown', 'flToneSim', 'flToneTestnet', 'flToneReal',174 ...['instruments', 'tooManyInstruments', 'leverage', 'leverageVenue', 'order', 'position', 'gross', 'orderAbovePosition', 'positionAboveGross', 'ordersPerDay', 'band', 'days'].map((p) => `flPol_${p}`)];175 for (const k of need) { const m = new RegExp(`\\b${k}: \\['([^']+)',\\s*'([^']+)'\\]`).exec(src); expect(m, k).not.toBeNull(); expect(m![1]!.length, k).toBeGreaterThan(3); expect(m![2]!.length, k).toBeGreaterThan(3); }176 // the three labels can never be confused with each other, in either language177 const tone = (k: string) => new RegExp(`\\b${k}: \\['([^']+)',\\s*'([^']+)'\\]`).exec(src)!;178 expect(tone('flToneTestnet')[1]).toMatch(/Testnet.*fictif/); expect(tone('flToneReal')[1]).toMatch(/Réel.*réel/); expect(tone('flToneReal')[2]).toMatch(/real money/i);179 // the live keys form ONE contiguous block180 const first = src.indexOf('\n fl'), end = src.indexOf('// end of the `fl` block');181 expect(first).toBeGreaterThan(0); expect(end).toBeGreaterThan(first);182 expect(/\n fl[A-Z]/.test(src.slice(end))).toBe(false);183 });184});185186describe('account state from the stream', () => {187 const order = (o: Partial<LiveOrder>): LiveOrder => ({ clientId: 'k1', cloid: '0x1', instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '73000', triggerPrice: null, reduceOnly: false, tif: 'gtc', state: 'unknown', final: false,188 venueOrderId: null, filled: '0', avgFillPrice: null, reason: null, origin: 'owner', hash: null, nonce: 1, expiresAt: 2, canonical: null, createdAt: 1, updatedAt: 10, submittedAt: 5, source: 'local', ...o });189 const detail = (): LiveDetail => ({ id: 'a', venue: 'hyperliquid', network: 'testnet', kind: 'venue', mode: 'MANUAL_LIVE', label: 'x', account: '0x1', agentAddress: '0x2', policy: {} as never, halt: null, divergence: null, halted: null, mainnetEnabled: false, tradable: true,190 authorization: null, pendingAuthorization: null, createdAt: 0, updatedAt: 0, venueState: { state: { equity: '10' } as never, positions: [], openOrders: [], error: null, ts: 1, source: 'venue' }, orders: [order({})], actions: [], fills: [], funding: [], riskEvents: [], equity: [], ts: 0 });191 it('an order event replaces the order with the same client id, never with an older state', () => {192 const d = applyLiveEvent(detail(), 'order', order({ state: 'open', updatedAt: 20, source: 'venue' }));193 expect(d.orders).toHaveLength(1); expect(d.orders[0]!.state).toBe('open');194 expect(mergeOrder(d.orders, order({ state: 'unknown', updatedAt: 15 }))[0]!.state).toBe('open'); // a late, older event changes nothing195 expect(mergeOrder(d.orders, order({ clientId: 'k2' }))).toHaveLength(2);196 });197 it('an account event that could not read the venue never empties the account', () => {198 const d0 = detail(); d0.venueState.positions = [{ instrument: 'hyperliquid:BTC' } as never];199 const d = applyLiveEvent(d0, 'account', { state: null, positions: [], openOrders: [], error: 'timeout', ts: 9, divergence: null, halt: null, mode: 'MANUAL_LIVE' });200 expect(d.venueState.error).toBe('timeout'); expect(d.venueState.state).toEqual({ equity: '10' });201 const ok = applyLiveEvent(d, 'account', { state: { equity: '11' }, positions: [], openOrders: [], ts: 12, divergence: 'position …', halt: null, mode: 'MANUAL_LIVE', source: 'venue' });202 expect(ok.venueState.error).toBeNull(); expect(ok.venueState.state).toEqual({ equity: '11' }); expect(ok.divergence).toBe('position …');203 });204 it('risk and unknown events', () => {205 expect(applyLiveEvent(detail(), 'risk', { ts: 5, kind: 'halt', code: 'halted', message: 'halted by the owner', data: {} }).riskEvents[0]).toMatchObject({ kind: 'halt', code: 'halted' });206 const d = detail(); expect(applyLiveEvent(d, 'whatever', { x: 1 })).toBe(d); expect(applyLiveEvent(d, 'order', null)).toBe(d);207 });208 it('labels and in-flight states', () => {209 expect(toneOf({ kind: 'paper', network: 'mainnet' })).toBe('paper'); expect(toneOf({ kind: 'venue', network: 'testnet' })).toBe('testnet'); expect(toneOf({ kind: 'venue', network: 'mainnet' })).toBe('mainnet');210 expect(toneOf({ kind: 'venue', network: 'anything-else' })).toBe('mainnet'); // never "play money" by default211 expect(['authorized', 'signed', 'submitted', 'unknown'].every((s) => inFlight(s as never))).toBe(true);212 expect(['open', 'filled', 'rejected', 'cancelled', 'risk_checked'].some((s) => inFlight(s as never))).toBe(false);213 });214});215