SPB Git

Repository Public

derivatives-gmx.test.ts 46.6 KB 517 lines typescript

1// GMX v2 trading, end to end inside the process: the REAL engine (gmx-exec.ts) and its routes, the REAL isolated signer class2// (public test mnemonic, real WebAuthn assertions from a software passkey, a scripted price view), PostgreSQL (database3// one per run, test-db.ts label deriv_gmx), GMX market data replayed from recorded fixtures, and a scripted fake of the4// chain side (GmxChain) that "mines" the signed transactions it is handed: it decodes them with the same ABI as the fork proof5// and emits real EventEmitter logs (EventLog2, ABI-encoded) for OrderCreated / OrderExecuted / OrderCancelled / OrderFrozen.6// No network: nothing is broadcast anywhere. The real contracts are exercised by scripts/fork-gmx.mts.7import { createHash } from 'node:crypto';8import { mkdtempSync } from 'node:fs';9import { tmpdir } from 'node:os';10import { join } from 'node:path';11import fastify from 'fastify';12import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';13import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, erc20Abi, keccak256, pad, parseTransaction, toHex, type Abi, type Hex } from 'viem';14import { D } from '@spw/derivatives';15import { decodeGmxCalldata, GMX_EVENT_EMITTER_ABI, GMX_MARKETS, GMX_PINS, gmxAuthorizationHash, gmxUsdToRaw, type GmxCreateOrderParams } from '@spw/derivatives/live';16import { TestPasskey } from '../../../tests/helpers/passkey.ts';17import { deriveKeys } from '../../isolated-signer/src/keys.ts';18import type { PriceView } from '../../isolated-signer/src/prices.ts';19import { Signer } from '../../isolated-signer/src/signer.ts';20import { Store } from '../../isolated-signer/src/store.ts';21import { Clock, fixtureVenues, useDatabase } from './derivatives-helpers.ts';2223const RP = 'wallet.test', ORIGIN = 'https://wallet.test';24const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');25const ME = keys.addresses.evm, ETH = GMX_MARKETS.find((m) => m.priceSymbol === 'ETH')!, BTC = GMX_MARKETS.find((m) => m.priceSymbol === 'BTC')!, USDC = ETH.collateral[0]!.token, VAULT = GMX_PINS.orderVault.address;2627let sql: any, ex: typeof import('../src/derivatives/gmx-exec.ts'), rt: typeof import('../src/derivatives/gmx-routes.ts'), dv: typeof import('../src/derivatives/index.ts'), UserError: typeof import('../src/config.ts').UserError;28type Svc = import('../src/derivatives/gmx-exec.ts').GmxService;29type Chain = import('../src/derivatives/gmx-chain.ts').GmxChain;30type Order = import('../src/derivatives/gmx-chain.ts').GmxChainOrder;31type Receipt = import('../src/derivatives/gmx-chain.ts').GmxReceipt;3233beforeAll(async () => {34  await useDatabase('deriv_gmx');35  const db = await import('../src/db.ts');36  sql = db.sql;37  await db.migrate(); await db.migrate();38  ex = await import('../src/derivatives/gmx-exec.ts'); rt = await import('../src/derivatives/gmx-routes.ts'); dv = await import('../src/derivatives/index.ts');39  UserError = (await import('../src/config.ts')).UserError;40});41beforeEach(async () => { await sql`truncate deriv_gmx_ops, proposals`; });42afterAll(async () => { await sql?.end(); });4344// --------------------------------------------------------------------------------------------------------------------------45// the scripted chain4647const EV2 = (GMX_EVENT_EMITTER_ABI as any[]).find((x) => x.name === 'EventLog2');48type Items = { address?: [string, string][]; uint?: [string, bigint][]; int?: [string, bigint][]; bool?: [string, boolean][]; bytes32?: [string, string][]; bytes?: [string, string][]; string?: [string, string][]; addressArr?: [string, string[]][]; bytes32Arr?: [string, string[]][] };49/** a real EventEmitter log, ABI-encoded like the contract does */50function gmxLog(name: string, key: string, account: string, it: Items) {51  const g = (items: [string, unknown][] = [], arr: [string, unknown][] = []) => ({ items: items.map(([k, value]) => ({ key: k, value })), arrayItems: arr.map(([k, value]) => ({ key: k, value })) });52  const data = { addressItems: g(it.address, it.addressArr), uintItems: g(it.uint), intItems: g(it.int), boolItems: g(it.bool), bytes32Items: g(it.bytes32, it.bytes32Arr), bytesItems: g(it.bytes), stringItems: g(it.string) };53  return { address: GMX_PINS.eventEmitter.address, topics: encodeEventTopics({ abi: GMX_EVENT_EMITTER_ABI as Abi, eventName: 'EventLog2', args: { eventNameHash: name, topic1: key, topic2: pad(account as Hex) } } as never) as string[],54    data: encodeAbiParameters(EV2.inputs.filter((x: any) => !x.indexed), [GMX_PINS.orderHandler.address, name, data] as never) };55}56const transferLog = (token: string, from: string, to: string, value: bigint) => ({ address: token, topics: [keccak256(toHex('Transfer(address,address,uint256)')), pad(from as Hex), pad(to as Hex)], data: pad(toHex(value)) });5758class FakeChain {59  clock: Clock;60  pinsOk = true; unreadable = false;61  usdc = 100_000_000n; eth = 10n ** 16n; allowance = 0n; nonce = 5; block = 1000;62  orders = new Map<string, Order>(); positions: Awaited<ReturnType<Chain['positions']>> = [];63  receipts = new Map<string, Receipt>(); events = new Map<string, { event: any; txid: string; blockNumber: number }[]>();64  pending: { raw: string; txid: string }[] = []; broadcasts: string[] = [];65  revertNext: string | null = null; simulateError: string | null = null; hideEvents = false; seq = 0;66  constructor(clock: Clock) { this.clock = clock; }67  private txid(tag: string) { return keccak256(toHex(`${tag}-${++this.seq}`)); }68  async checkPins() { if (this.unreadable) throw new UserError('chain_unreadable', 'Arbitrum could not be read', 503); return this.pinsOk ? { ok: true, problems: [], checkedAt: this.clock.now() } : { ok: false, problems: ['exchangeRouter no longer holds the CONTROLLER role in GMX\'s RoleStore'], checkedAt: this.clock.now() }; }69  async balances() { return { usdc: this.usdc, eth: this.eth, weth: 0n, usdcAllowance: this.allowance }; }70  async txParams() { return { baseFee: 20_000_000n, tip: 1_000_000n, nonce: this.nonce }; }71  async executionGas() { return { increase: 4_149_674n, decrease: 4_149_674n }; }72  orders2 = async () => [...this.orders.values()];73  async order(key: string) { return this.orders.get(key) ?? null; }74  async estimateGas() { return 900_000n; }75  async simulate() { return this.simulateError ? { unreachable: false, error: this.simulateError } : null; }76  async broadcast(raw: string) { if (!this.broadcasts.includes(raw)) this.broadcasts.push(raw); const txid = keccak256(raw as Hex); if (!this.pending.some((p) => p.txid === txid) && !this.receipts.has(txid)) this.pending.push({ raw, txid }); }77  async receipt(txid: string) { return this.receipts.get(txid) ?? null; }78  async orderEvents(key: string) { return this.hideEvents ? [] : (this.events.get(key) ?? []); }79  private emit(key: string, e: { address: string; topics: string[]; data: string }, txid: string, blockNumber: number, dec: (l: any) => any) { const l = this.events.get(key) ?? []; l.push({ event: dec(e), txid, blockNumber }); this.events.set(key, l); }80  dec!: (l: any) => any;81  /** mine everything that was broadcast: approvals set the allowance, order transactions create an order and emit OrderCreated, cancellations cancel */82  mine(): string[] {83    const keysMade: string[] = [];84    for (const { raw, txid } of this.pending.splice(0)) {85      const tx = parseTransaction(raw as Hex), blockNumber = ++this.block, base = { blockNumber, gasUsed: 930_000n, effectiveGasPrice: 20_000_000n };86      this.nonce = Math.max(this.nonce, (tx.nonce ?? 0) + 1);87      if (this.revertNext === 'next') { this.revertNext = null; this.receipts.set(txid, { ...base, ok: false, logs: [] }); continue; }88      if (tx.to!.toLowerCase() === USDC.toLowerCase()) { const d = decodeFunctionData({ abi: erc20Abi, data: tx.data! }); if (d.functionName !== 'approve' || d.args[0] !== GMX_PINS.router.address) throw new Error('unexpected token call'); this.allowance = d.args[1] as bigint; this.receipts.set(txid, { ...base, ok: true, logs: [] }); continue; }89      if (tx.to!.toLowerCase() !== GMX_PINS.exchangeRouter.address.toLowerCase()) throw new Error('a transaction went to an unexpected address');90      const d = decodeGmxCalldata(tx.data!);91      if (d.fn === 'cancelOrder') {92        const key = String(d.calls[0]!.args[0]), o = this.orders.get(key);93        if (!o) { this.receipts.set(txid, { ...base, ok: false, logs: [] }); continue; }94        const logs = this.cancelLogs(o, 'USER_INITIATED_CANCEL', '0x');95        this.orders.delete(key); this.receipts.set(txid, { ...base, ok: true, logs });96        this.emit(key, logs[0]!, txid, blockNumber, this.dec);97        continue;98      }99      const p = d.calls[d.calls.length - 1]!.args[0] as GmxCreateOrderParams, fee = d.calls[0]!.args[1] as bigint, increase = p.orderType === 2;100      if ((tx.value ?? 0n) !== fee) throw new Error('msg.value is not the execution fee');101      if (increase) { const amount = d.calls[1]!.args[2] as bigint; if (this.allowance < amount) { this.receipts.set(txid, { ...base, ok: false, logs: [] }); continue; } this.allowance -= amount; this.usdc -= amount; }102      this.eth -= fee;103      const key = keccak256(toHex(`order-${++this.seq}`));104      const o: Order = { key, account: ME, receiver: p.addresses.receiver, cancellationReceiver: p.addresses.cancellationReceiver, callbackContract: p.addresses.callbackContract, uiFeeReceiver: p.addresses.uiFeeReceiver, market: p.addresses.market, initialCollateralToken: p.addresses.initialCollateralToken,105        swapPathLength: p.addresses.swapPath.length, orderType: p.orderType, sizeDeltaUsd: p.numbers.sizeDeltaUsd, initialCollateralDeltaAmount: p.numbers.initialCollateralDeltaAmount, acceptablePrice: p.numbers.acceptablePrice, executionFee: fee,106        updatedAtTime: Math.floor(this.clock.now() / 1000), isLong: p.isLong, isFrozen: false };107      this.orders.set(key, o); keysMade.push(key);108      this.receipts.set(txid, { ...base, ok: true, logs: [gmxLog('OrderCreated', key, ME, {109        address: [['account', ME], ['receiver', o.receiver], ['callbackContract', o.callbackContract], ['uiFeeReceiver', o.uiFeeReceiver], ['market', o.market], ['initialCollateralToken', o.initialCollateralToken], ['cancellationReceiver', o.cancellationReceiver]], addressArr: [['swapPath', []]],110        uint: [['orderType', BigInt(o.orderType)], ['decreasePositionSwapType', 0n], ['sizeDeltaUsd', o.sizeDeltaUsd], ['initialCollateralDeltaAmount', o.initialCollateralDeltaAmount], ['triggerPrice', 0n], ['acceptablePrice', o.acceptablePrice], ['executionFee', fee], ['callbackGasLimit', 0n],111          ['minOutputAmount', 0n], ['uiFeeFactor', 0n], ['updatedAtTime', BigInt(o.updatedAtTime)], ['validFromTime', 0n], ['srcChainId', 0n]],112        bool: [['isLong', o.isLong], ['shouldUnwrapNativeToken', false], ['autoCancel', false]], bytes32: [['key', key]], bytes32Arr: [['dataList', []]] })] });113    }114    return keysMade;115  }116  private cancelLogs(o: Order, reason: string, reasonBytes: string) {117    const logs = [gmxLog('OrderCancelled', o.key, ME, { bytes32: [['key', o.key]], address: [['account', ME]], string: [['reason', reason]], bytes: [['reasonBytes', reasonBytes]] })];118    if (o.orderType === 2) { this.usdc += o.initialCollateralDeltaAmount; logs.push(transferLog(USDC, VAULT, ME, o.initialCollateralDeltaAmount) as never); }119    this.eth += o.executionFee / 3n;120    logs.push(gmxLog('ExecutionFeeRefund', o.key, ME, { address: [['receiver', ME]], uint: [['refundFeeAmount', o.executionFee / 3n]] }));121    return logs;122  }123  /** what a GMX keeper does: execute at a price (contract units), cancel with an error, or freeze */124  keeper(key: string, what: 'execute' | 'cancel' | 'freeze', price = 2063_550000000000n) {125    const o = this.orders.get(key)!, txid = this.txid('keeper'), blockNumber = ++this.block, base = { blockNumber, gasUsed: 3_000_000n, effectiveGasPrice: 20_000_000n, ok: true };126    if (what === 'freeze') { o.isFrozen = true; const l = gmxLog('OrderFrozen', key, ME, { bytes32: [['key', key]], address: [['account', ME]], string: [['reason', 'frozen for a test']], bytes: [['reasonBytes', '0x']] }); this.receipts.set(txid, { ...base, logs: [l] }); this.emit(key, l, txid, blockNumber, this.dec); return txid; }127    this.orders.delete(key);128    if (what === 'cancel') { const logs = this.cancelLogs(o, '', '0xe09ad0e9'); this.receipts.set(txid, { ...base, logs }); this.emit(key, logs[0]!, txid, blockNumber, this.dec); return txid; }129    const increase = o.orderType === 2, logs: any[] = [gmxLog('OrderExecuted', key, ME, { bytes32: [['key', key]], address: [['account', ME]], uint: [['secondaryOrderType', 0n]] })];130    if (increase) {131      const fee = (o.sizeDeltaUsd / 10n ** 24n) * 4n / 10_000n; // 0.04 % in USDC base units132      this.positions = [{ key: keccak256(toHex('position')), market: o.market, collateralToken: USDC, isLong: o.isLong, sizeInUsd: o.sizeDeltaUsd, sizeInTokens: o.sizeDeltaUsd / price, collateralAmount: o.initialCollateralDeltaAmount - fee, increasedAtTime: Math.floor(this.clock.now() / 1000) }];133      logs.push(gmxLog('PositionIncrease', key, ME, { address: [['account', ME], ['market', o.market]], uint: [['sizeInUsd', o.sizeDeltaUsd], ['executionPrice', price], ['sizeDeltaUsd', o.sizeDeltaUsd]], int: [['priceImpactUsd', -3n * 10n ** 27n]], bool: [['isLong', o.isLong]], bytes32: [['orderKey', key]] }),134        gmxLog('PositionFeesCollected', key, ME, { uint: [['positionFeeAmount', fee], ['collateralTokenPrice.min', 10n ** 24n]], bytes32: [['orderKey', key]] }));135    } else {136      const pos = this.positions[0]!, back = pos.collateralAmount - 30_000n; this.positions = []; this.usdc += back;137      logs.push(gmxLog('PositionDecrease', key, ME, { address: [['account', ME], ['market', o.market]], uint: [['sizeInUsd', 0n], ['executionPrice', price], ['sizeDeltaUsd', o.sizeDeltaUsd]], int: [['basePnlUsd', 0n], ['priceImpactUsd', -14n * 10n ** 27n]], bool: [['isLong', o.isLong]], bytes32: [['orderKey', key]] }),138        transferLog(USDC, o.market, ME, back));139    }140    this.eth += o.executionFee / 3n;141    logs.push(gmxLog('ExecutionFeeRefund', key, ME, { address: [['receiver', ME]], uint: [['refundFeeAmount', o.executionFee / 3n]] }));142    this.receipts.set(txid, { ...base, logs }); this.emit(key, logs[0]!, txid, blockNumber, this.dec);143    return txid;144  }145}146147// --------------------------------------------------------------------------------------------------------------------------148149interface World { clock: Clock; chain: FakeChain; port: Chain; svc: Svc; signer: Signer; store: Store; pk: TestPasskey; settings: { liveMainnet: boolean; gmxLive: boolean }; px: { ETH: number; BTC: number; USDC: number }; app: ReturnType<typeof fastify>; mid: string }150async function world(o: { settings?: Partial<World['settings']>; authorize?: boolean } = {}): Promise<World> {151  const clock = new Clock(), v = fixtureVenues(clock), d = dv.createDerivatives({ registry: dv.createRegistry(v.adapters, clock.now), now: clock.now });152  const m = await d.data.market(ETH.instrument), mid = D.str(D.div(D.add(D.from(m.liquidity!.oracleMin), D.from(m.liquidity!.oracleMax)), D.from('2'), 6, 'half_even'));153  const px = { ETH: Number(mid), BTC: 73_700, USDC: 1 };154  const prices: PriceView = { get: async (symbols) => Object.fromEntries(symbols.map((s): [string, number] => { const p = (px as Record<string, number | undefined>)[s]; if (p === undefined || !(p > 0)) throw new Error(`no price for ${s}`); return [s, p]; })) };155  const dir = mkdtempSync(join(tmpdir(), 'spw-gmx-api-'));156  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));157  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');158  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(clock.now() / 1000), prices);159  const pk = new TestPasskey(RP, ORIGIN);160  await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });161  const chain = new FakeChain(clock);162  const mod = await import('@spw/derivatives/live');163  chain.dec = (l) => mod.decodeGmxEvent(l);164  const port: Chain = { checkPins: () => chain.checkPins(), balances: () => chain.balances(), txParams: () => chain.txParams(), executionGas: () => chain.executionGas(), positions: async () => chain.positions, orders: () => chain.orders2(), order: (k) => chain.order(k),165    estimateGas: () => chain.estimateGas(), simulate: () => chain.simulate(), broadcast: (r) => chain.broadcast(r), receipt: (t) => chain.receipt(t), orderEvents: (k) => chain.orderEvents(k) };166  const settings = { liveMainnet: true, gmxLive: true, ...o.settings };167  const sp = { status: async () => signer.status() as never, sign: (i: unknown, a: unknown) => signer.sign(i, a as never), gmxAuthorize: (a: unknown, as: unknown) => signer.gmxAuthorize(a, as as never), gmxRevoke: async (id: string) => signer.gmxRevoke(id) };168  const svc = new ex.GmxService({ chain: port, signer: sp, now: clock.now, rpId: RP, credentials: async () => [{ id: pk.id, transports: ['internal'] }], settings: () => settings,169    market: { market: async (id) => { const x = await d.data.market(id); return { instrument: x.instrument, ticker: x.ticker, liquidity: x.liquidity }; }, apiPositions: async () => [] } });170  const app = fastify();171  app.setErrorHandler((e: any, _q, reply) => { reply.status(e.status ?? (e.name === 'ZodError' ? 400 : 500)).send({ error: e.code ?? 'bad_request', message: e.message }); });172  rt.gmxRoutes(app, svc);173  const w: World = { clock, chain, port, svc, signer, store, pk, settings, px, app, mid };174  if (o.authorize !== false && settings.gmxLive && settings.liveMainnet) await authorize(w);175  return w;176}177async function authorize(w: World, over: Record<string, unknown> = {}) {178  const prep = await w.svc.prepareAuthorization({ allowedMarkets: [ETH.market], maxLeverage: 3, maxOrderSizeUsd: '100', maxOrdersPerDay: 5, maxPriceDeviationBps: 100, days: 7, locale: 'fr', ...over } as never);179  return w.svc.approveAuthorization({ authorization: prep.authorization, assertion: w.pk.assert(Buffer.from(prep.hash, 'hex')) as never }, 'fr');180}181const hashOf = (prepared: { operation: { hash: string | null } }) => Buffer.from(prepared.operation.hash!, 'hex');182const LONG = { market: ETH.market, side: 'long' as const, action: 'increase' as const, collateral: '20', leverage: '3', slippageBps: 50 };183let idem = 0;184const key = () => `gmx-test-${String(++idem).padStart(6, '0')}`;185/** prepare + approve + mine the approval and the order transaction: returns the operation in `created` and the order key */186async function place(w: World, body: Record<string, unknown> = LONG) {187  const prep = await w.svc.prepareOrder(body as never, key());188  await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));189  w.chain.mine(); await w.svc.step(); // the approval (when there is one) is mined; the order transaction leaves190  const made = w.chain.mine(); await w.svc.step();191  const op = (await w.svc.operation(prep.operation.id)).operation;192  return { prep, op, key: made[0] ?? op.orderKey! };193}194const refusal = async (p: Promise<unknown>) => p.then(() => { throw new Error('was not refused'); }, (e: any) => ({ code: e.code as string, status: e.status as number, message: String(e.message) }));195196describe('locks: two server settings, the authorization, the pins', () => {197  it('with the GMX setting off: reading works, nothing can be prepared, and the status says it is a server setting', async () => {198    const w = await world({ settings: { gmxLive: false } });199    const s = await w.svc.status('fr');200    expect(s.tradable).toBe(false);201    expect(s.blockers).toEqual(['gmx_setting_off', 'not_authorized']);202    expect(s.blockerText.gmx_setting_off).toMatch(/SPW_DERIV_GMX_LIVE.*réglage du serveur/);203    expect(s.account?.address).toBe(ME); expect(s.wallet?.usdc).toBe('100'); expect(s.pins.ok).toBe(true); expect(s.markets.map((m) => m.base)).toEqual(['BTC', 'ETH']);204    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'gmx_disabled', status: 403 });205    expect(await refusal(w.svc.prepareAuthorization({ allowedMarkets: [ETH.market], maxLeverage: 3, maxOrderSizeUsd: '100', maxOrdersPerDay: 5, maxPriceDeviationBps: 100, days: 7, locale: 'fr' }))).toMatchObject({ code: 'gmx_disabled' });206    expect((await w.svc.preview(LONG as never)).blockers).toContain('gmx_setting_off'); // a preview is an estimate: allowed, and it says why nothing can follow207    expect(w.chain.broadcasts).toHaveLength(0);208  });209  it('with the mainnet setting off: refused too', async () => {210    const w = await world({ settings: { liveMainnet: false } });211    expect((await w.svc.status()).blockers).toContain('mainnet_setting_off');212    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'mainnet_disabled', status: 403 });213  });214  it('authorization: the signer stores the policy after a passkey over its canonical hash; without it nothing is prepared; revoking needs no passkey', async () => {215    const w = await world({ authorize: false });216    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'not_authorized', status: 409 });217    const prep = await w.svc.prepareAuthorization({ allowedMarkets: [ETH.market], maxLeverage: 3, maxOrderSizeUsd: '100', maxOrdersPerDay: 5, maxPriceDeviationBps: 100, days: 7, locale: 'fr' });218    expect(prep.hash).toBe(Buffer.from(gmxAuthorizationHash(prep.authorization)).toString('hex'));219    expect(prep.ack.text).toMatch(/GMX v2/); expect(prep.authorization.contracts).toBe(GMX_PINS.id); expect(prep.authorization.account).toBe(ME.toLowerCase());220    expect(await refusal(w.svc.approveAuthorization({ authorization: { ...prep.authorization, policy: { ...prep.authorization.policy, maxLeverage: 20 } }, assertion: w.pk.assert(Buffer.from(prep.hash, 'hex')) as never }))).toMatchObject({ code: 'bad_approval' });221    const s = await w.svc.approveAuthorization({ authorization: prep.authorization, assertion: w.pk.assert(Buffer.from(prep.hash, 'hex')) as never }, 'fr');222    expect(s.tradable).toBe(true); expect(s.blockers).toEqual([]); expect(s.authorization).toMatchObject({ state: 'active', policy: { maxLeverage: 3, maxOrderSizeUsd: '100' } });223    expect(s.markets.find((m) => m.base === 'ETH')!.allowed).toBe(true); expect(s.markets.find((m) => m.base === 'BTC')!.allowed).toBe(false);224    const r = await w.svc.revoke('fr');225    expect(r.tradable).toBe(false); expect(r.blockers).toEqual(['authorization_revoked']);226    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'not_authorized' });227  });228  it('pins fail closed: before preparing, and again at the moment the signed order would leave', async () => {229    const w = await world();230    w.chain.pinsOk = false;231    const s = await w.svc.status('fr');232    expect(s.blockers).toEqual(['contracts_changed']); expect(s.pins).toMatchObject({ ok: false }); expect(s.blockerText.contracts_changed).toMatch(/GMX a publié de nouveaux contrats : nouvelle revue nécessaire/);233    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'contract_changed', status: 503 });234    w.chain.pinsOk = true;235    const prep = await w.svc.prepareOrder(LONG as never, key());236    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));237    w.chain.mine(); // the approval is mined…238    w.chain.pinsOk = false; // …and GMX replaces its router before the order leaves239    await w.svc.step();240    const op = (await w.svc.operation(prep.operation.id)).operation;241    expect(op).toMatchObject({ state: 'failed', final: true }); expect(op.error).toMatch(/nouvelle revue nécessaire.*not sent.*approval of the Router stays/);242    expect(w.chain.broadcasts).toHaveLength(1); // the approval only243    w.chain.unreadable = true;244    expect((await w.svc.status()).blockers).toContain('chain_unreadable');245    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'chain_unreadable', status: 503 });246  });247});248249describe('an increase, from the estimate to the reconciled execution', () => {250  it('preview: fees from the venue\'s own factors, labelled estimates; the risk engine sees the wallet and the chain', async () => {251    const w = await world(), p = await w.svc.preview(LONG as never), e = p.estimate;252    expect(p.order).toMatchObject({ marketName: 'ETH/USD [WETH-USDC]', side: 'long', action: 'increase', collateral: '20', sizeUsd: '60', leverage: '3', slippageBps: 50 });253    expect(e.source).toBe('estimate');254    expect(Number(e.acceptablePrice)).toBeCloseTo(Number(e.oracleMax) * 1.005, 6); // a long opens at the oracle max; worst price 50 bps above255    expect(['4', '6']).toContain(e.positionFee.bps); expect(Number(e.positionFee.usd)).toBeCloseTo(60 * Number(e.positionFee.bps) / 10_000, 6);256    expect(e.executionFee.sentWei).toBe((4_149_674n * (30_000_000n + 1_000_000n)).toString()); // gas the contracts price × (1.5 × base fee + tip)257    expect(Number(e.priceImpact.uncertaintyUsd)).toBe(Math.abs(Number(e.priceImpact.usd)));258    expect(Number(e.liquidationPrice)).toBeGreaterThan(Number(w.mid) * 0.6); expect(Number(e.liquidationPrice)).toBeLessThan(Number(w.mid));259    expect(p.risk).toEqual({ ok: true }); expect(p.blockers).toEqual([]); expect(p.signerWould).toBeNull();260    const big = await w.svc.preview({ ...LONG, leverage: '4' } as never);261    expect(big.signerWould).toMatch(/leverage 4× is above the authorized 3×/);262    expect(await sql`select count(*)::int as n from deriv_gmx_ops`).toEqual([{ n: 0 }]);263  });264  it('prepare shows exactly what the passkey signs; approve → approval mined → order sent → created → executed by the keeper, reconciled from events', async () => {265    const w = await world();266    const prep = await w.svc.prepareOrder(LONG as never, key());267    expect(prep.operation).toMatchObject({ kind: 'increase', state: 'pending_approval', marketName: 'ETH/USD [WETH-USDC]', side: 'long', collateral: '20', sizeUsd: '60', needsApproval: true });268    expect(Object.fromEntries(prep.fields.map((f) => [f.label, f.value]))).toMatchObject({ action: 'increase', side: 'long', orderType: 'MarketIncrease', sizeUsd: '60', exchangeRouter: GMX_PINS.exchangeRouter.address, router: GMX_PINS.router.address, orderVault: VAULT });269    expect(Object.fromEntries(prep.fixed.map((f) => [f.label, f.value]))).toMatchObject({ receiver: ME, cancellationReceiver: ME, callbackContract: 'none (0x0)', uiFeeReceiver: 'none (0x0)', swapPath: 'empty', referralCode: 'none' });270    expect(prep.intent.gmx).toMatchObject({ action: 'increase', market: ETH.market, collateralToken: USDC, sizeDeltaUsd: gmxUsdToRaw('60').toString(), approveGas: '60000' });271    expect(prep.options!.challenge).toBe(hashOf(prep).toString('base64url'));272    expect((await w.svc.prepareOrder(LONG as never, `gmx-test-${String(idem).padStart(6, '0')}`)).operation.id).toBe(prep.operation.id); // idempotent273    const [p0] = await sql`select kind, state, amount::text, to_addr from proposals where id = ${prep.operation.proposalId}`;274    expect(p0).toEqual({ kind: 'gmx', state: 'pending_approval', amount: '20000000', to_addr: GMX_PINS.exchangeRouter.address });275276    expect(await refusal(w.svc.approve(prep.operation.id, new TestPasskey(RP, ORIGIN).assert(hashOf(prep))))).toMatchObject({ code: 'unknown_credential' });277    expect((await w.svc.operation(prep.operation.id)).operation).toMatchObject({ state: 'failed' }); // a refused approval ends the proposal: nothing was signed278    expect(w.chain.broadcasts).toHaveLength(0);279280    const again = await w.svc.prepareOrder(LONG as never, key());281    let op = (await w.svc.approve(again.operation.id, w.pk.assert(hashOf(again)))).operation;282    expect(op.state).toBe('signed'); expect(w.chain.broadcasts).toHaveLength(1); // only the exact-amount approval left283    const approval = parseTransaction(w.chain.broadcasts[0] as Hex);284    expect(decodeFunctionData({ abi: erc20Abi, data: approval.data! })).toMatchObject({ functionName: 'approve', args: [GMX_PINS.router.address, 20_000_000n] });285    w.chain.mine(); await w.svc.step();286    op = (await w.svc.operation(op.id)).operation;287    expect(op.state).toBe('sent'); expect(w.chain.broadcasts).toHaveLength(2);288    const [k] = w.chain.mine(); await w.svc.step();289    op = (await w.svc.operation(op.id)).operation;290    expect(op).toMatchObject({ state: 'created', final: false, orderKey: k, error: null, overdue: false });291    expect(op.cancellableAt).toBe(op.orderCreatedAt! + 300_000);292    let s = await w.svc.status();293    expect(s.pendingOrders).toMatchObject([{ key: k, orderType: 'MarketIncrease', side: 'long', sizeUsd: '60', collateral: '20', opId: op.id, frozen: false }]);294    expect(s.wallet).toMatchObject({ usdc: '80' }); expect(s.positions).toEqual([]);295    w.clock.advance(11 * 60_000); expect((await w.svc.operation(op.id)).operation).toMatchObject({ state: 'created', overdue: true }); // no keeper yet: said, not assumed296297    const kx = w.chain.keeper(k!, 'execute'); await w.svc.step();298    op = (await w.svc.operation(op.id)).operation;299    expect(op).toMatchObject({ state: 'executed', final: true, error: null });300    expect(op.outcome).toMatchObject({ event: 'OrderExecuted', txid: kx, executionPrice: '2063.55', sizeUsd: '60', positionFeeUsd: '0.024', priceImpactUsd: '-0.003', cancelledBy: null });301    expect(Number(op.outcome!.executionFeeRefundEth)).toBeGreaterThan(0);302    s = await w.svc.status();303    expect(s.pendingOrders).toEqual([]);304    expect(s.positions).toMatchObject([{ marketName: 'ETH/USD [WETH-USDC]', side: 'long', sizeUsd: '60', collateral: '19.976', collateralSymbol: 'USDC', source: 'chain', venueApi: { agrees: false } }]);305    expect(Number(s.positions[0]!.liquidationPrice)).toBeLessThan(Number(s.positions[0]!.entryPrice));306    expect(s.authorization!.today).toEqual({ increases: 1, reduces: 0 });307    expect(w.store.state.journal.filter((j) => j.type === 'gmx.sign.ok')).toHaveLength(1);308  });309  it('a second order while a transaction of the wallet is in flight is refused; an allowance already in place needs no approval', async () => {310    const w = await world();311    const a = await w.svc.prepareOrder(LONG as never, key());312    await w.svc.approve(a.operation.id, w.pk.assert(hashOf(a)));313    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'pending_tx', status: 409 });314    await sql`truncate deriv_gmx_ops, proposals`;315    const w2 = await world(); w2.chain.allowance = 20_000_000n;316    const b = await w2.svc.prepareOrder(LONG as never, key());317    expect(b.operation.needsApproval).toBe(false); expect(b.intent.gmx!.approveGas).toBe('0'); expect(b.intent.evm!.gasLimit).toBe('1170000'); // estimate × 1.3318    await w2.svc.approve(b.operation.id, w2.pk.assert(hashOf(b)));319    expect((await w2.svc.operation(b.operation.id)).operation.state).toBe('sent');320  });321});322323describe('refusals', () => {324  it('risk engine, wallet limits, balances, a market outside the table — nothing is stored', async () => {325    const w = await world();326    expect(await refusal(w.svc.prepareOrder({ ...LONG, leverage: '4' } as never, key()))).toMatchObject({ code: 'venue_policy', status: 422 }); // the signer's own rule, applied before asking327    expect(await refusal(w.svc.prepareOrder({ ...LONG, collateral: '60', leverage: '1' } as never, key()))).toMatchObject({ code: 'policy' }); // above the wallet's 50 USDC per transfer328    expect((await refusal(w.svc.prepareOrder({ ...LONG, market: BTC.market } as never, key()))).code).toMatch(/^risk_/); // pinned, not authorized329    expect(await refusal(w.svc.prepareOrder({ ...LONG, market: '0x450bb6774Dd8a756274E0ab4107953259d2ac541' } as never, key()))).toMatchObject({ code: 'unknown_market' });330    w.chain.usdc = 15_000_000n;331    expect(await refusal(w.svc.prepareOrder({ ...LONG, leverage: '2' } as never, key()))).toMatchObject({ code: 'risk_insufficient_collateral' }); // the risk engine's account view is the wallet's own USDC332    expect((await refusal(w.svc.prepareOrder(LONG as never, key()))).code).toBe('risk_leverage_exceeded'); // 60 USD on a wallet worth 15333    w.chain.usdc = 100_000_000n; w.chain.eth = 10n ** 13n;334    expect(await refusal(w.svc.prepareOrder(LONG as never, key()))).toMatchObject({ code: 'insufficient_gas' });335    expect(await refusal(w.svc.prepareClose({ market: ETH.market, side: 'long', fraction: '1', slippageBps: 50 }, key()))).toMatchObject({ code: 'no_position' });336    expect(await sql`select count(*)::int as n from deriv_gmx_ops`).toEqual([{ n: 0 }]);337  });338  it('the signer has the last word: its own price moved away after the preparation → refused, the proposal ends, nothing is sent', async () => {339    const w = await world(), prep = await w.svc.prepareOrder(LONG as never, key());340    w.px.ETH = Number(w.mid) * 1.03;341    const r = await refusal(w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep))));342    expect(r.code).toBe('price'); expect(r.message).toMatch(/more than 100 bps away from the signer's own ETH price/);343    const op = (await w.svc.operation(prep.operation.id)).operation;344    expect(op).toMatchObject({ state: 'failed', final: true }); expect(op.error).toMatch(/the signer refused/);345    expect(await sql`select state from proposals where id = ${op.proposalId}`).toEqual([{ state: 'failed' }]);346    expect(w.chain.broadcasts).toHaveLength(0);347  });348  it('before the passkey: withdrawn by the owner, or expired — nothing was signed', async () => {349    const w = await world(), a = await w.svc.prepareOrder(LONG as never, key());350    expect((await w.svc.withdraw(a.operation.id)).operation).toMatchObject({ state: 'withdrawn', final: true });351    expect(await refusal(w.svc.approve(a.operation.id, w.pk.assert(hashOf(a))))).toMatchObject({ code: 'bad_state' });352    const b = await w.svc.prepareOrder(LONG as never, key());353    w.clock.advance(181_000);354    expect(await refusal(w.svc.approve(b.operation.id, w.pk.assert(hashOf(b))))).toMatchObject({ code: 'expired', status: 410 });355    expect((await w.svc.operation(b.operation.id)).operation).toMatchObject({ state: 'expired', final: true });356    expect(w.store.state.journal.filter((j) => j.type === 'sign.ok')).toHaveLength(0);357  });358});359360describe('the order state machine after creation', () => {361  it('cancelled by the keeper: the reason from the event, and the collateral verified back in the wallet', async () => {362    const w = await world(), { op, key: k } = await place(w);363    expect(op.state).toBe('created');364    const tx = w.chain.keeper(k, 'cancel'); await w.svc.step();365    const done = (await w.svc.operation(op.id)).operation;366    expect(done).toMatchObject({ state: 'cancelled', final: true });367    expect(done.outcome).toMatchObject({ event: 'OrderCancelled', txid: tx, reasonCode: '0xe09ad0e9', cancelledBy: 'keeper', collateralReturned: true, collateralOut: '20' });368    expect(done.error).toMatch(/GMX cancelled the order.*the collateral came back to the wallet/);369    expect((await w.svc.status()).wallet!.usdc).toBe('100');370  });371  it('cancelled by the owner: refused before the request expiration, then a cancelOrder built by the signer', async () => {372    const w = await world(), { op, key: k } = await place(w);373    const early = await refusal(w.svc.prepareCancel(op.id));374    expect(early).toMatchObject({ code: 'not_yet_cancellable', status: 409 }); expect(early.message).toMatch(/300 s after its creation/);375    w.clock.advance(301_000);376    const prep = await w.svc.prepareCancel(op.id);377    expect(prep.operation).toMatchObject({ kind: 'cancel', state: 'pending_approval', orderKey: k, targetOpId: op.id, needsApproval: false, collateral: null });378    expect(prep.intent).toMatchObject({ asset: 'native', amount: '0', gmx: { action: 'cancel', orderKey: k, executionFee: '0' } });379    expect(Object.fromEntries(prep.fields.map((f) => [f.label, f.value]))).toMatchObject({ action: 'cancel', orderKey: k });380    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));381    const sent = parseTransaction(w.chain.broadcasts[w.chain.broadcasts.length - 1] as Hex);382    expect(decodeGmxCalldata(sent.data!)).toMatchObject({ fn: 'cancelOrder', calls: [{ args: [k] }] }); expect(sent.value ?? 0n).toBe(0n);383    w.chain.mine(); await w.svc.step();384    expect((await w.svc.operation(prep.operation.id)).operation).toMatchObject({ state: 'done', final: true });385    const target = (await w.svc.operation(op.id)).operation;386    expect(target).toMatchObject({ state: 'cancelled', final: true, outcome: { reason: 'USER_INITIATED_CANCEL', cancelledBy: 'owner', collateralReturned: true } });387    expect(await refusal(w.svc.prepareCancel(op.id))).toMatchObject({ code: 'bad_state' });388    expect((await w.svc.status()).authorization!.today).toEqual({ increases: 1, reduces: 1 });389  });390  it('frozen: shown as frozen, cancellable at once, then cancelled', async () => {391    const w = await world(), { op, key: k } = await place(w);392    w.chain.keeper(k, 'freeze'); await w.svc.step();393    const fr = (await w.svc.operation(op.id)).operation;394    expect(fr).toMatchObject({ state: 'frozen', final: false, outcome: { event: 'OrderFrozen', reason: 'frozen for a test' } }); expect(fr.cancellableAt).toBe(fr.orderCreatedAt);395    expect((await w.svc.status()).pendingOrders).toMatchObject([{ frozen: true }]);396    const prep = await w.svc.prepareCancel(op.id); // no delay for a frozen order397    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep))); w.chain.mine(); await w.svc.step();398    expect((await w.svc.operation(op.id)).operation).toMatchObject({ state: 'cancelled', final: true });399  });400  it('never found: an order that left the store without a visible event is `unknown`, never assumed — and resolves when the event appears', async () => {401    const w = await world(), { op, key: k } = await place(w);402    w.chain.hideEvents = true; w.chain.keeper(k, 'execute');403    await w.svc.step();404    expect((await sql`select state from deriv_gmx_ops where id = ${op.id}`)[0].state).toBe('created'); // gone from the order store, no event yet: not assumed either way405    for (let n = 0; n < 6; n++) await w.svc.step();406    const u = (await w.svc.operation(op.id)).operation;407    expect(u).toMatchObject({ state: 'unknown', final: false }); expect(u.error).toMatch(/outcome unknown/);408    w.chain.hideEvents = false; await w.svc.step();409    expect((await w.svc.operation(op.id)).operation).toMatchObject({ state: 'executed', final: true, error: null });410  });411  it('the order transaction reverts, or is never seen: failed / unknown, with what stays in place', async () => {412    const w = await world(), prep = await w.svc.prepareOrder(LONG as never, key());413    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));414    w.chain.mine(); await w.svc.step(); w.chain.revertNext = 'next'; w.chain.mine(); await w.svc.step();415    const f = (await w.svc.operation(prep.operation.id)).operation;416    expect(f).toMatchObject({ state: 'failed', final: true }); expect(f.error).toMatch(/reverted on-chain: no order was created.*approval of the Router stays in place/);417    expect(await sql`select state from proposals where id = ${f.proposalId}`).toEqual([{ state: 'failed' }]);418    w.chain.allowance = 0n;419    const b = await w.svc.prepareOrder(LONG as never, key());420    await w.svc.approve(b.operation.id, w.pk.assert(hashOf(b))); w.chain.mine(); await w.svc.step();421    w.chain.pending.splice(0); // the order transaction is dropped by the network422    w.clock.advance(16 * 60_000); await w.svc.step();423    const u = (await w.svc.operation(b.operation.id)).operation;424    expect(u).toMatchObject({ state: 'unknown', final: false, orderKey: null }); expect(u.error).toMatch(/no receipt was found/);425    await w.chain.broadcast(w.chain.broadcasts[w.chain.broadcasts.length - 1]!); w.chain.mine(); await w.svc.step(); // it lands after all426    expect((await w.svc.operation(b.operation.id)).operation).toMatchObject({ state: 'created', error: null });427  });428  it('the simulation right before sending refuses a transaction that would revert', async () => {429    const w = await world(), prep = await w.svc.prepareOrder(LONG as never, key());430    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));431    w.chain.mine(); w.chain.simulateError = 'execution reverted: InsufficientExecutionFee'; await w.svc.step();432    const op = (await w.svc.operation(prep.operation.id)).operation;433    expect(op.state).toBe('failed'); expect(op.error).toMatch(/would revert now \(execution reverted: InsufficientExecutionFee\); it was not sent/);434    expect(w.chain.broadcasts).toHaveLength(1);435  });436  it('restart: a new engine instance on the same database continues every operation where it was', async () => {437    const w = await world(), prep = await w.svc.prepareOrder(LONG as never, key());438    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));439    const again = new ex.GmxService(w.svc.deps); // the process restarted between the signature and the approval's receipt440    w.chain.mine(); await again.step();441    expect((await again.operation(prep.operation.id)).operation.state).toBe('sent');442    const [k] = w.chain.mine(); const third = new ex.GmxService(w.svc.deps); await third.step();443    expect((await third.operation(prep.operation.id)).operation).toMatchObject({ state: 'created', orderKey: k });444    w.chain.keeper(k!, 'execute'); await new ex.GmxService(w.svc.deps).step();445    expect((await third.operation(prep.operation.id)).operation.state).toBe('executed');446  });447});448449describe('closing a position', () => {450  it('a MarketDecrease of the on-chain size: no token leaves the wallet, no approval; executed → the collateral is back', async () => {451    const w = await world(), { key: k } = await place(w);452    w.chain.keeper(k, 'execute'); await w.svc.step();453    const prep = await w.svc.prepareClose({ market: ETH.market, side: 'long', fraction: '1', slippageBps: 50 }, key());454    expect(prep.operation).toMatchObject({ kind: 'decrease', sizeUsd: '60', collateral: null, needsApproval: false });455    expect(prep.intent).toMatchObject({ amount: '0', asset: USDC, gmx: { action: 'decrease', orderType: 'MarketDecrease', sizeDeltaUsd: gmxUsdToRaw('60').toString(), approveGas: '0' } });456    expect(Number(prep.operation.acceptablePrice)).toBeLessThan(Number(w.mid)); // a long closes at the oracle min; worst price below457    const [row] = await sql`select asset, amount::text from proposals where id = ${prep.operation.proposalId}`;458    expect(row.asset).toBe('native'); expect(BigInt(row.amount)).toBe(BigInt(prep.intent.maxFee)); // the record shows the native coin that may leave: fees only459    const before = w.chain.usdc;460    await w.svc.approve(prep.operation.id, w.pk.assert(hashOf(prep)));461    const [k2] = w.chain.mine(); await w.svc.step();462    expect(w.chain.usdc).toBe(before);463    expect((await w.svc.operation(prep.operation.id)).operation).toMatchObject({ state: 'created', orderKey: k2 });464    w.chain.keeper(k2!, 'execute'); await w.svc.step();465    const op = (await w.svc.operation(prep.operation.id)).operation;466    expect(op).toMatchObject({ state: 'executed', outcome: { event: 'OrderExecuted', pnlUsd: '0', priceImpactUsd: '-0.014', collateralOut: '19.946' } });467    const s = await w.svc.status();468    expect(s.positions).toEqual([]); expect(s.wallet!.usdc).toBe('99.946'); expect(s.authorization!.today).toEqual({ increases: 1, reduces: 1 });469    const half = await refusal(w.svc.prepareClose({ market: ETH.market, side: 'long', fraction: '0.5', slippageBps: 50 }, key()));470    expect(half.code).toBe('no_position');471  });472});473474describe('the derivatives router and GMX', () => {475  it('says the wallet can open a pinned GMX market when the engine is tradable, with the wallet USDC as collateral — and only reads GMX otherwise', async () => {476    const clock = new Clock(), v = fixtureVenues(clock);477    const chain = { read: async (eth: { value: string; source: string } | null) => ({ gasPriceEth: '0.00000000002', ethUsd: eth?.value ?? null, ethUsdSource: eth?.source ?? 'none', gmxExecutionFeeEth: '0.00008', gmxExecutionFeeNote: 'fixture', ts: clock.now() }) };478    const state = { tradable: true, usdc: '100' as string | null };479    const d = dv.createDerivatives({ registry: dv.createRegistry(v.adapters, clock.now), now: clock.now, chain, gmx: { tradability: async () => ({ ...state }) } });480    const ask = async () => (await d.router!.route({ base: 'BTC', side: 'long', notionalUsd: '60', leverage: '3', type: 'market', reduceOnly: false })).candidates.filter((c) => c.venue === 'gmx');481    const codes = (c: Awaited<ReturnType<typeof ask>>[number]) => c.eligibility.reasons.map((r) => r.code);482    let c = await ask();483    const pinned = c.find((x) => x.instrument === BTC.instrument)!, other = c.find((x) => x.instrument !== BTC.instrument)!;484    expect(pinned.eligibility).toMatchObject({ executableNow: true, executableAs: 'live' });485    expect(codes(pinned)).not.toContain('wallet_cannot_trade_here'); expect(codes(pinned)).not.toContain('no_collateral_account');486    expect(other.eligibility.executableNow).toBe(false); // another BTC pool of GMX: not in the pinned table487    state.usdc = '5'; clock.advance(11_000);488    c = await ask();489    expect(codes(c.find((x) => x.instrument === BTC.instrument)!)).toContain('insufficient_collateral_there');490    state.tradable = false; clock.advance(11_000);491    c = await ask();492    expect(c.find((x) => x.instrument === BTC.instrument)!.eligibility.executableNow).toBe(false);493    expect(codes(c.find((x) => x.instrument === BTC.instrument)!)).toContain('wallet_cannot_trade_here');494  });495});496497describe('routes', () => {498  it('shapes, idempotency header, errors as codes', async () => {499    const w = await world();500    const get = async (url: string) => { const r = await w.app.inject({ method: 'GET', url }); return { status: r.statusCode, body: r.json() }; };501    const post = async (url: string, payload: unknown, headers: Record<string, string> = {}) => { const r = await w.app.inject({ method: 'POST', url, payload: payload as never, headers }); return { status: r.statusCode, body: r.json() }; };502    const st = await get('/api/v1/derivatives/gmx/status?locale=fr');503    expect(st.status).toBe(200); expect(st.body).toMatchObject({ venue: 'gmx', network: 'arbitrum', chainId: 42161, tradable: true, settings: { liveMainnet: true, gmxLive: true }, positionsSource: 'chain', facts: { requestExpirationSec: 300, twoStep: true } });504    expect((await get('/api/v1/derivatives/gmx/jurisdiction')).body.fr.text).toMatch(/GMX v2/);505    expect((await post('/api/v1/derivatives/gmx/orders/preview', LONG)).body.estimate.source).toBe('estimate');506    expect((await post('/api/v1/derivatives/gmx/orders', LONG)).status).toBe(400);507    expect((await post('/api/v1/derivatives/gmx/orders/preview', { ...LONG, receiver: ME })).status).toBe(400); // strict body: no field for a receiver508    const made = await post('/api/v1/derivatives/gmx/orders', LONG, { 'idempotency-key': key() });509    expect(made.status).toBe(200); expect(made.body.operation.state).toBe('pending_approval');510    const ok = await post(`/api/v1/derivatives/gmx/operations/${made.body.operation.id}/approve`, { assertion: w.pk.assert(Buffer.from(made.body.operation.hash, 'hex')) });511    expect(ok.status).toBe(200); expect(ok.body.operation.state).toBe('signed');512    expect((await post(`/api/v1/derivatives/gmx/orders/${made.body.operation.id}/cancel`, {})).status).toBe(409);513    expect((await get(`/api/v1/derivatives/gmx/operations/${made.body.operation.id}`)).body.operation.id).toBe(made.body.operation.id);514    expect((await post('/api/v1/derivatives/gmx/authorization/revoke', {})).body.tradable).toBe(false);515  });516});517
Files main
Menu

Theme