SPB Git

Repository Public

venue-mandate.test.ts 59.7 KB 687 lines typescript

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

Theme