Repository Public
1// Controlled live trading — the shared, pure part (`@spw/derivatives/live`): canonical objects and their hashes, the pinned2// Hyperliquid asset table against the recorded venue meta, the acknowledgement text, and the leanness of the entry point that3// is bundled into the isolated signer.4import { createHash } from 'node:crypto';5import { readFileSync } from 'node:fs';6import { dirname, resolve } from 'node:path';7import { fileURLToPath } from 'node:url';8import { describe, expect, it } from 'vitest';9import {10 HL_PINNED_ASSETS, hex, hlPinnedAsset, hlPinnedMismatch, JURISDICTION_ACK, jurisdictionAck, jurisdictionAckOf, validateVenueAuthorization, venueAgentWireName, venueAuthorizationHash, VenueAuthorizationSchema,11 venueLeverageHash, venueOrderBytes, venueOrderHash, venueOrderNotional, VenueOrderSchema, VenuePolicySchema, venueWindowError, withinBand,12 type VenueAuthorization, type VenueLeverage, type VenueOrder,13} from '../src/live/index.ts';1415const NOW = 1794398400;16const A: VenueAuthorization = { v: 1, authorizationId: '7a1c1f0e-8f5f-4b1e-9c56-000000000001', keyId: 'spw-0123456789abcdef', venue: 'hyperliquid', network: 'testnet', account: `0x${'11'.repeat(20)}`, agentAddress: `0x${'22'.repeat(20)}`,17 agentName: 'spw-live', mode: 'MANUAL_LIVE', policy: { allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], maxLeverage: 3, maxOrderNotional: '200', maxPositionNotional: '400', maxGrossExposure: '600', maxOrdersPerDay: 20, maxPriceDeviationBps: 300 },18 createdAt: NOW, expiresAt: NOW + 30 * 86400, approveNonce: NOW * 1000, ackSha256: jurisdictionAck('hyperliquid', 'fr').sha256 };19const O: VenueOrder = { v: 1, authorizationId: A.authorizationId, venue: 'hyperliquid', network: 'testnet', account: A.account, kind: 'order', instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.002', price: '69500',20 triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: `0x${'ab'.repeat(16)}`, nonce: NOW * 1000 + 123, expiresAt: NOW * 1000 + 90_123 };21const L: VenueLeverage = { v: 1, authorizationId: A.authorizationId, venue: 'hyperliquid', network: 'testnet', account: A.account, kind: 'leverage', instrument: 'hyperliquid:BTC', leverage: 3, marginMode: 'cross', nonce: O.nonce, expiresAt: O.expiresAt };2223describe('canonical hashes', () => {24 it('are pinned: a change of the serialization changes these values and must be deliberate', () => {25 expect(hex(venueAuthorizationHash(A))).toBe('cc093bb259776c2fc21f3bcb7031c78586408821cd53dcf66f5e1cbcd4ba008c');26 expect(hex(venueOrderHash(O))).toBe('449b7eb372814fa1c02f78dc7a0fe5c90f6787e7ab1de6aa2208117a959d95db');27 expect(hex(venueLeverageHash(L))).toBe('b1484f9cb6689f9e691242e69147f12402851217edcd9a9bb0e2a2767814164e');28 });2930 it('the order hash is SHA-256 of a domain tag and length-prefixed (name, value) pairs in a fixed order — recomputed here by hand', () => {31 const fields: [string, string][] = [['v', '1'], ['authorizationId', O.authorizationId], ['venue', 'hyperliquid'], ['network', 'testnet'], ['account', O.account], ['kind', 'order'], ['instrument', O.instrument], ['side', 'buy'],32 ['type', 'limit'], ['size', '0.002'], ['price', '69500'], ['triggerPrice', ''], ['reduceOnly', '0'], ['tif', 'gtc'], ['clientId', O.clientId], ['nonce', String(O.nonce)], ['expiresAt', String(O.expiresAt)]];33 const parts: Buffer[] = [Buffer.from('SPW-VENUE-ORDER-v1'), Buffer.from([0])];34 for (const [k, v] of fields) { const h = Buffer.alloc(6); h.writeUInt16BE(Buffer.byteLength(k), 0); h.writeUInt32BE(Buffer.byteLength(v), 2); parts.push(h, Buffer.from(k), Buffer.from(v)); }35 expect(Buffer.from(venueOrderBytes(O)).equals(Buffer.concat(parts))).toBe(true);36 expect(hex(venueOrderHash(O))).toBe(createHash('sha256').update(Buffer.concat(parts)).digest('hex'));37 });3839 it('every field the approval screen shows changes the order hash', () => {40 const base = hex(venueOrderHash(O));41 const changes: Partial<VenueOrder>[] = [{ instrument: 'hyperliquid:ETH' }, { side: 'sell' }, { size: '0.0021' }, { price: '69501' }, { triggerPrice: '69000', type: 'stop_limit' }, { type: 'stop_market', triggerPrice: '69500' }, { reduceOnly: true },42 { tif: 'ioc' }, { clientId: `0x${'ac'.repeat(16)}` }, { nonce: O.nonce + 1 }, { expiresAt: O.expiresAt + 1 }, { network: 'mainnet' }, { account: `0x${'33'.repeat(20)}` }, { authorizationId: '7a1c1f0e-8f5f-4b1e-9c56-000000000002' }];43 const seen = new Set([base]);44 for (const c of changes) { const h = hex(venueOrderHash({ ...O, ...c })); expect(seen.has(h), JSON.stringify(c)).toBe(false); seen.add(h); }45 // decimals are hashed in canonical form: the same number written differently is the same order46 expect(hex(venueOrderHash({ ...O, size: '0.0020', price: '69500.0' }))).toBe(base);47 expect(() => venueOrderHash({ ...O, extra: 1 } as never)).toThrow();48 expect(() => venueOrderHash({ ...O, size: '-1' })).toThrow();49 expect(() => venueOrderHash({ ...O, clientId: 'my-order' })).toThrow();50 expect(VenueOrderSchema.safeParse({ ...O, type: 'market' }).success).toBe(false); // a market order is shown as what it is on the venue: limit + ioc51 });5253 it('every field of the authorization changes its hash; instrument order does not', () => {54 const base = hex(venueAuthorizationHash(A));55 const changes: Partial<VenueAuthorization>[] = [{ network: 'mainnet' }, { account: `0x${'33'.repeat(20)}` }, { agentAddress: `0x${'44'.repeat(20)}` }, { agentName: 'other' }, { mode: 'MANDATE_LIVE' }, { createdAt: NOW + 1 }, { expiresAt: A.expiresAt + 1 },56 { approveNonce: A.approveNonce + 1 }, { ackSha256: jurisdictionAck('hyperliquid', 'en').sha256 }, { keyId: 'spw-ffffffffffffffff' }, { authorizationId: '7a1c1f0e-8f5f-4b1e-9c56-000000000002' }];57 const policies: Partial<VenueAuthorization['policy']>[] = [{ allowedInstruments: ['hyperliquid:BTC'] }, { maxLeverage: 4 }, { maxOrderNotional: '201' }, { maxPositionNotional: '401' }, { maxGrossExposure: '601' }, { maxOrdersPerDay: 21 }, { maxPriceDeviationBps: 301 }];58 const seen = new Set([base]);59 for (const c of changes) { const h = hex(venueAuthorizationHash({ ...A, ...c })); expect(seen.has(h), JSON.stringify(c)).toBe(false); seen.add(h); }60 for (const c of policies) { const h = hex(venueAuthorizationHash({ ...A, policy: { ...A.policy, ...c } })); expect(seen.has(h), JSON.stringify(c)).toBe(false); seen.add(h); }61 expect(hex(venueAuthorizationHash({ ...A, policy: { ...A.policy, allowedInstruments: ['hyperliquid:ETH', 'hyperliquid:BTC'], maxOrderNotional: '200.00' } }))).toBe(base);62 expect(hex(venueLeverageHash({ ...L, leverage: 4 }))).not.toBe(hex(venueLeverageHash(L)));63 expect(hex(venueLeverageHash({ ...L, marginMode: 'isolated' }))).not.toBe(hex(venueLeverageHash(L)));64 // the three kinds of object can never be confused: different domains65 expect(new Set([hex(venueAuthorizationHash(A)), hex(venueOrderHash(O)), hex(venueLeverageHash(L))]).size).toBe(3);66 });67});6869describe('authorization rules', () => {70 it('MANUAL_LIVE only, at most 90 days, current, limits ordered, bounded policy', () => {71 expect(() => validateVenueAuthorization(A, NOW)).not.toThrow();72 expect(() => validateVenueAuthorization({ ...A, mode: 'MANDATE_LIVE' }, NOW)).toThrow(/MANDATE_LIVE .* not part of this delivery/);73 expect(() => validateVenueAuthorization({ ...A, expiresAt: NOW + 90 * 86400 }, NOW)).not.toThrow();74 expect(() => validateVenueAuthorization({ ...A, expiresAt: NOW + 90 * 86400 + 1 }, NOW)).toThrow(/at most 90 days/);75 expect(() => validateVenueAuthorization(A, A.expiresAt)).toThrow(/expired/);76 expect(() => validateVenueAuthorization(A, NOW + 601)).toThrow(/not current/);77 expect(() => validateVenueAuthorization({ ...A, approveNonce: (NOW + 3600) * 1000 }, NOW)).toThrow(/nonce is not current/);78 expect(() => validateVenueAuthorization({ ...A, agentAddress: A.account }, NOW)).toThrow(/must differ/);79 expect(() => validateVenueAuthorization({ ...A, policy: { ...A.policy, maxPositionNotional: '100' } }, NOW)).toThrow(/order limit above the position limit/);80 expect(() => validateVenueAuthorization({ ...A, policy: { ...A.policy, maxGrossExposure: '300' } }, NOW)).toThrow(/position limit above the gross/);81 expect(() => validateVenueAuthorization({ ...A, policy: { ...A.policy, allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:BTC'] } }, NOW)).toThrow(/duplicate/);82 for (const bad of [{ maxLeverage: 21 }, { maxLeverage: 0 }, { maxOrdersPerDay: 501 }, { maxPriceDeviationBps: 9 }, { maxPriceDeviationBps: 2001 }, { allowedInstruments: [] }, { allowedInstruments: ['dydx:BTC-USD'] }, { maxOrderNotional: '0' }, { maxOrderNotional: '1e3' }])83 expect(VenuePolicySchema.safeParse({ ...A.policy, ...bad }).success, JSON.stringify(bad)).toBe(false);84 expect(VenueAuthorizationSchema.safeParse({ ...A, account: A.account.toUpperCase() }).success).toBe(false);85 expect(venueAgentWireName(A)).toBe(`spw-live valid_until ${A.expiresAt * 1000}`);86 });8788 it('order window, notional and price band', () => {89 const now = O.nonce + 1_000;90 expect(venueWindowError(O, now)).toBeNull();91 expect(venueWindowError({ nonce: O.nonce, expiresAt: O.nonce + 120_000 }, now)).toBeNull();92 expect(venueWindowError({ nonce: O.nonce, expiresAt: O.nonce + 120_001 }, now)).toMatch(/longer than 120 s/);93 expect(venueWindowError({ nonce: O.nonce, expiresAt: O.nonce }, now)).toMatch(/not after/);94 expect(venueWindowError(O, O.expiresAt)).toMatch(/expired/);95 expect(venueWindowError({ nonce: now + 60_001, expiresAt: now + 100_000 }, now)).toMatch(/future/);96 expect(venueOrderNotional(O)).toBe('139');97 expect(venueOrderNotional({ size: '0.002', price: '68000', triggerPrice: '69000' })).toBe('138');98 expect(withinBand('70700', '70000', 100)).toBe(true); // exactly 100 bps99 expect(withinBand('70700.1', '70000', 100)).toBe(false);100 expect(withinBand('69300', '70000', 100)).toBe(true);101 expect(withinBand('69299.9', '70000', 100)).toBe(false);102 expect(withinBand('70000', '0', 100)).toBe(false);103 });104});105106describe('pinned Hyperliquid asset table', () => {107 const meta = (net: string) => JSON.parse(readFileSync(new URL(`./fixtures/hyperliquid/${net}-meta.json`, import.meta.url), 'utf8')).body.universe as { name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean; isDelisted?: boolean }[];108 it('equals the venue meta recorded on both networks, index for index', () => {109 for (const net of ['mainnet', 'testnet'] as const) {110 const u = meta(net);111 for (const p of HL_PINNED_ASSETS[net]) expect(u[p.asset], `${net} ${p.symbol}`).toMatchObject({ name: p.symbol, szDecimals: p.szDecimals, maxLeverage: p.maxLeverage });112 const table = u.map((x, i) => ({ symbol: x.name, asset: i, szDecimals: x.szDecimals, maxLeverage: x.maxLeverage, isolatedOnly: x.onlyIsolated === true, delisted: x.isDelisted === true }));113 expect(hlPinnedMismatch(net, table)).toBeNull();114 expect(hlPinnedMismatch(net, table.map((a) => (a.symbol === 'BTC' ? { ...a, delisted: true } : a)))).toMatch(/delisted/);115 expect(hlPinnedMismatch(net, table.map((a) => (a.symbol === 'BTC' ? { ...a, maxLeverage: 20 } : a)))).toBeNull(); // a lower venue maximum cannot mislead the signer116 }117 // the two networks number the assets differently: a mainnet payload cannot be read with the testnet table118 expect(hlPinnedAsset('mainnet', 'hyperliquid:BTC')!.asset).not.toBe(hlPinnedAsset('testnet', 'hyperliquid:BTC')!.asset);119 expect(hlPinnedAsset('testnet', 'hyperliquid:DOGE')).toBeNull();120 });121});122123describe('jurisdiction acknowledgement', () => {124 it('one constant, two languages, hashes pinned; nothing presents it as a legal clearance', () => {125 const fr = jurisdictionAck('hyperliquid', 'fr'), en = jurisdictionAck('hyperliquid', 'en');126 expect(fr.sha256).toBe('3eb96b500ab80008a04164202e66e40f0ef2cf2d7bd79348f836b98fa548ca98');127 expect(en.sha256).toBe('859fab5d4ecfdb5127e300cb96105ce2fc8e8ebbc80584ad02a2a88ee85ad783');128 expect(fr.sha256).toBe(createHash('sha256').update(JURISDICTION_ACK.hyperliquid.fr, 'utf8').digest('hex'));129 expect(jurisdictionAckOf('hyperliquid', en.sha256)).toMatchObject({ locale: 'en' });130 expect(jurisdictionAckOf('hyperliquid', 'ab'.repeat(32))).toBeNull();131 for (const t of [fr.text, en.text]) {132 expect(t).toMatch(/Ontario/); expect(t).toMatch(/AMF/); expect(t).toMatch(/21-332/); expect(t).toMatch(/§1\.9/);133 // the only sentence about lawfulness says whose responsibility it is to determine it; nothing states that trading is cleared134 expect(t).not.toMatch(/is (legal|permitted|allowed|cleared|compliant) (in|for)|you (may|can|are allowed to) trade|has been (cleared|approved|validated)/i);135 expect(t).not.toMatch(/est (légale?|permise?|autorisée?|conforme) (au|en|pour)|vous (pouvez|êtes autorisé à) négocier|a été (validée?|approuvée?|autorisée?)/i);136 }137 expect(en.text).toMatch(/not legal advice/); expect(en.text).toMatch(/not a legal clearance/); expect(en.text).toMatch(/your own decision/);138 expect(fr.text).toMatch(/pas un avis juridique/); expect(fr.text).toMatch(/pas une validation juridique/); expect(fr.text).toMatch(/votre propre décision/);139 // the facts are those of docs/v4/venues/jurisdiction.md140 const doc = readFileSync(new URL('../../../docs/v4/venues/jurisdiction.md', import.meta.url), 'utf8');141 for (const fact of ['United States of America or Ontario, Canada', '§1.6', '2026-06-15', '§1.9', '21-332', 'checked 2026-11-10']) expect(doc, fact).toContain(fact);142 });143});144145describe('the entry point bundled into the isolated signer is lean', () => {146 it('reaches no HTTP client, no websocket stream, no venue read adapter — only pure code', () => {147 const here = dirname(fileURLToPath(import.meta.url)), seen = new Set<string>();148 const walk = (file: string): void => {149 if (seen.has(file)) return;150 seen.add(file);151 const src = readFileSync(file, 'utf8');152 for (const m of src.matchAll(/^\s*(import|export)\s+(?!type\b)[^;]*?from\s+'(\.[^']+)'/gm)) walk(resolve(dirname(file), m[2]!));153 };154 walk(resolve(here, '../src/live/index.ts'));155 const files = [...seen].map((f) => f.slice(f.indexOf('/src/') + 5)).sort();156 expect(files).toEqual(['decimal.ts', 'live/canonical.ts', 'live/collateral.ts', 'live/hl-assets.ts', 'live/index.ts', 'live/jurisdiction.ts', 'live/mandate.ts', 'models.ts', 'venue.ts', 'venues/hyperliquid/error.ts', 'venues/hyperliquid/format.ts',157 'venues/hyperliquid/msgpack.ts', 'venues/hyperliquid/schemas.ts', 'venues/hyperliquid/signing.ts', 'venues/hyperliquid/trading.ts']);158 for (const f of seen) expect(readFileSync(f, 'utf8'), f).not.toMatch(/\bfetch\(|WebSocket|node:https?|node:net\b/);159 });160});161