SPB Git

Repository Public

venue.test.ts 30.7 KB 356 lines typescript

1// Live derivatives, signer side: the trading key, the venue authorization (passkey), and every refusal of /venue/sign.2// No network: prices are injected, nothing is sent to any venue. The mnemonic is the public BIP-39 test vector.3import { createHash } from 'node:crypto';4import { mkdtempSync } from 'node:fs';5import { tmpdir } from 'node:os';6import { join } from 'node:path';7import { describe, expect, it } from 'vitest';8import {9  createHlTrading, hlPinnedAssets, jurisdictionAck, recoverTypedDataSigner, venueAuthorizationHash, venueLeverageHash, venueOrderHash,10  type HlL1SignPayload, type HlUserSignPayload, type OrderRequest, type SignRequest, type VenueAuthorization, type VenueLeverage, type VenueOrder,11} from '@spw/derivatives/live';12import { buildUsdClassTransferTypedDataOnly, l1SignPayload } from '../../../packages/derivatives/src/venues/hyperliquid/signing.ts';13import type { Instrument } from '../../../packages/derivatives/src/models.ts';14import { TestPasskey } from '../../../tests/helpers/passkey.ts';15import { deriveKeys, venuePath } from '../src/keys.ts';16import { Signer } from '../src/signer.ts';17import { Store } from '../src/store.ts';1819const RP = 'wallet.test', ORIGIN = 'https://wallet.test';20const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');21const ACCOUNT = keys.addresses.evm.toLowerCase(), AGENT = keys.venueKey('hyperliquid', 'testnet').address;22const ACK = jurisdictionAck('hyperliquid', 'fr').sha256;23let n = 0;24const uuid = () => `7a1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`;25const inst = (symbol: string, szDecimals: number): Instrument => ({ venue: 'hyperliquid', symbol, id: `hyperliquid:${symbol}`, base: symbol, quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: '',26  tickSize: '0.1', lotSize: szDecimals === 5 ? '0.00001' : szDecimals === 4 ? '0.0001' : '0.01', minOrderSize: null, minNotional: '10', maxLeverage: '40', marginModes: ['cross', 'isolated'], fundingIntervalSec: 3600, markPriceMethod: '', status: 'active' });27const BTC = inst('BTC', 5), ETH = inst('ETH', 4);28const TTL = 90_000;29const trading = createHlTrading({ network: 'testnet', assets: hlPinnedAssets('testnet'), expiresAfterMs: TTL });3031async function setup() {32  const t = { now: Math.floor(Date.now() / 1000) };33  const dir = mkdtempSync(join(tmpdir(), 'spw-venue-'));34  const paths = [join(dir, 'state.json'), join(dir, 'policy.json')] as const;35  const store = new Store(...paths);36  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');37  const px: { BTC: number; ETH: number; fail: string } = { BTC: 70_000, ETH: 2_000, fail: '' };38  const prices = { get: async (syms: string[]) => { if (px.fail) throw new Error(px.fail); return Object.fromEntries(syms.map((s) => [s, (px as unknown as Record<string, number>)[s]!])); } };39  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t.now, prices);40  const pk = new TestPasskey(RP, ORIGIN);41  await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });42  /** the same files opened again by a new process */43  const restart = () => new Signer(keys, new Store(...paths), { rpId: RP, origin: ORIGIN }, () => t.now, prices);44  return { signer, store, pk, t, px, restart };45}46type World = Awaited<ReturnType<typeof setup>>;4748const authorization = (now: number, o: Partial<VenueAuthorization> = {}, policy: Partial<VenueAuthorization['policy']> = {}): VenueAuthorization => ({49  v: 1, authorizationId: uuid(), keyId: keys.keyId, venue: 'hyperliquid', network: 'testnet', account: ACCOUNT, agentAddress: AGENT, agentName: 'spw-live', mode: 'MANUAL_LIVE',50  policy: { allowedInstruments: ['hyperliquid:BTC'], maxLeverage: 3, maxOrderNotional: '200', maxPositionNotional: '400', maxGrossExposure: '600', maxOrdersPerDay: 5, maxPriceDeviationBps: 300, ...policy },51  createdAt: now, expiresAt: now + 30 * 86400, approveNonce: now * 1000, ackSha256: ACK, ...o });52async function authorized(w: World, o: Partial<VenueAuthorization> = {}, policy: Partial<VenueAuthorization['policy']> = {}) {53  const a = authorization(w.t.now, o, policy);54  const r = await w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a)));55  return { a, r };56}57let nonceSeq = 0;58/** an order as the API builds it: the venue action, the sign request, and the canonical order the owner approves */59function order(w: World, a: VenueAuthorization, o: Partial<OrderRequest> = {}, x: { instrument?: Instrument; nonce?: number; t?: typeof trading } = {}) {60  const instrument = x.instrument ?? BTC, nonce = x.nonce ?? w.t.now * 1000 + ++nonceSeq, t = x.t ?? trading;61  const req: OrderRequest = { instrument: instrument.id, side: 'buy', type: 'limit', size: '0.002', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: `k-${nonce}`, ...o };62  const built = t.buildOrder(req, { instrument, nonce }), norm = t.normalizeRequest(req, instrument);63  const canonical: VenueOrder = { v: 1, authorizationId: a.authorizationId, venue: 'hyperliquid', network: a.network, account: a.account, kind: 'order', instrument: norm.instrument, side: norm.side, type: norm.type as VenueOrder['type'],64    size: norm.size, price: norm.price!, triggerPrice: norm.triggerPrice, reduceOnly: norm.reduceOnly, tif: norm.tif, clientId: norm.clientId, nonce, expiresAt: nonce + TTL };65  return { sign: built.sign, action: built.action, canonical, nonce };66}67const send = (w: World, a: VenueAuthorization, b: { sign: SignRequest; canonical?: VenueOrder }, assertion = b.canonical ? w.pk.assert(venueOrderHash(b.canonical)) : undefined) =>68  w.signer.venueSign({ authorizationId: a.authorizationId, sign: b.sign, context: b.canonical ? { order: b.canonical } : undefined, assertion: assertion ?? undefined });69const code = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? `${e.code}: ${e.message}` : 'ok'; };70const journal = (w: World, type: string) => w.store.state.journal.filter((j) => j.type === type);7172describe('venue trading key', () => {73  it('is a dedicated key per network on its own path — never the wallet key, never a vault key', () => {74    const main = keys.venueKey('hyperliquid', 'mainnet'), test = keys.venueKey('hyperliquid', 'testnet');75    expect(venuePath('hyperliquid', 'mainnet')).toBe("m/44'/60'/9000'/0/0");76    expect(venuePath('hyperliquid', 'testnet')).toBe("m/44'/60'/9000'/0/1");77    expect(main.path).toBe("m/44'/60'/9000'/0/0");78    expect(new Set([main.address, test.address, ACCOUNT, keys.vault(1).address.toLowerCase(), keys.vault(8999).address.toLowerCase()]).size).toBe(5);79    expect(() => keys.vault(9000)).toThrow(/reserved/);          // m/44'/60'/9000'/0/0 would be the mainnet trading key80    expect(() => keys.venueKey('dydx' as never, 'mainnet')).toThrow();81  });82  it('POST /venue/key returns the address, never a key', async () => {83    const w = await setup();84    expect(w.signer.venueKey('hyperliquid', 'testnet')).toEqual({ venue: 'hyperliquid', network: 'testnet', address: AGENT, path: "m/44'/60'/9000'/0/1", account: ACCOUNT });85    expect(() => w.signer.venueKey('gmx', 'mainnet')).toThrow(/unknown venue/);86  });87});8889describe('venue authorization (passkey over the canonical authorization)', () => {90  it('the main key signs exactly the approveAgent of the signer\'s own trading key, and the authorization is stored', async () => {91    const w = await setup();92    const { a, r } = await authorized(w);93    const action = r.approve.action as { type: string; agentAddress: string; agentName: string; hyperliquidChain: string; nonce: number };94    expect(action).toMatchObject({ type: 'approveAgent', agentAddress: AGENT, hyperliquidChain: 'Testnet', nonce: a.approveNonce, agentName: `spw-live valid_until ${a.expiresAt * 1000}` });95    // the signature is the MAIN key's, over the typed data of that very action96    const rebuilt = trading.buildApproveAgent({ agentAddress: AGENT, agentName: action.agentName, nonce: a.approveNonce });97    expect(await recoverTypedDataSigner((rebuilt.sign.payload as HlUserSignPayload).typedData, r.approve.signature)).toBe(ACCOUNT);98    expect(r.hash).toBe(Buffer.from(venueAuthorizationHash(a)).toString('hex'));99    const st = w.signer.status().venues;100    expect(st).toHaveLength(1);101    expect(st[0]).toMatchObject({ revoked: false, expired: false, hash: r.hash, today: { orders: 0 } });102    expect(journal(w, 'venue.authorized')[0]!.data).toMatchObject({ authorizationId: a.authorizationId, ackSha256: ACK, mode: 'MANUAL_LIVE' });103  });104105  it('refuses without the owner\'s passkey, with another passkey, or when anything changed after approval', async () => {106    const w = await setup();107    const a = authorization(w.t.now);108    expect(await code(w.signer.venueAuthorize(a, undefined))).toMatch(/^bad_approval/);109    expect(await code(w.signer.venueAuthorize(a, new TestPasskey(RP, ORIGIN).assert(venueAuthorizationHash(a))))).toMatch(/^unknown_credential/);110    // approved limits 200 → the API asks for 20000 with the same assertion111    const wider = { ...a, policy: { ...a.policy, maxOrderNotional: '20000', maxPositionNotional: '20000', maxGrossExposure: '20000' } };112    expect(await code(w.signer.venueAuthorize(wider, w.pk.assert(venueAuthorizationHash(a))))).toMatch(/^bad_approval/);113    expect(await code(w.signer.venueAuthorize({ ...a, ackSha256: 'ab'.repeat(32) }, w.pk.assert(venueAuthorizationHash({ ...a, ackSha256: 'ab'.repeat(32) }))))).toMatch(/^acknowledgement/);114    expect(w.signer.status().venues).toHaveLength(0);115    expect(journal(w, 'venue.authorize.denied').length).toBe(4);116  });117118  it('refuses an account or a trading address that is not the signer\'s own, MANDATE_LIVE, long expiries and unpinned instruments', async () => {119    const w = await setup();120    const tryIt = (o: Partial<VenueAuthorization>, policy: Partial<VenueAuthorization['policy']> = {}) => { const a = authorization(w.t.now, o, policy); return code(w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a)))); };121    expect(await tryIt({ account: '0x' + '11'.repeat(20) })).toMatch(/^wrong_source/);122    expect(await tryIt({ agentAddress: '0x' + '22'.repeat(20) })).toMatch(/^wrong_agent/);                       // an attacker's agent address123    expect(await tryIt({ agentAddress: keys.venueKey('hyperliquid', 'mainnet').address })).toMatch(/^wrong_agent/); // the other network's key124    expect(await tryIt({ mode: 'MANDATE_LIVE' })).toMatch(/^unsupported: MANDATE_LIVE/);125    expect(await tryIt({ expiresAt: w.t.now + 91 * 86400 })).toMatch(/at most 90 days/);126    expect(await tryIt({ createdAt: w.t.now - 3600, approveNonce: (w.t.now - 3600) * 1000 })).toMatch(/not current/);127    expect(await tryIt({ keyId: 'spw-0000000000000000' })).toMatch(/^wrong_key/);128    expect(await tryIt({}, { allowedInstruments: ['hyperliquid:DOGE'] })).toMatch(/not in the signer's pinned testnet asset table/);129    expect(await tryIt({}, { allowedInstruments: ['hyperliquid:SOL'], maxLeverage: 15 })).toMatch(/above the venue maximum/);   // SOL is 10x on testnet130    expect(await tryIt({}, { maxOrderNotional: '1000' })).toMatch(/order limit above the position limit/);131    expect(await code(w.signer.venueAuthorize({ ...authorization(w.t.now), extra: 1 }, w.pk.assert(new Uint8Array(32))))).toMatch(/^bad_request/);132    w.signer.halt();133    expect(await tryIt({})).toMatch(/emergency halt/);134  });135136  it('one active authorization per venue account; an id is never recorded twice; mainnet and testnet are separate', async () => {137    const w = await setup();138    const { a } = await authorized(w);139    expect(await code(w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a))))).toMatch(/^replay/);140    const b = authorization(w.t.now);141    expect(await code(w.signer.venueAuthorize(b, w.pk.assert(venueAuthorizationHash(b))))).toMatch(/^conflict/);142    const m = authorization(w.t.now, { network: 'mainnet', agentAddress: keys.venueKey('hyperliquid', 'mainnet').address });143    const r = await w.signer.venueAuthorize(m, w.pk.assert(venueAuthorizationHash(m)));144    expect((r.approve.action as { hyperliquidChain: string }).hyperliquidChain).toBe('Mainnet');145    w.signer.venueRevoke(a.authorizationId);146    await w.signer.venueAuthorize(b, w.pk.assert(venueAuthorizationHash(b)));                      // after revocation a new one can be approved147  });148});149150describe('/venue/sign — orders', () => {151  it('signs an approved order with the trading key and counts it', async () => {152    const w = await setup(), { a } = await authorized(w);153    const o = order(w, a);154    const r = await send(w, a, o);155    expect(r).toMatchObject({ kind: 'order', signer: AGENT, hash: Buffer.from(venueOrderHash(o.canonical)).toString('hex') });156    expect(await recoverTypedDataSigner((o.sign.payload as HlL1SignPayload).typedData, r.signature)).toBe(AGENT);157    expect(w.signer.status().venues[0]).toMatchObject({ lastNonce: o.nonce, today: { orders: 1, cancels: 0 } });158    expect(journal(w, 'venue.sign.ok')[0]!.data).toMatchObject({ kind: 'order', instrument: 'hyperliquid:BTC', side: 'buy', size: '0.002', price: '69500', notional: '139', mid: '70000' });159    // a market order is an IOC limit at its worst price: that is what the owner approves160    const mkt = order(w, a, { type: 'market', tif: 'ioc', price: '70300' });161    expect(mkt.canonical).toMatchObject({ type: 'limit', tif: 'ioc', price: '70300' });162    await send(w, a, mkt);163    // a reduce-only order is not exempt: the signer cannot see positions, so the passkey is required all the same164    const ro = order(w, a, { side: 'sell', reduceOnly: true, type: 'market', tif: 'ioc', price: '69700' });165    expect(await code(send(w, a, ro, null as never))).toMatch(/^bad_approval: MANUAL_LIVE/);166  });167168  it('unknown, revoked and expired authorizations sign nothing — a revoked one not even after a restart', async () => {169    const w = await setup(), { a } = await authorized(w);170    expect(await code(w.signer.venueSign({ authorizationId: uuid(), sign: order(w, a).sign }))).toMatch(/^not_found/);171    w.signer.venueRevoke(a.authorizationId);172    expect(await code(send(w, a, order(w, a)))).toMatch(/^revoked/);173    const c = trading.buildCancel({ instrument: BTC, clientId: 'x', nonce: w.t.now * 1000 + 999 });174    expect(await code(send(w, a, { sign: c.sign }))).toMatch(/^revoked/);                               // not even a cancel175    const again = w.restart();176    expect(again.status().venues[0]).toMatchObject({ revoked: true });177    expect(await code(again.venueSign({ authorizationId: a.authorizationId, sign: order(w, a).sign }))).toMatch(/^revoked/);178    expect(() => w.signer.venueRevoke(uuid())).toThrow(/unknown venue authorization/);179180    const w2 = await setup(), { a: b } = await authorized(w2, { expiresAt: Math.floor(Date.now() / 1000) + 3600 });181    w2.t.now += 3601;182    expect(await code(send(w2, b, order(w2, b)))).toMatch(/^expired: authorization expired/);183  });184185  it('enforces its own stored policy: instrument, notional, daily count', async () => {186    const w = await setup(), { a } = await authorized(w);187    expect(await code(send(w, a, order(w, a, { price: '2000', size: '0.01' }, { instrument: ETH })))).toMatch(/^policy: hyperliquid:ETH is not allowed/);188    expect(await code(send(w, a, order(w, a, { size: '0.003' })))).toMatch(/^policy: order notional 208.5 is above the authorized 200/);189    // a stop far above the limit price is valued at its trigger price190    expect(await code(send(w, a, order(w, a, { type: 'stop_limit', side: 'sell', size: '0.0029', price: '68500', triggerPrice: '69000' })))).toMatch(/order notional 200.1 is above/);191    for (let k = 0; k < 5; k++) await send(w, a, order(w, a));192    expect(await code(send(w, a, order(w, a)))).toMatch(/^policy: daily order count reached/);193    w.t.now += 86400;                                                                                   // the next UTC day194    await send(w, a, order(w, a));195  });196197  it('price sanity against the signer\'s own exchange price; no price, no signature', async () => {198    const w = await setup(), { a } = await authorized(w);199    expect(await code(send(w, a, order(w, a, { price: '73000', size: '0.001' })))).toMatch(/^price: price 73000 is more than 300 bps away from the signer's own BTC price 70000/);200    expect(await code(send(w, a, order(w, a, { price: '67000' })))).toMatch(/^price: /);201    expect(await code(send(w, a, order(w, a, { type: 'stop_market', side: 'sell', reduceOnly: true, price: '69000', triggerPrice: '60000' })))).toMatch(/^price: trigger price 60000/);202    w.px.BTC = 35_000;                                                                                  // the API's view and the signer's differ by half203    expect(await code(send(w, a, order(w, a)))).toMatch(/^price: /);204    w.px.fail = 'price sources disagree on BTC';205    expect(await code(send(w, a, order(w, a)))).toMatch(/^no_price: .*price sources disagree/);206    expect(w.signer.status().venues[0]!.today.orders).toBe(0);207  });208209  it('replayed client id, stale nonce, expired or over-long window', async () => {210    const w = await setup(), { a } = await authorized(w);211    const first = order(w, a, { clientId: 'same-key' });212    await send(w, a, first);213    expect(await code(send(w, a, first))).toMatch(/^stale_nonce/);                                      // the very same request again214    expect(await code(send(w, a, order(w, a, { clientId: 'same-key' })))).toMatch(/^replay: an order with this client id/);215    expect(await code(send(w, a, order(w, a, { clientId: 'reuse' }, { nonce: first.nonce })))).toMatch(/^stale_nonce: nonce already used/);216    expect(await code(send(w, a, order(w, a, {}, { nonce: w.t.now * 1000 - 200_000 })))).toMatch(/^expired|^stale_nonce: nonce is stale/);217    // the venue's rule: once 100 nonces are stored, anything at or below the lowest of them is refused; in between is fine218    const big = await setup(), { a: b2 } = await authorized(big, {}, { maxOrdersPerDay: 500 });219    const base = big.t.now * 1000;220    for (let k = 0; k < 100; k++) await send(big, b2, { sign: trading.buildCancel({ instrument: BTC, venueOrderId: String(k + 1), nonce: base + 100 + 2 * k }).sign });221    expect(await code(send(big, b2, order(big, b2, {}, { nonce: base + 100 })))).toMatch(/^stale_nonce: nonce already used/);222    expect(await code(send(big, b2, order(big, b2, {}, { nonce: base + 50 })))).toMatch(/^stale_nonce: nonce is below the lowest/);223    await send(big, b2, order(big, b2, {}, { nonce: base + 101 }));                                     // unused, above the lowest: out of order is allowed224    expect(await code(send(w, a, order(w, a, {}, { nonce: w.t.now * 1000 + 120_000 })))).toMatch(/^policy: nonce is dated in the future/);225    const long = createHlTrading({ network: 'testnet', assets: hlPinnedAssets('testnet'), expiresAfterMs: 600_000 });226    const o = order(w, a, {}, { t: long }); o.canonical.expiresAt = o.nonce + 600_000;227    expect(await code(send(w, a, o))).toMatch(/^policy: validity window longer than 120 s/);228    const never = createHlTrading({ network: 'testnet', assets: hlPinnedAssets('testnet') });229    expect(await code(send(w, a, order(w, a, {}, { t: never })))).toMatch(/^policy: the action carries no venue expiry/);230    // the approval window passes between proposal and approval231    const late = order(w, a);232    w.t.now += 200;233    expect(await code(send(w, a, late))).toMatch(/^expired: approval window expired/);234  });235236  it('missing or wrong passkey assertion', async () => {237    const w = await setup(), { a } = await authorized(w);238    const o = order(w, a);239    expect(await code(send(w, a, o, null as never))).toMatch(/^bad_approval: MANUAL_LIVE/);240    expect(await code(send(w, a, o, new TestPasskey(RP, ORIGIN).assert(venueOrderHash(o.canonical))))).toMatch(/^unknown_credential/);241    expect(await code(send(w, a, o, w.pk.assert(venueOrderHash({ ...o.canonical, size: '0.001' }))))).toMatch(/^bad_approval/);   // an assertion made for another order242    expect(await code(send(w, a, o, w.pk.assert(venueOrderHash(o.canonical), { origin: 'https://evil.test' })))).toMatch(/^bad_approval/);243    expect(await code(send(w, a, o, w.pk.assert(venueOrderHash(o.canonical), { uv: false })))).toMatch(/^bad_approval/);244    expect(w.signer.status().venues[0]!.today.orders).toBe(0);245    await send(w, a, o);                                                                                // nothing above consumed the order246  });247248  it('an action tampered after the approval is refused, whichever side is changed', async () => {249    const w = await setup(), { a } = await authorized(w);250    const shown = order(w, a, { size: '0.001' });251    const assertion = w.pk.assert(venueOrderHash(shown.canonical));252    // (a) the payload is swapped for a larger order, the approved context is kept253    const bigger = order(w, a, { size: '0.0028', clientId: 'k-swap' }, { nonce: shown.nonce });254    expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: bigger.sign, context: { order: shown.canonical }, assertion }))).toMatch(/^mismatch: .*\(size\)/);255    // (b) the context is changed to match the swapped payload: the assertion no longer covers it256    expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: bigger.sign, context: { order: bigger.canonical }, assertion: w.pk.assert(venueOrderHash(shown.canonical)) }))).toMatch(/^bad_approval/);257    // (c) side, price, reduce-only, client id258    for (const [field, change] of [['side', { side: 'sell' }], ['price', { price: '69600' }], ['reduce-only', { reduceOnly: true }], ['time in force', { tif: 'alo' }], ['client id', { clientId: 'other' }]] as const) {259      const t2 = order(w, a, { size: '0.001', clientId: field === 'client id' ? 'other' : `k-${shown.nonce}`, ...change }, { nonce: shown.nonce });260      expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: t2.sign, context: { order: shown.canonical }, assertion: w.pk.assert(venueOrderHash(shown.canonical)) }))).toMatch(new RegExp(`^mismatch: .*\\(${field}\\)`));261    }262    // (d) the typed data no longer matches the action (hash of another action)263    const forged = structuredClone(shown.sign) as SignRequest & { payload: HlL1SignPayload };264    (forged.payload.action as { orders: { s: string }[] }).orders[0]!.s = '0.002';265    expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: forged, context: { order: shown.canonical }, assertion: w.pk.assert(venueOrderHash(shown.canonical)) }))).toMatch(/^rejected: .*typed data does not match/);266    // (e) two orders in one action267    const two = structuredClone(shown.action); two.orders.push({ ...two.orders[0]!, c: undefined as never }); delete (two.orders[1] as { c?: string }).c;268    const td = l1SignPayload(two as never, { network: 'testnet', nonce: shown.nonce, expiresAfter: shown.nonce + TTL }).typedData;269    expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: { venue: 'hyperliquid', network: 'testnet', kind: 'hl_l1', payload: { scheme: 'l1', action: two, nonce: shown.nonce, vaultAddress: null, expiresAfter: shown.nonce + TTL, typedData: td } },270      context: { order: shown.canonical }, assertion: w.pk.assert(venueOrderHash(shown.canonical)) }))).toMatch(/^unsupported: exactly one order/);271    expect(w.signer.status().venues[0]!.today.orders).toBe(0);272  });273274  it('anything that is not an order, a cancel or a leverage change is refused: `other`, a smuggled approveAgent, a transfer', async () => {275    const w = await setup(), { a } = await authorized(w);276    const nonce = w.t.now * 1000 + 50, exp = nonce + TTL;277    const l1 = (action: Record<string, unknown>, vault: string | null = null): SignRequest => ({ venue: 'hyperliquid', network: 'testnet', kind: 'hl_l1',278      payload: { scheme: 'l1', action, nonce, vaultAddress: vault, expiresAfter: exp, typedData: l1SignPayload(action as never, { network: 'testnet', nonce, vaultAddress: vault, expiresAfter: exp }).typedData } });279    const tryIt = (sign: SignRequest) => code(w.signer.venueSign({ authorizationId: a.authorizationId, sign, context: {}, assertion: w.pk.assert(new Uint8Array(32)) }));280    // correctly hashed L1 actions that an agent key COULD sign on the venue281    expect(await tryIt(l1({ type: 'vaultTransfer', vaultAddress: '0x' + 'aa'.repeat(20), isDeposit: true, usd: 1000000 }))).toMatch(/^unsupported: this payload is not an order.*vaultTransfer/);282    expect(await tryIt(l1({ type: 'agentSendAsset', destination: ACCOUNT, sourceDex: '', destinationDex: 'spot', token: 'USDC', amount: '10' }))).toMatch(/^unsupported: .*agentSendAsset/);283    expect(await tryIt(l1({ type: 'subAccountTransfer', subAccountUser: '0x' + 'bb'.repeat(20), isDeposit: true, usd: 5 }))).toMatch(/^unsupported/);284    expect(await tryIt(l1({ type: 'updateIsolatedMargin', asset: 3, isBuy: true, ntli: 1 }))).toMatch(/^unsupported/);285    expect(await tryIt(l1({ type: 'scheduleCancel' }))).toMatch(/^unsupported/);286    // an agent approval for an attacker's address, as a user-signed request and dressed up as an L1 action287    const evil = trading.buildApproveAgent({ agentAddress: '0x' + '66'.repeat(20), agentName: 'evil', nonce });288    expect(await tryIt(evil.sign)).toMatch(/^unsupported: an agent approval can only be signed through \/venue\/authorize/);289    expect(await tryIt(l1(evil.action as never))).toMatch(/^unsupported: .*approveAgent/);290    // a user-signed transfer (spot ⇄ perp) and a withdrawal-shaped request291    const xfer = buildUsdClassTransferTypedDataOnly({ network: 'testnet', amount: '25', toPerp: false, nonce });292    expect(await tryIt({ venue: 'hyperliquid', network: 'testnet', kind: 'hl_user', payload: { scheme: 'user', action: xfer.action, typedData: xfer.typedData } })).toMatch(/^unsupported: this payload is not an order.*usdClassTransfer/);293    expect(await tryIt({ venue: 'hyperliquid', network: 'testnet', kind: 'raw', payload: { hash: '0x' + '00'.repeat(32) } })).toMatch(/^rejected: .*unknown sign request kind/);294    // an order on behalf of a vault / sub-account, a builder fee, a mainnet payload under a testnet authorization295    const o = order(w, a);296    expect(await tryIt(l1(o.action as never, '0x' + 'cc'.repeat(20)))).toMatch(/^unsupported: sub-accounts and vaults/);297    expect(await tryIt(l1({ ...o.action, builder: { b: '0x' + 'dd'.repeat(20), f: 10 } }))).toMatch(/^rejected/);298    const main = createHlTrading({ network: 'mainnet', assets: hlPinnedAssets('mainnet'), expiresAfterMs: TTL }).buildOrder({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'm' }, { instrument: BTC, nonce });299    expect(await tryIt(main.sign)).toMatch(/^wrong_venue/);300    expect(await tryIt({ ...main.sign, network: 'testnet' })).toMatch(/^rejected: .*typed data does not match/);   // relabelled: the phantom agent is the mainnet one301    expect(journal(w, 'venue.sign.ok')).toHaveLength(0);302  });303304  it('asset table: the signer reads indices through its own pinned table, not the API\'s', async () => {305    const w = await setup(), { a } = await authorized(w);306    // the API (or a changed venue) numbers BTC as 0 — on testnet the pinned table says index 0 is SOL307    const wrongTable = createHlTrading({ network: 'testnet', assets: [{ symbol: 'BTC', asset: 0, szDecimals: 5, maxLeverage: 40, isolatedOnly: false, delisted: false }], expiresAfterMs: TTL });308    const o = order(w, a, { price: '69500', size: '0.01' }, { t: wrongTable });309    expect(await code(send(w, a, o))).toMatch(/^mismatch: .*\(instrument\)/);                             // the owner approved "BTC", the payload trades SOL310    const sizeOnSol = order(w, a, { size: '0.00123' }, { t: wrongTable });311    expect(await code(send(w, a, sizeOnSol))).toMatch(/^rejected: .*not valid for SOL/);                   // 5 decimals of size do not exist on SOL312    const unknown = createHlTrading({ network: 'testnet', assets: [{ symbol: 'BTC', asset: 77, szDecimals: 5, maxLeverage: 40, isolatedOnly: false, delisted: false }], expiresAfterMs: TTL });313    expect(await code(send(w, a, order(w, a, {}, { t: unknown })))).toMatch(/^rejected: .*asset 77, which is not in the testnet asset table/);314  });315316  it('emergency halt stops orders and leverage changes; cancels are still signed', async () => {317    const w = await setup(), { a } = await authorized(w);318    w.signer.halt();319    expect(await code(send(w, a, order(w, a)))).toMatch(/^policy: emergency halt/);320    const c = trading.buildCancel({ instrument: BTC, clientId: 'k-1', nonce: w.t.now * 1000 + 500 });321    expect((await send(w, a, { sign: c.sign })).kind).toBe('cancel');322  });323});324325describe('/venue/sign — cancels and leverage', () => {326  it('a cancel needs no passkey, but must be for an allowed instrument and a fresh nonce', async () => {327    const w = await setup(), { a } = await authorized(w);328    const byCloid = trading.buildCancel({ instrument: BTC, clientId: 'k-1', nonce: w.t.now * 1000 + 1 });329    const r = await send(w, a, { sign: byCloid.sign });330    expect(r.kind).toBe('cancel');331    expect(await recoverTypedDataSigner((byCloid.sign.payload as HlL1SignPayload).typedData, r.signature)).toBe(AGENT);332    await send(w, a, { sign: trading.buildCancel({ instrument: BTC, venueOrderId: '123456', nonce: w.t.now * 1000 + 2 }).sign });333    expect(await code(send(w, a, { sign: trading.buildCancel({ instrument: ETH, venueOrderId: '9', nonce: w.t.now * 1000 + 3 }).sign }))).toMatch(/^policy: hyperliquid:ETH is not allowed/);334    expect(await code(send(w, a, { sign: byCloid.sign }))).toMatch(/^stale_nonce/);335    expect(w.signer.status().venues[0]!.today).toEqual({ orders: 0, cancels: 2, leverage: 0 });336  });337338  it('a leverage change needs the passkey over its canonical form and stays at or below the authorized maximum', async () => {339    const w = await setup(), { a } = await authorized(w);340    const lev = (leverage: number, marginMode: 'cross' | 'isolated' = 'cross') => {341      const nonce = w.t.now * 1000 + ++nonceSeq, b = trading.buildUpdateLeverage({ instrument: BTC, leverage, marginMode, nonce });342      const canonical: VenueLeverage = { v: 1, authorizationId: a.authorizationId, venue: 'hyperliquid', network: 'testnet', account: ACCOUNT, kind: 'leverage', instrument: BTC.id, leverage, marginMode, nonce, expiresAt: nonce + TTL };343      return { sign: b.sign, canonical };344    };345    const go = (x: ReturnType<typeof lev>, canonical = x.canonical, assertion: unknown = w.pk.assert(venueLeverageHash(canonical))) =>346      code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: x.sign, context: { leverage: canonical }, assertion: assertion as never }));347    expect(await go(lev(3))).toBe('ok');348    expect(await go(lev(4))).toMatch(/^policy: leverage 4 is above the authorized 3/);349    expect(await go(lev(2), undefined, null)).toMatch(/^bad_approval: MANUAL_LIVE/);350    const shown = lev(2), sent = lev(3);351    expect(await go(sent, { ...shown.canonical, nonce: sent.canonical.nonce, expiresAt: sent.canonical.expiresAt })).toMatch(/^mismatch/);   // shows 2x, signs 3x352    expect(await go(lev(2, 'isolated'), { ...lev(2, 'isolated').canonical })).toMatch(/^mismatch/);                                          // context of another nonce353    expect(w.signer.status().venues[0]!.today.leverage).toBe(1);354  });355});356
Files main
Menu

Theme