SPB Git

Repository Public

derivatives-mandate.test.ts 55.6 KB 682 lines typescript

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

Theme