SPB Git

Repository Public

derivatives-live.test.ts 57.2 KB 721 lines typescript

1// Controlled live trading, end to end inside the process: the REAL execution engine and its routes, the REAL isolated signer2// class (in process, public test mnemonic, real WebAuthn assertions from a software passkey), PostgreSQL (database3// spw_deriv_live_test, created here if missing) and a scripted fake of the Hyperliquid exchange + info endpoints that recovers4// signers from signatures like the venue does. No network: a request to any real host fails the test.5import { createHash, randomBytes } from 'node:crypto';6import { mkdtempSync } from 'node:fs';7import { tmpdir } from 'node:os';8import { join } from 'node:path';9import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';10import { hlPinnedAssets, hlPinnedMismatch, jurisdictionAck, venueAuthorizationHash, type VenueAuthorization } from '@spw/derivatives/live';11import { TestPasskey } from '../../../tests/helpers/passkey.ts';12import { deriveKeys } from '../../isolated-signer/src/keys.ts';13import { Signer, SignerError } from '../../isolated-signer/src/signer.ts';14import { Store } from '../../isolated-signer/src/store.ts';15import { Clock, useDatabase } from './derivatives-helpers.ts';16import { FakeHl, routeFetch } from './hl-fake.ts';1718const RP = 'wallet.test', ORIGIN = 'https://wallet.test', BTC = 'hyperliquid:BTC', P = '/api/v1/derivatives/live';19const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');20const ACCOUNT = keys.addresses.evm.toLowerCase(), AGENT = keys.venueKey('hyperliquid', 'testnet').address;21const b64 = (hexHash: string) => Buffer.from(hexHash, 'hex');22const POLICY = { allowedInstruments: [BTC], maxLeverage: 3, maxOrderNotional: '500', maxPositionNotional: '1000', maxGrossExposure: '1500', maxOrdersPerDay: 50, maxPriceDeviationBps: 300 };2324let sql: any, lv: typeof import('../src/derivatives/live.ts'), ex: typeof import('../src/derivatives/exec.ts'), pp: typeof import('../src/derivatives/paper.ts'), UserError: typeof import('../src/config.ts').UserError;25type Svc = import('../src/derivatives/exec.ts').LiveService;2627beforeAll(async () => {28  await useDatabase('deriv_live');29  const db = await import('../src/db.ts');30  sql = db.sql;31  await db.migrate();32  await db.migrate(); // the schema is idempotent33  lv = await import('../src/derivatives/live.ts'); ex = await import('../src/derivatives/exec.ts'); pp = await import('../src/derivatives/paper.ts');34  UserError = (await import('../src/config.ts')).UserError;35});36beforeEach(async () => { await sql`truncate deriv_accounts, deriv_orders, deriv_fills, deriv_funding, deriv_risk_events, deriv_equity, deriv_actions`; });37afterAll(async () => { await sql?.end(); });3839let seq = 0;40const key = () => `live-key-${++seq}-${Math.random().toString(36).slice(2, 10)}`;4142function world() {43  const clock = new Clock(Date.parse('2026-11-11T12:00:00Z'));44  const t = new FakeHl('testnet', clock.now), m = new FakeHl('mainnet', clock.now);45  const dir = mkdtempSync(join(tmpdir(), 'spw-live-'));46  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));47  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');48  const px: { BTC: number; fail: string } = { BTC: 70_000, fail: '' };49  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(clock.now() / 1000), { get: async (s) => { if (px.fail) throw new Error(px.fail); return Object.fromEntries(s.map((k) => [k, (px as unknown as Record<string, number>)[k]!])); } });50  const pk = new TestPasskey(RP, ORIGIN);51  // what services/api/src/signer-client.ts does over HTTP: a signer refusal becomes a 422 `signer_<code>`52  const call = async <T>(fn: () => T | Promise<T>): Promise<T> => { try { return await fn(); } catch (e) { if (e instanceof SignerError) throw new UserError(`signer_${e.code}`, e.message, 422); throw e; } };53  const signs: unknown[] = [];54  const port: import('../src/derivatives/exec.ts').LiveSignerPort = {55    status: () => call(() => signer.status()), venueKey: (v, n) => call(() => signer.venueKey(v, n)),56    venueAuthorize: (a, s) => call(() => signer.venueAuthorize(a, s as never)), venueRevoke: (id) => call(() => signer.venueRevoke(id)),57    venueSign: (p) => { signs.push(p); return call(() => signer.venueSign(p as never)); },58  };59  const flags = { mainnet: false };60  const build = (): Svc => lv.createLive({ fetch: routeFetch([t, m]), signer: port, now: clock.now, rpId: RP, mainnetEnabled: () => flags.mainnet, credentials: async () => [{ id: pk.id, transports: ['internal'] }], confirmDelayMs: 0 });61  const w = { clock, t, m, signer, store, pk, px, port, flags, signs, svc: build(), restart: () => { w.svc = build(); return w.svc; },62    /** a minute later (the adapter's local rate budget is a one-minute window on the test clock) */63    next: (ms = 61_000) => clock.advance(ms) };64  return w;65}66type World = ReturnType<typeof world>;67const refusal = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? { code: e.code as string, status: e.status as number, message: e.message as string } : null; };68const audits = async (id: string) => (await sql`select type from audit_events where data->>'account' = ${id} order by seq`).map((r: { type: string }) => r.type) as string[];69const events = async (id: string, kind?: string) => (kind ? sql`select * from deriv_risk_events where account_id = ${id} and kind = ${kind} order by id` : sql`select * from deriv_risk_events where account_id = ${id} order by id`) as Promise<any[]>;70const row = async (id: string, k: string) => (await sql`select * from deriv_orders where account_id = ${id} and client_id = ${k}`)[0];7172/** an account on a network, enrolled passkey, funded on the fake venue, authorized with the passkey */73async function account(w: World, o: { network?: 'testnet' | 'mainnet'; fund?: string | null; policy?: Record<string, unknown> } = {}) {74  if (w.signer.status().credentials.length === 0) await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });75  const network = o.network ?? 'testnet', fake = network === 'testnet' ? w.t : w.m;76  if (o.fund !== null) fake.fund(ACCOUNT, o.fund ?? '1000');77  const a = await w.svc.createAccount({ venue: 'hyperliquid', network, label: 'test' });78  return a.id as string;79}80async function authorize(w: World, id: string, policy: Record<string, unknown> = {}) {81  const prep = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: { ...POLICY, ...policy }, days: 30, locale: 'fr' }));82  const done = await w.svc.approveAuthorization(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true });83  return { prep, done };84}85async function live(w: World, o: Parameters<typeof account>[1] = {}) { const id = await account(w, o); await authorize(w, id, o.policy); return id; }86const body = (o: Record<string, unknown>) => pp.OrderBodySchema.parse({ instrument: BTC, ...o });87const LIMIT = { side: 'buy', type: 'limit', size: '0.002', price: '69500' };88const MARKET = { side: 'buy', type: 'market', size: '0.002', slippageBps: 50, tif: 'ioc' };89async function place(w: World, id: string, o: Record<string, unknown> = LIMIT, k = key()) {90  const p = await w.svc.propose(id, body(o), k);91  const r = await w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never);92  return { k, p, order: r.order };93}9495describe('pinned asset table and canonical commitments', () => {96  it('the pinned table matches the recorded venue meta of both networks, and a renumbered venue is detected', async () => {97    const w = world();98    for (const net of ['mainnet', 'testnet'] as const) {99      const liveTable = await w.svc.deps.venues.get(net).adapter.assets();100      expect(hlPinnedMismatch(net, liveTable)).toBeNull();101      for (const p of hlPinnedAssets(net)) expect(liveTable.find((a) => a.symbol === p.symbol)).toMatchObject({ asset: p.asset, szDecimals: p.szDecimals, maxLeverage: p.maxLeverage });102      const moved = liveTable.map((a) => (a.symbol === 'BTC' ? { ...a, asset: 999 } : a));103      expect(hlPinnedMismatch(net, moved)).toMatch(/BTC moved from index/);104      expect(hlPinnedMismatch(net, liveTable.map((a) => (a.symbol === 'ETH' ? { ...a, szDecimals: 3 } : a)))).toMatch(/ETH: lot decimals changed/);105      expect(hlPinnedMismatch(net, liveTable.filter((a) => a.symbol !== 'SOL'))).toMatch(/SOL is no longer/);106    }107  });108});109110describe('a venue account starts READ_ONLY and nothing trades', () => {111  it('account creation derives the addresses on the signer; orders, leverage and cancel-all are refused; the venue\'s trading endpoint is never called', async () => {112    const w = world(), id = await account(w);113    const a = await w.svc.get(id);114    expect(a).toMatchObject({ mode: 'READ_ONLY', kind: 'venue', network: 'testnet', account: ACCOUNT, agentAddress: AGENT, authorization: null, tradable: false });115    expect(a.agentAddress).not.toBe(a.account);116    expect(a.venueState).toMatchObject({ source: 'venue', error: null, state: { equity: '1000', withdrawable: '1000' }, positions: [], openOrders: [] });117    expect((await w.svc.createAccount({ venue: 'hyperliquid', network: 'testnet', label: 'again' })).id).toBe(id);      // one account per venue and network118    expect(await refusal(w.svc.propose(id, body(LIMIT), key()))).toMatchObject({ code: 'mode_read_only', status: 422 });119    expect(await refusal(w.svc.preview(id, body(LIMIT)))).toMatchObject({ code: 'mode_read_only' });120    expect(await refusal(w.svc.proposeLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }))).toMatchObject({ code: 'mode_read_only' });121    expect(await refusal(w.svc.cancelAll(id))).toMatchObject({ code: 'mode_read_only' });122    expect(await refusal(w.svc.close(id, { instrument: BTC, fraction: '1' }))).toMatchObject({ code: 'mode_read_only' });123    expect(w.t.exchangeCalls()).toBe(0);124    expect(w.signs).toHaveLength(0);125    expect((await w.svc.collateral(id))).toMatchObject({ withdrawable: '1000', asset: 'USDC', source: 'venue', deposits: { supported: false }, withdrawals: { supported: true } });   // testnet: no deposit route; withdrawals are built (v6, collateral.ts)126    expect(await w.svc.active()).toEqual([]);                                                                            // the worker has nothing to do127  });128});129130describe('authorization: passkey, jurisdiction acknowledgement, the venue\'s own confirmation', () => {131  it('prepare shows the acknowledgement and commits to its hash; approve registers the trading key and records the acknowledgement', async () => {132    const w = world(), id = await account(w);133    const prep = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'fr' }));134    const ack = jurisdictionAck('hyperliquid', 'fr');135    expect(prep.acknowledgement).toEqual(ack);136    expect(prep.acknowledgement.text).toContain('pas un avis juridique');137    expect(prep.acknowledgement.text).not.toMatch(/est (légal|autorisé|permis)/i);138    expect(jurisdictionAck('hyperliquid', 'en').text).toContain('It is not a legal clearance');139    const auth = prep.authorization as VenueAuthorization;140    expect(auth).toMatchObject({ venue: 'hyperliquid', network: 'testnet', account: ACCOUNT, agentAddress: AGENT, mode: 'MANUAL_LIVE', ackSha256: ack.sha256, policy: POLICY });141    expect(auth.expiresAt - auth.createdAt).toBe(30 * 86400);142    expect(prep.hash).toBe(Buffer.from(venueAuthorizationHash(auth)).toString('hex'));143    expect(prep.options.challenge).toBe(b64(prep.hash).toString('base64url'));                                           // the passkey signs the authorization hash144    expect(prep.options.allowCredentials).toEqual([{ id: w.pk.id, type: 'public-key', transports: ['internal'] }]);145    expect(ex.ApproveAuthorizationSchema.safeParse({ assertion: {}, acknowledged: false }).success).toBe(false);          // the confirmation is not optional146    expect((await w.svc.get(id)).mode).toBe('READ_ONLY');147148    const done = await w.svc.approveAuthorization(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true });149    expect(done).toMatchObject({ mode: 'MANUAL_LIVE', tradable: true, authorization: { id: auth.authorizationId, hash: prep.hash, active: true, ackSha256: ack.sha256, policy: POLICY } });150    expect(done.policy).toMatchObject({ mode: 'MANUAL_LIVE', allowedInstruments: [BTC], maxLeverage: '3', maxOrderNotional: '500', maxGrossExposure: '1500', maxOrdersPerDay: 50 });151    // the venue itself lists the trading key as an API wallet of the account, with the authorization's expiry152    expect(w.t.user(ACCOUNT).agents.get(AGENT)).toEqual({ name: 'spw-live', validUntil: auth.expiresAt * 1000 });153    expect(w.t.exchangeCalls()).toBe(1); expect(w.t.exchangeCalls('approveAgent')).toBe(1);154    expect(await audits(id)).toEqual(['deriv.live.account_created', 'deriv.live.acknowledged', 'deriv.live.authorized']);155    const [acked] = await sql`select data from audit_events where type = 'deriv.live.acknowledged' and data->>'account' = ${id}`;156    expect(acked.data).toMatchObject({ ackSha256: ack.sha256, locale: 'fr', version: ack.version, authorizationId: auth.authorizationId });157    expect(await refusal(w.svc.approveAuthorization(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'bad_state' });158    expect(await refusal(w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'en' })))).toMatchObject({ code: 'bad_state', status: 409 });159  });160161  it('a wrong passkey changes nothing; an unfunded account stops at the venue\'s documented refusal and the signer authorization is revoked', async () => {162    const w = world(), id = await account(w, { fund: null });163    const prep = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'en' }));164    expect(await refusal(w.svc.approveAuthorization(id, { assertion: new TestPasskey(RP, ORIGIN).assert(b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'signer_unknown_credential', status: 422 });165    expect(w.signer.status().venues).toHaveLength(0);166    expect(w.t.exchangeCalls()).toBe(0);167    const again = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'en' }));168    const r = await refusal(w.svc.approveAuthorization(id, { assertion: w.pk.assert(b64(again.hash)) as never, acknowledged: true }));169    expect(r).toMatchObject({ code: 'venue_rejected', status: 422 });170    expect(r!.message).toContain(`Must deposit before performing actions. User: ${ACCOUNT}`);171    expect(w.signer.status().venues[0]).toMatchObject({ revoked: true });                                                  // nothing dangles on the signer172    expect((await w.svc.get(id))).toMatchObject({ mode: 'READ_ONLY', authorization: null });173    // the acknowledgement is recorded once the signer has verified the passkey over it — not for the failed attempt174    expect(await audits(id)).toEqual(['deriv.live.account_created', 'deriv.live.acknowledged', 'deriv.live.authorization_failed']);175    // limits the signer would refuse are refused before the passkey prompt176    expect(await refusal(w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: { ...POLICY, allowedInstruments: ['hyperliquid:DOGE'] }, days: 30, locale: 'en' })))).toMatchObject({ code: 'bad_policy' });177    expect(await refusal(w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: { ...POLICY, maxOrderNotional: '5000' }, days: 30, locale: 'en' })))).toMatchObject({ code: 'bad_policy' });178    expect(ex.AuthorizationBodySchema.safeParse({ policy: POLICY, days: 91, locale: 'en' }).success).toBe(false);179  });180181  it('the API-side policy can be tightened, never widened past the authorization', async () => {182    const w = world(), id = await live(w);183    const p = (await w.svc.get(id)).policy;184    expect((await w.svc.setPolicy(id, { ...p, maxOrderNotional: '100', maxOrdersPerDay: 5 })).policy).toMatchObject({ maxOrderNotional: '100', maxOrdersPerDay: 5 });185    for (const wider of [{ maxOrderNotional: '501' }, { maxLeverage: '4' }, { maxGrossExposure: '1500.01' }, { allowedInstruments: [BTC, 'hyperliquid:ETH'] }, { maxOrdersPerDay: 51 }, { mode: 'MANDATE_LIVE' }, { halt: 'x' }])186      expect(await refusal(w.svc.setPolicy(id, { ...p, ...wider } as never)), JSON.stringify(wider)).toMatchObject({ code: 'bad_policy', status: 400 });187  });188});189190describe('order: proposal → passkey → signer → venue → reconciliation', () => {191  it('a resting limit order: every state is persisted, the challenge is the order hash, the venue confirms it open, a cancel needs no passkey', async () => {192    const w = world(), id = await live(w), k = key();193    const preview = await w.svc.preview(id, body(LIMIT));194    expect(preview).toMatchObject({ risk: { ok: true }, estimate: { notional: '139', initialMargin: '6.95', leverage: '20', source: 'estimate' }, venueState: { source: 'venue' } });195    expect(await sql`select count(*)::int as n from deriv_orders`).toEqual([{ n: 0 }]);                                  // a preview stores nothing196197    const p = await w.svc.propose(id, body(LIMIT), k);198    expect(p).toMatchObject({ idempotent: false, order: { clientId: k, state: 'risk_checked', final: false, source: 'local', instrument: BTC, side: 'buy', size: '0.002', price: '69500' } });199    expect(p.canonical).toMatchObject({ kind: 'order', network: 'testnet', account: ACCOUNT, instrument: BTC, side: 'buy', type: 'limit', size: '0.002', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc' });200    expect(p.canonical!.clientId).toMatch(/^0x[0-9a-f]{32}$/);201    expect(p.canonical!.expiresAt - p.canonical!.nonce).toBe(90_000);                                                     // ≤ 2 minutes202    expect(p.options!.challenge).toBe(b64(p.hash!).toString('base64url'));203    expect(w.t.exchangeCalls('order')).toBe(0); expect(w.signs).toHaveLength(0);                                          // nothing signed, nothing sent before the passkey204205    const r = await w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never);206    expect(r.order).toMatchObject({ state: 'open', final: false, source: 'venue', filled: '0' });207    expect(r.order.venueOrderId).toMatch(/^\d+$/);208    const resting = w.t.openOrders(ACCOUNT);209    expect(resting).toHaveLength(1);210    expect(resting[0]).toMatchObject({ coin: 'BTC', side: 'B', limitPx: '69500', origSz: '0.002', cloid: p.canonical!.clientId, tif: 'Gtc' });211    // what was sent carried the venue expiry the owner approved, and the trading key signed it (the fake recovered an approved API wallet)212    const sent = w.t.calls.find((c) => c.type === 'order')!.body;213    expect(sent).toMatchObject({ nonce: p.canonical!.nonce, expiresAfter: p.canonical!.expiresAt, vaultAddress: null });214    expect(await audits(id)).toEqual(expect.arrayContaining(['deriv.live.order_proposed', 'deriv.live.order_submitted', 'deriv.live.order_open']));215216    w.next();217    const c = await w.svc.cancelOrder(id, k);218    expect(c).toMatchObject({ sent: true, venue: { outcome: 'accepted' }, order: { state: 'cancelled', final: true, source: 'venue' } });219    expect(w.t.openOrders(ACCOUNT)).toHaveLength(0);220    expect(w.signs.map((s: any) => !!s.assertion)).toEqual([true, false]);                                                // order: passkey; cancel: none221    expect((await w.svc.cancelOrder(id, k))).toMatchObject({ sent: false, order: { state: 'cancelled' } });               // already final: nothing is sent again222    expect(w.t.exchangeCalls('cancelByCloid')).toBe(1);223  });224225  it('a market order fills: fills, average price and position come from the venue; a partial fill is said to be partial', async () => {226    const w = world(), id = await live(w);227    const a = await place(w, id, MARKET);228    expect(a.p.canonical).toMatchObject({ type: 'limit', tif: 'ioc', price: '70355' });                                   // a market order is an IOC limit at its worst price229    expect(a.order).toMatchObject({ state: 'filled', final: true, filled: '0.002', avgFillPrice: '70005', source: 'venue' });230    const d = await w.svc.get(id);231    expect(d.fills).toHaveLength(1);232    expect(d.fills[0]).toMatchObject({ instrument: BTC, side: 'buy', price: '70005', size: '0.002', clientId: a.p.canonical!.clientId, source: 'venue' });233    expect(d.venueState.positions).toEqual([expect.objectContaining({ instrument: BTC, side: 'long', size: '0.002', entryPrice: '70005', source: 'venue' })]);234235    w.next();236    w.t.asks = [['70005', '0.001']];                                                                                      // only half of the size is on offer237    const b = await place(w, id, { ...MARKET, size: '0.003' });238    expect(b.order).toMatchObject({ state: 'partially_filled', final: true, filled: '0.001', avgFillPrice: '70005' });239    expect(b.order.reason).toMatch(/remainder not filled/);240    w.next();241    const s = await w.svc.reconcile(id);242    expect(s.divergence).toBeNull();                                                                                      // 0.003 long = what the wallet's own fills explain243    expect((await w.svc.get(id)).venueState.positions[0]).toMatchObject({ size: '0.003' });244    expect((await sql`select count(*)::int as n from deriv_equity where account_id = ${id}`)[0].n).toBeGreaterThan(0);245  });246247  it('a venue refusal is final and carries the venue\'s words; a risk refusal never reaches the signer', async () => {248    const w = world(), id = await live(w);249    const post = await place(w, id, { side: 'buy', type: 'limit', size: '0.002', price: '70010', tif: 'alo' });250    expect(post.order).toMatchObject({ state: 'rejected', final: true, filled: '0' });251    expect(post.order.reason).toContain('Post only order would have immediately matched');252    expect((await row(id, post.k)).venue_answer).toMatchObject({ status: 'ok', response: { type: 'order' } });         // the venue's own answer is kept253    expect(w.t.openOrders(ACCOUNT)).toHaveLength(0);254    w.next();255    const none = await place(w, id, { side: 'buy', type: 'market', size: '0.002', price: '70000', tif: 'ioc' });           // worst price below the best ask: nothing can match256    expect(none.order.state).toBe('rejected');257    expect(none.order.reason).toContain('could not immediately match');258259    w.next();260    const before = w.signs.length, k = key();261    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, size: '0.01' }), k))).toMatchObject({ code: 'order_notional_exceeded', status: 422 });   // 700 > 500262    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, price: '60000' }), key()))).toMatchObject({ code: 'price_deviation' });263    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, instrument: 'hyperliquid:ETH', price: w.t.ethMark(), size: '0.1' }), key()))).toMatchObject({ code: 'instrument_not_allowed', status: 422 });264    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, price: '69500.5' }), key()))).toMatchObject({ code: 'price_not_on_tick' });265    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, size: '0.0001' }), key()))).toMatchObject({ code: 'below_min_notional' });266    expect(await refusal(w.svc.propose(id, body({ ...LIMIT, leverage: '5' }), key()))).toMatchObject({ code: 'bad_request', status: 400 });267    expect(w.signs.length).toBe(before);268    expect(await row(id, k)).toMatchObject({ state: 'rejected', final: true, signature: null });269    expect((await events(id, 'order_refused')).map((e) => e.code)).toEqual(expect.arrayContaining(['order_notional_exceeded', 'price_deviation', 'price_not_on_tick', 'below_min_notional']));270    expect(await refusal(w.svc.approveOrder(id, 'no-such-order-key', {} as never))).toMatchObject({ code: 'unknown_order', status: 404 });271  });272273  it('the signer has the last word: its own price, the passkey, a tampered action — a refused order is never sent', async () => {274    const w = world(), id = await live(w);275    // (a) the signer's own exchange price is far from the price the API saw on the venue276    w.px.BTC = 60_000;277    const k1 = key(), p1 = await w.svc.propose(id, body(LIMIT), k1);278    const r1 = await refusal(w.svc.approveOrder(id, k1, w.pk.assert(b64(p1.hash!)) as never));279    expect(r1).toMatchObject({ code: 'signer_price', status: 422 });280    expect(r1!.message).toMatch(/more than 300 bps away from the signer's own BTC price 60000/);281    expect((await row(id, k1))).toMatchObject({ state: 'rejected', final: true, signature: null });282    w.px.BTC = 70_000; w.px.fail = 'price sources disagree on BTC';283    w.next();284    const k2 = key(), p2 = await w.svc.propose(id, body(LIMIT), k2);285    expect(await refusal(w.svc.approveOrder(id, k2, w.pk.assert(b64(p2.hash!)) as never))).toMatchObject({ code: 'signer_no_price' });286    w.px.fail = '';287    // (b) an assertion made for another order, and no assertion at all288    w.next();289    const k3 = key(), p3 = await w.svc.propose(id, body(LIMIT), k3), k4 = key(), p4 = await w.svc.propose(id, body({ ...LIMIT, size: '0.003' }), k4);290    expect(await refusal(w.svc.approveOrder(id, k4, w.pk.assert(b64(p3.hash!)) as never))).toMatchObject({ code: 'signer_bad_approval' });291    void p4;292    // (c) the stored action is changed after the proposal was shown (a compromised API): the signer compares it with what was approved293    w.next();294    const k5 = key(), p5 = await w.svc.propose(id, body(LIMIT), k5), stored = await row(id, k5);295    const big = await w.svc.deps.venues.get('testnet').trading().buildOrder({ instrument: BTC, side: 'buy', type: 'limit', size: '0.005', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: k5 },296      { instrument: await w.svc.deps.venues.get('testnet').instrument(BTC), nonce: Number(stored.nonce) });297    await sql`update deriv_orders set action = ${big.action as never}::json, sign_request = ${big.sign as never}::json where account_id = ${id} and client_id = ${k5}`;298    const r5 = await refusal(w.svc.approveOrder(id, k5, w.pk.assert(b64(p5.hash!)) as never));299    expect(r5).toMatchObject({ code: 'signer_mismatch' });300    expect(r5!.message).toMatch(/differ \(size\)/);301    expect(w.t.exchangeCalls('order')).toBe(0);                                                                          // none of the five reached the venue302    expect(w.signer.status().venues[0]!.today.orders).toBe(0);303    await place(w, id, LIMIT, k3);                                                                                        // the untouched proposal is still approvable304    expect(w.t.exchangeCalls('order')).toBe(1);305  });306});307308describe('idempotency: a retry can never place a second order', () => {309  it('same Idempotency-Key, double approval, approval after the end: one order on the venue', async () => {310    const w = world(), id = await live(w), k = key();311    const pair = await Promise.all([w.svc.propose(id, body(LIMIT), k), w.svc.propose(id, body(LIMIT), k)]);312    expect(pair.map((x) => x.idempotent).sort()).toEqual([false, true]);313    // exactly one call creates the proposal; which of the two is a matter of timing, and so is what the other one sees: the row314    // is inserted ('proposed') before its hash is computed ('risk_checked'), and the losing call may read it in between. It then315    // has no hash and nothing to approve YET — never another hash. (Asserting `again.hash === p.hash` here failed about every316    // second run of this test alone.)317    const p = pair.find((x) => !x.idempotent)!, again = pair.find((x) => x.idempotent)!;318    expect(p.hash).toMatch(/^[0-9a-f]{64}$/); expect(p.options).not.toBeNull();319    if (again.hash === null) expect(again).toMatchObject({ options: null, order: { state: 'proposed' } }); else expect(again.hash).toBe(p.hash);320    expect(await w.svc.propose(id, body(LIMIT), k)).toMatchObject({ idempotent: true, hash: p.hash, order: { state: 'risk_checked' } }); // once it is built, every retry gets the same proposal321    expect((await w.svc.propose(id, body(LIMIT), k)).options).not.toBeNull();322    expect((await w.svc.propose(id, body({ ...LIMIT, size: '0.004' }), k))).toMatchObject({ idempotent: true, order: { size: '0.002' } });   // the key decides, not the body323    expect((await sql`select count(*)::int as n from deriv_orders where account_id = ${id}`)[0].n).toBe(1);324    const assertion = w.pk.assert(b64(p.hash!)) as never;325    const both = await Promise.all([w.svc.approveOrder(id, k, assertion), w.svc.approveOrder(id, k, assertion)]);326    expect(both.map((x) => x.idempotent).sort()).toEqual([false, true]);327    expect(w.t.exchangeCalls('order')).toBe(1);328    expect(w.signs).toHaveLength(1);329    const later = await w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never);330    expect(later).toMatchObject({ idempotent: true, order: { state: 'open' } });331    const after = await w.svc.propose(id, body(LIMIT), k);332    expect(after).toMatchObject({ idempotent: true, options: null, order: { state: 'open' } });                           // nothing left to approve333    expect(w.t.exchangeCalls('order')).toBe(1);334    expect(w.t.openOrders(ACCOUNT)).toHaveLength(1);335    // a refused proposal keeps its key too336    const bad = key();337    await refusal(w.svc.propose(id, body({ ...LIMIT, size: '0.01' }), bad));338    expect(await w.svc.propose(id, body(LIMIT), bad)).toMatchObject({ idempotent: true, options: null, order: { state: 'rejected' } });339  });340});341342describe('unknown outcomes are resolved by the venue, never by assumption', () => {343  it('timeout AFTER the venue took the order → unknown → found by its client id', async () => {344    const w = world(), id = await live(w);345    w.t.fault = 'lost_after';346    const a = await place(w, id, LIMIT);347    expect(a.order).toMatchObject({ state: 'open', source: 'venue' });                                                    // looked up at once by cloid348    expect((await events(id, 'order_unknown'))).toHaveLength(1);349    expect(await audits(id)).not.toContain('deriv.live.order_submitted');                                                 // the HTTP answer never said so350    expect(w.t.exchangeCalls('order')).toBe(1);351352    w.next();353    w.t.fault = 'garbage_after';                                                                                          // an unreadable answer to a market order that did fill354    const b = await place(w, id, MARKET);355    expect(b.order).toMatchObject({ state: 'filled', final: true, filled: '0.002', avgFillPrice: '70005' });356  });357358  it('timeout BEFORE the venue saw it → unknown while the signature can still land → never placed once it has expired', async () => {359    const w = world(), id = await live(w);360    w.t.fault = 'lost_before';361    const a = await place(w, id, LIMIT);362    expect(a.order).toMatchObject({ state: 'unknown', final: false, source: 'local' });363    expect(a.order.reason).toMatch(/may or may not exist/);364    w.next(30_000);365    await w.svc.reconcile(id);366    expect((await row(id, a.k)).state).toBe('unknown');                                                                   // 30 s: the signed action is still valid on the venue367    w.next(70_000);368    await w.svc.reconcile(id);369    expect(await row(id, a.k)).toMatchObject({ state: 'rejected', final: true });370    expect((await row(id, a.k)).reject_reason).toMatch(/never placed/);371    expect(w.t.exchangeCalls('order')).toBe(1);                                                                           // never sent again372    expect(await audits(id)).toContain('deriv.live.order_never_placed');373374    w.t.fault = 'http502_before';375    const b = await place(w, id, LIMIT);376    expect(b.order.state).toBe('unknown');377  });378379  it('restart between signing and sending: nothing is re-signed or re-sent; the venue decides what happened', async () => {380    const w = world(), id = await live(w);381    const prepare = async () => {382      const k = key(), p = await w.svc.propose(id, body(LIMIT), k), r = await row(id, k);383      // the process got the signature, stored it, and died384      const signed = await w.port.venueSign({ authorizationId: p.canonical!.authorizationId, sign: r.sign_request, context: { order: r.request }, assertion: w.pk.assert(b64(p.hash!)) });385      await sql`update deriv_orders set state = 'signed', signature = ${sql.json(signed.signature)} where account_id = ${id} and client_id = ${k}`;386      return { k, r, signature: signed.signature };387    };388    // (a) it died before sending389    const a = await prepare();390    const svc = w.restart();391    await svc.reconcile(id);392    expect((await row(id, a.k)).state).toBe('unknown');393    expect(await refusal(svc.approveOrder(id, a.k, {} as never))).toBeNull();                                             // an approval retried after the restart does nothing394    expect(w.t.exchangeCalls('order')).toBe(0);395    w.next(100_000);396    await svc.reconcile(id);397    expect(await row(id, a.k)).toMatchObject({ state: 'rejected', final: true });398    expect(w.t.exchangeCalls('order')).toBe(0);399400    // (b) it died after the venue received the order401    const b = await prepare();402    await w.t.fetch('https://api.hyperliquid-testnet.xyz/exchange' as never, { method: 'POST', body: JSON.stringify({ action: b.r.action, nonce: Number(b.r.nonce), signature: b.signature, vaultAddress: null, expiresAfter: Number(b.r.nonce) + 90_000 }) });403    const svc2 = w.restart();404    await svc2.reconcile(id);405    expect(await row(id, b.k)).toMatchObject({ state: 'open', final: false });406    expect(w.t.exchangeCalls('order')).toBe(1);407408    // (c) it died while waiting for the signer: no signature was stored, so nothing can have been sent409    const k = key();410    await w.svc.propose(id, body(LIMIT), k);411    await sql`update deriv_orders set state = 'authorized', updated_at = ${new Date(w.clock.now())} where account_id = ${id} and client_id = ${k}`;412    await w.restart().reconcile(id);413    expect((await row(id, k)).state).toBe('authorized');414    w.next();415    await w.svc.reconcile(id);416    expect(await row(id, k)).toMatchObject({ state: 'rejected', final: true });417    expect((await row(id, k)).reject_reason).toMatch(/nothing was sent/);418  });419420  it('a proposal that is not approved in time expires locally; nothing was signed', async () => {421    const w = world(), id = await live(w), k = key();422    const p = await w.svc.propose(id, body(LIMIT), k);423    w.next(95_000);424    expect(await refusal(w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never))).toMatchObject({ code: 'proposal_expired', status: 410 });425    expect(await row(id, k)).toMatchObject({ state: 'cancelled', final: true, signature: null });426    const k2 = key();427    await w.svc.propose(id, body(LIMIT), k2);428    expect((await w.svc.cancelOrder(id, k2))).toMatchObject({ sent: false, order: { state: 'cancelled' } });               // withdrawn before approval: a local row only429    expect(w.signs).toHaveLength(0); expect(w.t.exchangeCalls('order')).toBe(0);430  });431});432433describe('reconciliation worker and divergence', () => {434  it('fills, funding and order states come from the venue; a resting order that fills later is picked up', async () => {435    const w = world(), id = await live(w);436    const a = await place(w, id, LIMIT);437    expect(await w.svc.active()).toEqual([id]);438    w.next();439    // the market comes down to the resting bid: the venue fills it (as its matching engine would)440    const o = w.t.openOrders(ACCOUNT)[0]!;441    (w.t as any).fill(w.t.user(ACCOUNT), o, '69500', '0.002'); o.status = 'filled'; o.statusTimestamp = w.clock.now();442    w.t.user(ACCOUNT).funding.push({ time: w.clock.now(), hash: `0x${'0'.repeat(64)}`, delta: { type: 'funding', coin: 'BTC', usdc: '-0.0123', szi: '0.002', fundingRate: '0.0000125' } });443    const s = await w.svc.step();444    expect(s).toMatchObject({ accounts: 1, reconciled: 1, fills: 1, funding: 1, divergences: 0, errors: [] });445    expect(await row(id, a.k)).toMatchObject({ state: 'filled', final: true });446    const d = await w.svc.get(id);447    expect(d.orders[0]).toMatchObject({ state: 'filled', filled: '0.002', avgFillPrice: '69500', source: 'venue' });448    expect(d.funding).toEqual([expect.objectContaining({ instrument: BTC, amount: '-0.0123', source: 'venue' })]);449    w.next();450    expect(await w.svc.step()).toMatchObject({ reconciled: 0, fills: 0, divergences: 0 });                                // nothing left in flight; a second pass changes nothing451  });452453  it('an order the wallet did not place, or a position its fills do not explain, halts new orders until it clears', async () => {454    const w = world(), id = await live(w);455    const seen: string[] = [];456    w.svc.subscribe(id, (ev) => { seen.push(ev.event); });457    const foreign = w.t.foreignOrder(ACCOUNT, { px: '65000', sz: '0.001' });458    expect((await w.svc.reconcile(id)).divergence).toBeNull();                                                            // seen once: could be a race, not yet a divergence459    w.next();460    const r = await w.svc.reconcile(id);461    expect(r.divergence).toMatch(new RegExp(`order ${foreign} on hyperliquid:BTC rests on the venue and was not placed by the wallet`));462    expect((await w.svc.get(id))).toMatchObject({ mode: 'MANUAL_LIVE' });463    expect((await w.svc.get(id)).halted).toMatch(/records differ/);464    expect((await events(id, 'divergence'))).toHaveLength(1);465    expect(await audits(id)).toContain('deriv.live.divergence');466    expect(seen).toEqual(expect.arrayContaining(['account', 'risk']));467    w.next();468    expect(await refusal(w.svc.propose(id, body(LIMIT), key()))).toMatchObject({ code: 'halted', status: 422 });469    expect(await refusal(w.svc.proposeLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }))).toMatchObject({ code: 'halted', status: 423 });470    w.next();471    await w.svc.reconcile(id);472    expect((await events(id, 'divergence'))).toHaveLength(1);                                                             // the same divergence is recorded once473    w.t.removeOrder(ACCOUNT, foreign);474    w.next();475    expect((await w.svc.reconcile(id)).divergence).toBeNull();476    expect((await events(id, 'divergence_cleared'))).toHaveLength(1);477    w.next();478    await place(w, id, LIMIT);                                                                                            // trading again479480    // a position that the wallet's fills do not explain (a trade from another interface, a liquidation)481    w.next();482    w.t.forcePosition(ACCOUNT, 'BTC', '0.005');483    await w.svc.reconcile(id); w.next();484    const p = await w.svc.reconcile(id);485    expect(p.divergence).toMatch(/position hyperliquid:BTC: venue 0.005, wallet records 0/);486    w.next();487    expect(await refusal(w.svc.propose(id, body(LIMIT), key()))).toMatchObject({ code: 'halted' });488    // the way out stays open: a reduce-only order that reduces the venue's real position is still proposed489    const close = await w.svc.close(id, { instrument: BTC, fraction: '0.5' });490    expect(close).toMatchObject({ order: { state: 'risk_checked', reduceOnly: true, side: 'sell', size: '0.0025' }, slippageBoundBps: 50 });491    w.next();492    expect((await w.svc.rebase(id)).divergence).toBeNull();                                                               // the owner accepts the venue's positions as the reference493    w.next();494    expect((await w.svc.reconcile(id)).divergence).toBeNull();495  });496});497498describe('kill switch: halt, cancel-all and close are three separate actions', () => {499  it('halt stops new orders only; cancel-all cancels resting orders only; close is a passkey-approved reduce-only order with a stated bound', async () => {500    const w = world(), id = await live(w);501    await place(w, id, MARKET);                                                                                           // a position: 0.002 long502    w.next();503    const resting = await place(w, id, LIMIT);                                                                            // and a resting order504    w.next();505    const pending = key();506    await w.svc.propose(id, body(LIMIT), pending);                                                                        // and a proposal waiting for the passkey507    const signsBefore = w.signs.length, sentBefore = w.t.exchangeCalls();508509    const h = await w.svc.halt(id, { reason: 'test' });510    expect(h.halt).toMatch(/^halted by the owner: test/);511    expect(w.signs.length).toBe(signsBefore); expect(w.t.exchangeCalls()).toBe(sentBefore);                               // a halt signs nothing and sends nothing512    expect(w.t.openOrders(ACCOUNT)).toHaveLength(1);                                                                      // the resting order is still there513    expect(h.venueState.positions).toHaveLength(1);                                                                       // the position is still open514    expect(await row(id, pending)).toMatchObject({ state: 'cancelled', final: true });                                    // the unapproved proposal is withdrawn515    w.next();516    expect(await refusal(w.svc.propose(id, body(LIMIT), key()))).toMatchObject({ code: 'halted' });517    expect(await refusal(w.svc.propose(id, body({ ...MARKET, side: 'sell', size: '0.005', reduceOnly: true }), key()))).toMatchObject({ code: 'reduce_only_would_flip' });518519    w.next();520    const c = await w.svc.cancelAll(id);521    expect(c).toMatchObject({ requested: 1, accepted: 1, failed: [], remaining: [], source: 'venue' });522    expect(w.t.openOrders(ACCOUNT)).toHaveLength(0);523    expect(await row(id, resting.k)).toMatchObject({ state: 'cancelled', final: true });524    expect((await w.svc.get(id)).venueState.positions).toHaveLength(1);                                                   // cancel-all closed nothing525    expect(w.signs[w.signs.length - 1]).not.toHaveProperty('assertion');                                                  // and needed no passkey526527    w.next();528    const k = key(), close = await w.svc.close(id, { instrument: BTC, fraction: '1', slippageBps: 30 }, k);529    expect(close).toMatchObject({ slippageBoundBps: 30, worstPrice: '69786', order: { state: 'risk_checked', side: 'sell', size: '0.002', reduceOnly: true, origin: 'close' } });   // 69995 × (1 − 0.003), rounded UP onto the venue's grid: a sell never accepts less than the bound530    expect(close.canonical).toMatchObject({ reduceOnly: true, type: 'limit', tif: 'ioc', price: '69786' });531    expect((await w.svc.get(id)).venueState.positions).toHaveLength(1);                                                   // proposing closed nothing532    expect((await refusal(w.svc.approveOrder(id, k, {} as never)))!.code).toMatch(/^signer_(bad_approval|unknown_credential)$/);   // not without the passkey533    w.next();534    const k2 = key(), close2 = await w.svc.close(id, { instrument: BTC, fraction: '1' }, k2);535    const done = await w.svc.approveOrder(id, k2, w.pk.assert(b64(close2.hash!)) as never);536    expect(done.order).toMatchObject({ state: 'filled', final: true, filled: '0.002', avgFillPrice: '69995' });537    expect((await w.svc.get(id)).venueState.positions).toHaveLength(0);538    expect(await refusal(w.svc.close(id, { instrument: BTC, fraction: '1' }))).toMatchObject({ code: 'no_position' });539540    w.next();541    expect((await w.svc.resume(id)).halt).toBeNull();542    await place(w, id, LIMIT);543    expect(await audits(id)).toEqual(expect.arrayContaining(['deriv.live.halt', 'deriv.live.cancel_all', 'deriv.live.close_proposed', 'deriv.live.resume']));544  });545546  it('revoking needs no passkey and is permanent: proposals are refused, the signer signs nothing, resting orders are named', async () => {547    const w = world(), id = await live(w);548    await place(w, id, LIMIT);549    w.next();550    const r = await w.svc.revoke(id, { cancelOpenOrders: false });551    expect(r).toMatchObject({ mode: 'READ_ONLY', tradable: false, authorization: { active: false } });552    expect(r.warning).toMatch(/1 order\(s\) still rest on the venue/);553    expect(r.warning).toBe("1 order(s) still rest on the venue. The wallet can no longer cancel them (a revoked authorization signs nothing): cancel them in the venue's own interface, or approve a new authorization and cancel.");   // v8: default en, byte for byte554    { // v8 hardening (D2): the same warning in the body's locale555      const wf = world(), idf = await live(wf);556      await place(wf, idf, LIMIT); wf.next();557      const rf = await wf.svc.revoke(idf, { cancelOpenOrders: false, locale: 'fr' });558      expect(rf.locale).toBe('fr'); expect(rf.warning).toMatch(/^1 ordre\(s\) restent sur la plateforme\. Le portefeuille ne peut plus les annuler/);559    }560    expect(w.signer.status().venues[0]).toMatchObject({ revoked: true });561    expect(await refusal(w.svc.propose(id, body(LIMIT), key()))).toMatchObject({ code: 'mode_read_only' });562    expect(await refusal(w.svc.cancelAll(id))).toMatchObject({ code: 'mode_read_only' });563    // even if the API insisted, the signer refuses a revoked authorization564    const c = w.svc.deps.venues.get('testnet').trading().buildCancel({ instrument: await w.svc.deps.venues.get('testnet').instrument(BTC), venueOrderId: '1001', nonce: w.clock.now() });565    expect(await refusal(w.port.venueSign({ authorizationId: r.authorization!.id, sign: c.sign }))).toMatchObject({ code: 'signer_revoked' });566567    // with cancelOpenOrders the resting orders are cancelled first, then the authorization is revoked568    const w2 = world(), id2 = await live(w2);569    await place(w2, id2, LIMIT);570    w2.next();571    const r2 = await w2.svc.revoke(id2, { cancelOpenOrders: true });572    expect(r2).toMatchObject({ mode: 'READ_ONLY', warning: null, cancelled: { requested: 1, accepted: 1 } });573    expect(w2.t.openOrders(ACCOUNT)).toHaveLength(0);574    // a new authorization can be approved afterwards (new id, new passkey approval)575    w2.next();576    await authorize(w2, id2);577    expect((await w2.svc.get(id2)).mode).toBe('MANUAL_LIVE');578  });579});580581describe('leverage', () => {582  it('needs the passkey over its canonical form, is confirmed by the venue\'s own setting, and stays within the authorization', async () => {583    const w = world(), id = await live(w);584    const p = await w.svc.proposeLeverage(id, { instrument: BTC, leverage: 3, marginMode: 'cross' });585    expect(p).toMatchObject({ canonical: { kind: 'leverage', instrument: BTC, leverage: 3, marginMode: 'cross' }, current: { leverage: '20', marginMode: 'cross' }, action: { state: 'risk_checked' } });586    expect(p.options.challenge).toBe(b64(p.hash).toString('base64url'));587    expect((await refusal(w.svc.approveLeverage(id, p.action.id, {} as never)))!.code).toMatch(/^signer_(bad_approval|unknown_credential)$/);588    w.next();589    const p2 = await w.svc.proposeLeverage(id, { instrument: BTC, leverage: 3, marginMode: 'cross' });590    const r = await w.svc.approveLeverage(id, p2.action.id, w.pk.assert(b64(p2.hash)) as never);591    expect(r).toMatchObject({ idempotent: false, action: { state: 'confirmed' }, venue: { confirmed: true, leverage: { leverage: '3', marginMode: 'cross' }, source: 'venue' } });592    expect(w.t.user(ACCOUNT).leverage.get('BTC')).toEqual({ type: 'cross', value: 3 });593    expect((await w.svc.approveLeverage(id, p2.action.id, w.pk.assert(b64(p2.hash)) as never)).idempotent).toBe(true);594    expect(w.t.exchangeCalls('updateLeverage')).toBe(1);595    expect(await refusal(w.svc.proposeLeverage(id, { instrument: BTC, leverage: 4, marginMode: 'cross' }))).toMatchObject({ code: 'leverage_exceeded' });596    expect(await refusal(w.svc.proposeLeverage(id, { instrument: 'hyperliquid:ETH', leverage: 2, marginMode: 'cross' }))).toMatchObject({ code: 'instrument_not_allowed' });597    // the order preview now uses the venue's setting598    w.next();599    expect((await w.svc.preview(id, body(LIMIT))).estimate).toMatchObject({ leverage: '3', initialMargin: '46.333333333333333334' });600  });601});602603describe('asset table and mainnet locks', () => {604  it('a venue whose asset table no longer matches the pinned one is not traded', async () => {605    const w = world(), id = await live(w);606    const u = w.t.meta.universe, btc = u.findIndex((x: any) => x.name === 'BTC'), eth = u.findIndex((x: any) => x.name === 'ETH');607    [u[btc], u[eth]] = [u[eth], u[btc]];                                                                                  // the venue renumbers BTC and ETH608    w.next(120_000);609    const r = await refusal(w.svc.propose(id, body(LIMIT), key()));610    expect(r).toMatchObject({ code: 'asset_table_mismatch', status: 503 });611    expect(r!.message).toMatch(/BTC moved from index 3 to 4/);612    expect(await refusal(w.svc.preview(id, body(LIMIT)))).toMatchObject({ code: 'asset_table_mismatch' });613    expect(await refusal(w.svc.proposeLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }))).toMatchObject({ code: 'asset_table_mismatch' });614    expect(w.signs).toHaveLength(0); expect(w.t.exchangeCalls('order')).toBe(0);615  });616617  it('mainnet with the server setting off: read-only account, every proposal refused with a clear message, the mainnet exchange endpoint never called; testnet works', async () => {618    const w = world();619    expect(w.svc.config()).toMatchObject({ mainnetEnabled: false, mandateLive: { available: false } });620    // v6: a mainnet venue account is not even created while the setting is off (it was created READ_ONLY before); one that exists stays, read-only621    expect(await refusal(w.svc.createAccount({ venue: 'hyperliquid', network: 'mainnet', label: 'm' }))).toMatchObject({ code: 'mainnet_disabled', status: 403 });622    w.flags.mainnet = true;623    const main = await account(w, { network: 'mainnet' });624    w.flags.mainnet = false;625    expect((await w.svc.get(main))).toMatchObject({ mode: 'READ_ONLY', network: 'mainnet', mainnetEnabled: false, tradable: false, agentAddress: keys.venueKey('hyperliquid', 'mainnet').address });626    const r = await refusal(w.svc.prepareAuthorization(main, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'fr' })));627    expect(r).toMatchObject({ code: 'mainnet_disabled', status: 403 });628    expect(r!.message).toMatch(/SPW_DERIV_LIVE_MAINNET/);629    for (const p of [() => w.svc.propose(main, body(LIMIT), key()), () => w.svc.preview(main, body(LIMIT)), () => w.svc.close(main, { instrument: BTC, fraction: '1' }),630      () => w.svc.proposeLeverage(main, { instrument: BTC, leverage: 2, marginMode: 'cross' }), () => w.svc.cancelAll(main)])631      expect(await refusal(p())).toMatchObject({ code: 'mainnet_disabled', status: 403 });632    const test = await live(w);                                                                                           // the same wallet on testnet633    expect((await place(w, test, LIMIT)).order.state).toBe('open');634    expect(w.m.exchangeCalls()).toBe(0);635636    // the setting is turned off AFTER an account was authorized: both locks hold637    w.flags.mainnet = true;638    await authorize(w, main);639    expect(w.m.exchangeCalls('approveAgent')).toBe(1);                                                                    // (a fake: nothing real is ever reached)640    w.flags.mainnet = false;641    w.next();642    expect(await refusal(w.svc.propose(main, body(LIMIT), key()))).toMatchObject({ code: 'mainnet_disabled' });643    const trading = w.svc.deps.venues.get('mainnet').trading(), inst = await w.svc.deps.venues.get('mainnet').instrument(BTC);644    const built = trading.buildOrder({ instrument: BTC, side: 'buy', type: 'limit', size: '0.002', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'direct' }, { instrument: inst, nonce: w.clock.now() });645    await expect(trading.submit(built.action, { r: `0x${'1'.repeat(64)}`, s: `0x${'1'.repeat(64)}`, v: 27 }, w.clock.now())).rejects.toThrow(/mainnet exchange endpoint is disabled/);646    expect(w.m.exchangeCalls()).toBe(1);                                                                                  // still only the approval made while it was on647    // mainnet and testnet are separate keys and separate authorizations648    const st = w.signer.status().venues;649    expect(new Set(st.map((v) => v.authorization.agentAddress)).size).toBe(2);650    expect(new Set(st.map((v) => v.authorization.network))).toEqual(new Set(['mainnet', 'testnet']));651  });652});653654describe('routes', () => {655  it('every live route needs the owner session and the CSRF header; the whole flow works over HTTP', async () => {656    const w = world();657    await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });658    w.t.fund(ACCOUNT, '1000');659    const token = randomBytes(32).toString('base64url');660    await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;661    const cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;662    const app = await (await import('../src/server.ts')).buildServer({ live: w.svc });663    const api = async (method: 'GET' | 'POST', path: string, payload?: unknown, o: { cookie?: string | null; idem?: string; csrf?: boolean } = {}) => {664      const headers: Record<string, string> = {};665      if (o.cookie !== null) headers.cookie = o.cookie ?? cookie;666      if (method !== 'GET') { headers.origin = ORIGIN; if (o.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }667      if (o.idem) headers['idempotency-key'] = o.idem;668      const r = await app.inject({ method, url: P + path, headers, payload: payload === undefined ? (method === 'GET' ? undefined : '{}') : JSON.stringify(payload) });669      return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })() };670    };671    try {672      const zero = '00000000-0000-4000-8000-000000000000';673      for (const p of ['/config', '/jurisdiction', '/accounts', `/accounts/${zero}`, `/accounts/${zero}/collateral`, `/accounts/${zero}/stream`]) expect((await api('GET', p, undefined, { cookie: null })).status, p).toBe(401);674      const posts = ['/venue-key', '/accounts', `/accounts/${zero}/authorization/prepare`, `/accounts/${zero}/authorization/approve`, `/accounts/${zero}/authorization/revoke`, `/accounts/${zero}/policy`, `/accounts/${zero}/orders/preview`,675        `/accounts/${zero}/orders`, `/accounts/${zero}/orders/some-client-id/approve`, `/accounts/${zero}/orders/some-client-id/cancel`, `/accounts/${zero}/leverage`, `/accounts/${zero}/leverage/${zero}/approve`, `/accounts/${zero}/halt`,676        `/accounts/${zero}/resume`, `/accounts/${zero}/cancel-all`, `/accounts/${zero}/close`, `/accounts/${zero}/rebase`, `/accounts/${zero}/reconcile`];677      for (const p of posts) {678        expect((await api('POST', p, {}, { cookie: null, idem: key() })).status, p).toBe(401);679        expect((await api('POST', p, {}, { csrf: false, idem: key() })).status, p).toBe(403);680      }681      expect(w.t.calls).toHaveLength(0);682683      expect((await api('GET', '/config')).json).toMatchObject({ mainnetEnabled: false, bounds: { maxAuthorizationDays: 90, orderApprovalTtlMs: 90000 } });684      const j = (await api('GET', '/jurisdiction')).json;685      expect(j.fr.sha256).toBe(jurisdictionAck('hyperliquid', 'fr').sha256); expect(j.en.text).toContain('not legal advice');686      expect((await api('POST', '/venue-key', { venue: 'hyperliquid', network: 'testnet' })).json).toMatchObject({ address: AGENT, account: ACCOUNT });687      expect((await api('POST', '/venue-key', { venue: 'dydx', network: 'mainnet' })).status).toBe(404);688      const created = await api('POST', '/accounts', { venue: 'hyperliquid', network: 'testnet', label: 'HL testnet' });689      expect(created.status).toBe(200);690      const id = created.json.id as string;691      expect((await api('GET', '/accounts')).json.accounts).toEqual([expect.objectContaining({ id, mode: 'READ_ONLY', kind: 'venue' })]);692      expect((await api('GET', `/accounts/${zero}`)).status).toBe(404);693      expect((await api('POST', `/accounts/${id}/authorization/prepare`, { policy: POLICY, days: 120, locale: 'fr' })).status).toBe(400);694      const prep = await api('POST', `/accounts/${id}/authorization/prepare`, { policy: POLICY, days: 30, locale: 'fr' });695      expect(prep.status).toBe(200);696      expect((await api('POST', `/accounts/${id}/authorization/approve`, { assertion: w.pk.assert(b64(prep.json.hash)) })).status).toBe(400);   // `acknowledged: true` is required697      const ok = await api('POST', `/accounts/${id}/authorization/approve`, { assertion: w.pk.assert(b64(prep.json.hash)), acknowledged: true });698      expect(ok.status).toBe(200); expect(ok.json.mode).toBe('MANUAL_LIVE');699700      expect((await api('POST', `/accounts/${id}/orders`, { instrument: BTC, ...LIMIT })).status).toBe(400);                 // no Idempotency-Key701      expect((await api('POST', `/accounts/${id}/orders`, { instrument: BTC, ...LIMIT, clientId: 'x' }, { idem: key() })).status).toBe(400);702      const k = key(), prop = await api('POST', `/accounts/${id}/orders`, { instrument: BTC, ...LIMIT }, { idem: k });703      expect(prop.status).toBe(200); expect(prop.json.order.state).toBe('risk_checked');704      const too = await api('POST', `/accounts/${id}/orders`, { instrument: BTC, ...LIMIT, size: '0.01' }, { idem: key() });705      expect(too.status).toBe(422); expect(too.json.error).toBe('order_notional_exceeded');706      expect((await api('POST', `/accounts/${id}/orders/${k}/approve`, {})).status).toBe(400);707      const appr = await api('POST', `/accounts/${id}/orders/${k}/approve`, { assertion: w.pk.assert(b64(prop.json.hash)) });708      expect(appr.status).toBe(200); expect(appr.json.order).toMatchObject({ state: 'open', source: 'venue' });709      w.next();710      expect((await api('POST', `/accounts/${id}/halt`, { reason: 'route test' })).json.halt).toMatch(/route test/);711      expect((await api('POST', `/accounts/${id}/cancel-all`)).json).toMatchObject({ requested: 1, accepted: 1 });712      expect((await api('POST', `/accounts/${id}/close`, { instrument: BTC, fraction: '1' })).json.error).toBe('no_position');713      w.next();714      const rec = await api('POST', `/accounts/${id}/reconcile`);715      expect(rec.status).toBe(200); expect(rec.json.account.orders.find((o: any) => o.clientId === k).state).toBe('cancelled');716      expect((await api('GET', `/accounts/${id}/collateral`)).json).toMatchObject({ withdrawable: '1000', source: 'venue' });717      expect((await api('POST', `/accounts/${id}/authorization/revoke`, {})).json.mode).toBe('READ_ONLY');718    } finally { app.server.closeAllConnections?.(); await app.close(); }719  });720});721
Files main
Menu

Theme