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