Repository Public
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