SPB Git

Repository Public

live.test.ts 14.5 KB 161 lines typescript

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

Theme