Repository Public
1// MANDATE_LIVE end to end inside the process: the REAL execution engine and its routes, the REAL isolated signer class with2// its REAL venue reader (in process, public test mnemonic, real WebAuthn assertions from a software passkey), PostgreSQL3// (database spw_deriv_mandate_test, created here if missing) and the scripted fake of the Hyperliquid exchange + info4// endpoints. No network: a request to any real host fails the test. Also: the fixes of the live engine that came with it.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 { jurisdictionAck, VENUE_MANDATE_WIND_DOWN_S, venueMandateHash, type VenueMandate } from '@spw/derivatives/live';11import type { OrderRequest } from '@spw/derivatives';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../../isolated-signer/src/keys.ts';14import { Signer, SignerError } from '../../isolated-signer/src/signer.ts';15import { Store } from '../../isolated-signer/src/store.ts';16import { hyperliquidReader } from '../../isolated-signer/src/venue-read.ts';17import { Clock, useDatabase } from './derivatives-helpers.ts';18import { FakeHl, routeFetch } from './hl-fake.ts';1920const RP = 'wallet.test', ORIGIN = 'https://wallet.test', BTC = 'hyperliquid:BTC', P = '/api/v1/derivatives/live';21const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');22const ACCOUNT = keys.addresses.evm.toLowerCase();23const b64 = (hexHash: string) => Buffer.from(hexHash, 'hex');24const SHA = 'c0de'.repeat(16), AGENT_ID = 'perp-agent-1';25const POLICY = { allowedInstruments: [BTC], maxLeverage: 3, maxOrderNotional: '500', maxPositionNotional: '1000', maxGrossExposure: '1500', maxOrdersPerDay: 50, maxPriceDeviationBps: 300 };26const MANDATE = { instruments: [{ instrument: BTC, maxLeverage: 3, maxPositionNotional: '600' }], maxOrderNotional: '300', maxGrossExposure: '800', maxOrdersPerDay: 20, maxOrdersPerHour: 10, maxDailyTurnover: '3000',27 floorEquity: '500', days: 10, locale: 'fr', strategySha256: SHA, agentId: AGENT_ID };2829let sql: any, lv: typeof import('../src/derivatives/live.ts'), ex: typeof import('../src/derivatives/exec.ts'), pp: typeof import('../src/derivatives/paper.ts'), mm: typeof import('../src/derivatives/mandate.ts'),30 UserError: typeof import('../src/config.ts').UserError;31type Svc = import('../src/derivatives/exec.ts').LiveService;3233beforeAll(async () => {34 await useDatabase('deriv_mandate');35 const db = await import('../src/db.ts');36 sql = db.sql;37 await db.migrate();38 await db.migrate(); // the schema is idempotent39 lv = await import('../src/derivatives/live.ts'); ex = await import('../src/derivatives/exec.ts'); pp = await import('../src/derivatives/paper.ts'); mm = await import('../src/derivatives/mandate.ts');40 UserError = (await import('../src/config.ts')).UserError;41});42beforeEach(async () => { await sql`truncate deriv_accounts, deriv_orders, deriv_fills, deriv_funding, deriv_risk_events, deriv_equity, deriv_actions, deriv_mandates`; });43afterAll(async () => { await sql?.end(); });4445let seq = 0;46const key = () => `mandate-key-${++seq}-${Math.random().toString(36).slice(2, 10)}`;4748function world() {49 const clock = new Clock(Date.parse('2026-11-11T12:00:00Z'));50 const t = new FakeHl('testnet', clock.now), m = new FakeHl('mainnet', clock.now);51 const dir = mkdtempSync(join(tmpdir(), 'spw-mandate-api-'));52 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));53 store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');54 const px: { BTC: number; fail: string } = { BTC: 70_000, fail: '' };55 /** an info request type the venue fails on (502), for the API and for the signer alike */56 const faults: { info: string | null; signerInfo: string | null } = { info: null, signerInfo: null };57 const base = routeFetch([t, m]);58 const failing = (which: 'info' | 'signerInfo') => (async (url: string, init?: RequestInit) => {59 const type = JSON.parse(String(init?.body ?? '{}')).type;60 if (String(url).endsWith('/info') && faults[which] === type) return new Response('bad gateway', { status: 502 });61 return base(url as never, init);62 }) as unknown as typeof fetch;63 const signerInfoCalls: { url: string; type: string }[] = [];64 // the signer's OWN reader, pointed at the fake venue: its real parsing, its real pinned hosts65 const reader = hyperliquidReader(async (url, body) => {66 signerInfoCalls.push({ url, type: (body as { type: string }).type });67 const r = await failing('signerInfo')(url, { method: 'POST', body: JSON.stringify(body) });68 if (!r.ok) throw new Error(`HTTP ${r.status}`);69 return r.json();70 });71 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(clock.now() / 1000),72 { 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]!])); } }, reader);73 const pk = new TestPasskey(RP, ORIGIN);74 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; } };75 const signs: unknown[] = [], mandateSigns: any[] = [];76 const port: import('../src/derivatives/exec.ts').LiveSignerPort = {77 status: () => call(() => signer.status()), venueKey: (v, n) => call(() => signer.venueKey(v, n)),78 venueAuthorize: (a, s) => call(() => signer.venueAuthorize(a, s as never)), venueRevoke: (id) => call(() => signer.venueRevoke(id)),79 venueSign: (p) => { signs.push(p); return call(() => signer.venueSign(p as never)); },80 };81 const mandatePort: import('../src/derivatives/exec.ts').MandateSignerPort = {82 venueMandateApprove: (x, s) => call(() => signer.venueMandateApprove(x, s as never)), venueMandateRevoke: (id) => call(() => signer.venueMandateRevoke(id)),83 venueMandateSign: (p) => { mandateSigns.push(p); return call(() => signer.venueMandateSign(p as never)); },84 };85 const flags = { mainnet: false, mandateMainnet: false };86 const build = (): Svc => lv.createLive({ fetch: failing('info'), signer: port, mandateSigner: mandatePort, now: clock.now, rpId: RP, mainnetEnabled: () => flags.mainnet, mandateMainnetEnabled: () => flags.mandateMainnet,87 credentials: async () => [{ id: pk.id, transports: ['internal'] }], confirmDelayMs: 0 });88 const w = { clock, t, m, signer, store, pk, px, port, flags, faults, signs, mandateSigns, signerInfoCalls, svc: build(),89 /** a minute later (the adapter's local rate budget is a one-minute window on the test clock) */90 next: (ms = 61_000) => clock.advance(ms) };91 return w;92}93type World = ReturnType<typeof world>;94const 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; };95const 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[];96const events = async (id: string, kind: string) => sql`select * from deriv_risk_events where account_id = ${id} and kind = ${kind} order by id` as Promise<any[]>;97const row = async (id: string, k: string) => (await sql`select * from deriv_orders where account_id = ${id} and client_id = ${k}`)[0];9899/** an authorized (MANUAL_LIVE) account, funded on the fake venue, with the venue's leverage setting inside the mandate */100async function live(w: World, o: { network?: 'testnet' | 'mainnet'; leverage?: number | null } = {}) {101 if (w.signer.status().credentials.length === 0) await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });102 const network = o.network ?? 'testnet', fake = network === 'testnet' ? w.t : w.m;103 fake.fund(ACCOUNT, '1000');104 if (o.leverage !== null) fake.user(ACCOUNT).leverage.set('BTC', { type: 'cross', value: o.leverage ?? 3 });105 const id = (await w.svc.createAccount({ venue: 'hyperliquid', network, label: 'test' })).id as string;106 const prep = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'fr' }));107 await w.svc.approveAuthorization(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true });108 return id;109}110async function mandate(w: World, id: string, o: Record<string, unknown> = {}) {111 const prep = await w.svc.prepareMandate(id, ex.MandateBodySchema.parse({ ...MANDATE, ...o }));112 const done = await w.svc.approveMandate(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true });113 return { prep, done, m: prep.mandate as VenueMandate };114}115async function mandated(w: World, o: Record<string, unknown> = {}) { const id = await live(w); return { id, ...(await mandate(w, id, o)) }; }116const req = (o: Partial<OrderRequest> = {}): OrderRequest => ({ instrument: BTC, side: 'buy', type: 'limit', size: '0.002', price: '69500', triggerPrice: null, reduceOnly: false, tif: 'gtc', clientId: 'ignored', ...o });117const MARKET_BUY = { type: 'market' as const, tif: 'ioc' as const, price: '70350' }, CLOSE = { side: 'sell' as const, type: 'market' as const, tif: 'ioc' as const, price: '69650', reduceOnly: true };118const ctx = (k = key(), o: Record<string, string> = {}) => ({ strategySha256: SHA, agentId: AGENT_ID, clientId: k, ...o });119const place = (w: World, id: string, o: Partial<OrderRequest> = {}, k = key()) => mm.placeMandateOrder(id, req(o), ctx(k), w.svc);120const body = (o: Record<string, unknown>) => pp.OrderBodySchema.parse({ instrument: BTC, ...o });121const LIMIT = { side: 'buy', type: 'limit', size: '0.002', price: '69500' };122123describe('configuration: a second, separate mainnet lock; reasons in French and English', () => {124 it('GET /config says what is available and why not, with a locale', async () => {125 const w = world();126 const en = w.svc.config(), fr = w.svc.config('fr');127 expect(en).toMatchObject({ mainnetEnabled: false, locale: 'en', modes: ['READ_ONLY', 'MANUAL_LIVE', 'MANDATE_LIVE'], mainnet: { enabled: false, code: 'mainnet_disabled' },128 mandateLive: { available: true, code: null, reason: null, mainnet: { enabled: false, code: 'mandate_mainnet_disabled' }, bounds: { maxDays: 30, maxOrdersPerHour: 60, windDownSeconds: 86400, orderTtlMs: 30000 } } });129 expect(en.mainnet.reason).toMatch(/SPW_DERIV_LIVE_MAINNET is not "on"/);130 expect(fr.mainnet.reason).toMatch(/désactivée sur ce serveur \(SPW_DERIV_LIVE_MAINNET/);131 expect(fr.mandateLive.mainnet.reason).toMatch(/SPW_DERIV_LIVE_MAINNET=on ET SPW_DERIV_MANDATE_MAINNET=on/);132 expect(en.reasons.mainnet_disabled).toEqual({ en: en.mainnet.reason, fr: fr.mainnet.reason });133 // each lock alone is not enough134 w.flags.mandateMainnet = true;135 expect(w.svc.config().mandateLive.mainnet).toMatchObject({ enabled: false });136 w.flags.mainnet = true;137 expect(w.svc.config()).toMatchObject({ mainnet: { enabled: true, code: null, reason: null }, mandateLive: { mainnet: { enabled: true, code: null, reason: null } } });138 w.flags.mandateMainnet = false;139 expect(w.svc.config().mandateLive.mainnet.enabled).toBe(false);140 // the process-wide settings: exactly `on`, read at every use141 const before = [process.env.SPW_DERIV_LIVE_MAINNET, process.env.SPW_DERIV_MANDATE_MAINNET];142 try {143 delete process.env.SPW_DERIV_MANDATE_MAINNET; expect(lv.mandateMainnetEnabled()).toBe(false);144 for (const v of ['1', 'true', 'ON', 'on ']) { process.env.SPW_DERIV_MANDATE_MAINNET = v; expect(lv.mandateMainnetEnabled()).toBe(false); }145 process.env.SPW_DERIV_MANDATE_MAINNET = 'on'; expect(lv.mandateMainnetEnabled()).toBe(true); expect(lv.mainnetLiveEnabled()).toBe(before[0] === 'on');146 } finally { if (before[1] === undefined) delete process.env.SPW_DERIV_MANDATE_MAINNET; else process.env.SPW_DERIV_MANDATE_MAINNET = before[1]; }147 // an engine whose signer connection offers no mandate says so, and every mandate entry point refuses148 const none = lv.createLive({ fetch: routeFetch([w.t, w.m]), signer: w.port, now: w.clock.now, rpId: RP, mainnetEnabled: () => false, credentials: async () => [], confirmDelayMs: 0 });149 expect(none.config('fr')).toMatchObject({ modes: ['READ_ONLY', 'MANUAL_LIVE'], mandateLive: { available: false, code: 'mandate_unavailable' } });150 const id = await live(w);151 expect(await refusal(none.prepareMandate(id, ex.MandateBodySchema.parse(MANDATE)))).toMatchObject({ code: 'mandate_unavailable', status: 503 });152 expect(await refusal(none.placeMandateOrder(id, req(), ctx()))).toMatchObject({ code: 'mandate_unavailable', status: 503 });153 });154});155156describe('mandate: prepare (what is shown), approve (one passkey), the account holds both modes', () => {157 it('prepare shows the limits, the acknowledgement again, what the signer enforces and trusts, the wind-down; approve stores it on the signer', async () => {158 const w = world(), id = await live(w);159 expect(await w.svc.get(id)).toMatchObject({ mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE'], mandate: null, pendingMandate: null });160 const prep = await w.svc.prepareMandate(id, ex.MandateBodySchema.parse(MANDATE));161 const ack = jurisdictionAck('hyperliquid', 'fr'), auth = (await w.svc.get(id)).authorization!;162 expect(prep.mandate).toMatchObject({ v: 1, authorizationId: auth.id, venue: 'hyperliquid', network: 'testnet', account: ACCOUNT, strategySha256: SHA, agentId: AGENT_ID, instruments: MANDATE.instruments,163 maxOrderNotional: '300', maxGrossExposure: '800', maxOrdersPerDay: 20, maxOrdersPerHour: 10, maxDailyTurnover: '3000', floorEquity: '500', maxPriceDeviationBps: 300, ackSha256: ack.sha256 });164 expect(prep.mandate.expiresAt - prep.mandate.createdAt).toBe(10 * 86400);165 expect(prep.hash).toBe(Buffer.from(venueMandateHash(prep.mandate)).toString('hex'));166 expect(prep.options.challenge).toBe(b64(prep.hash).toString('base64url')); // the passkey signs the mandate hash167 expect(prep.acknowledgement).toEqual(ack);168 expect(prep).toMatchObject({ locale: 'fr', windDown: { seconds: 86400 }, authorization: { id: auth.id, policy: POLICY } });169 expect(prep.warning).toMatch(/SANS demander votre clé d'accès/);170 expect(prep.enforcedBySigner.join(' ')).toMatch(/lues par le signataire lui-même/);171 expect(prep.trustedBySigner.join(' ')).toMatch(/point d'accès public d'information de la plateforme/);172 expect(prep.notEnforced.join(' ')).toMatch(/consignée, pas vérifiée/);173 expect(prep.windDown.text).toMatch(/Pendant 24 heures/);174 expect((await w.svc.prepareMandate(id, ex.MandateBodySchema.parse({ ...MANDATE, locale: 'en' }))).enforcedBySigner.join(' ')).toMatch(/read by the signer itself/);175 expect(await w.svc.get(id)).toMatchObject({ modes: ['MANUAL_LIVE'], mandate: null, pendingMandate: { mandateId: expect.any(String) } });176 expect(ex.ApproveMandateSchema.safeParse({ assertion: {}, acknowledged: false }).success).toBe(false); // the confirmation is not optional177 expect(await refusal(w.svc.approveMandate(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'signer_bad_approval' }); // the first preparation was replaced178 expect(await refusal(w.svc.approveMandate(id, { assertion: w.pk.assert(b64(prep.hash)) as never, acknowledged: true }))).toMatchObject({ code: 'bad_state', status: 409 }); // and a refused one is consumed179180 const { done, m } = await mandate(w, id);181 expect(done).toMatchObject({ mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], pendingMandate: null,182 mandate: { id: m.mandateId, phase: 'active', active: true, agentId: AGENT_ID, strategySha256: SHA, revokedAt: null, windDownUntil: null, limits: { maxGrossExposure: '800' } } });183 expect(w.signer.status().venueMandates).toHaveLength(1);184 expect(w.signer.status().venueMandates[0]).toMatchObject({ phase: 'active', mandate: { mandateId: m.mandateId } });185 expect(w.t.exchangeCalls()).toBe(1); // only the authorization's approveAgent: a mandate sends nothing to the venue186 expect(await audits(id)).toEqual(expect.arrayContaining(['deriv.live.mandate_refused', 'deriv.live.mandate_acknowledged', 'deriv.live.mandate_approved']));187 expect((await w.svc.mandateStatus(id)).signer).toMatchObject({ phase: 'active', today: { orders: 0 } });188 expect((await w.svc.list())[0]).toMatchObject({ modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: { phase: 'active' } });189 // one mandate at a time190 expect(await refusal(w.svc.prepareMandate(id, ex.MandateBodySchema.parse(MANDATE)))).toMatchObject({ code: 'bad_state', status: 409 });191 });192193 it('refuses a read-only account, anything wider than the authorization, and bodies outside the bounds', async () => {194 const w = world();195 await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });196 w.t.fund(ACCOUNT, '1000');197 const ro = (await w.svc.createAccount({ venue: 'hyperliquid', network: 'testnet', label: 'ro' })).id;198 expect(await refusal(w.svc.prepareMandate(ro, ex.MandateBodySchema.parse(MANDATE)))).toMatchObject({ code: 'mode_read_only', status: 422 });199 expect(await refusal(w.svc.approveMandate(ro, { assertion: {} as never, acknowledged: true }))).toMatchObject({ code: 'mode_read_only' });200 expect(await refusal(w.svc.placeMandateOrder(ro, req(), ctx()))).toMatchObject({ code: 'mode_read_only' });201 const id = await live(w);202 const bad = async (o: Record<string, unknown>) => refusal(w.svc.prepareMandate(id, ex.MandateBodySchema.parse({ ...MANDATE, ...o })));203 expect(await bad({ maxGrossExposure: '1500.01' })).toMatchObject({ code: 'bad_policy', status: 400 });204 expect(await bad({ maxOrderNotional: '501', instruments: [{ instrument: BTC, maxLeverage: 3, maxPositionNotional: '600' }] })).toMatchObject({ code: 'bad_policy' });205 expect(await bad({ instruments: [{ instrument: BTC, maxLeverage: 4, maxPositionNotional: '600' }] })).toMatchObject({ code: 'bad_policy' });206 expect(await bad({ instruments: [{ instrument: 'hyperliquid:ETH', maxLeverage: 3, maxPositionNotional: '600' }] })).toMatchObject({ code: 'bad_policy' });207 expect(await bad({ maxOrdersPerDay: 51 })).toMatchObject({ code: 'bad_policy' });208 expect(await bad({ maxPriceDeviationBps: 301 })).toMatchObject({ code: 'bad_policy' });209 expect(await bad({ days: 30 })).toBeNull(); // the authorization lasts 30 days: just inside210 for (const o of [{ days: 31 }, { maxOrdersPerHour: 61 }, { floorEquity: '0' }, { strategySha256: 'nope' }, { agentId: 'has space' }, { extra: 1 }]) expect(ex.MandateBodySchema.safeParse({ ...MANDATE, ...o }).success).toBe(false);211 // approving with a passkey assertion over something else212 const prep = await w.svc.prepareMandate(id, ex.MandateBodySchema.parse(MANDATE));213 expect(await refusal(w.svc.approveMandate(id, { assertion: w.pk.assert(randomBytes(32)) as never, acknowledged: true }))).toMatchObject({ code: 'signer_bad_approval', status: 422 });214 expect(await w.svc.get(id)).toMatchObject({ modes: ['MANUAL_LIVE'], mandate: null });215 expect(w.signer.status().venueMandates).toHaveLength(0);216 void prep;217 // under the signer's emergency halt no mandate is prepared218 w.signer.halt();219 expect(await bad({})).toMatchObject({ code: 'halted', status: 423 });220 });221});222223describe('placeMandateOrder: the same pipeline as a manual order, without the passkey', () => {224 it('a limit order rests, a market order fills, a reduce-only order closes — no passkey, the venue\'s own state decides, manual orders still need the passkey', async () => {225 const w = world(), { id, m } = await mandated(w);226 const k = key(), r = await place(w, id, {}, k);227 expect(r).toMatchObject({ idempotent: false, order: { clientId: k, state: 'open', final: false, source: 'venue', origin: 'mandate', instrument: BTC, side: 'buy', size: '0.002', price: '69500' }, estimate: { notional: '139', source: 'estimate' } });228 expect(r.order.expiresAt! - r.order.nonce!).toBe(30_000); // a short venue expiry229 expect(w.signs).toHaveLength(0); // /venue/sign (passkey path) was not used230 expect(w.mandateSigns).toHaveLength(1);231 expect(w.mandateSigns[0]).toMatchObject({ mandateId: m.mandateId, context: { order: { clientId: r.order.cloid, reduceOnly: false } } });232 expect(Object.keys(w.mandateSigns[0])).not.toContain('assertion');233 expect(w.t.openOrders(ACCOUNT)).toHaveLength(1);234 expect(await row(id, k)).toMatchObject({ mandate_id: m.mandateId, agent: { mandateId: m.mandateId, agentId: AGENT_ID, strategySha256: SHA }, origin: 'mandate' });235 // the signer read the venue itself: info only, on the testnet host236 expect(new Set(w.signerInfoCalls.map((c) => c.url))).toEqual(new Set(['https://api.hyperliquid-testnet.xyz/info']));237 expect(w.signerInfoCalls.map((c) => c.type)).toEqual(['frontendOpenOrders', 'clearinghouseState', 'activeAssetData']);238239 // idempotent: the same key places nothing again and answers the reconciled order240 const again = await place(w, id, { size: '0.004', price: '69000' }, k);241 expect(again).toMatchObject({ idempotent: true, order: { clientId: k, state: 'open', size: '0.002' }, estimate: { notional: '139' } });242 expect(w.t.exchangeCalls('order')).toBe(1); expect(w.mandateSigns).toHaveLength(1);243 expect((await mm.mandateOrder(id, k, w.svc))).toMatchObject({ state: 'open' });244 expect(await mm.mandateOrder(id, 'no-such-order-key', w.svc)).toBeNull();245246 // cancel: no passkey, through the authorization247 const c = await mm.cancelMandateOrder(id, k, w.svc);248 expect(c).toMatchObject({ sent: true, order: { state: 'cancelled', final: true } });249250 w.next();251 const buy = await place(w, id, MARKET_BUY);252 expect(buy.order).toMatchObject({ state: 'filled', final: true, filled: '0.002', avgFillPrice: '70005', source: 'venue' });253 const s = await mm.mandateAccountState(id, w.svc);254 expect(s).toMatchObject({ accountId: id, network: 'testnet', mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], canOpen: true, canReduce: true, blocked: null, halted: null, error: null, source: 'venue',255 mandate: { phase: 'active' }, positions: [{ instrument: BTC, side: 'long', size: '0.002', source: 'venue' }] });256 expect(s.state!.equity).toMatch(/^999\./);257 expect(await mm.mandatePositions(id, w.svc)).toHaveLength(1);258 const close = await place(w, id, CLOSE);259 expect(close.order).toMatchObject({ state: 'filled', reduceOnly: true, filled: '0.002' });260 expect(await mm.mandatePositions(id, w.svc)).toEqual([]);261 expect(w.signer.status().venueMandates[0]).toMatchObject({ today: { orders: 2, reduceOnly: 1 } });262 expect((await w.svc.reconcile(id)).divergence).toBeNull(); // mandate orders are the wallet's own: no divergence263 expect(await audits(id)).toEqual(expect.arrayContaining(['deriv.live.mandate_order', 'deriv.live.order_submitted', 'deriv.live.order_filled']));264265 // manual mode is unchanged: a proposal waits for the passkey, and is refused without it266 w.next();267 const mk = key(), prop = await w.svc.propose(id, body(LIMIT), mk);268 expect(prop.options).not.toBeNull(); expect(prop.order.state).toBe('risk_checked');269 expect(await refusal(w.svc.approveOrder(id, mk, {} as never))).toMatchObject({ code: 'signer_unknown_credential' });270 const mk2 = key(), prop2 = await w.svc.propose(id, body(LIMIT), mk2);271 expect((await w.svc.approveOrder(id, mk2, w.pk.assert(b64(prop2.hash!)) as never)).order.state).toBe('open');272 // … and a key of a manual order cannot be replayed as a mandate order273 expect(await refusal(place(w, id, {}, mk2))).toMatchObject({ code: 'idempotency', status: 409 });274 });275276 it('who asks matters: another strategy version, another agent, no mandate, a malformed request', async () => {277 const w = world(), id = await live(w);278 expect(await refusal(place(w, id))).toMatchObject({ code: 'mandate_none', status: 422 });279 await mandate(w, id);280 expect(await refusal(mm.placeMandateOrder(id, req(), ctx(key(), { strategySha256: 'ab'.repeat(32) }), w.svc))).toMatchObject({ code: 'mandate_mismatch', status: 422 });281 expect(await refusal(mm.placeMandateOrder(id, req(), ctx(key(), { agentId: 'someone-else' }), w.svc))).toMatchObject({ code: 'mandate_mismatch' });282 expect(await refusal(mm.placeMandateOrder(id, req({ price: null }), ctx(), w.svc))).toMatchObject({ code: 'bad_request', status: 400 });283 await expect(mm.placeMandateOrder(id, req(), { ...ctx(), clientId: 'short' }, w.svc)).rejects.toThrow();284 expect(w.mandateSigns).toHaveLength(0); expect(w.t.exchangeCalls('order')).toBe(0);285 });286287 it('the API\'s own risk engine refuses first (stored, with its code); what only the signer can see is refused by the signer', async () => {288 const w = world(), { id } = await mandated(w);289 const k1 = key();290 expect(await refusal(place(w, id, { size: '0.005' }, k1))).toMatchObject({ code: 'order_notional_exceeded', status: 422 }); // 347.5 > 300 (mandate), although the authorization allows 500291 expect(await row(id, k1)).toMatchObject({ state: 'rejected', final: true, origin: 'mandate' });292 expect((await events(id, 'order_refused'))[0]).toMatchObject({ code: 'order_notional_exceeded', data: { origin: 'mandate', agentId: AGENT_ID } });293 expect(await refusal(place(w, id, { size: '0.005' }, k1))).toBeNull(); // the same key answers the stored refusal, places nothing294 expect(await refusal(place(w, id, { price: '60000' }))).toMatchObject({ code: 'price_deviation' });295 expect(await refusal(place(w, id, { instrument: 'hyperliquid:ETH', size: '0.05', price: '2000' }))).toMatchObject({ code: 'instrument_not_allowed' });296 expect(w.mandateSigns).toHaveLength(0);297298 // resting orders: the API's engine does not count them in the exposure, the signer does — 4 × 140 = 560, the fifth would make 700 > 600299 for (let k = 0; k < 4; k++) expect((await place(w, id)).order.state).toBe('open');300 w.next();301 const k5 = key(), r5 = await refusal(place(w, id, {}, k5));302 expect(r5).toMatchObject({ code: 'signer_position_exceeded', status: 422 });303 expect(r5!.message).toMatch(/worst-case BTC position 700/);304 expect(await row(id, k5)).toMatchObject({ state: 'rejected', final: true });305 expect((await row(id, k5)).reject_reason).toMatch(/^not signed: worst-case BTC position/);306 expect(w.t.exchangeCalls('order')).toBe(4);307308 // the signer's own price differs from the venue's: refused by the signer, nothing sent309 w.px.BTC = 80_000;310 expect(await refusal(place(w, id, { size: '0.0002' }))).toMatchObject({ code: 'signer_price' });311 w.px.BTC = 70_000; w.px.fail = 'price sources disagree on BTC';312 expect(await refusal(place(w, id, { size: '0.0002' }))).toMatchObject({ code: 'signer_no_price' });313 w.px.fail = '';314 // the signer cannot read the venue itself: fail closed, although the API can315 w.faults.signerInfo = 'clearinghouseState';316 expect(await refusal(place(w, id, { size: '0.0002' }))).toMatchObject({ code: 'signer_venue_unreachable' });317 w.faults.signerInfo = null;318 expect(w.t.exchangeCalls('order')).toBe(4);319 expect((await events(id, 'order_refused')).map((e) => e.code)).toEqual(expect.arrayContaining(['signer_position_exceeded', 'signer_price', 'signer_no_price', 'signer_venue_unreachable']));320 });321322 it('floor on the account value, and the leverage setting: said by the API, enforced by the signer', async () => {323 const w = world(), id = await live(w, { leverage: null }); // the venue's default: 20× cross324 await mandate(w, id);325 expect(await refusal(place(w, id))).toMatchObject({ code: 'signer_leverage_setting' });326 const who = { strategySha256: SHA, agentId: AGENT_ID };327 expect(await refusal(mm.setMandateLeverage(id, { instrument: BTC, leverage: 4, marginMode: 'cross' }, who, w.svc))).toMatchObject({ code: 'leverage_exceeded' });328 expect(await refusal(mm.setMandateLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }, { ...who, agentId: 'x' }, w.svc))).toMatchObject({ code: 'mandate_mismatch' });329 const lev = await mm.setMandateLeverage(id, { instrument: BTC, leverage: 3, marginMode: 'cross' }, who, w.svc);330 expect(lev).toMatchObject({ action: { kind: 'leverage', state: 'confirmed' }, venue: { confirmed: true, leverage: { leverage: '3', marginMode: 'cross' }, source: 'venue' } });331 expect(w.signs).toHaveLength(0); // no passkey path332 expect((await place(w, id, MARKET_BUY)).order.state).toBe('filled');333 // the account value falls below the mandate's floor (500): only reduce-only orders334 w.next();335 w.t.user(ACCOUNT).usdc = '499';336 expect(await refusal(place(w, id))).toMatchObject({ code: 'mandate_floor_equity', status: 422 });337 expect(w.mandateSigns.filter((s) => s.context).length).toBe(2); // the signer was not even asked338 expect((await place(w, id, CLOSE)).order.state).toBe('filled');339 });340341 it('two concurrent calls with the same key place one order', async () => {342 const w = world(), { id } = await mandated(w);343 const k = key(), [a, b] = await Promise.all([place(w, id, {}, k), place(w, id, {}, k)]);344 expect([a.idempotent, b.idempotent].sort()).toEqual([false, true]);345 expect(a.order.state).toBe('open'); expect(b.order.state).toBe('open'); expect(a.order.cloid).toBe(b.order.cloid);346 expect(w.t.exchangeCalls('order')).toBe(1); expect(w.mandateSigns).toHaveLength(1);347 });348});349350describe('state machine with the scripted exchange: `unknown` is resolved only by the venue', () => {351 it('answer lost after the venue took the order → the venue\'s own record decides: filled', async () => {352 const w = world(), { id } = await mandated(w);353 for (const fault of ['lost_after', 'garbage_after'] as const) {354 w.t.fault = fault;355 const r = await place(w, id, { ...MARKET_BUY, size: '0.001' });356 expect(r.order).toMatchObject({ state: 'filled', final: true, filled: '0.001', source: 'venue' });357 w.next();358 }359 expect((await events(id, 'order_unknown')).length).toBe(2);360 expect(w.t.exchangeCalls('order')).toBe(2);361 });362363 it('request lost before the venue saw it → unknown, never re-sent; after the venue expiry of the signature (30 s) it is "never placed"', async () => {364 const w = world(), { id } = await mandated(w);365 for (const fault of ['lost_before', 'http502_before'] as const) {366 w.t.fault = fault;367 const k = key(), r = await place(w, id, {}, k);368 expect(r.order).toMatchObject({ state: 'unknown', final: false, source: 'local' });369 expect((await place(w, id, {}, k))).toMatchObject({ idempotent: true, order: { state: 'unknown' } }); // asking again sends nothing370 w.clock.advance(34_000);371 expect((await mm.mandateOrder(id, k, w.svc))!.state).toBe('unknown'); // 30 s expiry + 5 s grace not over372 w.clock.advance(2_000);373 expect(await mm.mandateOrder(id, k, w.svc)).toMatchObject({ state: 'rejected', final: true, reason: expect.stringMatching(/^never placed/) });374 expect((await place(w, id, {}, k))).toMatchObject({ idempotent: true, order: { state: 'rejected' } });375 w.next();376 }377 expect(w.t.exchangeCalls('order')).toBe(2); expect(w.t.openOrders(ACCOUNT)).toHaveLength(0);378 expect(w.mandateSigns).toHaveLength(2);379 });380381 it('a venue refusal is a state, not an exception; the worker reconciles mandate orders like the others', async () => {382 const w = world(), { id } = await mandated(w);383 const r = await place(w, id, { tif: 'alo', price: '70100' }); // post-only that would cross384 expect(r.order).toMatchObject({ state: 'rejected', final: true });385 expect(r.order.reason).toMatch(/Post only order would have immediately matched/);386 const k = key();387 await place(w, id, {}, k);388 w.next();389 // filled on the venue while resting: the reconciliation worker sees it390 w.t.bids = [['69400', '1']]; w.t.asks = [['69450', '1']];391 const o = w.t.openOrders(ACCOUNT)[0]!;392 (w.t as any).fill(w.t.user(ACCOUNT), o, '69500', '0.002'); o.status = 'filled'; o.statusTimestamp = w.clock.now();393 const step = await w.svc.step();394 expect(step).toMatchObject({ accounts: 1, errors: [] });395 expect(await row(id, k)).toMatchObject({ state: 'filled' });396 expect((await w.svc.get(id)).divergence).toBeNull();397 });398});399400describe('stopping: kill switch, divergence, revocation, expiry, wind-down', () => {401 it('owner halt and divergence stop mandate orders that can add exposure; reduce-only orders still pass; the kill switch is unchanged', async () => {402 const w = world(), { id } = await mandated(w);403 await place(w, id, MARKET_BUY);404 await w.svc.halt(id, { reason: 'test' });405 const k = key();406 expect(await refusal(place(w, id, {}, k))).toMatchObject({ code: 'halted', status: 422 });407 expect(await row(id, k)).toMatchObject({ state: 'rejected', final: true });408 expect(await mm.mandateAccountState(id, w.svc)).toMatchObject({ canOpen: false, canReduce: true, halted: expect.stringMatching(/halted by the owner/) });409 expect(await refusal(mm.setMandateLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }, { strategySha256: SHA, agentId: AGENT_ID }, w.svc))).toMatchObject({ code: 'halted', status: 423 });410 expect((await place(w, id, { ...CLOSE, size: '0.001' })).order.state).toBe('filled');411 await w.svc.resume(id);412 w.next();413 expect((await place(w, id)).order.state).toBe('open');414415 // a position the wallet did not create: two passes, then new exposure is stopped416 w.t.forcePosition(ACCOUNT, 'BTC', '0.01');417 await w.svc.reconcile(id); w.next(); await w.svc.reconcile(id);418 expect((await w.svc.get(id)).divergence).toMatch(/position hyperliquid:BTC/);419 const r = await refusal(place(w, id));420 expect(r).toMatchObject({ code: 'halted' }); expect(r!.message).toMatch(/differ/);421 expect((await place(w, id, { ...CLOSE, size: '0.001' })).order.state).toBe('filled'); // reducing the venue's real position passes422423 // the signer's emergency halt: refused by the signer; reduce-only still signed424 w.t.forcePosition(ACCOUNT, 'BTC', '0.002');425 await w.svc.rebase(id);426 w.next();427 w.signer.halt();428 expect(await refusal(place(w, id))).toMatchObject({ code: 'signer_halted' });429 expect((await place(w, id, { ...CLOSE, size: '0.001' })).order.state).toBe('filled');430 expect((await w.svc.cancelAll(id))).toMatchObject({ requested: 1, accepted: 1 }); // cancels are signed under the emergency halt431 });432433 it('revoking the mandate: no new exposure at once; cancels and reduce-only orders for 24 h; manual trading goes on; then nothing', async () => {434 const w = world(), { id, m } = await mandated(w);435 await place(w, id, MARKET_BUY);436 await place(w, id); // one resting order437 w.next();438 const rv = await w.svc.revokeMandate(id, { cancelOpenOrders: true, locale: 'fr' });439 expect(rv).toMatchObject({ mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE'], cancelled: { requested: 1, accepted: 1 }, cancelError: null, locale: 'fr',440 mandate: { id: m.mandateId, phase: 'wind_down', active: false }, windDown: { openPositions: 1, openOrders: 0 } });441 expect(rv.windDown.until).toBe(rv.mandate!.revokedAt! + VENUE_MANDATE_WIND_DOWN_S * 1000 - (rv.mandate!.revokedAt! % 1000));442 expect(rv.windDown.text).toMatch(/NE ferme PAS les positions ouvertes/);443 expect(w.signer.status().venueMandates[0]).toMatchObject({ revoked: true, phase: 'wind_down' });444 expect(await refusal(place(w, id))).toMatchObject({ code: 'mandate_wind_down', status: 422 });445 expect(await refusal(mm.setMandateLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }, { strategySha256: SHA, agentId: AGENT_ID }, w.svc))).toMatchObject({ code: 'mandate_wind_down' });446 expect(await mm.mandateAccountState(id, w.svc)).toMatchObject({ canOpen: false, canReduce: true });447 // the engine can still close during the wind-down — without a passkey448 w.next();449 expect((await place(w, id, { ...CLOSE, size: '0.001' })).order.state).toBe('filled');450 expect((await w.svc.revokeMandate(id, { cancelOpenOrders: false })).mandate).toMatchObject({ phase: 'wind_down' }); // idempotent451 // the owner trades manually as before (passkey), and can approve a new mandate452 const mk = key(), prop = await w.svc.propose(id, body(LIMIT), mk);453 expect((await w.svc.approveOrder(id, mk, w.pk.assert(b64(prop.hash!)) as never)).order.state).toBe('open');454 // 24 h later the wind-down is over455 w.clock.advance(VENUE_MANDATE_WIND_DOWN_S * 1000);456 expect((await w.svc.get(id)).mandate).toMatchObject({ phase: 'ended' });457 expect(await refusal(place(w, id, { ...CLOSE, size: '0.001' }))).toMatchObject({ code: 'mandate_none' });458 expect((await mm.cancelMandateOrder(id, mk, w.svc)).order.state).toBe('cancelled'); // cancels live with the authorization459 const next = await mandate(w, id, { days: 5 });460 expect(next.done).toMatchObject({ modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: { id: next.m.mandateId, phase: 'active' } });461 expect(await audits(id)).toEqual(expect.arrayContaining(['deriv.live.mandate_revoked']));462 });463464 it('an expired mandate winds down the same way', async () => {465 const w = world(), { id } = await mandated(w, { days: 1 });466 await place(w, id, MARKET_BUY);467 w.clock.advance(86_400_000);468 expect((await w.svc.get(id))).toMatchObject({ modes: ['MANUAL_LIVE'], mandate: { phase: 'wind_down', expired: true, revokedAt: null } });469 const r = await refusal(place(w, id));470 expect(r).toMatchObject({ code: 'mandate_wind_down' }); expect(r!.message).toMatch(/has expired/);471 expect((await place(w, id, { ...CLOSE, size: '0.001' })).order.state).toBe('filled');472 });473474 it('revoking the AUTHORIZATION stops everything at once: no wind-down, not even a cancel', async () => {475 const w2 = world(), { id: id2 } = await mandated(w2);476 await place(w2, id2);477 const rv = await w2.svc.revoke(id2, { cancelOpenOrders: true });478 expect(rv).toMatchObject({ mode: 'READ_ONLY', modes: ['READ_ONLY'], cancelled: { requested: 1, accepted: 1 }, cancelError: null, mandate: { phase: 'ended' } });479 expect(w2.signer.status().venueMandates[0]).toMatchObject({ revoked: true, phase: 'ended' });480 expect(await refusal(place(w2, id2, CLOSE))).toMatchObject({ code: 'mode_read_only' });481 expect(await mm.mandateAccountState(id2, w2.svc)).toMatchObject({ canOpen: false, canReduce: false, blocked: { code: 'mode_read_only' } });482 // even asked directly, the signer signs nothing for it — not a reduce-only order, not a cancel483 const sent = w2.mandateSigns[0];484 await expect(w2.signer.venueMandateSign(sent)).rejects.toThrow(/authorization of this mandate was revoked/);485 });486});487488describe('mainnet: autonomous trading needs BOTH server settings', () => {489 it('SPW_DERIV_LIVE_MAINNET alone authorizes manual trading only; without SPW_DERIV_MANDATE_MAINNET no mandate is prepared, approved or used — nothing reaches the exchange endpoint', async () => {490 const w = world();491 w.flags.mainnet = true;492 const id = await live(w, { network: 'mainnet' });493 expect(w.m.exchangeCalls()).toBe(1); // the authorization's approveAgent (a fake: nothing real is ever reached)494 const r = await refusal(w.svc.prepareMandate(id, ex.MandateBodySchema.parse(MANDATE)));495 expect(r).toMatchObject({ code: 'mandate_mainnet_disabled', status: 403 });496 expect(r!.message).toMatch(/SPW_DERIV_MANDATE_MAINNET=on/);497 expect(await refusal(place(w, id))).toMatchObject({ code: 'mandate_mainnet_disabled', status: 403 });498 w.flags.mandateMainnet = true;499 const { m } = await mandate(w, id);500 expect(m.network).toBe('mainnet');501 // either setting turned off afterwards closes it again502 w.flags.mandateMainnet = false;503 expect(await refusal(place(w, id))).toMatchObject({ code: 'mandate_mainnet_disabled' });504 expect(await refusal(mm.setMandateLeverage(id, { instrument: BTC, leverage: 2, marginMode: 'cross' }, { strategySha256: SHA, agentId: AGENT_ID }, w.svc))).toMatchObject({ code: 'mandate_mainnet_disabled' });505 expect(await mm.mandateAccountState(id, w.svc)).toMatchObject({ canOpen: false, canReduce: false, blocked: { code: 'mandate_mainnet_disabled' } });506 w.flags.mandateMainnet = true; w.flags.mainnet = false;507 expect(await refusal(place(w, id))).toMatchObject({ code: 'mainnet_disabled', status: 403 });508 expect(await refusal(w.svc.approveMandate(id, { assertion: {} as never, acknowledged: true }))).toMatchObject({ code: 'mainnet_disabled' });509 expect(w.m.exchangeCalls()).toBe(1); expect(w.mandateSigns).toHaveLength(0);510 // stopping needs neither511 expect((await w.svc.revokeMandate(id, { cancelOpenOrders: false })).mandate).toMatchObject({ phase: 'wind_down' });512 expect(w.signer.status().venueMandates[0]).toMatchObject({ revoked: true });513 });514});515516describe('fixes of the live engine that came with the mandate', () => {517 it('propose(): a concurrent duplicate with the same Idempotency-Key waits for the built proposal; the estimate comes back on a replay', async () => {518 const w = world(), id = await live(w);519 const k = key(), [a, b, c] = await Promise.all([w.svc.propose(id, body(LIMIT), k), w.svc.propose(id, body(LIMIT), k), w.svc.propose(id, body({ ...LIMIT, size: '0.003' }), k)]);520 expect([a.idempotent, b.idempotent, c.idempotent].filter((x) => !x)).toHaveLength(1);521 for (const p of [a, b, c]) {522 expect(p.options).not.toBeNull(); // (it was `options: null` for the duplicate)523 expect(p).toMatchObject({ hash: a.hash, order: { state: 'risk_checked', size: '0.002' }, estimate: { notional: '139', initialMargin: expect.any(String), source: 'estimate' } });524 expect(p.options!.challenge).toBe(b64(a.hash!).toString('base64url'));525 }526 const later = await w.svc.propose(id, body(LIMIT), k);527 expect(later).toMatchObject({ idempotent: true, estimate: a.estimate });528 expect((await sql`select count(*)::int as n from deriv_orders where account_id = ${id}`)[0].n).toBe(1);529 // a refused proposal, concurrently: every caller gets the stored refusal, one of them as the error530 const k2 = key(), big = body({ ...LIMIT, size: '0.01' });531 const rs = await Promise.all([w.svc.propose(id, big, k2), w.svc.propose(id, big, k2)].map((p) => p.then((x) => x.order.state, (e) => e.code)));532 expect(rs.sort()).toEqual(['order_notional_exceeded', 'rejected']);533 expect((await w.svc.propose(id, big, k2))).toMatchObject({ idempotent: true, options: null, estimate: null, order: { state: 'rejected' } });534 // a first call that fails before storing anything does not poison the key535 const k3 = key();536 w.faults.info = 'clearinghouseState';537 const both = await Promise.all([w.svc.propose(id, body(LIMIT), k3), w.svc.propose(id, body(LIMIT), k3)].map((p) => refusal(p)));538 expect(both.map((x) => x?.code)).toEqual(['venue_unavailable', 'venue_unavailable']);539 w.faults.info = null;540 expect((await w.svc.propose(id, body(LIMIT), k3)).order.state).toBe('risk_checked');541 // close() replays with its estimate too542 await w.svc.approveOrder(id, k, w.pk.assert(b64(a.hash!)) as never);543 const mk = key(), mp = await w.svc.propose(id, body({ side: 'buy', type: 'market', size: '0.002', slippageBps: 50, tif: 'ioc' }), mk);544 await w.svc.approveOrder(id, mk, w.pk.assert(b64(mp.hash!)) as never);545 const ck = key(), c1 = await w.svc.close(id, { instrument: BTC, fraction: '1' }, ck), c2 = await w.svc.close(id, { instrument: BTC, fraction: '1' }, ck);546 expect(c2).toMatchObject({ idempotent: true, estimate: c1.estimate }); expect(c1.estimate).not.toBeNull();547 });548549 it('a mainnet account is refused server-side while mainnet is off, with the reason', async () => {550 const w = world();551 await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });552 const r = await refusal(w.svc.createAccount({ venue: 'hyperliquid', network: 'mainnet', label: 'm' }));553 expect(r).toMatchObject({ code: 'mainnet_disabled', status: 403 });554 expect(r!.message).toBe(w.svc.config('en').mainnet.reason);555 expect(await w.svc.list()).toEqual([]);556 w.flags.mainnet = true;557 expect((await w.svc.createAccount({ venue: 'hyperliquid', network: 'mainnet', label: 'm' })).mode).toBe('READ_ONLY');558 });559560 it('a halt (or a divergence) and an asset-table mismatch at approval END the proposal instead of leaving it approvable', async () => {561 const w = world(), id = await live(w);562 const k = key(), p = await w.svc.propose(id, body(LIMIT), k);563 await sql`update deriv_accounts set divergence = 'order 77 on hyperliquid:BTC rests on the venue and was not placed by the wallet' where id = ${id}`;564 expect(await refusal(w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never))).toMatchObject({ code: 'halted', status: 423 });565 expect(await row(id, k)).toMatchObject({ state: 'cancelled', final: true });566 expect((await row(id, k)).reject_reason).toMatch(/^withdrawn: trading was halted before the approval/);567 await sql`update deriv_accounts set divergence = null where id = ${id}`;568 expect(await w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never)).toMatchObject({ idempotent: true, order: { state: 'cancelled' } }); // it does not come back569 // a reduce-only proposal is not ended by a halt (it may still be approved)570 const k2 = key(), p2 = await w.svc.propose(id, body(LIMIT), k2);571 const u = w.t.meta.universe, btc = u.findIndex((x: any) => x.name === 'BTC'), eth = u.findIndex((x: any) => x.name === 'ETH');572 [u[btc], u[eth]] = [u[eth], u[btc]]; // the venue renumbers BTC and ETH573 w.next(61_000); // the proposal has 29 s left574 expect(await refusal(w.svc.approveOrder(id, k2, w.pk.assert(b64(p2.hash!)) as never))).toMatchObject({ code: 'asset_table_mismatch', status: 503 });575 expect(await row(id, k2)).toMatchObject({ state: 'cancelled', final: true });576 expect((await row(id, k2)).reject_reason).toMatch(/^withdrawn: live trading is suspended: the venue's asset table/);577 expect(w.signs).toHaveLength(0); expect(w.t.exchangeCalls('order')).toBe(0);578 });579580 it('revoke says why cancel-all failed instead of answering `cancelled: null` alone', async () => {581 const w = world(), id = await live(w);582 const k = key(), p = await w.svc.propose(id, body(LIMIT), k);583 await w.svc.approveOrder(id, k, w.pk.assert(b64(p.hash!)) as never);584 w.next();585 w.faults.info = 'frontendOpenOrders'; // the venue cannot list the open orders586 const r = await w.svc.revoke(id, { cancelOpenOrders: true, locale: 'fr' });587 expect(r).toMatchObject({ mode: 'READ_ONLY', cancelled: null, cancelError: { code: 'venue_unavailable', message: expect.stringMatching(/502|unavailable|HTTP/i) }, locale: 'fr' });588 expect(r.venueAgent).toMatch(/refuse désormais de l'utiliser/);589 w.faults.info = null;590 // nothing to say when it was not asked for591 expect(await w.svc.revoke(id, { cancelOpenOrders: false })).toMatchObject({ cancelled: null, cancelError: null, locale: 'en' });592 });593594 it('server-authored texts follow the locale (English by default, as before); codes do not change', async () => {595 const w = world();596 await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });597 w.t.fund(ACCOUNT, '1000');598 const id = (await w.svc.createAccount({ venue: 'hyperliquid', network: 'testnet', label: 'test' })).id as string;599 const en = await w.svc.collateral(id), fr = await w.svc.collateral(id, 'fr');600 expect(en).toMatchObject({ locale: 'en', deposits: { supported: false, how: expect.stringMatching(/^docs\/v4\/venues\/hyperliquid.md, section 15: native USDC on Arbitrum/) }, withdrawals: { supported: true, how: expect.stringMatching(/withdraw3 action, signed by the wallet key/) } });601 expect(fr).toMatchObject({ locale: 'fr', deposits: { supported: false, how: expect.stringMatching(/USDC natif sur Arbitrum/) }, withdrawals: { supported: true, how: expect.stringMatching(/withdraw3 signée par la clé du portefeuille/) } });602 const pf = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'fr' }));603 expect(pf).toMatchObject({ locale: 'fr', agent: { can: ['placer des ordres', 'annuler des ordres', 'changer le levier'], cannot: ['retirer des fonds', 'transférer vers une autre adresse'] } });604 expect(pf.agent.note).toMatch(/le signataire refuse de signer autre chose/);605 expect(pf.whatIsSigned.byMainKey).toMatch(/une seule action approveAgent/); expect(pf.whatIsSigned.byPasskey).toMatch(/SHA-256 de cette autorisation/);606 const pe = await w.svc.prepareAuthorization(id, ex.AuthorizationBodySchema.parse({ policy: POLICY, days: 30, locale: 'en' }));607 expect(pe).toMatchObject({ locale: 'en', agent: { can: ['place orders', 'cancel orders', 'change leverage'], cannot: ['withdraw', 'transfer to another address'] },608 whatIsSigned: { byMainKey: 'one approveAgent action naming the trading key above (the wallet key signs nothing else to trade; its only other venue message is a withdrawal to the wallet\'s own address, approved separately with the passkey)', byPasskey: 'the SHA-256 of this authorization, which includes the hash of the acknowledgement text' } });609 expect(pe.agent.note).toMatch(/^On the venue an API wallet could also move collateral/);610 });611});612613describe('routes', () => {614 it('mandate routes need the owner session and the CSRF header; no route places a mandate order; the stream\'s first `status` agrees with the later ones', async () => {615 const w = world(), id = await live(w);616 const token = randomBytes(32).toString('base64url');617 await sql`insert into sessions (id_hash, expires_at) values (${createHash('sha256').update(token).digest('hex')}, now() + interval '1 hour')`;618 const cookie = `${(await import('../src/auth.ts')).COOKIE}=${token}`;619 const app = await (await import('../src/server.ts')).buildServer({ live: w.svc });620 const api = async (method: 'GET' | 'POST', path: string, payload?: unknown, o: { cookie?: string | null; csrf?: boolean } = {}) => {621 const headers: Record<string, string> = {};622 if (o.cookie !== null) headers.cookie = o.cookie ?? cookie;623 if (method !== 'GET') { headers.origin = ORIGIN; if (o.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }624 const r = await app.inject({ method, url: P + path, headers, payload: payload === undefined ? (method === 'GET' ? undefined : '{}') : JSON.stringify(payload) });625 return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })() };626 };627 try {628 for (const p of [`/accounts/${id}/mandate`]) expect((await api('GET', p, undefined, { cookie: null })).status, p).toBe(401);629 for (const p of ['prepare', 'approve', 'revoke']) {630 expect((await api('POST', `/accounts/${id}/mandate/${p}`, {}, { cookie: null })).status, p).toBe(401);631 expect((await api('POST', `/accounts/${id}/mandate/${p}`, {}, { csrf: false })).status, p).toBe(403);632 }633 // there is no HTTP way to place an order without a passkey634 for (const p of [`/accounts/${id}/mandate/orders`, `/accounts/${id}/mandate/order`, `/accounts/${id}/mandate/sign`, `/accounts/${id}/mandate/place`, `/accounts/${id}/mandate/leverage`])635 expect((await api('POST', p, { ...req(), ...ctx() })).status, p).toBe(404);636 expect((await api('GET', '/config?locale=fr')).json).toMatchObject({ locale: 'fr', mainnet: { code: 'mainnet_disabled', reason: expect.stringMatching(/désactivée/) }, mandateLive: { available: true } });637 expect((await api('GET', '/config')).json).toMatchObject({ locale: 'en', mainnetEnabled: false });638 expect((await api('GET', `/accounts/${id}/collateral?locale=fr`)).json.deposits.how).toMatch(/USDC natif/);639 expect((await api('POST', '/accounts', { venue: 'hyperliquid', network: 'mainnet' })).json).toMatchObject({ error: 'mainnet_disabled' });640 expect((await api('POST', `/accounts/${id}/mandate/prepare`, { ...MANDATE, days: 31 })).status).toBe(400);641 expect((await api('POST', `/accounts/${id}/mandate/approve`, { assertion: {}, acknowledged: true })).status).toBe(409);642 const prep = await api('POST', `/accounts/${id}/mandate/prepare`, MANDATE);643 expect(prep.status).toBe(200);644 expect((await api('POST', `/accounts/${id}/mandate/approve`, { assertion: w.pk.assert(b64(prep.json.hash)) })).status).toBe(400); // `acknowledged: true` is required645 const ok = await api('POST', `/accounts/${id}/mandate/approve`, { assertion: w.pk.assert(b64(prep.json.hash)), acknowledged: true });646 expect(ok.status).toBe(200); expect(ok.json).toMatchObject({ mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: { phase: 'active' } });647 expect((await api('GET', `/accounts/${id}/mandate`)).json).toMatchObject({ modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], mandate: { id: prep.json.mandate.mandateId }, signer: { phase: 'active' } });648649 // the stream: `halt` is the owner's halt in the first `status` event as in the later ones; `halted` is the effective reason650 await sql`update deriv_accounts set divergence = 'order 9 on hyperliquid:BTC rests on the venue and was not placed by the wallet' where id = ${id}`;651 await app.listen({ port: 0, host: '127.0.0.1' });652 const port = (app.server.address() as { port: number }).port, abort = new AbortController();653 const res = await fetch(`http://127.0.0.1:${port}${P}/accounts/${id}/stream`, { headers: { cookie }, signal: abort.signal });654 expect(res.status).toBe(200);655 const reader = res.body!.getReader(), dec = new TextDecoder();656 let buf = '';657 const statuses = async (n: number): Promise<any[]> => {658 for (;;) {659 const found = [...buf.matchAll(/event: status\ndata: (.*)\n\n/g)].map((x) => JSON.parse(x[1]!));660 if (found.length >= n) return found;661 const { value, done } = await reader.read();662 if (done) throw new Error('stream ended');663 buf += dec.decode(value, { stream: true });664 }665 };666 const first = (await statuses(1))[0];667 expect(first).toMatchObject({ mode: 'MANUAL_LIVE', modes: ['MANUAL_LIVE', 'MANDATE_LIVE'], halt: null, divergence: expect.stringMatching(/order 9/) });668 expect(first.halted).toMatch(/the venue's state and the wallet's records differ/); // (the first event used to put this text in `halt`)669 await w.svc.halt(id, { reason: 'stream test' });670 const second = (await statuses(2))[1];671 expect(second.halt).toMatch(/halted by the owner: stream test/); expect(second.halted).toBe(second.halt);672 await w.svc.resume(id);673 const third = (await statuses(3))[2];674 expect(third.halt).toBeNull(); expect(third.halted).toMatch(/records differ/);675 abort.abort();676677 const rv = await api('POST', `/accounts/${id}/mandate/revoke`, { locale: 'fr' });678 expect(rv.status).toBe(200); expect(rv.json).toMatchObject({ modes: ['MANUAL_LIVE'], mandate: { phase: 'wind_down' }, cancelled: null, cancelError: null, locale: 'fr' });679 } finally { app.server.closeAllConnections?.(); await app.close(); }680 });681});682