SPB Git

Repository Public

gmx-chain.ts 16.0 KB 183 lines typescript

1// GMX v2 trading (v8) — the chain side of the execution engine: everything it READS from Arbitrum One (pins, roles, markets,2// balances, the wallet's positions and orders through GMX's Reader, receipts, order events) and the two things it SENDS3// (already signed transactions). No key exists here. Tests replace this port with a scripted fake (GmxChain).4//5// Pins fail closed: the bytecode of every pinned contract must hash to the pinned value on EVERY registry RPC endpoint, the6// pinned ExchangeRouter and OrderHandler must still hold their roles in RoleStore, the ExchangeRouter's immutables must be the7// pinned Router / OrderHandler / DataStore, and each pinned market must read back from Reader. Anything else =8// "GMX a publié de nouveaux contrats : nouvelle revue nécessaire" (docs/v4/venues/gmx.md Part C).9import { createHash } from 'node:crypto';10import { decodeFunctionResult, encodeAbiParameters, encodeFunctionData, erc20Abi, hexToBigInt, hexToNumber, keccak256, numberToHex, pad, parseAbi, toEventSelector, type Hex } from 'viem';11import { getNetwork } from '@spw/core';12import {13  decodeGmxEvent, GMX_DATASTORE_ABI, GMX_DATASTORE_KEYS, GMX_MARKETS, GMX_PINNED_CONTRACTS, GMX_PINS, GMX_READER_ABI, GMX_REQUEST_EXPIRATION_SEC, GMX_REQUIRED_ROLES, GMX_ROLE_KEYS, GMX_ROLE_STORE_ABI, gmxExecutionGas, gmxKey,14  type GmxEvent,15} from '@spw/derivatives/live';16import { rpcAny, rpcOne, rpcQuorum } from '../chains/rpc.ts';17import { UserError } from '../config.ts';1819export interface GmxLog { address: string; topics: string[]; data: string }20export interface GmxReceipt { ok: boolean; blockNumber: number; gasUsed: bigint; effectiveGasPrice: bigint; logs: GmxLog[] }21export interface GmxChainPosition { key: string; market: string; collateralToken: string; isLong: boolean; sizeInUsd: bigint; sizeInTokens: bigint; collateralAmount: bigint; increasedAtTime: number }22export interface GmxChainOrder {23  key: string; account: string; receiver: string; cancellationReceiver: string; callbackContract: string; uiFeeReceiver: string; market: string; initialCollateralToken: string; swapPathLength: number;24  orderType: number; sizeDeltaUsd: bigint; initialCollateralDeltaAmount: bigint; acceptablePrice: bigint; executionFee: bigint; updatedAtTime: number; isLong: boolean; isFrozen: boolean;25}26export interface GmxPinCheck { ok: boolean; problems: string[]; checkedAt: number }27export interface GmxCall { from: string; to: string; data: string; value: bigint }28export interface GmxChain {29  /** never throws for a mismatch (that is `ok: false` with the problems); throws UserError `chain_unreadable` when the chain cannot be read */30  checkPins(me: string | null): Promise<GmxPinCheck>;31  balances(me: string): Promise<{ usdc: bigint; eth: bigint; weth: bigint; usdcAllowance: bigint }>;32  /** base fee of the latest block, the tip this wallet uses, and the wallet's pending nonce — every RPC endpoint must agree on the nonce */33  txParams(me: string): Promise<{ baseFee: bigint; tip: bigint; nonce: number }>;34  /** gas the contracts price an execution at (GasUtils.adjustGasLimitForEstimate), per order type */35  executionGas(): Promise<{ increase: bigint; decrease: bigint }>;36  positions(me: string): Promise<GmxChainPosition[]>;37  orders(me: string): Promise<GmxChainOrder[]>;38  /** null = no such order on-chain (executed, cancelled, or never created) */39  order(key: string): Promise<GmxChainOrder | null>;40  /** null when the call cannot be estimated (it would revert, or the node is unreachable) */41  estimateGas(c: GmxCall): Promise<bigint | null>;42  /** null = the call succeeds against the current state; otherwise why not. `unreachable` = no answer, try again */43  simulate(c: GmxCall): Promise<null | { unreachable: boolean; error: string }>;44  broadcast(raw: string): Promise<void>;45  receipt(txid: string): Promise<GmxReceipt | null>;46  /** events of the pinned EventEmitter whose first topic key is `key` (OrderExecuted, OrderCancelled, OrderFrozen…), from `fromBlock` */47  orderEvents(key: string, fromBlock: number): Promise<{ event: GmxEvent; txid: string; blockNumber: number }[]>;48}4950const sha = (h: string) => createHash('sha256').update(Buffer.from(h.slice(2), 'hex')).digest('hex');51const eq = (a: unknown, b: string) => String(a).toLowerCase() === b.toLowerCase();52const P = GMX_PINS;53const routerViews = parseAbi(['function router() view returns (address)', 'function orderHandler() view returns (address)', 'function dataStore() view returns (address)', 'function roleStore() view returns (address)']);54const EVENT_LOG_2 = toEventSelector('EventLog2(address,string,string,bytes32,bytes32,(((string,address)[],(string,address[])[]),((string,uint256)[],(string,uint256[])[]),((string,int256)[],(string,int256[])[]),((string,bool)[],(string,bool[])[]),((string,bytes32)[],(string,bytes32[])[]),((string,bytes)[],(string,bytes[])[]),((string,string)[],(string,string[])[])))');55/** Position.getPositionKey: keccak256(abi.encode(account, market, collateralToken, isLong)) */56export const gmxPositionKey = (account: string, market: string, collateralToken: string, isLong: boolean): string =>57  keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'bool' }], [account as Hex, market as Hex, collateralToken as Hex, isLong]));58const PIN_TTL_MS = 60_000, GAS_TTL_MS = 600_000, LOG_SPAN = 40_000;5960export function realGmxChain(now: () => number = Date.now): GmxChain {61  const n = getNetwork(P.network), urls = n.rpc;62  const unreadable = (e: unknown): never => { if (e instanceof UserError && !/^rpc_/.test(e.code)) throw e; throw new UserError('chain_unreadable', `Arbitrum could not be read (${(e as Error).message.slice(0, 120)}); nothing was prepared`, 503); };63  const callAny = (to: string, data: Hex) => rpcAny<Hex>(urls, 'eth_call', [{ to, data }, 'latest']);64  const callAll = (to: string, data: Hex) => rpcQuorum<Hex>(urls, 'eth_call', [{ to, data }, 'latest'], (a, b) => a.toLowerCase() === b.toLowerCase());65  const reader = async (fn: 'getMarket' | 'getAccountPositions' | 'getAccountOrders' | 'getOrder', args: unknown[], all = false): Promise<any> =>66    decodeFunctionResult({ abi: GMX_READER_ABI, functionName: fn, data: await (all ? callAll : callAny)(P.reader.address, encodeFunctionData({ abi: GMX_READER_ABI, functionName: fn, args } as never)) } as never);67  const uint = async (key: string) => hexToBigInt(await callAny(P.dataStore.address, encodeFunctionData({ abi: GMX_DATASTORE_ABI, functionName: 'getUint', args: [key as Hex] })));68  const orderOf = (key: string, o: any): GmxChainOrder => ({ key: key.toLowerCase(), account: o.addresses.account, receiver: o.addresses.receiver, cancellationReceiver: o.addresses.cancellationReceiver, callbackContract: o.addresses.callbackContract,69    uiFeeReceiver: o.addresses.uiFeeReceiver, market: o.addresses.market, initialCollateralToken: o.addresses.initialCollateralToken, swapPathLength: o.addresses.swapPath.length, orderType: Number(o.numbers.orderType), sizeDeltaUsd: o.numbers.sizeDeltaUsd,70    initialCollateralDeltaAmount: o.numbers.initialCollateralDeltaAmount, acceptablePrice: o.numbers.acceptablePrice, executionFee: o.numbers.executionFee, updatedAtTime: Number(o.numbers.updatedAtTime), isLong: o.flags.isLong, isFrozen: o.flags.isFrozen });71  let pins: { at: number; me: string | null; v: GmxPinCheck } | null = null, gas: { at: number; v: { increase: bigint; decrease: bigint } } | null = null;7273  return {74    async checkPins(me) {75      if (pins && pins.v.ok && pins.me === me && now() - pins.at < PIN_TTL_MS) return pins.v;76      const problems: string[] = [];77      try {78        // 1. bytecode, on every endpoint79        await Promise.all(GMX_PINNED_CONTRACTS.map(async (c) => {80          const code = await rpcQuorum<string>(urls, 'eth_getCode', [P[c].address, 'latest']);81          if (sha(code) !== P[c].codeSha256) problems.push(`the bytecode of ${c} (${P[c].address}) is not the reviewed one`);82        }));83        // 2. roles: a router GMX has replaced loses them84        await Promise.all(GMX_REQUIRED_ROLES.map(async (r) => {85          const has = decodeFunctionResult({ abi: GMX_ROLE_STORE_ABI, functionName: 'hasRole', data: await callAll(P.roleStore.address, encodeFunctionData({ abi: GMX_ROLE_STORE_ABI, functionName: 'hasRole', args: [P[r.holder].address as Hex, GMX_ROLE_KEYS[r.role]] })) });86          if (has !== true) problems.push(`${r.holder} no longer holds the ${r.role} role in GMX's RoleStore`);87        }));88        // 3. wiring: the router's immutables, the fee token, the cancellation delay, the markets89        for (const [fn, want] of [['router', P.router.address], ['orderHandler', P.orderHandler.address], ['dataStore', P.dataStore.address], ['roleStore', P.roleStore.address]] as const) {90          const got = decodeFunctionResult({ abi: routerViews, functionName: fn, data: await callAll(P.exchangeRouter.address, encodeFunctionData({ abi: routerViews, functionName: fn })) });91          if (!eq(got, want)) problems.push(`ExchangeRouter.${fn}() is not the pinned address`);92        }93        const wnt = decodeFunctionResult({ abi: GMX_DATASTORE_ABI, functionName: 'getAddress', data: await callAll(P.dataStore.address, encodeFunctionData({ abi: GMX_DATASTORE_ABI, functionName: 'getAddress', args: [gmxKey('WNT')] })) });94        if (!eq(wnt, P.wnt)) problems.push('the wrapped native token of the DataStore is not the pinned one');95        if ((await uint(GMX_DATASTORE_KEYS.REQUEST_EXPIRATION_TIME)) !== BigInt(GMX_REQUEST_EXPIRATION_SEC)) problems.push('the request expiration time changed');96        for (const m of GMX_MARKETS) {97          const x = await reader('getMarket', [P.dataStore.address, m.market], true);98          if (!eq(x.marketToken, m.market) || !eq(x.indexToken, m.indexToken) || !eq(x.longToken, m.longToken) || !eq(x.shortToken, m.shortToken)) problems.push(`the market ${m.name} no longer reads back as pinned`);99        }100        // 4. an EIP-7702 delegation on the wallet address would run someone's code on every coin received101        if (me && (await rpcQuorum<string>(urls, 'eth_getCode', [me, 'latest'])) !== '0x') problems.push('the wallet address carries code (delegation)');102      } catch (e) { unreadable(e); }103      const v = { ok: problems.length === 0, problems, checkedAt: now() };104      pins = { at: now(), me, v };105      return v;106    },107    async balances(me) {108      try {109        const erc = async (token: string, fn: 'balanceOf' | 'allowance', args: string[]) => hexToBigInt(await callAny(token, encodeFunctionData({ abi: erc20Abi, functionName: fn, args } as never)));110        const usdc = GMX_MARKETS[0]!.collateral[0]!.token;111        const [u, e, w, a] = await Promise.all([erc(usdc, 'balanceOf', [me]), rpcAny<Hex>(urls, 'eth_getBalance', [me, 'latest']), erc(P.wnt, 'balanceOf', [me]), erc(usdc, 'allowance', [me, P.router.address])]);112        return { usdc: u, eth: hexToBigInt(e), weth: w, usdcAllowance: a };113      } catch (e) { return unreadable(e); }114    },115    async txParams(me) {116      try {117        const [block, nonce] = await Promise.all([rpcAny<{ baseFeePerGas: Hex }>(urls, 'eth_getBlockByNumber', ['latest', false]), rpcQuorum<Hex>(urls, 'eth_getTransactionCount', [me, 'pending'])]);118        return { baseFee: hexToBigInt(block.baseFeePerGas), tip: 1_000_000n, nonce: hexToNumber(nonce) };119      } catch (e) { return unreadable(e); }120    },121    async executionGas() {122      if (gas && now() - gas.at < GAS_TTL_MS) return gas.v;123      try {124        const K = GMX_DATASTORE_KEYS;125        const [inc, dec, baseAmount, perOraclePrice, multiplierFactor] = await Promise.all([uint(K.INCREASE_ORDER_GAS_LIMIT), uint(K.DECREASE_ORDER_GAS_LIMIT), uint(K.ESTIMATED_GAS_FEE_BASE_AMOUNT_V2_1), uint(K.ESTIMATED_GAS_FEE_PER_ORACLE_PRICE), uint(K.ESTIMATED_GAS_FEE_MULTIPLIER_FACTOR)]);126        // a wrong key reads zero: never turn that into a fee of zero127        if (inc === 0n || dec === 0n || multiplierFactor === 0n) throw new Error('GMX DataStore answered zero for a gas parameter');128        const v = { increase: gmxExecutionGas({ orderGasLimit: inc, baseAmount, perOraclePrice, multiplierFactor }), decrease: gmxExecutionGas({ orderGasLimit: dec, baseAmount, perOraclePrice, multiplierFactor }) };129        gas = { at: now(), v };130        return v;131      } catch (e) { return unreadable(e); }132    },133    async positions(me) {134      try {135        const ps = await reader('getAccountPositions', [P.dataStore.address, me, 0n, 100n]) as any[];136        return ps.map((p) => ({ key: gmxPositionKey(p.addresses.account, p.addresses.market, p.addresses.collateralToken, p.flags.isLong), market: p.addresses.market, collateralToken: p.addresses.collateralToken, isLong: p.flags.isLong,137          sizeInUsd: p.numbers.sizeInUsd, sizeInTokens: p.numbers.sizeInTokens, collateralAmount: p.numbers.collateralAmount, increasedAtTime: Number(p.numbers.increasedAtTime) }));138      } catch (e) { return unreadable(e); }139    },140    async orders(me) {141      try { return (await reader('getAccountOrders', [P.dataStore.address, me, 0n, 100n]) as any[]).map((x) => orderOf(x.orderKey, x.order)); } catch (e) { return unreadable(e); }142    },143    async order(key) {144      try { const o = orderOf(key, await reader('getOrder', [P.dataStore.address, key])); return /^0x0{40}$/.test(o.account) ? null : o; } catch (e) { return unreadable(e); }145    },146    async estimateGas(c) {147      // every endpoint must answer; the larger estimate is kept148      const rs = await Promise.allSettled(urls.map((u) => rpcOne<Hex>(u, 'eth_estimateGas', [{ from: c.from, to: c.to, data: c.data, value: numberToHex(c.value) }])));149      if (rs.some((r) => r.status === 'rejected')) return null;150      return rs.map((r) => hexToBigInt((r as PromiseFulfilledResult<Hex>).value)).reduce((x, y) => (y > x ? y : x));151    },152    async simulate(c) {153      // on EVERY endpoint: one revert is a refusal, one silence is "try again"154      const rs = await Promise.allSettled(urls.map((u) => rpcOne<string>(u, 'eth_call', [{ from: c.from, to: c.to, data: c.data, value: numberToHex(c.value) }, 'latest'])));155      const bad = rs.flatMap((r) => (r.status === 'rejected' ? [r.reason as Error & { rpcCode?: number }] : []));156      const revert = bad.find((e) => e.rpcCode !== undefined);157      if (revert) return { unreachable: false, error: revert.message.slice(0, 160) };158      return bad.length ? { unreachable: true, error: bad[0]!.message.slice(0, 160) } : null;159    },160    async broadcast(raw) {161      // to every endpoint: a transaction already known is not an error162      const rs = await Promise.allSettled(urls.map((u) => rpcOne<string>(u, 'eth_sendRawTransaction', [raw])));163      if (rs.every((r) => r.status === 'rejected' && !/already known|nonce too low|known transaction/i.test(String((r.reason as Error).message)))) throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message ?? 'broadcast failed').slice(0, 200));164    },165    async receipt(txid) {166      const rc = await rpcAny<{ status: Hex; blockNumber: Hex; gasUsed: Hex; effectiveGasPrice: Hex; logs: GmxLog[] } | null>(urls, 'eth_getTransactionReceipt', [txid]).catch(() => null);167      return rc ? { ok: hexToNumber(rc.status) === 1, blockNumber: hexToNumber(rc.blockNumber), gasUsed: hexToBigInt(rc.gasUsed), effectiveGasPrice: hexToBigInt(rc.effectiveGasPrice ?? '0x0'), logs: rc.logs } : null;168    },169    async orderEvents(key, fromBlock) {170      try {171        const head = hexToNumber(await rpcAny<Hex>(urls, 'eth_blockNumber', []));172        const out: { event: GmxEvent; txid: string; blockNumber: number }[] = [];173        for (let from = fromBlock; from <= head; from += LOG_SPAN) {174          const logs = await rpcAny<(GmxLog & { transactionHash: string; blockNumber: Hex; removed?: boolean })[]>(urls, 'eth_getLogs', [{ address: P.eventEmitter.address, topics: [EVENT_LOG_2, null, pad(key as Hex)], fromBlock: numberToHex(from), toBlock: numberToHex(Math.min(head, from + LOG_SPAN - 1)) }]);175          for (const l of logs) { if (l.removed) continue; const event = decodeGmxEvent(l); if (event) out.push({ event, txid: l.transactionHash, blockNumber: hexToNumber(l.blockNumber) }); }176          if (from + LOG_SPAN > fromBlock + 20 * LOG_SPAN) break; // at most ~2 days of Arbitrum blocks per pass177        }178        return out;179      } catch (e) { return unreadable(e); }180    },181  };182}183
Files main
Menu

Theme