Repository Public
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