Repository Public
1// Derivative mandates, signer side: approval (passkey), and every rule of /venue/mandate/sign — accepted just inside, refused2// just outside. No network: prices are injected, and the venue's info endpoint is a scripted fake behind the REAL reader3// (venue-read.ts), so its parsing is exercised too. Nothing is sent to any venue. The mnemonic is the public BIP-39 test vector.4import { createHash } from 'node:crypto';5import { mkdtempSync, readFileSync } from 'node:fs';6import { tmpdir } from 'node:os';7import { join } from 'node:path';8import { describe, expect, it } from 'vitest';9import {10 createHlTrading, D, hlPinnedAssets, jurisdictionAck, recoverTypedDataSigner, VENUE_MANDATE_WIND_DOWN_S, venueAuthorizationHash, venueMandateHash, venueOrderHash, worstCaseExposure,11 type HlL1SignPayload, type OrderRequest, type SignRequest, type VenueAuthorization, type VenueMandate, type VenueOrder,12} from '@spw/derivatives/live';13import { buildUsdClassTransferTypedDataOnly, l1SignPayload } from '../../../packages/derivatives/src/venues/hyperliquid/signing.ts';14import type { Instrument } from '../../../packages/derivatives/src/models.ts';15import { TestPasskey } from '../../../tests/helpers/passkey.ts';16import { deriveKeys } from '../src/keys.ts';17import { Signer } from '../src/signer.ts';18import { Store } from '../src/store.ts';19import { hyperliquidReader, noVenueReader, VENUE_INFO_URL } from '../src/venue-read.ts';2021const RP = 'wallet.test', ORIGIN = 'https://wallet.test';22const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');23const ACCOUNT = keys.addresses.evm.toLowerCase(), AGENT = keys.venueKey('hyperliquid', 'testnet').address;24const ACK = jurisdictionAck('hyperliquid', 'fr').sha256, SHA = 'ab'.repeat(32);25let n = 0;26const uuid = () => `8b2d2f1e-9a6a-4c2f-8d67-${String(++n).padStart(12, '0')}`;27const inst = (symbol: string, lot: string): Instrument => ({ venue: 'hyperliquid', symbol, id: `hyperliquid:${symbol}`, base: symbol, quote: 'USD', kind: 'perpetual', collateral: 'USDC', settlement: '',28 tickSize: '0.1', lotSize: lot, minOrderSize: null, minNotional: '10', maxLeverage: '40', marginModes: ['cross', 'isolated'], fundingIntervalSec: 3600, markPriceMethod: '', status: 'active' });29const BTC = inst('BTC', '0.00001'), ETH = inst('ETH', '0.0001'), SOL = inst('SOL', '0.01');30const TTL = 90_000;31const tradingOf = (ttl: number | null) => createHlTrading({ network: 'testnet', assets: hlPinnedAssets('testnet'), expiresAfterMs: ttl });32const trading = tradingOf(TTL);3334/** the venue's info endpoint, scripted: what the signer's own reader receives */35interface FakeVenue {36 accountValue: string; positions: { coin: string; szi: string; mark: string; lev?: { type: 'cross' | 'isolated'; value: number } }[]; orders: Record<string, unknown>[];37 lev: Record<string, { type: 'cross' | 'isolated'; value: number }>; fail: string; ageMs: number; totalNtlPos: string | null; mutate: ((type: string, answer: any) => unknown) | null; calls: { url: string; type: string }[];38}39const openOrder = (o: { coin?: string; side?: 'B' | 'A'; px: string; sz: string; reduceOnly?: boolean; trigger?: string; cloid?: string; timestamp?: number }) => ({ coin: o.coin ?? 'BTC', side: o.side ?? 'B', limitPx: o.px, sz: o.sz, oid: 1,40 timestamp: o.timestamp ?? 1, origSz: o.sz, isTrigger: o.trigger !== undefined, triggerPx: o.trigger ?? '0.0', reduceOnly: o.reduceOnly ?? false, orderType: 'Limit', tif: 'Gtc', cloid: o.cloid ?? null });4142async function setup() {43 const t = { now: Math.floor(Date.now() / 1000) };44 const dir = mkdtempSync(join(tmpdir(), 'spw-mandate-'));45 const paths = [join(dir, 'state.json'), join(dir, 'policy.json')] as const;46 const store = new Store(...paths);47 store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');48 const px: Record<string, number> & { fail?: never } = { BTC: 70_000, ETH: 2_000, SOL: 100 }, pxFail = { why: '' };49 const prices = { get: async (syms: string[]) => { if (pxFail.why) throw new Error(pxFail.why); return Object.fromEntries(syms.map((s) => [s, px[s]!])); } };50 const venue: FakeVenue = { accountValue: '1000', positions: [], orders: [], lev: { BTC: { type: 'cross', value: 3 }, ETH: { type: 'cross', value: 3 }, SOL: { type: 'cross', value: 3 } }, fail: '', ageMs: 0, totalNtlPos: null, mutate: null, calls: [] };51 const value = (p: { szi: string; mark: string }) => D.mul(D.abs(D.from(p.szi)), D.from(p.mark));52 const post = async (url: string, body: any): Promise<unknown> => {53 venue.calls.push({ url, type: body.type });54 if (venue.fail) throw new Error(venue.fail);55 if (body.user !== ACCOUNT) throw new Error('the signer asked about another account');56 let answer: unknown;57 if (body.type === 'clearinghouseState') {58 const assetPositions = venue.positions.map((p) => ({ type: 'oneWay', position: { coin: p.coin, szi: p.szi, entryPx: p.mark, positionValue: D.str(value(p)), unrealizedPnl: '0.0', liquidationPx: null, marginUsed: '0.0',59 leverage: p.lev ?? venue.lev[p.coin] ?? { type: 'cross', value: 20 }, cumFunding: { allTime: '0.0', sinceOpen: '0.0', sinceChange: '0.0' } } }));60 const total = venue.totalNtlPos ?? D.str(venue.positions.reduce((s, p) => D.add(s, value(p)), D.zero));61 const summary = { accountValue: venue.accountValue, totalNtlPos: total, totalRawUsd: '0.0', totalMarginUsed: '0.0' };62 answer = { marginSummary: summary, crossMarginSummary: summary, crossMaintenanceMarginUsed: '0.0', withdrawable: '0.0', assetPositions, time: t.now * 1000 - venue.ageMs };63 } else if (body.type === 'frontendOpenOrders') answer = venue.orders;64 else if (body.type === 'activeAssetData') answer = { user: body.user, coin: body.coin, leverage: venue.lev[body.coin] ?? { type: 'cross', value: 20 }, markPx: '0' };65 else throw new Error(`fake venue: no answer for ${body.type}`);66 return venue.mutate ? venue.mutate(body.type, answer) : answer;67 };68 const make = (s: Store) => new Signer(keys, s, { rpId: RP, origin: ORIGIN }, () => t.now, prices, hyperliquidReader(post));69 const signer = make(store);70 const pk = new TestPasskey(RP, ORIGIN);71 await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });72 return { signer, store, pk, t, px, pxFail, venue, restart: () => make(new Store(...paths)) };73}74type World = Awaited<ReturnType<typeof setup>>;7576const authorization = (now: number, o: Partial<VenueAuthorization> = {}, policy: Partial<VenueAuthorization['policy']> = {}): VenueAuthorization => ({77 v: 1, authorizationId: uuid(), keyId: keys.keyId, venue: 'hyperliquid', network: 'testnet', account: ACCOUNT, agentAddress: AGENT, agentName: 'spw-live', mode: 'MANUAL_LIVE',78 policy: { allowedInstruments: ['hyperliquid:BTC', 'hyperliquid:ETH'], maxLeverage: 5, maxOrderNotional: '300', maxPositionNotional: '500', maxGrossExposure: '800', maxOrdersPerDay: 50, maxPriceDeviationBps: 300, ...policy },79 createdAt: now, expiresAt: now + 60 * 86400, approveNonce: now * 1000, ackSha256: ACK, ...o });80const mandate = (a: VenueAuthorization, now: number, o: Partial<VenueMandate> = {}): VenueMandate => ({81 v: 1, mandateId: uuid(), authorizationId: a.authorizationId, keyId: a.keyId, venue: 'hyperliquid', network: a.network, account: a.account, agentAddress: a.agentAddress, strategySha256: SHA, agentId: 'agent-7',82 instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '400' }], maxOrderNotional: '200', maxGrossExposure: '600', maxOrdersPerDay: 10, maxOrdersPerHour: 6, maxDailyTurnover: '1500',83 floorEquity: '100', maxPriceDeviationBps: 300, createdAt: now, expiresAt: now + 20 * 86400, ackSha256: ACK, ...o });84const code = async (p: Promise<unknown> | (() => unknown)) => { const e = await Promise.resolve().then(() => (typeof p === 'function' ? p() : p)).then(() => null, (x) => x); return e ? `${e.code}: ${e.message}` : 'ok'; };85const journal = (w: World, type: string) => w.store.state.journal.filter((j) => j.type === type);8687async function authorized(w: World, o: Partial<VenueAuthorization> = {}, policy: Partial<VenueAuthorization['policy']> = {}) {88 const a = authorization(w.t.now, o, policy);89 await w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a)));90 return a;91}92/** an authorization and an approved mandate on it */93async function mandated(w: World, o: Partial<VenueMandate> = {}, policy: Partial<VenueAuthorization['policy']> = {}) {94 const a = await authorized(w, {}, policy), m = mandate(a, w.t.now, o);95 await w.signer.venueMandateApprove(m, w.pk.assert(venueMandateHash(m)));96 return { a, m };97}98let nonceSeq = 0;99/** an order as the engine builds it: the venue action and the sign request. No passkey anywhere. */100function order(w: World, o: Partial<OrderRequest> = {}, x: { instrument?: Instrument; nonce?: number; t?: ReturnType<typeof tradingOf> } = {}) {101 const instrument = x.instrument ?? BTC, nonce = x.nonce ?? w.t.now * 1000 + ++nonceSeq, t = x.t ?? trading;102 const req: OrderRequest = { instrument: instrument.id, side: 'buy', type: 'limit', size: '0.002', price: '70000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: `k-${nonce}`, ...o };103 const built = t.buildOrder(req, { instrument, nonce }), norm = t.normalizeRequest(req, instrument);104 const canonical = (a: VenueAuthorization): VenueOrder => ({ v: 1, authorizationId: a.authorizationId, venue: 'hyperliquid', network: a.network, account: a.account, kind: 'order', instrument: norm.instrument, side: norm.side,105 type: norm.type as VenueOrder['type'], size: norm.size, price: norm.price!, triggerPrice: norm.triggerPrice, reduceOnly: norm.reduceOnly, tif: norm.tif, clientId: norm.clientId, nonce, expiresAt: nonce + TTL });106 return { sign: built.sign, nonce, canonical };107}108const send = (w: World, m: VenueMandate, b: { sign: SignRequest }, context?: { order?: unknown }) => w.signer.venueMandateSign({ mandateId: m.mandateId, sign: b.sign, ...(context ? { context } : {}) });109const buy = (w: World, m: VenueMandate, o: Partial<OrderRequest> = {}, x: Parameters<typeof order>[2] = {}) => code(send(w, m, order(w, o, x)));110const st = (w: World, m: VenueMandate) => w.signer.status().venueMandates.find((x) => x.mandate.mandateId === m.mandateId)!;111/** past the venue expiry and the grace of everything signed so far: those orders can no longer be accepted by the venue */112const expirePending = (w: World) => { w.t.now += 101; };113114describe('worst-case exposure (pure)', () => {115 const P = { 'hyperliquid:BTC': '70000', 'hyperliquid:ETH': '2000' };116 const o = (side: 'buy' | 'sell', size: string, price = '70000', reduceOnly = false, instrument = 'hyperliquid:BTC') => ({ instrument, side, size, price, reduceOnly });117 it('position at the price given + every order that can add, per side; the larger side is the bound; reduce-only orders never count', () => {118 expect(worstCaseExposure([], [], P)).toEqual({ perInstrument: {}, gross: '0' });119 expect(worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '0.01' }], [], P)).toEqual({ perInstrument: { 'hyperliquid:BTC': '700' }, gross: '700' });120 expect(worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '-0.01' }], [], P).gross).toBe('700');121 // long 0.01 + buys 0.003 → 910; sells 0.02 → short side 1400: whatever fills, |position| ≤ 1400122 expect(worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '0.01' }], [o('buy', '0.003'), o('sell', '0.02')], P).perInstrument['hyperliquid:BTC']).toBe('1400');123 // an opposite-side order never REDUCES the bound unless the venue guarantees it (reduce-only), and then it is simply not counted124 expect(worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '0.01' }], [o('sell', '0.01')], P).gross).toBe('700');125 expect(worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '0.01' }], [o('sell', '0.01', '70000', true), o('buy', '5', '70000', true)], P).gross).toBe('700');126 // an order is valued at the higher of its own price and the instrument's price127 expect(worstCaseExposure([], [o('buy', '0.01', '60000')], P).gross).toBe('700');128 expect(worstCaseExposure([], [o('sell', '0.01', '71000')], P).gross).toBe('710');129 expect(worstCaseExposure([{ instrument: 'hyperliquid:ETH', size: '0.5' }], [o('buy', '0.001')], P)).toEqual({ perInstrument: { 'hyperliquid:ETH': '1000', 'hyperliquid:BTC': '70' }, gross: '1070' });130 });131 it('refuses to value what it cannot: a position without a price, two positions on one instrument', () => {132 expect(() => worstCaseExposure([{ instrument: 'hyperliquid:DOGE', size: '10' }], [], P)).toThrow(/no price/);133 expect(() => worstCaseExposure([{ instrument: 'hyperliquid:BTC', size: '1' }, { instrument: 'hyperliquid:BTC', size: '1' }], [], P)).toThrow(/two positions/);134 expect(worstCaseExposure([{ instrument: 'hyperliquid:DOGE', size: '0' }], [o('buy', '100', '0.2', false, 'hyperliquid:DOGE')], P).gross).toBe('20'); // an order names its own price135 });136});137138describe('mandate approval (one passkey assertion over the canonical mandate)', () => {139 it('stores the mandate; nothing is signed by any key; the status shows it active', async () => {140 const w = await setup(), a = await authorized(w), m = mandate(a, w.t.now);141 const r = await w.signer.venueMandateApprove(m, w.pk.assert(venueMandateHash(m)));142 expect(r).toEqual({ mandateId: m.mandateId, hash: Buffer.from(venueMandateHash(m)).toString('hex'), authorizationId: a.authorizationId });143 expect(st(w, m)).toMatchObject({ phase: 'active', revoked: false, expired: false, hash: r.hash, today: { orders: 0, reduceOnly: 0, turnover: '0' }, ordersLastHour: 0, pendingOrders: 0 });144 expect(journal(w, 'venue.mandate.approved')[0]!.data).toMatchObject({ mandateId: m.mandateId, authorizationId: a.authorizationId, strategySha256: SHA, agentId: 'agent-7', floorEquity: '100', ackSha256: ACK });145 expect(w.venue.calls).toHaveLength(0);146 expect(w.restart().status().venueMandates).toHaveLength(1); // durable147 });148149 it('refuses without the passkey, with another passkey, and when anything changed after the approval', async () => {150 const w = await setup(), a = await authorized(w), m = mandate(a, w.t.now);151 expect(await code(w.signer.venueMandateApprove(m, undefined))).toMatch(/^bad_approval/);152 expect(await code(w.signer.venueMandateApprove(m, new TestPasskey(RP, ORIGIN).assert(venueMandateHash(m))))).toMatch(/^unknown_credential/);153 for (const wider of [{ maxGrossExposure: '601' }, { floorEquity: '99' }, { maxDailyTurnover: '1501' }, { maxOrdersPerHour: 7 }, { strategySha256: 'cd'.repeat(32) }, { agentId: 'agent-8' }, { expiresAt: m.expiresAt + 1 },154 { instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '401' }] }, { instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 4, maxPositionNotional: '400' }] }])155 expect(await code(w.signer.venueMandateApprove({ ...m, ...wider }, w.pk.assert(venueMandateHash(m))))).toMatch(/^bad_approval/);156 expect(w.signer.status().venueMandates).toHaveLength(0);157 expect(journal(w, 'venue.mandate.denied').length).toBe(11);158 });159160 it('a mandate can only be narrower than its authorization, and below the hard ceilings', async () => {161 const w = await setup(), a = await authorized(w);162 const hashOf = (m: VenueMandate) => { try { return venueMandateHash(m); } catch { return new Uint8Array(32); } }; // (a mandate outside the schema has no hash)163 const tryIt = (o: Partial<VenueMandate>) => { const m = mandate(a, w.t.now, o); return code(() => w.signer.venueMandateApprove(m, w.pk.assert(hashOf(m)))); };164 const i = (o: Partial<VenueMandate['instruments'][number]>) => ({ instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '400', ...o }] });165 expect(await tryIt(i({ instrument: 'hyperliquid:SOL' }))).toMatch(/^unsupported: hyperliquid:SOL is not allowed by the authorization/);166 expect(await tryIt(i({ maxLeverage: 6 }))).toMatch(/leverage 6 is above the authorization's 5/);167 expect(await tryIt(i({ maxLeverage: 5 }))).toBe('ok'); // just inside (then revoked, for the next ones)168 for (const x of w.signer.status().venueMandates) w.signer.venueMandateRevoke(x.mandate.mandateId);169 expect(await tryIt(i({ maxPositionNotional: '500.01' }))).toMatch(/position limit above the authorization's 500/);170 expect(await tryIt({ maxOrderNotional: '300.01', ...i({ maxPositionNotional: '500' }) })).toMatch(/order limit above the authorization's 300/);171 expect(await tryIt({ maxGrossExposure: '800.01' })).toMatch(/gross exposure limit above the authorization's 800/);172 expect(await tryIt({ maxOrdersPerDay: 51 })).toMatch(/orders per day above the authorization's 50/);173 expect(await tryIt({ maxPriceDeviationBps: 301 })).toMatch(/price band wider than the authorization's 300/);174 expect(await tryIt({ maxOrderNotional: '260', ...i({ maxPositionNotional: '250' }) })).toMatch(/order limit above every position limit/);175 expect(await tryIt({ maxGrossExposure: '399' })).toMatch(/position limit above the mandate's gross exposure limit/);176 expect(await tryIt({ maxOrdersPerHour: 11 })).toMatch(/orders per hour above orders per day/);177 expect(await tryIt({ maxOrdersPerHour: 61, maxOrdersPerDay: 50 })).toMatch(/^bad_request/); // hard ceiling: 60 an hour178 expect(await tryIt({ floorEquity: '0' })).toMatch(/^bad_request/); // a loss limit is not optional179 expect(await tryIt({ expiresAt: w.t.now + 30 * 86400 + 1 })).toMatch(/at most 30 days/);180 expect(await tryIt({ expiresAt: w.t.now + 30 * 86400 })).toBe('ok'); // just inside181 for (const x of w.signer.status().venueMandates) w.signer.venueMandateRevoke(x.mandate.mandateId);182 expect(await tryIt({ createdAt: w.t.now - 601 })).toMatch(/not current/);183 expect(await tryIt({ ackSha256: 'ab'.repeat(32) })).toMatch(/^acknowledgement/);184 expect(await tryIt({ keyId: 'spw-0000000000000000' })).toMatch(/^wrong_key/);185 expect(await tryIt({ agentAddress: `0x${'22'.repeat(20)}` })).toMatch(/does not name the venue, network, account and trading key/);186 expect(await tryIt({ account: `0x${'11'.repeat(20)}` })).toMatch(/does not name/);187 expect(await tryIt({ network: 'mainnet' })).toMatch(/does not name/);188 expect(await code(w.signer.venueMandateApprove({ ...mandate(a, w.t.now), extra: 1 }, w.pk.assert(new Uint8Array(32))))).toMatch(/^bad_request/);189 });190191 it('needs a live MANUAL_LIVE authorization of this signer; one active mandate per authorization; an id is never recorded twice; not under the emergency halt', async () => {192 const w = await setup(), a = await authorized(w);193 const approve = (m: VenueMandate) => code(() => w.signer.venueMandateApprove(m, w.pk.assert(venueMandateHash(m))));194 expect(await approve(mandate({ ...a, authorizationId: uuid() }, w.t.now))).toMatch(/^no_authorization/);195 // a mandate that would outlive its authorization196 const short = await (async () => { w.signer.venueRevoke(a.authorizationId); return authorized(w, { expiresAt: w.t.now + 5 * 86400 }); })();197 expect(await approve(mandate(short, w.t.now))).toMatch(/would outlive its authorization/);198 const m = mandate(short, w.t.now, { expiresAt: w.t.now + 5 * 86400 });199 expect(await approve(m)).toBe('ok');200 expect(await approve(m)).toMatch(/^replay/);201 expect(await approve(mandate(short, w.t.now, { expiresAt: w.t.now + 86400 }))).toMatch(/^conflict/);202 w.signer.venueMandateRevoke(m.mandateId);203 const m2 = mandate(short, w.t.now, { expiresAt: w.t.now + 86400 });204 expect(await approve(m2)).toBe('ok'); // after a revocation a new one can be approved205 w.signer.venueMandateRevoke(m2.mandateId);206 expect(await approve(mandate(a, w.t.now))).toMatch(/^revoked: the venue authorization was revoked/);207 w.t.now += 5 * 86400;208 expect(await approve(mandate(short, w.t.now, { expiresAt: w.t.now + 60 }))).toMatch(/^expired/);209 const w2 = await setup(), a2 = await authorized(w2);210 w2.signer.halt();211 const mh = mandate(a2, w2.t.now);212 expect(await code(() => w2.signer.venueMandateApprove(mh, w2.pk.assert(venueMandateHash(mh))))).toMatch(/^halted/);213 });214});215216describe('/venue/mandate/sign — an order without a passkey', () => {217 it('signs with the trading key after reading the venue account itself, and counts the order before the signature leaves', async () => {218 const w = await setup(), { a, m } = await mandated(w);219 const o = order(w);220 const r = await send(w, m, o);221 expect(r).toMatchObject({ kind: 'order', signer: AGENT, mandateId: m.mandateId, phase: 'active', hash: Buffer.from(venueOrderHash(o.canonical(a))).toString('hex') });222 expect(await recoverTypedDataSigner((o.sign.payload as HlL1SignPayload).typedData, r.signature)).toBe(AGENT);223 expect(st(w, m)).toMatchObject({ today: { orders: 1, reduceOnly: 0, turnover: '140' }, ordersLastHour: 1, pendingOrders: 1 });224 expect(w.signer.status().venues[0]).toMatchObject({ lastNonce: o.nonce, today: { orders: 1 } }); // the authorization's own daily count moves too225 expect(journal(w, 'venue.mandate.sign.ok')[0]!.data).toMatchObject({ kind: 'order', instrument: 'hyperliquid:BTC', side: 'buy', size: '0.002', price: '70000', notional: '140', mid: '70000', accountValue: '1000',226 worstPosition: '140', worstGross: '140', leverageSetting: 3, phase: 'active' });227 // the signer asked the venue's INFO endpoint only, on the pinned testnet host, about its own account228 expect(new Set(w.venue.calls.map((c) => c.url))).toEqual(new Set([VENUE_INFO_URL.testnet]));229 expect(w.venue.calls.map((c) => c.type)).toEqual(['frontendOpenOrders', 'clearinghouseState', 'activeAssetData']);230 // counters survive a restart231 expect(w.restart().status().venueMandates[0]).toMatchObject({ today: { orders: 1, turnover: '140' }, pendingOrders: 1 });232 // a market order is an IOC limit at its worst price; a trigger order is bounded by its trigger price too233 expect(await buy(w, m, { type: 'market', tif: 'ioc', price: '70300', size: '0.001' })).toBe('ok');234 });235236 it('the Signer class without a venue reader (CLI, tests) signs nothing that can add exposure', async () => {237 const w = await setup(), { m } = await mandated(w);238 const bare = new Signer(keys, w.store, { rpId: RP, origin: ORIGIN }, () => w.t.now, { get: async (s) => Object.fromEntries(s.map((k) => [k, w.px[k]!])) });239 expect(await code(bare.venueMandateSign({ mandateId: m.mandateId, sign: order(w).sign }))).toMatch(/^venue_unreachable: .*no venue reader configured/);240 expect(await code(noVenueReader.leverage('testnet', ACCOUNT, 'BTC'))).toMatch(/no venue reader/);241 });242243 it('order notional: accepted at the limit, refused one lot above — reduce-only orders included', async () => {244 const w = await setup(), { m } = await mandated(w, { maxOrderNotional: '140' });245 expect(await buy(w, m, { size: '0.002' })).toBe('ok'); // 0.002 × 70000 = 140246 expect(await buy(w, m, { size: '0.00201' })).toMatch(/^order_notional: order notional 140.7 is above the mandate's 140/);247 expect(await buy(w, m, { side: 'sell', reduceOnly: true, size: '0.00201' })).toMatch(/^order_notional/);248 // a stop is bounded by the higher of its limit and its trigger price249 expect(await buy(w, m, { type: 'stop_limit', side: 'sell', reduceOnly: true, size: '0.002', price: '69000', triggerPrice: '70000' })).toBe('ok');250 expect(await buy(w, m, { type: 'stop_limit', side: 'sell', reduceOnly: true, size: '0.002', price: '69000', triggerPrice: '70100' })).toMatch(/^order_notional/);251 });252253 it('price band against the signer\'s own price: every price and trigger, reduce-only included; no price, no signature', async () => {254 const w = await setup(), { m } = await mandated(w); // 300 bps around 70000: 67900 … 72100255 expect(await buy(w, m, { price: '67900', size: '0.001' })).toBe('ok');256 expect(await buy(w, m, { price: '67899', size: '0.001' })).toMatch(/^price: price 67899 is more than 300 bps away from the signer's own BTC price 70000/);257 expect(await buy(w, m, { side: 'sell', price: '72100', size: '0.001' })).toBe('ok');258 expect(await buy(w, m, { side: 'sell', price: '72101', size: '0.001' })).toMatch(/^price/);259 expect(await buy(w, m, { side: 'sell', reduceOnly: true, type: 'market', tif: 'ioc', price: '67899', size: '0.001' })).toMatch(/^price/); // a give-away close is refused too260 expect(await buy(w, m, { type: 'stop_market', side: 'sell', reduceOnly: true, size: '0.001', price: '68000', triggerPrice: '67899' })).toMatch(/^price: trigger price/);261 w.px.BTC = 75_000; // the market moved: the same order is now outside262 expect(await buy(w, m, { price: '70000', size: '0.001' })).toMatch(/^price/);263 w.pxFail.why = 'price sources disagree on BTC';264 expect(await buy(w, m, { size: '0.001' })).toMatch(/^no_price: .*price sources disagree/);265 expect(await buy(w, m, { side: 'sell', reduceOnly: true, size: '0.001' })).toMatch(/^no_price/);266 });267268 it('instrument outside the mandate (even if the authorization allows it), and outside the pinned table', async () => {269 const w = await setup(), { m } = await mandated(w);270 expect(await buy(w, m, { size: '0.05', price: '2000' }, { instrument: ETH })).toMatch(/^instrument: hyperliquid:ETH is not allowed by the mandate/);271 expect(await buy(w, m, { size: '0.05', price: '2000', side: 'sell', reduceOnly: true }, { instrument: ETH })).toMatch(/^instrument/); // not even to close the owner's own position272 expect(await buy(w, m, { size: '1', price: '100' }, { instrument: SOL })).toMatch(/^instrument/);273 const doge = createHlTrading({ network: 'testnet', assets: [...hlPinnedAssets('testnet'), { symbol: 'DOGE', asset: 173, szDecimals: 0, maxLeverage: 10, isolatedOnly: false, delisted: false }], expiresAfterMs: TTL });274 expect(await buy(w, m, { size: '100', price: '0.2' }, { instrument: { ...BTC, symbol: 'DOGE', id: 'hyperliquid:DOGE', lotSize: '1' }, t: doge })).toMatch(/^rejected: payload refused/);275 expect(w.venue.calls).toHaveLength(0);276 });277});278279describe('the exposure bound, under adversarial sequences', () => {280 it('many small orders: the worst case is counted as if every one filled — accepted at the limit, refused one cent below it', async () => {281 const w = await setup(), { m } = await mandated(w, { instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '420' }] });282 expect(await buy(w, m)).toBe('ok'); expect(await buy(w, m)).toBe('ok');283 expect(await buy(w, m)).toBe('ok'); // 3 × 140 = 420 ≤ 420284 expect(await buy(w, m, { size: '0.00001' })).toMatch(/^position_exceeded: worst-case BTC position 420.7 .* above the mandate's 420/);285 const w2 = await setup(), { m: m2 } = await mandated(w2, { instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '419.99' }] });286 expect(await buy(w2, m2)).toBe('ok'); expect(await buy(w2, m2)).toBe('ok');287 expect(await buy(w2, m2)).toMatch(/^position_exceeded/);288 expect(st(w2, m2)).toMatchObject({ today: { orders: 2, turnover: '280' }, pendingOrders: 2 }); // a refusal counts nothing289 });290291 it('orders left to expire free the bound only after their venue expiry (+ grace); filled ones stay counted through the venue\'s position', async () => {292 const w = await setup(), { m } = await mandated(w); // position limit 400293 expect(await buy(w, m)).toBe('ok'); expect(await buy(w, m)).toBe('ok');294 expect(await buy(w, m)).toMatch(/^position_exceeded/); // 420 > 400 although the venue shows nothing yet295 w.t.now += 99; // still inside expiry (90 s) + grace (10 s)296 expect(await buy(w, m)).toMatch(/^position_exceeded/);297 w.t.now += 2;298 expect(st(w, m).pendingOrders).toBe(0);299 expect(await buy(w, m)).toBe('ok'); // they can no longer reach the venue, and the venue shows no position300 // the other outcome: they filled. The venue's own position replaces the ledger entries.301 expirePending(w);302 w.venue.positions = [{ coin: 'BTC', szi: '0.004', mark: '70000' }]; // 280303 expect(await buy(w, m, { size: '0.00172' })).toMatch(/^position_exceeded: worst-case BTC position 400.4/);304 expect(await buy(w, m, { size: '0.00171' })).toBe('ok'); // 280 + 119.7 = 399.7305 // … valued at the SIGNER's price, not the venue's: the market rises 2 %, the same position is now worth 285.6306 expirePending(w); w.px.BTC = 71_400;307 expect(await buy(w, m, { size: '0.00161', price: '71400' })).toMatch(/^position_exceeded/); // 285.6 + 114.954308 expect(await buy(w, m, { size: '0.0016', price: '71400' })).toBe('ok'); // 285.6 + 114.24 = 399.84309 });310311 it('alternating sides: an opposite order never lowers the bound; each side is its own worst case', async () => {312 const w = await setup(), { m } = await mandated(w, { maxOrdersPerHour: 10 });313 expect(await buy(w, m, { side: 'buy' })).toBe('ok'); expect(await buy(w, m, { side: 'sell' })).toBe('ok');314 expect(await buy(w, m, { side: 'buy' })).toBe('ok'); expect(await buy(w, m, { side: 'sell' })).toBe('ok'); // long side 280, short side 280315 expect(await buy(w, m, { side: 'buy' })).toMatch(/^position_exceeded/); // long side would be 420316 expect(await buy(w, m, { side: 'sell' })).toMatch(/^position_exceeded/);317 // long 0.004 on the venue and a pending non-reduce-only sell of the same size: the sell does not make room for a buy318 expirePending(w);319 w.venue.positions = [{ coin: 'BTC', szi: '0.004', mark: '70000' }];320 expect(await buy(w, m, { side: 'sell', size: '0.0028' })).toBe('ok'); // short side: 196321 expect(await buy(w, m, { side: 'buy', size: '0.00172' })).toMatch(/^position_exceeded/); // long side: 280 + 120.4322 // flipping with non-reduce-only orders is counted in full on the short side, with no credit for the long position: 196 + 199.5, then + 7323 expect(await buy(w, m, { side: 'sell', size: '0.00285' })).toBe('ok');324 expect(await buy(w, m, { side: 'sell', size: '0.0001' })).toMatch(/^position_exceeded: worst-case BTC position 402.5/);325 });326327 it('reduce-only: never counted, never needs the venue — and saying "reduce-only" about a payload that is not is refused', async () => {328 const w = await setup(), { a, m } = await mandated(w);329 w.venue.positions = [{ coin: 'BTC', szi: '0.0055', mark: '70000' }]; // 385: almost at the limit330 expect(await buy(w, m, { size: '0.00022' })).toMatch(/^position_exceeded/);331 const before = w.venue.calls.length;332 w.venue.fail = 'ECONNREFUSED';333 for (let k = 0; k < 12; k++) expect(await buy(w, m, { side: 'sell', reduceOnly: true, type: 'market', tif: 'ioc', price: '69800' })).toBe('ok'); // more than every counter allows334 expect(w.venue.calls.length).toBe(before);335 expect(st(w, m)).toMatchObject({ today: { orders: 0, reduceOnly: 12, turnover: '0' }, ordersLastHour: 0, pendingOrders: 0 });336 w.venue.fail = '';337 // a reduce-only BUY while long cannot add either (the venue refuses it): still not counted338 expect(await buy(w, m, { side: 'buy', reduceOnly: true })).toBe('ok');339 // the API describes a plain sell as reduce-only, or a larger order as a small one: the payload is what counts340 const plain = order(w, { side: 'sell', size: '0.0028' });341 expect(await code(send(w, m, plain, { order: { ...plain.canonical(a), reduceOnly: true } }))).toMatch(/^mismatch: .*\(reduce-only\)/);342 expect(await code(send(w, m, plain, { order: { ...plain.canonical(a), size: '0.0001' } }))).toMatch(/^mismatch: .*\(size\)/);343 expect(await code(send(w, m, plain, { order: { ...plain.canonical(a), instrument: 'hyperliquid:ETH' } }))).toMatch(/^mismatch: .*\(instrument\)/);344 expect(await code(send(w, m, plain, { order: { nonsense: true } }))).toMatch(/^bad_request/);345 expect(await code(send(w, m, plain, { order: plain.canonical(a) }))).toBe('ok'); // a truthful description changes nothing346 // and without any description the non-reduce-only sell is counted on the short side like any order347 expect(await buy(w, m, { side: 'sell', size: '0.0028' })).toBe('ok'); // short side: 196 + 196 = 392348 expect(await buy(w, m, { side: 'sell', size: '0.0002' })).toMatch(/^position_exceeded: worst-case BTC position 406/);349 });350351 it('what rests on the venue counts (also when the wallet did not place it); reduce-only resting orders do not; a manual order just signed counts', async () => {352 const w = await setup(), { a, m } = await mandated(w);353 w.venue.orders = [openOrder({ px: '69000', sz: '0.004' }), openOrder({ side: 'A', px: '71000', sz: '0.002', reduceOnly: true }), openOrder({ coin: 'ETH', px: '2000', sz: '0.01' })];354 expect(await buy(w, m, { size: '0.00172' })).toMatch(/^position_exceeded: worst-case BTC position 400.4/); // resting buy valued at the signer's 70000, not its 69000355 expect(await buy(w, m, { size: '0.00171' })).toBe('ok');356 // a resting stop is valued at its trigger price when that is higher357 w.venue.orders = [openOrder({ px: '69000', sz: '0.004', trigger: '72000' })]; expirePending(w);358 expect(await buy(w, m, { size: '0.00161' })).toMatch(/^position_exceeded: worst-case BTC position 400.7/); // 0.004 × 72000 = 288, + 112.7359 expect(await buy(w, m, { size: '0.0016' })).toBe('ok'); // 288 + 112 = 400360 // an order the owner approved with his passkey a moment ago is not on the venue yet: the ledger has it361 w.venue.orders = []; expirePending(w);362 const manual = order(w, { size: '0.004' }), c = manual.canonical(a);363 await w.signer.venueSign({ authorizationId: a.authorizationId, sign: manual.sign, context: { order: c }, assertion: w.pk.assert(venueOrderHash(c)) });364 expect(st(w, m).pendingOrders).toBe(1);365 expect(await buy(w, m, { size: '0.00172' })).toMatch(/^position_exceeded/);366 expect(await buy(w, m, { size: '0.00171' })).toBe('ok');367 });368369 it('a signed order that the venue lists as resting is counted once (through the venue) — but an OLDER order with the same client id is not mistaken for it', async () => {370 const w = await setup(), { a, m } = await mandated(w);371 const o1 = order(w), cloid = o1.canonical(a).clientId;372 expect(await code(send(w, m, o1))).toBe('ok'); // 140, in the ledger373 expect(await buy(w, m)).toBe('ok'); // 280374 expect(await buy(w, m, { size: '0.00001' })).toBe('ok'); // 280.7375 expect(await buy(w, m)).toMatch(/^position_exceeded/); // 420.7376 // the venue now lists the first one as resting, half filled: remaining 0.001 + position 0.001 instead of the ledger's 0.002377 w.venue.orders = [openOrder({ px: '70000', sz: '0.001', cloid, timestamp: w.t.now * 1000 + 400 })];378 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '70000' }];379 expect(await buy(w, m, { size: '0.0017' })).toBe('ok'); // 70 + 70 + 140.7 + 119 = 399.7 (it would be 469.7 counted twice)380 // replay attempt: a resting order from long ago happens to carry the client id of an order signed now — the new one stays in the ledger381 const w2 = await setup(), { a: a2, m: m2 } = await mandated(w2);382 const n1 = order(w2, { size: '0.0028' }), old = n1.canonical(a2).clientId;383 w2.venue.orders = [openOrder({ px: '70000', sz: '0.00001', cloid: old, timestamp: (w2.t.now - 4 * 86400) * 1000, reduceOnly: true })];384 expect(await code(send(w2, m2, n1))).toBe('ok'); // 196385 expect(st(w2, m2).pendingOrders).toBe(1);386 expect(await buy(w2, m2, { size: '0.0028' })).toBe('ok'); // 392387 expect(await buy(w2, m2, { size: '0.0002' })).toMatch(/^position_exceeded: worst-case BTC position 406/); // the first is still counted in full388 });389390 it('gross exposure over every instrument of the account, including positions the mandate does not trade', async () => {391 const two = { instruments: [{ instrument: 'hyperliquid:BTC', maxLeverage: 3, maxPositionNotional: '400' }, { instrument: 'hyperliquid:ETH', maxLeverage: 3, maxPositionNotional: '400' }], maxGrossExposure: '600' };392 const w = await setup(), { m } = await mandated(w, two);393 w.venue.positions = [{ coin: 'BTC', szi: '-0.005', mark: '70000' }, { coin: 'ETH', szi: '0.075', mark: '2000' }]; // 350 + 150 = 500394 expect(await buy(w, m, { size: '0.0501', price: '2000' }, { instrument: ETH })).toMatch(/^gross_exceeded: worst-case gross exposure 600.2 is above the mandate's 600/);395 expect(await buy(w, m, { size: '0.05', price: '2000' }, { instrument: ETH })).toBe('ok'); // 350 + 250 = 600396 });397398 it('gross: a position on an instrument the mandate does not trade still fills the gross limit', async () => {399 const w = await setup(), { m } = await mandated(w);400 w.venue.positions = [{ coin: 'ETH', szi: '0.25', mark: '2000' }]; // 500, the owner's own401 expect(await buy(w, m, { size: '0.00143' })).toMatch(/^gross_exceeded: worst-case gross exposure 600.1/);402 expect(await buy(w, m, { size: '0.00142' })).toBe('ok'); // 500 + 99.4403 });404405 it('a position the signer cannot price itself stops every order that can add exposure', async () => {406 const w = await setup(), { m } = await mandated(w);407 w.venue.positions = [{ coin: 'DOGE', szi: '100', mark: '0.2' }];408 expect(await buy(w, m)).toMatch(/^unpriced_position: the account holds a position on DOGE/);409 expect(await buy(w, m, { side: 'sell', reduceOnly: true })).toBe('ok');410 });411412 it('replay and nonce games buy nothing: a client id, a nonce and an expiry are single-use and short', async () => {413 const w = await setup(), { m } = await mandated(w);414 const o = order(w);415 expect(await code(send(w, m, o))).toBe('ok');416 expect(await code(send(w, m, o))).toMatch(/^stale_nonce: nonce already used/);417 expect(await buy(w, m, { clientId: `k-${o.nonce}` })).toMatch(/^replay: an order with this client id was already signed/);418 expect(await buy(w, m, {}, { nonce: (w.t.now - 121) * 1000 })).toMatch(/^(stale_nonce|expired_action)/);419 expect(await buy(w, m, {}, { nonce: (w.t.now + 61) * 1000 })).toMatch(/^policy: nonce is dated in the future/);420 expect(await buy(w, m, {}, { t: tradingOf(120_000) })).toBe('ok'); // venue expiry at most 120 s after the nonce421 expect(await buy(w, m, {}, { t: tradingOf(120_001) })).toMatch(/^policy: validity window longer than 120 s/);422 expect(await buy(w, m, {}, { t: tradingOf(null) })).toMatch(/^policy: the action carries no venue expiry/);423 expect(st(w, m).today.orders).toBe(2);424 });425426 it('only one order per action; nothing but orders, cancels and leverage changes; no vault; the right network', async () => {427 const w = await setup(), { m } = await mandated(w);428 const nonce = w.t.now * 1000 + 777, one = (trading.buildOrder({ instrument: BTC.id, side: 'buy', type: 'limit', size: '0.001', price: '70000', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'two-a' }, { instrument: BTC, nonce }).action as any);429 const two = { ...one, orders: [one.orders[0], { ...one.orders[0], c: `0x${'12'.repeat(16)}` }] };430 const l1 = (action: unknown, vaultAddress: string | null = null): SignRequest => ({ venue: 'hyperliquid', network: 'testnet', kind: 'hl_l1',431 payload: { scheme: 'l1', action, nonce, vaultAddress, expiresAfter: nonce + TTL, typedData: l1SignPayload(action as never, { network: 'testnet', nonce, vaultAddress, expiresAfter: nonce + TTL }).typedData } });432 expect(await code(send(w, m, { sign: l1(two) }))).toMatch(/^unsupported: exactly one order per signature/);433 expect(await code(send(w, m, { sign: l1(one, `0x${'33'.repeat(20)}`) }))).toMatch(/^unsupported: sub-accounts and vaults/);434 expect(await code(send(w, m, { sign: trading.buildApproveAgent({ agentAddress: `0x${'66'.repeat(20)}`, agentName: 'evil', nonce }).sign }))).toMatch(/^unsupported: this payload is not an order, a cancel or a leverage change/);435 const xfer = buildUsdClassTransferTypedDataOnly({ network: 'testnet', amount: '10', toPerp: false, nonce });436 expect(await code(send(w, m, { sign: { venue: 'hyperliquid', network: 'testnet', kind: 'hl_user', payload: { scheme: 'user', action: xfer.action, typedData: xfer.typedData } } }))).toMatch(/^unsupported/);437 expect(await code(send(w, m, { sign: l1({ type: 'vaultTransfer', vaultAddress: `0x${'44'.repeat(20)}`, isDeposit: true, usd: 1 }) }))).toMatch(/^(unsupported|rejected)/);438 expect(await code(send(w, m, { sign: { ...order(w).sign, network: 'mainnet' } }))).toMatch(/^wrong_venue/);439 expect(await code(w.signer.venueMandateSign({ mandateId: uuid(), sign: order(w).sign }))).toMatch(/^not_found/);440 expect(await code(w.signer.venueMandateSign({ mandateId: m.mandateId } as never))).toMatch(/^bad_request/);441 expect(st(w, m).today.orders).toBe(0);442 expect(journal(w, 'venue.mandate.sign.denied').length).toBe(7); // every refusal of a known mandate is logged with its code443 for (const j of journal(w, 'venue.mandate.sign.denied')) expect(['unsupported', 'rejected', 'wrong_venue', 'bad_request']).toContain((j.data as { code: string }).code);444 });445});446447describe('counters: orders per day and per hour, daily turnover', () => {448 it('orders per hour (rolling) and per day (UTC): accepted up to the limit, refused after; reduce-only orders and cancels are never counted', async () => {449 const w = await setup(), { m } = await mandated(w, { maxOrdersPerHour: 3, maxOrdersPerDay: 5, maxDailyTurnover: '100000' });450 w.t.now = Math.floor(Date.parse('2026-11-12T00:00:10Z') / 1000); // the start of a UTC day, so that the day does not turn inside the test451 const m2 = mandate((w.signer.status().venues[0]!.authorization as VenueAuthorization), w.t.now, { maxOrdersPerHour: 3, maxOrdersPerDay: 5, maxDailyTurnover: '100000' });452 w.signer.venueMandateRevoke(m.mandateId);453 await w.signer.venueMandateApprove(m2, w.pk.assert(venueMandateHash(m2)));454 const small = { size: '0.0002' };455 for (let k = 0; k < 3; k++) expect(await buy(w, m2, small)).toBe('ok');456 expect(await buy(w, m2, small)).toMatch(/^orders_hour: the mandate's 3 orders per hour are used/);457 expect(await buy(w, m2, { ...small, side: 'sell', reduceOnly: true })).toBe('ok');458 w.t.now += 3599;459 expect(await buy(w, m2, small)).toMatch(/^orders_hour/);460 w.t.now += 1; // the first one is now an hour old461 expect(await buy(w, m2, small)).toBe('ok');462 w.t.now += 3600;463 expect(await buy(w, m2, small)).toBe('ok'); // 5 today464 w.t.now += 3600;465 expect(await buy(w, m2, small)).toMatch(/^orders_day: the mandate's 5 orders per day are used/);466 expect(await code(send(w, m2, { sign: trading.buildCancel({ instrument: BTC, clientId: 'anything', nonce: w.t.now * 1000 + 5 }).sign }))).toBe('ok');467 expect(st(w, m2)).toMatchObject({ today: { orders: 5, reduceOnly: 1, cancels: 1 } });468 w.t.now = Math.floor(Date.parse('2026-11-13T00:00:10Z') / 1000); // next UTC day469 expect(await buy(w, m2, small)).toBe('ok');470 });471472 it('the authorization\'s own daily count also bounds the mandate (manual and mandate orders share it)', async () => {473 const w = await setup(), { a, m } = await mandated(w, { maxOrdersPerDay: 3, maxOrdersPerHour: 3 }, { maxOrdersPerDay: 3 });474 const manual = order(w, { size: '0.0002' }), c = manual.canonical(a);475 await w.signer.venueSign({ authorizationId: a.authorizationId, sign: manual.sign, context: { order: c }, assertion: w.pk.assert(venueOrderHash(c)) });476 expect(await buy(w, m, { size: '0.0002' })).toBe('ok'); expect(await buy(w, m, { size: '0.0002' })).toBe('ok');477 expect(await buy(w, m, { size: '0.0002' })).toMatch(/^orders_day: the authorization's 3 orders per day are used/);478 });479480 it('daily turnover: the sum of the notionals of the orders that can add exposure', async () => {481 const w = await setup(), { m } = await mandated(w, { maxDailyTurnover: '280' });482 expect(await buy(w, m)).toBe('ok');483 expirePending(w);484 expect(await buy(w, m, { side: 'sell', reduceOnly: true })).toBe('ok'); // closing costs no turnover allowance485 expect(await buy(w, m)).toBe('ok'); // 280 = 280486 expirePending(w);487 expect(await buy(w, m, { size: '0.00001' })).toMatch(/^turnover: daily turnover would reach 280.7, above the mandate's 280/);488 });489});490491describe('loss limit and leverage, from the signer\'s own venue read', () => {492 it('account value below the floor: only reduce-only orders and cancels', async () => {493 const w = await setup(), { m } = await mandated(w); // floor 100494 w.venue.accountValue = '100';495 expect(await buy(w, m)).toBe('ok');496 w.venue.accountValue = '99.99';497 expect(await buy(w, m)).toMatch(/^floor_equity: the venue reports an account value of 99.99, below the mandate's floor of 100/);498 w.venue.accountValue = '-5';499 expect(await buy(w, m)).toMatch(/^floor_equity/);500 expect(await buy(w, m, { side: 'sell', reduceOnly: true })).toBe('ok');501 expect(await code(send(w, m, { sign: trading.buildCancel({ instrument: BTC, clientId: 'x', nonce: w.t.now * 1000 + 9 }).sign }))).toBe('ok');502 });503504 it('no order that can add exposure while the venue\'s leverage setting is above the mandate; the mandate can lower it, never raise it past its limit', async () => {505 const w = await setup(), { m } = await mandated(w); // BTC ≤ 3×506 w.venue.lev.BTC = { type: 'cross', value: 20 }; // the venue's default on a new account507 expect(await buy(w, m)).toMatch(/^leverage_setting: BTC is set to 20× on the venue, above the mandate's 3×/);508 const lev = (leverage: number, marginMode: 'cross' | 'isolated' = 'cross', instrument = BTC) => code(send(w, m, { sign: trading.buildUpdateLeverage({ instrument, leverage, marginMode, nonce: w.t.now * 1000 + ++nonceSeq }).sign }));509 expect(await lev(4)).toMatch(/^leverage_exceeded: leverage 4 is above the mandate's 3×/);510 expect(await lev(3)).toBe('ok'); // no passkey511 w.venue.lev.BTC = { type: 'cross', value: 3 };512 expect(await buy(w, m)).toBe('ok');513 expect(await lev(2, 'cross', ETH)).toMatch(/^instrument/);514 // with an open position the leverage setting is the position's own, and the margin mode cannot change515 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '70000', lev: { type: 'cross', value: 5 } }];516 expect(await buy(w, m, { size: '0.001' })).toMatch(/^leverage_setting: BTC is set to 5×/);517 expect(await lev(2, 'isolated')).toMatch(/^margin_mode: BTC has an open position in cross margin/);518 expect(await lev(2, 'cross')).toBe('ok');519 w.venue.positions = [];520 expect(await lev(2, 'isolated')).toBe('ok'); // no position: the mode may change521 w.venue.fail = 'timeout';522 expect(await lev(2)).toMatch(/^venue_unreachable/); // a leverage change needs the read too523 w.venue.fail = '';524 w.signer.halt();525 expect(await lev(2)).toMatch(/^halted/);526 expect(st(w, m).today.leverage).toBe(3);527 });528529 it('at most 20 leverage changes a day', async () => {530 const w = await setup(), { m } = await mandated(w);531 w.t.now = Math.floor(Date.parse('2026-11-12T00:00:10Z') / 1000);532 const m2 = mandate((w.signer.status().venues[0]!.authorization as VenueAuthorization), w.t.now);533 w.signer.venueMandateRevoke(m.mandateId);534 await w.signer.venueMandateApprove(m2, w.pk.assert(venueMandateHash(m2)));535 const lev = () => code(send(w, m2, { sign: trading.buildUpdateLeverage({ instrument: BTC, leverage: 2, marginMode: 'cross', nonce: w.t.now * 1000 + ++nonceSeq }).sign }));536 for (let k = 0; k < 20; k++) expect(await lev()).toBe('ok');537 expect(await lev()).toMatch(/^leverage_rate/);538 });539});540541describe('the signer\'s own read of the venue: fail closed', () => {542 it('unreachable, HTTP error, timeout → nothing that can add exposure is signed; reduce-only and cancels still are', async () => {543 const w = await setup(), { m } = await mandated(w);544 for (const why of ['fetch failed', 'HTTP 502', 'The operation was aborted due to timeout', 'response too large']) {545 w.venue.fail = why;546 expect(await buy(w, m)).toMatch(/^venue_unreachable: the signer could not read the venue account itself/);547 }548 expect(await buy(w, m, { side: 'sell', reduceOnly: true })).toBe('ok');549 expect(await code(send(w, m, { sign: trading.buildCancel({ instrument: BTC, clientId: 'x', nonce: w.t.now * 1000 + 3 }).sign }))).toBe('ok');550 w.venue.fail = '';551 // the leverage read alone failing is enough552 w.venue.mutate = (type, answer) => { if (type === 'activeAssetData') throw new Error('HTTP 429'); return answer; };553 expect(await buy(w, m)).toMatch(/^venue_unreachable: the signer could not read the leverage setting/);554 expect(st(w, m).today.orders).toBe(0);555 });556557 it('stale: the venue\'s own timestamp must be within 30 s of the signer\'s clock, either way', async () => {558 const w = await setup(), { m } = await mandated(w);559 w.venue.ageMs = 30_000;560 expect(await buy(w, m)).toBe('ok');561 w.venue.ageMs = 30_001;562 expect(await buy(w, m)).toMatch(/^venue_stale: the venue's account state is dated 30 s from the signer's clock/);563 w.venue.ageMs = -30_001;564 expect(await buy(w, m)).toMatch(/^venue_stale/);565 });566567 it('inconsistent or malformed answers are not believed', async () => {568 const w = await setup(), { m } = await mandated(w);569 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '70000' }];570 w.venue.totalNtlPos = '70.07'; // within 0.1 % + 0.01571 expect(await buy(w, m)).toBe('ok');572 w.venue.totalNtlPos = '700'; // the venue's total says ten times its positions573 expect(await buy(w, m)).toMatch(/^venue_inconsistent: the venue's positions \(70\) do not add up to its own total \(700\)/);574 w.venue.totalNtlPos = null;575 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '70000' }, { coin: 'BTC', szi: '0.002', mark: '70000' }];576 expect(await buy(w, m)).toMatch(/^venue_inconsistent: the venue lists two positions on BTC/);577 // the venue values the position 3 % away from the signer's price (band 300 bps): just inside, then just outside578 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '72100' }];579 expect(await buy(w, m)).toBe('ok');580 w.venue.positions = [{ coin: 'BTC', szi: '0.001', mark: '72101' }];581 expect(await buy(w, m)).toMatch(/^venue_inconsistent: the venue values the BTC position at 72.101; at the signer's own price it is 70/);582 w.venue.positions = [];583 const shapes: [string, (answer: any) => unknown][] = [584 ['clearinghouseState', () => '<html>upstream error</html>'], ['clearinghouseState', (x) => ({ ...x, time: 'now' })], ['clearinghouseState', (x) => ({ ...x, marginSummary: { ...x.marginSummary, accountValue: 1000 } })],585 ['clearinghouseState', (x) => ({ ...x, marginSummary: { ...x.marginSummary, accountValue: '1e3' } })], ['clearinghouseState', (x) => ({ ...x, assetPositions: [{ type: 'oneWay', position: { coin: 'BTC', szi: 'NaN', positionValue: '0', leverage: { type: 'cross', value: 3 } } }] })],586 ['clearinghouseState', (x) => ({ ...x, assetPositions: null })], ['frontendOpenOrders', () => ({ orders: [] })], ['frontendOpenOrders', () => [{ coin: 'BTC', side: 'X', limitPx: '1', sz: '1', reduceOnly: false, isTrigger: false }]],587 ['frontendOpenOrders', () => [{ coin: 'BTC', side: 'B', limitPx: '-1', sz: '1', reduceOnly: false, isTrigger: false }]], ['frontendOpenOrders', () => [{ coin: 'BTC', side: 'B', limitPx: '1', sz: '1', reduceOnly: 'no', isTrigger: false }]],588 ['activeAssetData', (x) => ({ ...x, leverage: { type: 'cross', value: 2.5 } })], ['activeAssetData', (x) => ({ ...x, coin: 'ETH' })], ['activeAssetData', (x) => ({ ...x, leverage: null })],589 ];590 for (const [type, f] of shapes) {591 w.venue.mutate = (t, answer) => (t === type ? f(answer) : answer);592 expect(await buy(w, m), `${type}: ${String(f)}`).toMatch(/^venue_unreachable: .*unexpected venue answer/);593 }594 w.venue.mutate = null;595 expect(st(w, m).today.orders).toBe(2);596 });597598 it('the reader reads the recorded venue answers exactly, asks only the info endpoint of the pinned host, and never for another address', async () => {599 const fx = (name: string) => JSON.parse(readFileSync(new URL(`../../../packages/derivatives/test/fixtures/hyperliquid/mainnet-${name}.json`, import.meta.url), 'utf8')).body;600 const asked: { url: string; body: any }[] = [];601 const reader = hyperliquidReader(async (url, body: any) => { asked.push({ url, body }); return body.type === 'clearinghouseState' ? fx('clearinghouseState') : body.type === 'frontendOpenOrders' ? fx('frontendOpenOrders') : { leverage: { type: 'isolated', value: 7 }, coin: 'BTC' }; });602 const user = `0x${'ab'.repeat(20)}`, v = await reader.account('mainnet', user);603 expect(v).toMatchObject({ time: 1794366349403, accountValue: '6515.769962', totalNtlPos: '51217.3' });604 expect(v.positions[0]).toEqual({ coin: 'BTC', szi: '-0.3', positionValue: '22114.8', leverage: { type: 'cross', value: 40 } });605 expect(v.positions).toHaveLength(3); expect(v.openOrders).toHaveLength(7);606 expect(v.openOrders[0]).toEqual({ coin: 'AVAX', side: 'buy', size: '0.0', price: '9.4532', reduceOnly: true, cloid: null, timestamp: 1794365388140 });607 expect(await reader.leverage('mainnet', user, 'BTC')).toEqual({ type: 'isolated', value: 7 });608 expect(new Set(asked.map((a) => a.url))).toEqual(new Set(['https://api.hyperliquid.xyz/info']));609 expect(asked.map((a) => a.body)).toEqual([{ type: 'frontendOpenOrders', user }, { type: 'clearinghouseState', user }, { type: 'activeAssetData', user, coin: 'BTC' }]);610 expect(Object.values(VENUE_INFO_URL).every((u) => u.endsWith('/info'))).toBe(true);611 await expect(reader.account('mainnet', '0xNOPE')).rejects.toThrow(/account address/);612 await expect(reader.account('devnet' as never, user)).rejects.toThrow(/network/);613 await expect(reader.leverage('mainnet', user, 'BTC USD')).rejects.toThrow(/coin/);614 });615});616617describe('stopping: emergency halt, revocation, expiry, wind-down', () => {618 const cancel = (w: World, m: VenueMandate) => code(send(w, m, { sign: trading.buildCancel({ instrument: BTC, clientId: 'c', nonce: w.t.now * 1000 + ++nonceSeq }).sign }));619 const close = (w: World, m: VenueMandate) => buy(w, m, { side: 'sell', reduceOnly: true, type: 'market', tif: 'ioc', price: '69800' });620 const lev = (w: World, m: VenueMandate) => code(send(w, m, { sign: trading.buildUpdateLeverage({ instrument: BTC, leverage: 2, marginMode: 'cross', nonce: w.t.now * 1000 + ++nonceSeq }).sign }));621622 it('emergency halt: everything is blocked except cancels and reduce-only orders', async () => {623 const w = await setup(), { m } = await mandated(w);624 w.signer.halt();625 expect(await buy(w, m)).toMatch(/^halted: emergency halt is active: only cancels and reduce-only orders are signed/);626 expect(await lev(w, m)).toMatch(/^halted/);627 expect(await close(w, m)).toBe('ok');628 expect(await cancel(w, m)).toBe('ok');629 expect(w.venue.calls).toHaveLength(0);630 });631632 it('revoked mandate: no new exposure from that instant; cancels and reduce-only orders for 24 h (wind-down); then nothing', async () => {633 const w = await setup(), { m } = await mandated(w);634 expect(await buy(w, m)).toBe('ok');635 const r = w.signer.venueMandateRevoke(m.mandateId);636 expect(r).toMatchObject({ revoked: true, windDownUntil: w.t.now + VENUE_MANDATE_WIND_DOWN_S });637 expect(w.signer.venueMandateRevoke(m.mandateId).revokedAt).toBe(r.revokedAt); // idempotent; never re-activated638 expect(st(w, m)).toMatchObject({ phase: 'wind_down', revoked: true });639 expect(await buy(w, m)).toMatch(/^wind_down: the mandate was revoked: only cancels and reduce-only orders are signed/);640 expect(await lev(w, m)).toMatch(/^wind_down/);641 expect(await close(w, m)).toBe('ok');642 expect(await cancel(w, m)).toBe('ok');643 expect(await code(w.restart().venueMandateSign({ mandateId: m.mandateId, sign: order(w).sign }))).toMatch(/^wind_down/); // a restart changes nothing644 w.t.now += VENUE_MANDATE_WIND_DOWN_S - 1;645 expect(await close(w, m)).toBe('ok'); // last second of the wind-down646 w.t.now += 1;647 expect(st(w, m).phase).toBe('ended');648 expect(await close(w, m)).toMatch(/^revoked: the mandate was revoked and its wind-down period is over/);649 expect(await cancel(w, m)).toMatch(/^revoked/);650 expect(await buy(w, m)).toMatch(/^revoked/);651 // the owner can still cancel through the authorization itself (manual path, no passkey for a cancel)652 const a = w.signer.status().venues[0]!.authorization as VenueAuthorization;653 expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: trading.buildCancel({ instrument: BTC, clientId: 'c', nonce: w.t.now * 1000 + ++nonceSeq }).sign }))).toBe('ok');654 });655656 it('expired mandate: the same wind-down, counted from the expiry', async () => {657 const w = await setup(), { m } = await mandated(w, { expiresAt: Math.floor(Date.now() / 1000) + 3600 });658 w.t.now = m.expiresAt - 1;659 expect(await buy(w, m)).toBe('ok'); // last second660 w.t.now = m.expiresAt;661 expect(st(w, m)).toMatchObject({ phase: 'wind_down', expired: true });662 expect(await buy(w, m)).toMatch(/^wind_down: the mandate has expired/);663 expect(await close(w, m)).toBe('ok');664 w.t.now = m.expiresAt + VENUE_MANDATE_WIND_DOWN_S;665 expect(await close(w, m)).toMatch(/^expired: the mandate expired and its wind-down period is over/);666 });667668 it('the authorization is the root: revoked or expired, the mandate signs nothing at all — no wind-down, not even a cancel', async () => {669 const w = await setup(), { a, m } = await mandated(w);670 w.signer.venueRevoke(a.authorizationId);671 expect(st(w, m).phase).toBe('ended');672 for (const p of [buy(w, m), close(w, m), cancel(w, m), lev(w, m)]) expect(await p).toMatch(/^revoked: the venue authorization of this mandate was revoked: nothing is signed/);673 const w2 = await setup(), a2 = await authorized(w2, { expiresAt: Math.floor(Date.now() / 1000) + 2 * 86400 }), m2 = mandate(a2, w2.t.now, { expiresAt: a2.expiresAt });674 await w2.signer.venueMandateApprove(m2, w2.pk.assert(venueMandateHash(m2)));675 w2.t.now = a2.expiresAt;676 for (const p of [buy(w2, m2), close(w2, m2), cancel(w2, m2)]) expect(await p).toMatch(/^expired: the venue authorization of this mandate has expired/);677 expect(w2.venue.calls).toHaveLength(0);678 });679680 it('manual mode is untouched by a mandate: an order through /venue/sign still needs the passkey', async () => {681 const w = await setup(), { a } = await mandated(w);682 const o = order(w), c = o.canonical(a);683 expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: o.sign, context: { order: c } }))).toMatch(/^bad_approval: MANUAL_LIVE: every order needs the owner's passkey approval/);684 expect(await code(w.signer.venueSign({ authorizationId: a.authorizationId, sign: o.sign, context: { order: c }, assertion: w.pk.assert(venueOrderHash(c)) }))).toBe('ok');685 });686});687