SPB Git

Repository Public

gmx-exec.ts 71.3 KB 688 lines typescript

1// GMX v2 trading (v8) — the execution engine. Facts, pins and evidence: docs/v4/venues/gmx.md Part C; routes and shapes:2// docs/v4/API-LIVE-NOTES.md ("GMX v2 trading").3//4// A GMX order is a wallet transaction, so it travels as an ordinary wallet PROPOSAL (table `proposals`, kind `gmx`: prepare →5// simulate → passkey → signer → broadcast → receipt, one transaction in flight per network) whose intent carries a `gmx`6// section; a row of `deriv_gmx_ops` then follows the ORDER: its key comes from the `OrderCreated` event of the receipt, and7// it is tracked on-chain (Reader + the EventEmitter's logs) until GMX's keepers executed it, cancelled it or froze it.8// "created" is never shown as "executed"; an order that cannot be found is `unknown`, never assumed.9//10// What this file can NOT do: it holds no key and builds no transaction that gets signed — the signer rebuilds everything from11// the intent and refuses anything outside the pinned contracts, the fixed fields and the venue policy the owner approved.12// Three locks: SPW_DERIV_LIVE_MAINNET=on, SPW_DERIV_GMX_LIVE=on (default off), and that passkey-approved venue policy.13import { randomUUID } from 'node:crypto';14import { generateAuthenticationOptions } from '@simplewebauthn/server';15import { decodeEventLog, erc20Abi, formatUnits, toFunctionSelector, type Hex } from 'viem';16import { z } from 'zod';17import { checkPolicy, checkPolicyNoSpend, getNetwork, IntentSchema, intentHash, toB64u, toHex, type Intent, type Policy } from '@spw/core';18import { checkOrder, D, gmxBalanceOi, gmxPriceImpact, liquidationPriceIsolated, type AccountState, type Dec, type DecStr, type GmxLiquidity, type Instrument, type Position, type RiskPolicy, type Ticker } from '@spw/derivatives';19import {20  buildGmxTxs, decodeGmxEvent, GMX_GAS, GMX_MARKETS, GMX_PINNED_CONTRACTS, GMX_PINS, GMX_POLICY_BOUNDS, GMX_REQUEST_EXPIRATION_SEC, gmxAcceptablePrice, GmxAuthorizationSchema, gmxAuthorizationHash, gmxEventKey, gmxExecutionFee,21  gmxMarket, gmxOrderCreatedDiff, gmxPolicyVerdict, gmxPriceFromRaw, gmxUsdFromRaw, gmxUsdToRaw, hex, jurisdictionAck, scaleExact, unscale, validateGmxAuthorization, verifyGmxIntent, ZERO_ADDRESS,22  type GmxAuthorization, type GmxEvent, type GmxMarketPin, type GmxPolicy, type GmxSection,23} from '@spw/derivatives/live';24import { UserError } from '../config.ts';25import { audit, sql } from '../db.ts';26import { move, type ProposalRow } from '../transfers.ts';27import type { GmxChain, GmxChainOrder, GmxChainPosition, GmxPinCheck, GmxReceipt } from './gmx-chain.ts';2829export const GMX_STEP_MS = 5_000;30/** a `created` order no keeper touched after this long is flagged (tracking goes on) */31export const GMX_KEEPER_OVERDUE_MS = 10 * 60_000;32/** a sent transaction without a receipt after this long is `unknown` (tracking goes on) */33export const GMX_RECEIPT_OVERDUE_MS = 15 * 60_000;34const TTL_SEC = 180, FIXED_ORDER_GAS = 2_500_000n, APPROVE_GAS = 60_000n, EXPECTED_ORDER_GAS = 1_000_000n, SEND_WINDOW_MS = 8 * 60_000;35const LOCALES = ['fr', 'en'] as const;36type Locale = (typeof LOCALES)[number];37const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 300);38const lc = (s: string) => s.toLowerCase();39const WEI = 18;40/** custom errors of GMX seen as cancellation reasons, by selector (the first is the one produced on the fork proof) */41const GMX_ERROR_NAMES: Record<string, string> = { [toFunctionSelector('OrderNotFulfillableAtAcceptablePrice(uint256,uint256)')]: 'OrderNotFulfillableAtAcceptablePrice: the oracle price was beyond the acceptable price of the order' };42/** a decimal rounded to `dp` places (display of estimates; never feeds a transaction) */43const rd = (d: Dec, dp = 6): DecStr => D.str(D.div(d, D.one, dp, 'half_even'));4445export type GmxBlocker = 'gmx_setting_off' | 'mainnet_setting_off' | 'not_authorized' | 'authorization_expired' | 'authorization_revoked' | 'contracts_changed' | 'signer_halted' | 'chain_unreadable' | 'wallet_unreadable';46export const GMX_BLOCKER_TEXT: Record<GmxBlocker, Record<Locale, string>> = {47  gmx_setting_off: { en: 'GMX trading is disabled on this server (SPW_DERIV_GMX_LIVE is not "on"). It is a server setting: it cannot be changed from the app. Reading stays available.', fr: 'La négociation sur GMX est désactivée sur ce serveur (SPW_DERIV_GMX_LIVE n\'est pas « on »). C\'est un réglage du serveur : il ne se modifie pas depuis l\'application. La lecture reste disponible.' },48  mainnet_setting_off: { en: 'Live derivative trading on mainnet is disabled on this server (SPW_DERIV_LIVE_MAINNET is not "on"). It is a server setting.', fr: 'La négociation réelle de dérivés sur le réseau principal est désactivée sur ce serveur (SPW_DERIV_LIVE_MAINNET n\'est pas « on »). C\'est un réglage du serveur.' },49  not_authorized: { en: 'No venue policy is approved: approve one with your passkey to trade on GMX.', fr: 'Aucune politique de plateforme n\'est approuvée : approuvez-en une avec votre clé d\'accès pour négocier sur GMX.' },50  authorization_expired: { en: 'The GMX authorization has expired: approve a new one.', fr: 'L\'autorisation GMX a expiré : approuvez-en une nouvelle.' },51  authorization_revoked: { en: 'The GMX authorization was revoked: approve a new one.', fr: 'L\'autorisation GMX a été révoquée : approuvez-en une nouvelle.' },52  contracts_changed: { en: 'GMX has published new contracts: a new review is needed. Nothing is signed for GMX until this wallet\'s pinned table is reviewed again.', fr: 'GMX a publié de nouveaux contrats : nouvelle revue nécessaire. Rien n\'est signé pour GMX tant que la table épinglée de ce portefeuille n\'a pas été revue.' },53  signer_halted: { en: 'The signer\'s emergency halt is active: nothing is signed.', fr: 'L\'arrêt d\'urgence du signataire est actif : rien n\'est signé.' },54  chain_unreadable: { en: 'Arbitrum could not be read: the contracts cannot be checked right now.', fr: 'Arbitrum n\'a pas pu être lu : les contrats ne peuvent pas être vérifiés pour le moment.' },55  wallet_unreadable: { en: 'The signer could not be reached: the wallet address and limits are unknown.', fr: 'Le signataire est injoignable : l\'adresse et les limites du portefeuille sont inconnues.' },56};5758// ---------------------------------------------------------------------------------------------------------------------------59// Ports (replaced by fakes in tests)6061export interface GmxSignerAuth { authorization: GmxAuthorization; hash: string; approvedAt: string; revoked: boolean; revokedAt: string | null; expired: boolean; current: boolean; today: { increases: number; reduces: number } }62export interface GmxSignerPort {63  status(fresh?: boolean): Promise<{ keyId: string; halted: boolean; addresses: Record<string, string>; policy: Policy; spentToday: Record<string, string>; gmx?: GmxSignerAuth[] }>;64  sign(intent: unknown, assertion: unknown): Promise<{ raw: string; txid: string; hash: string; pre?: { raw: string; txid: string } }>;65  gmxAuthorize(authorization: unknown, assertion: unknown): Promise<{ authorizationId: string; hash: string }>;66  gmxRevoke(authorizationId: string): Promise<{ revoked: true; revokedAt: string }>;67}68export interface GmxMarketPort {69  /** the venue's own market data for one pinned instrument: oracle range, open interest, fee and impact factors, rates */70  market(instrument: string): Promise<{ instrument: Instrument; ticker: Ticker; liquidity: GmxLiquidity | null }>;71  /** positions of an address as GMX's API reports them — a CROSS-CHECK of the on-chain read, never the source */72  apiPositions(account: string): Promise<Position[]>;73}74export interface GmxDeps {75  chain: GmxChain; signer: GmxSignerPort; market: GmxMarketPort; now: () => number; rpId: string;76  credentials: () => Promise<{ id: string; transports?: string[] | null }[]>;77  settings: () => { liveMainnet: boolean; gmxLive: boolean };78}7980// ---------------------------------------------------------------------------------------------------------------------------81// Bodies8283const Dec = z.string().regex(/^(0|[1-9]\d{0,11})(\.\d{1,18})?$/);84const Address = z.string().regex(/^0x[0-9a-fA-F]{40}$/);85export const GmxOrderBodySchema = z.strictObject({ market: Address, side: z.enum(['long', 'short']), action: z.enum(['increase', 'decrease']), collateral: Dec.optional(), leverage: Dec.optional(), sizeUsd: Dec.optional(), slippageBps: z.number().int().min(1).max(500).default(50) });86export type GmxOrderBody = z.infer<typeof GmxOrderBodySchema>;87export const GmxCloseBodySchema = z.strictObject({ market: Address, side: z.enum(['long', 'short']), fraction: Dec.default('1'), slippageBps: z.number().int().min(1).max(500).default(50) });88export const GmxAuthBodySchema = z.strictObject({ allowedMarkets: z.array(Address).min(1).max(GMX_POLICY_BOUNDS.maxMarkets), maxLeverage: z.number().int().min(1).max(GMX_POLICY_BOUNDS.maxLeverage), maxOrderSizeUsd: Dec,89  maxOrdersPerDay: z.number().int().min(1).max(GMX_POLICY_BOUNDS.maxOrdersPerDay), maxPriceDeviationBps: z.number().int().min(GMX_POLICY_BOUNDS.minPriceBandBps).max(GMX_POLICY_BOUNDS.maxPriceBandBps),90  days: z.number().int().min(1).max(GMX_POLICY_BOUNDS.maxDays), locale: z.enum(LOCALES).default('fr') });91export const GmxApproveAuthSchema = z.strictObject({ authorization: GmxAuthorizationSchema, assertion: z.record(z.string(), z.unknown()) });92export const GmxAssertionSchema = z.strictObject({ assertion: z.record(z.string(), z.unknown()) });9394export type GmxOpState = 'pending_approval' | 'signed' | 'sent' | 'created' | 'executed' | 'cancelled' | 'frozen' | 'done' | 'failed' | 'expired' | 'withdrawn' | 'unknown';95interface Row {96  id: string; idempotency_key: string | null; kind: 'increase' | 'decrease' | 'cancel'; state: GmxOpState; final: boolean; account: string; market: string | null; is_long: boolean | null; collateral_token: string | null;97  amount: string | null; size_usd: string | null; acceptable_price: string | null; slippage_bps: number | null; execution_fee: string; max_fee: string; needs_approval: boolean; order_key: string | null; target_op: string | null;98  proposal_id: string | null; op_hash: string | null; txid: string | null; pre_txid: string | null; block_number: string | null; request: Record<string, any>; estimate: Record<string, any> | null; outcome: Record<string, any> | null;99  error: string | null; attempts: number; expires_at: Date | null; sent_at: Date | null; order_created_at: Date | null; done_at: Date | null; checked_at: Date | null; created_at: Date; updated_at: Date;100}101interface Computed {102  pin: GmxMarketPin; isLong: boolean; increase: boolean; amount: bigint; sizeRaw: bigint; acceptable: bigint; slippageBps: number; leverage: DecStr | null;103  estimate: Record<string, any>; risk: { ok: true } | { ok: false; code: string; reason: string }; signerWould: string | null; executionFee: bigint; base: bigint; tip: bigint;104}105106export class GmxService {107  readonly deps: GmxDeps;108  readonly now: () => number;109  constructor(deps: GmxDeps) { this.deps = deps; this.now = deps.now; }110111  // ------------------------------------------------------------------ helpers112113  private async set(r: Row, patch: Record<string, unknown>, from?: GmxOpState): Promise<Row> {114    const p = { ...patch, updated_at: new Date(this.now()) };115    const [u] = from === undefined ? await sql<Row[]>`update deriv_gmx_ops set ${sql(p as never)} where id = ${r.id} returning *` : await sql<Row[]>`update deriv_gmx_ops set ${sql(p as never)} where id = ${r.id} and state = ${from} returning *`;116    if (!u) throw new UserError('conflict', 'the operation changed concurrently; reload', 409);117    return u;118  }119  private async row(id: string): Promise<Row> {120    const [r] = await sql<Row[]>`select * from deriv_gmx_ops where id = ${id}`;121    if (!r) throw new UserError('unknown_operation', 'unknown GMX operation', 404);122    return r;123  }124  private log(type: string, r: Row, data: Record<string, unknown> = {}, actor = 'system'): Promise<void> {125    return audit(`deriv.gmx.${type}`, actor, { id: r.id, kind: r.kind, market: r.market, isLong: r.is_long, amount: r.amount, sizeUsd: r.size_usd, orderKey: r.order_key, txid: r.txid, ...data }).catch((e: Error) => console.error(`[gmx] audit ${type} failed: ${e.message}`));126  }127  private async options(hash: Uint8Array, ttlSec: number) {128    const creds = await this.deps.credentials();129    const options = await generateAuthenticationOptions({ rpID: this.deps.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: ttlSec * 1000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });130    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');131    return options;132  }133  private authOf(st: { gmx?: GmxSignerAuth[] }): GmxSignerAuth | null {134    const all = st.gmx ?? [];135    return all.find((a) => !a.revoked && !a.expired && a.current) ?? [...all].sort((x, y) => y.authorization.createdAt - x.authorization.createdAt)[0] ?? null;136  }137  private authState(a: GmxSignerAuth): 'active' | 'expired' | 'revoked' | 'stale' { return a.revoked ? 'revoked' : !a.current ? 'stale' : a.expired ? 'expired' : 'active'; }138  private explorer = (kind: 'tx' | 'addr', v: string) => { const n = getNetwork(GMX_PINS.network); return (kind === 'tx' ? n.explorerTx : n.explorerAddr) + v; };139140  /** the gates of everything that prepares or approves: both server settings, the signer, an active authorization, the pins */141  private async gate(needAuth = true): Promise<{ st: Awaited<ReturnType<GmxSignerPort['status']>>; me: string; auth: GmxSignerAuth | null; pins: GmxPinCheck }> {142    const s = this.deps.settings();143    if (!s.gmxLive) throw new UserError('gmx_disabled', `${GMX_BLOCKER_TEXT.gmx_setting_off.en} Nothing was built, signed or sent.`, 403);144    if (!s.liveMainnet) throw new UserError('mainnet_disabled', `${GMX_BLOCKER_TEXT.mainnet_setting_off.en} Nothing was built, signed or sent.`, 403);145    const st = await this.deps.signer.status(true), me = st.addresses[GMX_PINS.network];146    if (!me) throw new UserError('unsupported_network', 'the signer has no key for Arbitrum', 503);147    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);148    const auth = this.authOf(st);149    if (needAuth && (!auth || this.authState(auth) !== 'active')) throw new UserError('not_authorized', auth ? `the GMX authorization is ${this.authState(auth)}: approve a new one` : 'GMX trading is not authorized: approve a venue policy with your passkey first', 409);150    const pins = await this.deps.chain.checkPins(me);151    if (!pins.ok) throw new UserError('contract_changed', `GMX a publié de nouveaux contrats : nouvelle revue nécessaire (${pins.problems.join('; ').slice(0, 240)}). Nothing was built, signed or sent.`, 503);152    return { st, me, auth, pins };153  }154155  // ------------------------------------------------------------------ views156157  private opView(r: Row) {158    const pin = r.market ? gmxMarket(r.market) : null, c = pin?.collateral.find((x) => r.collateral_token && lc(x.token) === lc(r.collateral_token)) ?? null, now = this.now();159    const created = r.order_created_at?.getTime() ?? null;160    return {161      id: r.id, kind: r.kind, state: r.state, final: r.final, market: r.market, marketName: pin?.name ?? null, side: r.is_long === null ? null : r.is_long ? 'long' as const : 'short' as const,162      collateral: r.amount !== null && c && r.kind === 'increase' ? formatUnits(BigInt(r.amount), c.decimals) : null, collateralSymbol: c?.symbol ?? null,163      sizeUsd: r.size_usd !== null ? gmxUsdFromRaw(BigInt(r.size_usd)) : null, acceptablePrice: r.acceptable_price !== null && pin ? gmxPriceFromRaw(BigInt(r.acceptable_price), pin.indexDecimals) : null,164      slippageBps: r.slippage_bps, leverage: (r.request.leverage as string | undefined) ?? null, executionFeeEth: formatUnits(BigInt(r.execution_fee), WEI), maxFeeEth: formatUnits(BigInt(r.max_fee), WEI), needsApproval: r.needs_approval,165      orderKey: r.order_key, targetOpId: r.target_op, hash: r.op_hash, proposalId: r.proposal_id, txid: r.txid, explorer: r.txid ? this.explorer('tx', r.txid) : null, approvalTxid: r.pre_txid,166      createdAt: r.created_at.getTime(), expiresAt: r.expires_at?.getTime() ?? null, sentAt: r.sent_at?.getTime() ?? null, orderCreatedAt: created, doneAt: r.done_at?.getTime() ?? null, checkedAt: r.checked_at?.getTime() ?? null,167      cancellableAt: created !== null && (r.state === 'created' || r.state === 'frozen') ? (r.state === 'frozen' ? created : created + GMX_REQUEST_EXPIRATION_SEC * 1000) : null,168      overdue: r.state === 'created' && created !== null && now - created > GMX_KEEPER_OVERDUE_MS, estimate: r.estimate, outcome: r.outcome, error: r.error,169    };170  }171  private posView(p: GmxChainPosition, mark: Dec | null, liq: GmxLiquidity | null, api: Position[] | null) {172    const pin = gmxMarket(p.market), c = pin?.collateral.find((x) => lc(x.token) === lc(p.collateralToken)) ?? null, dec = pin?.indexDecimals ?? 18, cd = c?.decimals ?? 6;173    const sizeUsd = D.from(gmxUsdFromRaw(p.sizeInUsd)), tokens = D.from(unscale(p.sizeInTokens, dec)), coll = D.from(unscale(p.collateralAmount, cd));174    const entry = D.isZero(tokens) ? null : D.div(sizeUsd, tokens, 12, 'half_even');175    const pnl = mark && entry ? (p.isLong ? D.sub(D.mul(tokens, mark), sizeUsd) : D.sub(sizeUsd, D.mul(tokens, mark))) : null;176    let liqPx: DecStr | null = null;177    try { if (liq && entry && !D.isZero(tokens)) { const v = liquidationPriceIsolated({ side: p.isLong ? 'long' : 'short', size: tokens, entryPrice: entry, margin: coll, maintenanceFraction: D.from(liq.minCollateralFactorForLiquidation), feeRate: D.from(liq.positionFeeFactor.balanceNotImproved) }).value; liqPx = v ? rd(v, 2) : null; } } catch { liqPx = null; }178    // cross-check with GMX's own API: same market, side and size (the API is an indexer: a short lag is possible)179    let agrees: boolean | null = null, detail: string | null = null;180    if (api && pin) {181      const m = api.find((x) => x.instrument === pin.instrument && x.side === (p.isLong ? 'long' : 'short'));182      if (!m) { agrees = false; detail = 'GMX\'s API does not list this position (its indexer may lag)'; }183      else { const diff = D.abs(D.sub(D.from(m.size), tokens)); agrees = D.cmp(D.mul(diff, D.from('1000')), tokens) <= 0; detail = agrees ? null : `GMX's API reports ${m.size} tokens, the chain ${D.str(tokens)}`; }184    }185    return { key: p.key, market: p.market, marketName: pin?.name ?? p.market, instrument: pin?.instrument ?? null, side: p.isLong ? 'long' as const : 'short' as const, collateralToken: p.collateralToken, collateralSymbol: c?.symbol ?? '?',186      sizeUsd: D.str(sizeUsd), sizeTokens: D.str(tokens), collateral: D.str(coll), entryPrice: entry ? rd(entry, 4) : '0', markPrice: mark ? D.str(mark) : null,187      pnlUsd: pnl ? rd(pnl) : null, leverage: D.isZero(coll) ? null : D.str(D.div(sizeUsd, coll, 2, 'half_even')), liquidationPrice: liqPx, increasedAt: p.increasedAtTime, source: 'chain' as const, venueApi: { agrees, detail } };188  }189  private orderView(o: GmxChainOrder, ops: Row[]) {190    const pin = gmxMarket(o.market), c = pin?.collateral.find((x) => lc(x.token) === lc(o.initialCollateralToken));191    return { key: o.key, market: o.market, marketName: pin?.name ?? o.market, orderType: ['MarketSwap', 'LimitSwap', 'MarketIncrease', 'LimitIncrease', 'MarketDecrease', 'LimitDecrease', 'StopLossDecrease', 'Liquidation', 'StopIncrease'][o.orderType] ?? String(o.orderType),192      side: o.isLong ? 'long' as const : 'short' as const, sizeUsd: gmxUsdFromRaw(o.sizeDeltaUsd), collateral: formatUnits(o.initialCollateralDeltaAmount, c?.decimals ?? 6), acceptablePrice: pin ? gmxPriceFromRaw(o.acceptablePrice, pin.indexDecimals) : o.acceptablePrice.toString(),193      executionFeeEth: formatUnits(o.executionFee, WEI), updatedAt: o.updatedAtTime, cancellableAt: o.isFrozen ? o.updatedAtTime : o.updatedAtTime + GMX_REQUEST_EXPIRATION_SEC, frozen: o.isFrozen, opId: ops.find((r) => r.order_key === o.key)?.id ?? null };194  }195196  async status(locale: Locale = 'en') {197    const settings = this.deps.settings(), n = getNetwork(GMX_PINS.network), blockers: GmxBlocker[] = [];198    if (!settings.gmxLive) blockers.push('gmx_setting_off');199    if (!settings.liveMainnet) blockers.push('mainnet_setting_off');200    let st: Awaited<ReturnType<GmxSignerPort['status']>> | null = null, walletError: string | null = null;201    try { st = await this.deps.signer.status(); } catch (e) { walletError = msg(e); blockers.push('wallet_unreadable'); }202    const me = st?.addresses[GMX_PINS.network] ?? null, auth = st ? this.authOf(st) : null, as = auth ? this.authState(auth) : null;203    if (st?.halted) blockers.push('signer_halted');204    if (st && (!auth || as === 'stale')) blockers.push(as === 'stale' ? 'contracts_changed' : 'not_authorized'); else if (as === 'expired') blockers.push('authorization_expired'); else if (as === 'revoked') blockers.push('authorization_revoked');205    let pins: { ok: boolean | null; problems: string[]; checkedAt: number | null } = { ok: null, problems: [], checkedAt: null };206    try { pins = await this.deps.chain.checkPins(me); if (!pins.ok && !blockers.includes('contracts_changed')) blockers.push('contracts_changed'); }207    catch (e) { pins = { ok: null, problems: [msg(e)], checkedAt: null }; blockers.push('chain_unreadable'); }208209    let wallet = null;210    if (st && me) {211      try {212        const b = await this.deps.chain.balances(me), key = `${n.id}:USDC`;213        const perTx = BigInt(st.policy.perTx[key] ?? '0'), perDay = BigInt(st.policy.daily[key] ?? '0'), spent = BigInt(st.spentToday[key] ?? '0'), room = perDay > spent ? perDay - spent : 0n;214        wallet = { usdc: formatUnits(b.usdc, 6), eth: formatUnits(b.eth, WEI), weth: formatUnits(b.weth, WEI), limits: { perTransfer: formatUnits(perTx, 6), perDay: formatUnits(perDay, 6), spentToday: formatUnits(spent, 6), maxNow: formatUnits([perTx, room, b.usdc].reduce((x, y) => (y < x ? y : x)), 6) },215          feeCeilingEth: formatUnits(BigInt(st.policy.maxFee[n.id] ?? '0'), WEI) };216      } catch (e) { walletError = msg(e); }217    }218    const data = new Map<string, { mark: Dec | null; liq: GmxLiquidity | null }>();219    await Promise.all(GMX_MARKETS.map(async (m) => { try { const x = await this.deps.market.market(m.instrument); data.set(m.market, { mark: x.liquidity ? D.div(D.add(D.from(x.liquidity.oracleMin), D.from(x.liquidity.oracleMax)), D.from('2'), 12, 'half_even') : D.from(x.ticker.mark), liq: x.liquidity }); } catch { data.set(m.market, { mark: null, liq: null }); } }));220    let positions: ReturnType<GmxService['posView']>[] = [], pendingOrders: ReturnType<GmxService['orderView']>[] = [], positionsError: string | null = null;221    await this.refreshOpen().catch(() => {});222    const ops = await sql<Row[]>`select * from deriv_gmx_ops order by created_at desc limit 20`;223    if (me) {224      try {225        const [ps, os, api] = await Promise.all([this.deps.chain.positions(me), this.deps.chain.orders(me), this.deps.market.apiPositions(me).catch(() => null)]);226        positions = ps.map((p) => this.posView(p, data.get(gmxMarket(p.market)?.market ?? '')?.mark ?? null, data.get(gmxMarket(p.market)?.market ?? '')?.liq ?? null, api));227        pendingOrders = os.map((o) => this.orderView(o, ops));228      } catch (e) { positionsError = msg(e); }229    }230    let executionGas: string | null = null;231    try { executionGas = (await this.deps.chain.executionGas()).increase.toString(); } catch { executionGas = null; }232    return {233      venue: 'gmx' as const, network: GMX_PINS.network, chainId: GMX_PINS.chainId, settings, tradable: blockers.length === 0, blockers,234      blockerText: Object.fromEntries((Object.keys(GMX_BLOCKER_TEXT) as GmxBlocker[]).map((k) => [k, GMX_BLOCKER_TEXT[k][locale]])) as Record<GmxBlocker, string>,235      pins: { id: GMX_PINS.id, reviewedAt: GMX_PINS.id.slice(-10), ok: pins.ok, checkedAt: pins.checkedAt, problems: pins.problems, contracts: GMX_PINNED_CONTRACTS.map((c) => ({ name: c, address: GMX_PINS[c].address, explorer: this.explorer('addr', GMX_PINS[c].address) })) },236      account: me ? { address: me, explorer: this.explorer('addr', me) } : null, wallet, walletError,237      authorization: auth ? { id: auth.authorization.authorizationId, state: as!, policy: auth.authorization.policy, createdAt: auth.authorization.createdAt, expiresAt: auth.authorization.expiresAt, today: auth.today } : null,238      markets: GMX_MARKETS.map((m) => { const d = data.get(m.market); return { market: m.market, name: m.name, instrument: m.instrument, base: m.priceSymbol, indexDecimals: m.indexDecimals, collateral: m.collateral.map((c) => ({ token: c.token, symbol: c.symbol, decimals: c.decimals })),239        allowed: !!auth && as === 'active' && auth.authorization.policy.allowedMarkets.some((x) => lc(x) === lc(m.market)), mark: d?.mark ? rd(d.mark, 2) : null, oracleMin: d?.liq?.oracleMin ?? null, oracleMax: d?.liq?.oracleMax ?? null }; }),240      positions, positionsSource: 'chain' as const, positionsError, pendingOrders, operations: ops.map((r) => this.opView(r)),241      facts: { requestExpirationSec: GMX_REQUEST_EXPIRATION_SEC, executionGas, twoStep: true as const }, ts: this.now(),242    };243  }244245  /** for the derivatives router: can a GMX order be prepared now, and the collateral the wallet holds on Arbitrum (never throws) */246  async tradability(): Promise<{ tradable: boolean; usdc: DecStr | null }> {247    try { const { me } = await this.gate(); return { tradable: true, usdc: await this.deps.chain.balances(me).then((b) => formatUnits(b.usdc, 6), () => null) }; } catch { return { tradable: false, usdc: null }; }248  }249250  async operation(id: string) { let r = await this.row(id); if (!r.final) r = await this.refresh(r).catch(() => r); return { operation: this.opView(r) }; }251252  // ------------------------------------------------------------------ authorization253254  async prepareAuthorization(body: z.infer<typeof GmxAuthBodySchema>) {255    const { st, me } = await this.gate(false);256    const now = Math.floor(this.now() / 1000), ack = jurisdictionAck('gmx', body.locale);257    // the execution-fee ceiling of the policy: what fits under the wallet's own fee ceiling on Arbitrum, never above the hard bound258    const ceiling = BigInt(st.policy.maxFee[GMX_PINS.network] ?? '0'), maxExec = ceiling < GMX_POLICY_BOUNDS.maxExecutionFeeWei ? ceiling : GMX_POLICY_BOUNDS.maxExecutionFeeWei;259    if (maxExec === 0n) throw new UserError('policy', 'the wallet policy sets no fee ceiling for Arbitrum', 422);260    const markets = body.allowedMarkets.map((m) => { const p = gmxMarket(m); if (!p) throw new UserError('unknown_market', `${m} is not a market this wallet trades`, 422); return p.market; });261    const authorization: GmxAuthorization = { v: 1, authorizationId: randomUUID(), keyId: st.keyId, venue: 'gmx', network: 'arbitrum', account: lc(me), mode: 'MANUAL_LIVE', contracts: GMX_PINS.id, exchangeRouter: GMX_PINS.exchangeRouter.address,262      policy: { allowedMarkets: markets, maxLeverage: body.maxLeverage, maxOrderSizeUsd: D.str(D.from(body.maxOrderSizeUsd)), maxOrdersPerDay: body.maxOrdersPerDay, maxPriceDeviationBps: body.maxPriceDeviationBps, maxExecutionFeeWei: maxExec.toString() },263      createdAt: now, expiresAt: now + body.days * 86400, ackSha256: ack.sha256 };264    try { validateGmxAuthorization(authorization, now); } catch (e) { throw new UserError('bad_authorization', msg(e), 422); }265    const hash = gmxAuthorizationHash(authorization);266    await audit('deriv.gmx.authorization_prepared', 'owner', { id: authorization.authorizationId, hash: hex(hash), policy: authorization.policy, expiresAt: authorization.expiresAt });267    return { authorization, hash: hex(hash), ack, options: await this.options(hash, 300), maxExecutionFeeEth: formatUnits(maxExec, WEI) };268  }269  async approveAuthorization(body: z.infer<typeof GmxApproveAuthSchema>, locale: Locale = 'en') {270    await this.gate(false);271    const r = await this.deps.signer.gmxAuthorize(body.authorization, body.assertion); // the signer verifies the passkey over the same canonical bytes, the account, the contract set and the acknowledgement hash272    await audit('deriv.gmx.authorized', 'owner', { id: r.authorizationId, hash: r.hash, policy: body.authorization.policy, expiresAt: body.authorization.expiresAt, ackSha256: body.authorization.ackSha256 });273    return this.status(locale);274  }275  /** stopping never needs a passkey, a server setting or a readable chain */276  async revoke(locale: Locale = 'en') {277    const st = await this.deps.signer.status(true);278    for (const a of st.gmx ?? []) if (!a.revoked) { await this.deps.signer.gmxRevoke(a.authorization.authorizationId); await audit('deriv.gmx.revoked', 'owner', { id: a.authorization.authorizationId }); }279    return this.status(locale);280  }281282  // ------------------------------------------------------------------ pricing, fees, risk (estimates)283284  private async compute(body: GmxOrderBody, me: string | null, auth: GmxSignerAuth | null, walletUsdc: bigint | null): Promise<Computed> {285    const pin = gmxMarket(body.market);286    if (!pin) throw new UserError('unknown_market', 'this market is not in the wallet\'s pinned GMX market table', 422);287    const c = pin.collateral[0]!, isLong = body.side === 'long', increase = body.action === 'increase', now = this.now();288    const md = await this.deps.market.market(pin.instrument), l = md.liquidity;289    if (!l) throw new UserError('venue_unreadable', 'GMX\'s market parameters could not be read: no price, no estimate', 503);290    if (now - l.ts > 30_000) throw new UserError('stale_data', 'GMX\'s market data is more than 30 s old', 503);291    const oMin = D.from(l.oracleMin), oMax = D.from(l.oracleMax), mid = D.div(D.add(oMin, oMax), D.from('2'), 18, 'half_even');292    // longs open at the oracle max and close at the min; shorts the other way round293    const reference = isLong === increase ? oMax : oMin;294    const acceptable = gmxAcceptablePrice(D.str(reference), body.slippageBps, { isLong, increase, indexDecimals: pin.indexDecimals });295    let amount = 0n, sizeRaw: bigint, leverage: DecStr | null = null, positions: GmxChainPosition[] = [], closing: GmxChainPosition | null = null;296    if (me) positions = await this.deps.chain.positions(me).catch(() => []);297    if (increase) {298      if (!body.collateral) throw new UserError('bad_order', 'an increase names the collateral sent', 422);299      try { amount = scaleExact(body.collateral, c.decimals); } catch { throw new UserError('bad_order', `a ${c.symbol} amount has at most ${c.decimals} decimals`, 422); }300      if (amount <= 0n) throw new UserError('bad_order', 'the collateral must be positive', 422);301      if ((body.leverage === undefined) === (body.sizeUsd === undefined)) throw new UserError('bad_order', 'give the leverage or the size, not both', 422);302      const size = body.sizeUsd !== undefined ? D.from(body.sizeUsd) : D.quantize(D.mul(D.from(body.collateral), D.from(body.leverage!)), D.from('0.01'), 'down');303      sizeRaw = gmxUsdToRaw(D.str(size));304      leverage = D.str(D.div(size, D.from(body.collateral), 2, 'half_even'));305    } else {306      const pos = positions.find((p) => lc(p.market) === lc(pin.market) && p.isLong === isLong && lc(p.collateralToken) === lc(c.token));307      if (!pos) throw new UserError('no_position', `the wallet holds no ${body.side} position on ${pin.name} (read on-chain)`, 422);308      sizeRaw = body.sizeUsd === undefined ? pos.sizeInUsd : gmxUsdToRaw(body.sizeUsd);309      if (sizeRaw > pos.sizeInUsd) sizeRaw = pos.sizeInUsd; // closing more than the position closes it310      closing = pos;311    }312    if (sizeRaw <= 0n) throw new UserError('bad_order', 'the size must be positive', 422);313    const sizeUsd = D.from(gmxUsdFromRaw(sizeRaw));314    // tokens: an increase buys size ÷ price; a decrease closes the same share of the position's tokens as of its USD size315    const tokens = closing ? D.from(unscale((closing.sizeInTokens * sizeRaw) / closing.sizeInUsd, pin.indexDecimals)) : D.div(sizeUsd, reference, pin.indexDecimals, 'down');316317    // fees — every figure an estimate (ROUTER.md): price impact formula verified for decreases against the venue, ±100 %318    const oi = gmxBalanceOi(l), pi = l.priceImpact;319    const imp = gmxPriceImpact({ longOi: oi.long, shortOi: oi.short, sizeDeltaUsd: sizeUsd, isLong, increase, factorPositive: D.from(pi.factorPositive), factorNegative: D.from(pi.factorNegative), exponentPositive: D.from(pi.exponentPositive), exponentNegative: D.from(pi.exponentNegative), virtualInventoryUsd: oi.virtualUsd });320    const capPos = D.mul(sizeUsd, D.from(pi.maxFactorPositive)), capNeg = D.neg(D.mul(sizeUsd, D.from(pi.maxFactorNegative)));321    const impact = D.cmp(imp.impactUsd, capPos) > 0 ? capPos : D.cmp(imp.impactUsd, capNeg) < 0 ? capNeg : imp.impactUsd, capped = D.cmp(impact, imp.impactUsd) !== 0;322    const feeFactor = D.from(imp.balanceWasImproved ? l.positionFeeFactor.balanceImproved : l.positionFeeFactor.balanceNotImproved), positionFee = D.mul(sizeUsd, feeFactor);323    let base = 0n, tip = 1_000_000n, execGas: bigint | null = null, ethUsd: Dec | null = null;324    try { const g = await this.deps.chain.executionGas(); execGas = increase ? g.increase : g.decrease; } catch { execGas = null; }325    if (me) { try { const t = await this.deps.chain.txParams(me); base = t.baseFee; tip = t.tip; } catch { base = 0n; } }326    try { const e = pin.priceSymbol === 'ETH' ? mid : await this.deps.market.market(GMX_MARKETS.find((m) => m.priceSymbol === 'ETH')!.instrument).then((x) => (x.liquidity ? D.div(D.add(D.from(x.liquidity.oracleMin), D.from(x.liquidity.oracleMax)), D.from('2'), 18, 'half_even') : D.from(x.ticker.mark))); ethUsd = e; } catch { ethUsd = null; }327    const executionFee = execGas !== null && base > 0n ? gmxExecutionFee(execGas, base, tip) : 0n, maxFeePerGas = base * 2n + tip;328    const eth = (wei: bigint) => formatUnits(wei, WEI), usdOf = (wei: bigint) => (ethUsd ? D.str(D.div(D.mul(D.from(eth(wei)), ethUsd), D.one, 4, 'up')) : null);329    const gasMax = (FIXED_ORDER_GAS + (increase ? APPROVE_GAS : 0n)) * maxFeePerGas, gasExpected = EXPECTED_ORDER_GAS * (base + tip);330    const paysFunding = D.neg(D.from(isLong ? l.fundingRateLongPerHour : l.fundingRateShortPerHour)), paysBorrow = D.abs(D.from(isLong ? l.borrowingRateLongPerHour : l.borrowingRateShortPerHour));331    const perDay = (rate: Dec) => rd(D.mul(D.mul(sizeUsd, rate), D.from('24')));332    let liqPx: DecStr | null = null;333    if (increase && !D.isZero(tokens)) { try { const v = liquidationPriceIsolated({ side: body.side, size: tokens, entryPrice: reference, margin: D.sub(D.from(body.collateral!), positionFee), maintenanceFraction: D.from(l.minCollateralFactorForLiquidation), feeRate: D.from(l.positionFeeFactor.balanceNotImproved) }).value; liqPx = v ? rd(v, 2) : null; } catch { liqPx = null; } }334    const q = (d: Dec) => rd(d);335    const total = usdOf(executionFee) !== null && usdOf(gasExpected) !== null ? rd(D.add(D.add(positionFee, D.from(usdOf(executionFee)!)), D.from(usdOf(gasExpected)!)), 4) : null;336    const estimate = {337      source: 'estimate' as const, referencePrice: D.str(reference), oracleMin: l.oracleMin, oracleMax: l.oracleMax, acceptablePrice: gmxPriceFromRaw(acceptable, pin.indexDecimals), slippageBps: body.slippageBps,338      sizeUsd: D.str(sizeUsd), sizeTokens: D.str(tokens), collateral: increase ? D.str(D.from(body.collateral!)) : null, leverage,339      positionFee: { usd: q(positionFee), bps: D.str(D.mul(feeFactor, D.from('10000'))), balanceImproved: imp.balanceWasImproved },340      priceImpact: { usd: q(impact), uncertaintyUsd: q(D.abs(impact)), settledAt: 'close' as const, capped,341        note: 'recorded at the open, settled at the close, netted with the close impact and capped; the formula is verified against the venue for decreases only — count it as ±100 %' },342      executionFee: { eth: eth(executionFee), usd: usdOf(executionFee), sentWei: executionFee.toString(), note: 'sent with the order for GMX\'s keeper; the keeper keeps what the execution cost, the rest is refunded to the wallet (about a third came back on the fork proof)' },343      gas: { maxEth: eth(gasMax), expectedEth: eth(gasExpected), usd: usdOf(gasExpected) }, approval: increase,344      funding: { perHour: D.str(paysFunding), usdPerDay: perDay(paysFunding) }, borrow: { perHour: D.str(paysBorrow), usdPerDay: perDay(paysBorrow) },345      liquidationPrice: liqPx, liquidationNote: 'estimate: collateral minus the opening fee, closing fee counted; funding, borrowing, price impact and the liquidation fee are ignored; GMX liquidates on the oracle min/max',346      totalAtEntryUsd: total, notes: ['Today\'s funding and borrowing rates held constant: they change every second.', 'A market order executes at GMX\'s oracle price when the keeper runs, not at this reference: the acceptable price is the bound.'], ts: now,347    };348349    // risk engine: account view = wallet collateral + on-chain positions350    let risk: Computed['risk'], signerWould: string | null = null;351    if (!auth || this.authState(auth) !== 'active') risk = { ok: false, code: 'not_authorized', reason: 'no active GMX authorization' };352    else {353      const pol = auth.authorization.policy, free = D.from(formatUnits(walletUsdc ?? 0n, 6));354      const views = positions.map((p) => this.posView(p, lc(p.market) === lc(pin.market) ? mid : null, null, null));355      const margin = views.reduce((s, v) => D.add(s, D.from(v.collateral)), D.zero);356      const account: AccountState = { venue: 'gmx', account: me ?? '', equity: D.str(D.add(free, margin)), collateral: D.str(D.add(free, margin)), freeCollateral: D.str(free), marginUsed: D.str(margin), maintenanceMargin: null, withdrawable: D.str(free), ts: now, source: 'venue' };357      const riskPolicy: RiskPolicy = { mode: 'MANUAL_LIVE', allowedInstruments: pol.allowedMarkets.flatMap((m) => (gmxMarket(m) ? [gmxMarket(m)!.instrument] : [])), halt: null, maxDataAgeMs: 30_000, maxDeviationBps: String(pol.maxPriceDeviationBps), maxMarkIndexBps: '100',358        maxLeverage: String(pol.maxLeverage), maxOrderNotional: D.str(D.mul(D.from(pol.maxOrderSizeUsd), D.from('1.1'))), maxGrossExposure: D.str(D.mul(D.from(pol.maxOrderSizeUsd), D.from('3'))), maxPositionNotional: D.str(D.mul(D.from(pol.maxOrderSizeUsd), D.from('2'))),359        minFreeCollateral: '0', minLiquidationDistanceBps: '200', maxDailyLoss: '1000000000', maxTotalLoss: '1000000000', maxOrdersPerMinute: 5, maxOrdersPerDay: pol.maxOrdersPerDay };360      const [{ count: lastMinute } = { count: 0 }] = await sql<{ count: number }[]>`select count(*)::int as count from deriv_gmx_ops where kind <> 'cancel' and state not in ('pending_approval', 'withdrawn', 'expired') and created_at > ${new Date(now - 60_000)}`;361      const r = checkOrder(riskPolicy, { instrument: pin.instrument, side: isLong === increase ? 'buy' : 'sell', type: 'market', size: D.str(tokens), price: gmxPriceFromRaw(acceptable, pin.indexDecimals), triggerPrice: null, reduceOnly: !increase, tif: 'ioc', clientId: randomUUID() },362        { instrument: md.instrument, account, positions: views.filter((v) => v.instrument).map((v): Position => ({ instrument: v.instrument!, side: v.side, size: v.sizeTokens, entryPrice: v.entryPrice, markPrice: v.markPrice ?? v.entryPrice, liquidationPrice: null, leverage: v.leverage ?? '1', marginMode: 'isolated', margin: v.collateral, unrealizedPnl: v.pnlUsd ?? '0', realizedPnl: null, fundingPaid: null, source: 'venue' })),363          openOrders: [], book: { instrument: pin.instrument, bids: [[l.oracleMin, '1000000000']], asks: [[l.oracleMax, '1000000000']], ts: l.ts }, ticker: { ...md.ticker, ts: Math.max(md.ticker.ts, l.ts) }, now,364          usage: { realizedToday: '0', realizedTotal: '0', ordersLastMinute: lastMinute, ordersToday: auth.today.increases }, ...(leverage ? { leverage } : {}), maintenanceMarginFraction: l.minCollateralFactorForLiquidation });365      risk = r.ok ? { ok: true } : { ok: false, code: r.code, reason: r.reason };366      // what the signer would refuse, as far as it can be known here (its own price is not: the oracle mid stands in for it)367      try {368        const plan = { action: body.action, market: pin, collateral: c, amount, sizeDeltaUsd: sizeRaw, acceptablePrice: acceptable, executionFee, isLong, orderKey: null, gas: 0n, approveGas: 0n, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n, nonce: 0, params: null };369        const v = gmxPolicyVerdict(pol as GmxPolicy, plan, { index: D.toNumber(mid), collateral: 1 });370        signerWould = v && v !== 'no price' ? v : null;371      } catch { signerWould = null; }372    }373    return { pin, isLong, increase, amount, sizeRaw, acceptable, slippageBps: body.slippageBps, leverage, estimate, risk, signerWould, executionFee, base, tip };374  }375376  async preview(body: GmxOrderBody) {377    let st = null as Awaited<ReturnType<GmxSignerPort['status']>> | null;378    try { st = await this.deps.signer.status(); } catch { st = null; }379    const me = st?.addresses[GMX_PINS.network] ?? null, auth = st ? this.authOf(st) : null;380    const usdc = me ? await this.deps.chain.balances(me).then((b) => b.usdc, () => null) : null;381    const c = await this.compute(body, me, auth, usdc), s = this.deps.settings(), blockers: GmxBlocker[] = [];382    if (!s.gmxLive) blockers.push('gmx_setting_off'); if (!s.liveMainnet) blockers.push('mainnet_setting_off');383    if (!auth) blockers.push('not_authorized'); else if (this.authState(auth) !== 'active') blockers.push(this.authState(auth) === 'stale' ? 'contracts_changed' : this.authState(auth) === 'expired' ? 'authorization_expired' : 'authorization_revoked');384    if (st?.halted) blockers.push('signer_halted');385    return { order: { market: c.pin.market, marketName: c.pin.name, side: body.side, action: body.action, collateral: c.estimate.collateral, sizeUsd: c.estimate.sizeUsd, acceptablePrice: c.estimate.acceptablePrice, slippageBps: c.slippageBps, leverage: c.leverage },386      estimate: c.estimate, risk: c.risk, blockers, signerWould: c.signerWould };387  }388389  // ------------------------------------------------------------------ prepare (order, close, cancel) and approve390391  private fieldsOf(i: Intent, pin: GmxMarketPin | null) {392    const g = i.gmx!, n = getNetwork(i.network), c = pin?.collateral.find((x) => x.token === g.collateralToken);393    const fields: { label: string; value: string }[] = [{ label: 'action', value: g.action }, { label: 'network', value: `${n.name} (${i.chainRef})` }];394    if (g.action !== 'cancel') fields.push({ label: 'market', value: `${pin!.name} — ${g.market}` }, { label: 'side', value: g.isLong ? 'long' : 'short' }, { label: 'orderType', value: g.orderType },395      { label: 'collateral', value: `${formatUnits(BigInt(i.amount), c!.decimals)} ${c!.symbol} (${g.collateralToken})` }, { label: 'sizeUsd', value: gmxUsdFromRaw(BigInt(g.sizeDeltaUsd)) },396      { label: 'acceptablePrice', value: gmxPriceFromRaw(BigInt(g.acceptablePrice), pin!.indexDecimals) }, { label: 'executionFee', value: `${formatUnits(BigInt(g.executionFee), WEI)} ETH` });397    else fields.push({ label: 'orderKey', value: g.orderKey! });398    fields.push({ label: 'maxFee', value: `${formatUnits(BigInt(i.maxFee), WEI)} ETH` }, { label: 'exchangeRouter', value: g.exchangeRouter }, { label: 'router', value: g.router }, { label: 'orderVault', value: g.orderVault }, { label: 'expiresAt', value: new Date(i.expiresAt * 1000).toISOString() });399    const fixed = g.action === 'cancel' ? [{ label: 'receiver', value: `${i.from} (collateral and fee return to the wallet: fixed when the order was created)` }] : [400      { label: 'receiver', value: i.from }, { label: 'cancellationReceiver', value: i.from }, { label: 'callbackContract', value: 'none (0x0)' }, { label: 'uiFeeReceiver', value: 'none (0x0)' }, { label: 'swapPath', value: 'empty' },401      { label: 'referralCode', value: 'none' }, { label: 'minOutputAmount', value: '0 (nothing is swapped)' }, { label: 'shouldUnwrapNativeToken', value: 'false' }];402    return { fields, fixed };403  }404405  private async prepared(r: Row, intent: Intent, ttl: number) {406    const f = this.fieldsOf(intent, r.market ? gmxMarket(r.market) : null);407    // leverage is not a GMX field: it is size ÷ collateral, shown for the reader next to the two figures the passkey signs408    if (r.kind === 'increase' && typeof r.request.leverage === 'string') f.fields.splice(f.fields.findIndex((x) => x.label === 'sizeUsd') + 1, 0, { label: 'leverage', value: `${r.request.leverage}×` });409    return { operation: this.opView(r), intent, estimate: r.estimate, ...f, options: await this.options(intentHash(intent), ttl) };410  }411412  /** one stored proposal + one operation row, waiting for the passkey. Nothing is signed. */413  private async store(o: { kind: Row['kind']; idem: string | null; me: string; st: Awaited<ReturnType<GmxSignerPort['status']>>; auth: GmxSignerAuth; g: Omit<GmxSection, 'venue' | 'authorizationId' | 'exchangeRouter' | 'router' | 'orderVault' | 'triggerPrice'>;414    asset: { id: string; symbol: string; decimals: number }; amount: bigint; gas: bigint | null; base: bigint; tip: bigint; nonce: number; row: Partial<Row> }) {415    const n = getNetwork(GMX_PINS.network), now = Math.floor(this.now() / 1000), proposalId = randomUUID(), maxFeePerGas = o.base * 2n + o.tip;416    const section: GmxSection = { venue: 'gmx-v2', authorizationId: o.auth.authorization.authorizationId, exchangeRouter: GMX_PINS.exchangeRouter.address, router: GMX_PINS.router.address, orderVault: GMX_PINS.orderVault.address, triggerPrice: '0', ...o.g };417    const build = (gas: bigint): Intent => IntentSchema.parse({ v: 1, proposalId, keyId: o.st.keyId, network: n.id, chainRef: n.chainRef, asset: o.asset.id, symbol: o.asset.symbol, decimals: o.asset.decimals, amount: o.amount.toString(), from: o.me, to: GMX_PINS.exchangeRouter.address,418      maxFee: ((gas + BigInt(section.approveGas)) * maxFeePerGas + BigInt(section.executionFee)).toString(), createdAt: now, expiresAt: now + TTL_SEC, policyVersion: o.st.policy.version, gmx: section,419      evm: { nonce: o.nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: o.tip.toString() } });420    let intent = build(o.gas ?? FIXED_ORDER_GAS), txs: ReturnType<typeof buildGmxTxs>;421    // the same rules and the same bytes as the signer, before the owner is asked422    try { txs = buildGmxTxs(intent, o.me); } catch (e) { throw new UserError('simulation_failed', msg(e), 422); }423    if (!txs.approve) {424      // no approval to wait for: the call can be simulated and its gas measured against the current state425      const call = { from: o.me, to: txs.main.to as string, data: txs.main.data as string, value: txs.main.value ?? 0n };426      const sim = await this.deps.chain.simulate(call);427      if (sim) throw new UserError(sim.unreachable ? 'chain_unreadable' : 'simulation_failed', sim.unreachable ? 'the transaction could not be simulated: Arbitrum did not answer' : `the transaction would revert now (${sim.error}); nothing was prepared`, sim.unreachable ? 503 : 422);428      const est = await this.deps.chain.estimateGas(call);429      if (est !== null) { const min = o.kind === 'cancel' ? GMX_GAS.cancelMin : GMX_GAS.orderMin, gas = (est * 13n) / 10n; intent = build(gas < min ? min : gas > FIXED_ORDER_GAS * 2n ? FIXED_ORDER_GAS * 2n : gas); try { txs = buildGmxTxs(intent, o.me); } catch (e) { throw new UserError('simulation_failed', msg(e), 422); } }430    }431    const spent = BigInt(o.st.spentToday[`${n.id}:${o.asset.symbol}`] ?? '0');432    const verdict = o.kind === 'increase' ? checkPolicy(o.st.policy, intent, spent, now, o.st.halted) : checkPolicyNoSpend(o.st.policy, intent, now, o.st.halted);433    if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);434    const bal = await this.deps.chain.balances(o.me);435    if (bal.eth < BigInt(intent.maxFee)) throw new UserError('insufficient_gas', `the wallet holds ${formatUnits(bal.eth, WEI)} ETH on ${n.name}; gas and the execution fee can take up to ${formatUnits(BigInt(intent.maxFee), WEI)} ETH`, 422);436    const hash = intentHash(intent), opId = randomUUID(), created = new Date(this.now()), expires = new Date(intent.expiresAt * 1000);437    // one operation under review at a time: the previous unapproved one is withdrawn438    for (const old of await sql<Row[]>`select * from deriv_gmx_ops where state = 'pending_approval'`) await this.withdraw(old.id).catch(() => {});439    // the proposal row is the wallet's record and its per-network lock; its amount column must be positive: for a decrease or a cancellation it shows the native coin that may leave (fees)440    const shown = o.amount > 0n ? { asset: o.asset.id, symbol: o.asset.symbol, decimals: o.asset.decimals, amount: o.amount } : { asset: 'native', symbol: n.native.symbol, decimals: n.native.decimals, amount: BigInt(intent.maxFee) };441    await sql`insert into proposals (id, idempotency_key, state, kind, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, expires_at, created_at, updated_at)442      values (${proposalId}, ${`gmx-${opId}`}, 'pending_approval', 'gmx', ${n.id}, ${shown.asset}, ${shown.symbol}, ${shown.decimals}, ${shown.amount.toString()}, ${o.me}, ${GMX_PINS.exchangeRouter.address}, ${`GMX ${o.kind}`}, ${(EXPECTED_ORDER_GAS * (o.base + o.tip)).toString()}, ${intent.maxFee},443        ${sql.json(['gmx_order'])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${expires}, ${created}, ${created})`;444    const [r] = await sql<Row[]>`insert into deriv_gmx_ops ${sql({ id: opId, idempotency_key: o.idem, kind: o.kind, state: 'pending_approval', account: lc(o.me), execution_fee: section.executionFee, max_fee: intent.maxFee, needs_approval: !!txs.approve,445      proposal_id: proposalId, op_hash: toHex(hash), request: sql.json({}), expires_at: expires, created_at: created, updated_at: created, ...o.row } as never)} returning *`;446    await this.log('prepared', r!, { proposal: proposalId, hash: toHex(hash), executionFee: section.executionFee, maxFee: intent.maxFee, approval: !!txs.approve }, 'owner');447    return this.prepared(r!, intent, TTL_SEC);448  }449450  private async busy(exceptProposal?: string): Promise<void> {451    const [b] = await sql<{ id: string }[]>`select id from proposals where network = ${GMX_PINS.network} and id <> ${exceptProposal ?? '00000000-0000-0000-0000-000000000000'} and state in ('approved', 'signed', 'broadcast') limit 1`;452    if (b) throw new UserError('pending_tx', 'a previous transaction of the wallet on Arbitrum One is not confirmed yet; wait for it', 409);453  }454  private async again(idem: string | null) {455    if (!idem) return null;456    const [dup] = await sql<Row[]>`select * from deriv_gmx_ops where idempotency_key = ${idem}`;457    if (!dup) return null;458    const [p] = await sql<ProposalRow[]>`select * from proposals where id = ${dup.proposal_id}`;459    if (!p?.intent) throw new UserError('bad_state', 'this request was already made', 409);460    return dup.state === 'pending_approval' ? this.prepared(dup, p.intent, TTL_SEC) : { operation: this.opView(dup), intent: p.intent, estimate: dup.estimate, ...this.fieldsOf(p.intent, dup.market ? gmxMarket(dup.market) : null), options: null };461  }462463  async prepareOrder(body: GmxOrderBody, idem: string) {464    const dup = await this.again(idem); if (dup) return dup;465    const { st, me, auth } = await this.gate();466    await this.busy();467    const bal = await this.deps.chain.balances(me), c = await this.compute(body, me, auth, bal.usdc), col = c.pin.collateral[0]!;468    if (!c.risk.ok) throw new UserError(`risk_${c.risk.code}`, c.risk.reason, 422);469    // the signer's venue policy, applied before the owner is asked (the signer applies it again with its OWN price)470    if (c.signerWould) throw new UserError('venue_policy', c.signerWould, 422);471    if (c.executionFee === 0n) throw new UserError('chain_unreadable', 'the execution fee could not be computed (gas parameters or base fee unreadable)', 503);472    if (c.increase && bal.usdc < c.amount) throw new UserError('insufficient_collateral', `the wallet holds ${formatUnits(bal.usdc, col.decimals)} ${col.symbol} on Arbitrum One`, 422);473    const t = await this.deps.chain.txParams(me), needsApproval = c.increase && bal.usdcAllowance < c.amount;474    return this.store({ kind: body.action, idem, me, st, auth: auth!, asset: { id: col.token, symbol: col.symbol, decimals: col.decimals }, amount: c.amount, gas: null, base: t.baseFee, tip: t.tip, nonce: t.nonce,475      g: { action: body.action, market: c.pin.market, indexToken: c.pin.indexToken, collateralToken: col.token, orderType: c.increase ? 'MarketIncrease' : 'MarketDecrease', isLong: c.isLong, sizeDeltaUsd: c.sizeRaw.toString(), acceptablePrice: c.acceptable.toString(),476        executionFee: gmxExecutionFee((await this.deps.chain.executionGas())[c.increase ? 'increase' : 'decrease'], t.baseFee, t.tip).toString(), approveGas: needsApproval ? APPROVE_GAS.toString() : '0' },477      row: { market: c.pin.market, is_long: c.isLong, collateral_token: col.token, amount: c.amount.toString(), size_usd: c.sizeRaw.toString(), acceptable_price: c.acceptable.toString(), slippage_bps: c.slippageBps, estimate: sql.json(c.estimate as never) as never,478        request: sql.json({ leverage: c.leverage, body } as never) as never } });479  }480481  async prepareClose(body: z.infer<typeof GmxCloseBodySchema>, idem: string) {482    const dup = await this.again(idem); if (dup) return dup;483    const { me } = await this.gate();484    const pin = gmxMarket(body.market), f = D.from(body.fraction);485    if (!pin) throw new UserError('unknown_market', 'this market is not in the wallet\'s pinned GMX market table', 422);486    if (D.isZero(f) || D.cmp(f, D.one) > 0) throw new UserError('bad_order', 'the fraction is above 0 and at most 1', 422);487    const pos = (await this.deps.chain.positions(me)).find((p) => lc(p.market) === lc(pin.market) && p.isLong === (body.side === 'long'));488    if (!pos) throw new UserError('no_position', `the wallet holds no ${body.side} position on ${pin.name} (read on-chain)`, 422);489    const size = D.cmp(f, D.one) === 0 ? pos.sizeInUsd : (pos.sizeInUsd * scaleExact(body.fraction, 18)) / 10n ** 18n;490    return this.prepareOrder({ market: pin.market, side: body.side, action: 'decrease', sizeUsd: gmxUsdFromRaw(size), slippageBps: body.slippageBps }, idem);491  }492493  /** the owner cancels a still-pending order of this engine: possible for a market order only after GMX's request expiration */494  async prepareCancel(opId: string) {495    let target = await this.row(opId);496    if (!target.final) target = await this.refresh(target).catch(() => target);497    if ((target.state !== 'created' && target.state !== 'frozen') || !target.order_key) throw new UserError('bad_state', `the order is ${target.state}: there is nothing to cancel`, 409);498    const { st, me, auth } = await this.gate();499    await this.busy();500    const o = await this.deps.chain.order(target.order_key);501    if (!o) { await this.refresh(target).catch(() => {}); throw new UserError('bad_state', 'the order is no longer pending on-chain', 409); }502    if (lc(o.account) !== lc(me)) throw new UserError('bad_state', 'the order does not belong to this wallet', 409);503    const at = o.isFrozen ? 0 : o.updatedAtTime + GMX_REQUEST_EXPIRATION_SEC;504    if (Math.floor(this.now() / 1000) < at) throw new UserError('not_yet_cancellable', `GMX lets the account cancel a market order ${GMX_REQUEST_EXPIRATION_SEC} s after its creation: ${at - Math.floor(this.now() / 1000)} s left. Its keepers normally execute or cancel it before that.`, 409);505    const t = await this.deps.chain.txParams(me), n = getNetwork(GMX_PINS.network);506    return this.store({ kind: 'cancel', idem: null, me, st, auth: auth!, asset: { id: 'native', symbol: n.native.symbol, decimals: n.native.decimals }, amount: 0n, gas: 1_500_000n, base: t.baseFee, tip: t.tip, nonce: t.nonce,507      g: { action: 'cancel', market: ZERO_ADDRESS, indexToken: ZERO_ADDRESS, collateralToken: ZERO_ADDRESS, orderType: 'None', isLong: false, sizeDeltaUsd: '0', acceptablePrice: '0', executionFee: '0', approveGas: '0', orderKey: target.order_key },508      row: { order_key: target.order_key, target_op: target.id, market: target.market, is_long: target.is_long } });509  }510511  /** before the passkey only: nothing was signed */512  async withdraw(opId: string) {513    let r = await this.row(opId);514    if (r.state !== 'pending_approval') throw new UserError('bad_state', `the operation is ${r.state}: it can no longer be withdrawn here`, 409);515    const [p] = await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}`;516    if (p && p.state === 'pending_approval') await move(p, 'rejected', { error: 'withdrawn by the owner' });517    r = await this.set(r, { state: 'withdrawn', final: true, done_at: new Date(this.now()), error: 'withdrawn before approval; nothing was signed' }, 'pending_approval');518    await this.log('withdrawn', r, {}, 'owner');519    return { operation: this.opView(r) };520  }521522  /** The HTTP call is not the approval: the passkey assertion over the intent hash is, and only the signer judges it. */523  async approve(opId: string, assertion: unknown) {524    let r = await this.row(opId);525    await this.gate();526    let [p] = await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}`;527    if (r.state !== 'pending_approval' || !p || p.state !== 'pending_approval' || !p.intent) throw new UserError('bad_state', `the operation is ${r.state}`, 409);528    if (p.expires_at && p.expires_at.getTime() < this.now()) { await this.refresh(r); throw new UserError('expired', 'approval window expired; start again', 410); }529    await this.busy(p.id);530    r = await this.set(r, { state: 'signed' }, 'pending_approval');531    p = await move(p, 'approved');532    let signed: Awaited<ReturnType<GmxSignerPort['sign']>>;533    try { signed = await this.deps.signer.sign(p.intent, assertion); }534    catch (e) {535      await move(p, 'failed', { error: msg(e) });536      r = await this.set(r, { state: 'failed', final: true, done_at: new Date(this.now()), error: `the signer refused: ${msg(e)}. Nothing was signed or sent.` });537      await this.log('refused', r, { error: msg(e) });538      throw e;539    }540    if (signed.hash !== p.intent_hash) { await move(p, 'failed', { error: 'signer hash mismatch' }); await this.set(r, { state: 'failed', final: true, error: 'signer hash mismatch; nothing was sent' }); throw new UserError('hash_mismatch', 'signer returned a different intent hash', 500); }541    p = await move(p, 'signed', { raw: signed.raw, txid: signed.txid, ...(signed.pre ? { pre_raw: signed.pre.raw, pre_txid: signed.pre.txid } : {}) });542    r = await this.set(r, { txid: signed.txid, pre_txid: signed.pre?.txid ?? null });543    await this.log('signed', r, { txid: signed.txid, hash: signed.hash, approval: signed.pre?.txid ?? null }, 'owner');544    await this.advanceSend(p).catch((e) => console.error(`[gmx] send ${r.id}: ${msg(e)}`));545    r = await this.refresh(await this.row(r.id)).catch(() => r);546    return { operation: this.opView(r) };547  }548549  // ------------------------------------------------------------------ sending and tracking550551  /**552   * After signing: mine the exact-amount approval if there is one, simulate the order transaction against the real state,553   * then relay it. Called right after the approval and by the wallet's worker for every signed `gmx` proposal (restart-safe).554   */555  async advanceSend(p: ProposalRow): Promise<ProposalRow> {556    if (p.state !== 'signed' || !p.raw || !p.intent || !p.txid) return p;557    const chain = this.deps.chain, s = this.deps.settings();558    const fail = (error: string) => move(p, 'failed', { error });559    if (!s.gmxLive || !s.liveMainnet) return fail('GMX trading was switched off on the server before the transaction left; nothing was sent');560    if (p.expires_at && this.now() - p.expires_at.getTime() > SEND_WINDOW_MS) return fail('the order could not be sent in time; nothing was sent');561    if (p.pre_raw && p.pre_txid && p.pre_state !== 'confirmed') {562      await chain.broadcast(p.pre_raw).catch(() => {});563      const rc = await chain.receipt(p.pre_txid);564      if (!rc) { if (p.pre_state !== 'sent') await sql`update proposals set pre_state = 'sent', updated_at = now() where id = ${p.id}`; return p; }565      if (!rc.ok) return fail('the token approval reverted; no order was created');566      await sql`update proposals set pre_state = 'confirmed', updated_at = now() where id = ${p.id}`;567      await audit('deriv.gmx.approval_confirmed', 'chain', { id: p.id, txid: p.pre_txid });568    }569    // last gate before the order leaves: pins again, and it must succeed against the current chain state570    const pins = await chain.checkPins(p.from_addr).catch(() => null);571    if (!pins) return p;572    if (!pins.ok) return fail(`GMX a publié de nouveaux contrats : nouvelle revue nécessaire (${pins.problems.join('; ').slice(0, 200)}); the order was not sent${p.pre_raw ? '. The exact-amount approval of the Router stays in place' : ''}`);573    const txs = buildGmxTxs(p.intent, p.from_addr);574    const sim = await chain.simulate({ from: p.from_addr, to: txs.main.to as string, data: txs.main.data as string, value: txs.main.value ?? 0n });575    if (sim?.unreachable) return p; // try again on the next tick576    if (sim) return fail(`the order transaction would revert now (${sim.error.slice(0, 120)}); it was not sent${p.pre_raw ? '. The exact-amount approval of the Router stays in place' : ''}`);577    try { await chain.broadcast(p.raw); } catch (e) { await sql`update proposals set error = ${'broadcast attempt failed: ' + msg(e).slice(0, 200)}, updated_at = now() where id = ${p.id}`; return p; }578    return move(p, 'broadcast', { broadcast_at: new Date(this.now()), last_broadcast_at: new Date(this.now()), error: null });579  }580581  private transfersTo(rc: GmxReceipt, token: string, from: string, to: string): bigint {582    let sum = 0n;583    for (const l of rc.logs) { if (lc(l.address) !== lc(token)) continue; try { const ev = decodeEventLog({ abi: erc20Abi, data: l.data as Hex, topics: l.topics as [Hex, ...Hex[]] }); if (ev.eventName === 'Transfer' && lc(ev.args.from) === lc(from) && lc(ev.args.to) === lc(to)) sum += ev.args.value; } catch { /* another event */ } }584    return sum;585  }586587  /** what the keeper's (or the owner's) transaction did to the order, read from its own events */588  private async outcomeOf(r: Row, e: { event: GmxEvent; txid: string }): Promise<Record<string, unknown>> {589    const rc = await this.deps.chain.receipt(e.txid), pin = r.market ? gmxMarket(r.market) : null, c = pin?.collateral.find((x) => r.collateral_token && lc(x.token) === lc(r.collateral_token));590    const evs = (rc?.logs ?? []).map((l) => decodeGmxEvent(l)).filter((x): x is GmxEvent => !!x);591    const posEv = evs.find((x) => (x.eventName === 'PositionIncrease' || x.eventName === 'PositionDecrease') && typeof x.fields.orderKey === 'string' && lc(x.fields.orderKey) === r.order_key);592    const feeEv = evs.find((x) => x.eventName === 'PositionFeesCollected' && typeof x.fields.orderKey === 'string' && lc(x.fields.orderKey) === r.order_key);593    const refundEv = evs.find((x) => x.eventName === 'ExecutionFeeRefund' && lc(String(x.fields.receiver ?? '')) === r.account);594    const sUsd = (v: unknown) => (typeof v === 'bigint' ? (v < 0n ? `-${gmxUsdFromRaw(-v)}` : gmxUsdFromRaw(v)) : null);595    const out = rc && c ? this.transfersTo(rc, c.token, GMX_PINS.orderVault.address, r.account) + (pin ? this.transfersTo(rc, c.token, pin.market, r.account) : 0n) : null;596    const reasonBytes = typeof e.event.fields.reasonBytes === 'string' ? e.event.fields.reasonBytes : null, reason = typeof e.event.fields.reason === 'string' ? e.event.fields.reason : null;597    const cancelled = e.event.eventName === 'OrderCancelled';598    return { event: e.event.eventName, txid: e.txid, explorer: this.explorer('tx', e.txid),599      executionPrice: posEv && pin && typeof posEv.fields.executionPrice === 'bigint' ? gmxPriceFromRaw(posEv.fields.executionPrice, pin.indexDecimals) : null, sizeUsd: sUsd(posEv?.fields.sizeDeltaUsd),600      positionFeeUsd: feeEv && c && typeof feeEv.fields.positionFeeAmount === 'bigint' && typeof feeEv.fields['collateralTokenPrice.min'] === 'bigint' ? gmxUsdFromRaw(feeEv.fields.positionFeeAmount * (feeEv.fields['collateralTokenPrice.min'] as bigint)) : null,601      positionFeeAmount: feeEv && c && typeof feeEv.fields.positionFeeAmount === 'bigint' ? formatUnits(feeEv.fields.positionFeeAmount, c.decimals) : null,602      priceImpactUsd: sUsd(posEv?.fields.priceImpactUsd), pnlUsd: sUsd(posEv?.fields.basePnlUsd), collateralOut: out !== null && c ? formatUnits(out, c.decimals) : null,603      executionFeeRefundEth: refundEv && typeof refundEv.fields.refundFeeAmount === 'bigint' ? formatUnits(refundEv.fields.refundFeeAmount, WEI) : null,604      reason: cancelled || e.event.eventName === 'OrderFrozen' ? (reason || (reasonBytes ? GMX_ERROR_NAMES[reasonBytes.slice(0, 10)] ?? `error ${reasonBytes.slice(0, 10)}` : null)) : null, reasonCode: reasonBytes ? reasonBytes.slice(0, 10) : null,605      cancelledBy: cancelled ? ((await sql<{ id: string }[]>`select id from deriv_gmx_ops where kind = 'cancel' and txid = ${e.txid} limit 1`).length ? 'owner' : 'keeper') : null,606      // a cancelled increase: the collateral must have come back to the wallet in that very transaction607      collateralReturned: cancelled && r.kind === 'increase' && out !== null && r.amount !== null ? out === BigInt(r.amount) : null };608  }609610  /** one reconciliation step of one operation; every state is in the database, so a restart continues exactly */611  async refresh(r: Row): Promise<Row> {612    if (r.final) return r;613    const now = this.now(), chain = this.deps.chain, stamp = { checked_at: new Date(now) };614    const [p] = r.proposal_id ? await sql<ProposalRow[]>`select * from proposals where id = ${r.proposal_id}` : [];615    if (r.state === 'pending_approval') {616      if (r.expires_at && r.expires_at.getTime() < now) {617        if (p && p.state === 'pending_approval') await move(p, 'expired', { error: 'approval window expired' }).catch(() => {});618        r = await this.set(r, { ...stamp, state: 'expired', final: true, done_at: new Date(now), error: 'approval window expired; nothing was signed' }, 'pending_approval');619        await this.log('expired', r);620      }621      return r;622    }623    if (r.state === 'signed') {624      let q = p;625      if (q && q.state === 'signed') q = await this.advanceSend(q);626      if (q && q.state === 'failed') { r = await this.set(r, { ...stamp, state: 'failed', final: true, done_at: new Date(now), error: q.error ?? 'the transaction was not sent' }); await this.log('failed', r, { error: r.error }); return r; }627      if (q && ['broadcast', 'confirming', 'finalized', 'reorged'].includes(q.state)) r = await this.set(r, { ...stamp, state: 'sent', sent_at: q.broadcast_at ?? new Date(now) });628      else return this.set(r, stamp);629    }630    if (r.state === 'sent' || (r.state === 'unknown' && !r.order_key)) {631      const rc = r.txid ? await chain.receipt(r.txid) : null;632      if (!rc) {633        if (p && (p.state === 'failed' || p.state === 'replaced')) { r = await this.set(r, { ...stamp, state: 'failed', final: true, done_at: new Date(now), error: p.error ?? `the transaction was ${p.state}` }); await this.log('failed', r, { error: r.error }); return r; }634        const since = r.sent_at?.getTime() ?? r.updated_at.getTime();635        return this.set(r, { ...stamp, ...(r.state === 'sent' && now - since > GMX_RECEIPT_OVERDUE_MS ? { state: 'unknown', error: 'the transaction was sent but no receipt was found: it is not known whether an order exists. Still looking.' } : {}) });636      }637      // the receipt is known: release the wallet's per-network lock now (its own worker would do the same from the same receipt a moment later)638      if (p && p.state === 'broadcast') await move(p, rc.ok ? 'confirming' : 'failed', rc.ok ? { confirmations: 1 } : { error: 'the transaction reverted on-chain' }).catch(() => {});639      if (!rc.ok) { r = await this.set(r, { ...stamp, state: 'failed', final: true, done_at: new Date(now), block_number: rc.blockNumber, error: `the transaction reverted on-chain: ${r.kind === 'cancel' ? 'the order was not cancelled by it' : 'no order was created'}; only gas was spent${r.needs_approval ? '. The exact-amount approval of the Router stays in place' : ''}` }); await this.log('reverted', r); return r; }640      const evs = rc.logs.map((l) => decodeGmxEvent(l)).filter((x): x is GmxEvent => !!x);641      if (r.kind === 'cancel') {642        const can = evs.find((x) => x.eventName === 'OrderCancelled' && gmxEventKey(x) === r.order_key);643        r = await this.set(r, { ...stamp, state: can ? 'done' : 'failed', final: true, done_at: new Date(now), block_number: rc.blockNumber, error: can ? null : 'the cancellation was mined but emitted no OrderCancelled event for the order' });644        await this.log(can ? 'cancel_mined' : 'cancel_without_event', r);645        if (r.target_op) await this.refresh(await this.row(r.target_op)).catch(() => {});646        return r;647      }648      const made = evs.find((x) => x.eventName === 'OrderCreated' && lc(String(x.fields.account ?? '')) === r.account);649      if (!made || !gmxEventKey(made)) { r = await this.set(r, { ...stamp, state: 'failed', final: true, done_at: new Date(now), block_number: rc.blockNumber, error: 'the transaction succeeded but emitted no OrderCreated event for this wallet: nothing to track' }); await this.log('no_order_event', r); return r; }650      // the event must carry exactly what was approved651      let diff: string[] = [];652      try { if (p?.intent) diff = gmxOrderCreatedDiff(made, verifyGmxIntent(p.intent, p.from_addr), p.from_addr); } catch (e) { diff = [msg(e)]; }653      r = await this.set(r, { ...stamp, state: 'created', order_key: gmxEventKey(made), block_number: rc.blockNumber, order_created_at: new Date(now), error: diff.length ? `the on-chain order differs from the approved one: ${diff.join('; ').slice(0, 240)}` : null,654        request: sql.json({ ...r.request, createGasUsed: rc.gasUsed.toString(), createGasPrice: rc.effectiveGasPrice.toString(), diff } as never) });655      await this.log(diff.length ? 'created_with_difference' : 'created', r, { diff });656    }657    if ((r.state === 'created' || r.state === 'frozen' || r.state === 'unknown') && r.order_key) {658      const o = await chain.order(r.order_key);659      if (o && !o.isFrozen) return this.set(r, { ...stamp, ...(r.state !== 'created' ? { state: 'created', error: null } : {}) });660      const events = await chain.orderEvents(r.order_key, Number(r.block_number ?? 0));661      const fin = events.find((e) => e.event.eventName === 'OrderExecuted') ?? events.find((e) => e.event.eventName === 'OrderCancelled');662      if (o?.isFrozen && !fin) {663        if (r.state === 'frozen') return this.set(r, stamp);664        const fr = events.find((e) => e.event.eventName === 'OrderFrozen');665        r = await this.set(r, { ...stamp, state: 'frozen', outcome: fr ? sql.json(await this.outcomeOf(r, fr) as never) : null, error: 'GMX froze the order: it could not be executed. It can be cancelled.' }); await this.log('frozen', r); return r;666      }667      if (!fin) {668        // gone from the order store, and no event found yet (log index lag): not assumed either way669        const attempts = r.attempts + 1;670        return this.set(r, { ...stamp, attempts, ...(attempts >= 6 && r.state !== 'unknown' ? { state: 'unknown', error: 'the order is no longer pending on-chain, but its OrderExecuted / OrderCancelled event was not found: outcome unknown. Still looking.' } : {}) });671      }672      const outcome = await this.outcomeOf(r, fin), executed = fin.event.eventName === 'OrderExecuted';673      r = await this.set(r, { ...stamp, state: executed ? 'executed' : 'cancelled', final: true, done_at: new Date(now), outcome: sql.json(outcome as never),674        error: executed ? null : `GMX cancelled the order${outcome.reason ? ` (${String(outcome.reason).slice(0, 120)})` : ''}${r.kind === 'increase' ? (outcome.collateralReturned === true ? '; the collateral came back to the wallet' : '; the return of the collateral could NOT be confirmed from the transaction — check the wallet balance') : ''}` });675      await this.log(executed ? 'executed' : 'cancelled', r, { outcome });676    }677    return r;678  }679680  private async refreshOpen(): Promise<{ checked: number; errors: string[] }> {681    const rows = await sql<Row[]>`select * from deriv_gmx_ops where not final order by created_at`, errors: string[] = [];682    for (const r of rows) await this.refresh(r).catch((e) => { errors.push(`${r.id}: ${msg(e)}`); });683    return { checked: rows.length, errors };684  }685  /** the worker's pass: every operation in flight */686  step(): Promise<{ checked: number; errors: string[] }> { return this.refreshOpen(); }687}688
Files main
Menu

Theme