SPB Git

Repository Public

gmx.ts 47.4 KB 513 lines typescript

1// GMX v2 (GMX Synthetics) on Arbitrum One — the part of live trading shared by the isolated signer and the API (v8).2// Pure: no network, no clock, no key. Facts, sources and evidence: docs/v4/venues/gmx.md, Part C.3//4// A GMX order is an ON-CHAIN TRANSACTION of the wallet's own key: there is no venue account and no trading key. So the5// signer BUILDS the transaction itself, from an owner-approved intent (packages/core/src/intent.ts, section `gmx`) and the6// constants pinned here; nothing a provider or the API built is ever signed:7//8//   ERC20(collateral).approve(Router, amount)                       exact amount, only when asked (increase)9//   ExchangeRouter.multicall{value: executionFee}([10//     sendWnt(OrderVault, executionFee),                             wraps msg.value into the order vault = the keeper's fee11//     sendTokens(collateral, OrderVault, amount),                    increase only; Router.pluginTransfer(token, msg.sender, …)12//     createOrder(params) ])13//   ExchangeRouter.cancelOrder(key)                                  cancel14//15// What the verified sources say (ExchangeRouter 0x7dE39FF2…, OrderHandler 0xa5D2d452…, Router, read on Arbiscan 2026-11-11):16//   - `sendTokens` pulls from `msg.sender` only (`router.pluginTransfer(token, msg.sender, receiver, amount)`), through the17//     allowance given to `Router`; `Router.pluginTransfer` is `onlyRouterPlugin` (a role in RoleStore);18//   - `createOrder` records `account = msg.sender`; for an increase the collateral of the order is what the vault MEASURED19//     (`orderVault.recordTransferIn`), the execution fee is the WNT the vault measured (≥ `params.numbers.executionFee`);20//   - every output of an executed order goes to `receiver`; a cancelled increase returns its collateral to21//     `cancellationReceiver` and the unused execution fee to `cancellationReceiver` (to `receiver` on execution or freeze);22//   - `cancelOrder(key)` requires `order.account == msg.sender`; a market order can be cancelled by its account only23//     `REQUEST_EXPIRATION_TIME` (300 s, DataStore) after its creation; keepers (`ORDER_KEEPER`) may cancel it after the same delay.24// With receiver = cancellationReceiver = the wallet, no callback contract, no UI fee receiver, no swap path and no25// referral code, the only addresses that can receive anything from an order are GMX's own contracts and the wallet.26//27// THE PINNED TABLE IS A TRUST INPUT OF THE SIGNER: it ships in its bundle and is never received from the API.28import { sha256 } from '@noble/hashes/sha2.js';29import { decodeEventLog, decodeFunctionData, encodeAbiParameters, encodeFunctionData, erc20Abi, keccak256, type Abi, type Hex, type TransactionSerializableEIP1559 } from 'viem';30import { z } from 'zod';31import { D, type DecStr } from '../decimal.ts';32import { DecStrSchema } from '../models.ts';33import { hex } from './canonical.ts';3435// ---------------------------------------------------------------------------------------------------------------------------36// Pins3738export interface GmxPin { /** EIP-55 */ readonly address: string; /** SHA-256 of the runtime bytecode, identical on both registry RPC endpoints */ readonly codeSha256: string; readonly codeBytes: number }39export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';40export const ZERO_BYTES32 = `0x${'0'.repeat(64)}`;4142/**43 * GMX contracts are NOT proxies: GMX ships new logic as NEW deployments and moves the roles in RoleStore. A new44 * ExchangeRouter therefore has another address (this table then no longer matches GMX's documentation), and the old one45 * loses `CONTROLLER` / `ROUTER_PLUGIN` (the on-chain role check then fails). Either way trading stops until this table is46 * reviewed again — docs/v4/venues/gmx.md "How to re-review after a GMX release".47 */48export const GMX_PINS = {49  /** an authorization commits to this identifier: a re-review that changes the table voids every authorization */50  id: 'gmx-v2-arbitrum-2026-11-11',51  reviewed: '2026-11-11: addresses = docs.gmx.io/docs/api/contracts/addresses + gmx-synthetics deployments/arbitrum (branch updates @ bf30ebb4); runtime bytecode hashed on both registry RPC endpoints; verified sources of ExchangeRouter and OrderHandler read on Arbiscan (solc 0.8.29, not proxies)',52  network: 'arbitrum', chainId: 42161,53  exchangeRouter: { address: '0x7dE39FF2e232A2203196788d37e234cF8F1b83f1', codeSha256: '76169686cbf23f3e226d92841776b8e1f1b0647ebc7a76ce29a75eb014bffde5', codeBytes: 20999 },54  /** the ERC-20 spender: `Router.pluginTransfer` is the only code that moves the wallet's tokens */55  router: { address: '0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6', codeSha256: 'f50f32351ad61911d5a96e82483ce53780e61eec6cd2a12ccdd85f5637544484', codeBytes: 1503 },56  orderVault: { address: '0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5', codeSha256: 'e3e5559ff93aefd36027dfa7deb77ff350028c80fded2687456243e680dc0669', codeBytes: 4570 },57  dataStore: { address: '0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8', codeSha256: '0524e4f2a73947f6e74c2a3e33bc399c56db0d5a6d428ab5c013957a0964f77b', codeBytes: 10443 },58  reader: { address: '0xfA26cBb46e2614609406de08CA1Dc7f70a684184', codeSha256: 'a8ac6f41c73e92794168cda29d75d90831e433f865e666cb72136eddd4ba5fc7', codeBytes: 23870 },59  /** immutable of the ExchangeRouter (constructor argument); holds the order logic (linked libraries are part of its bytecode) */60  orderHandler: { address: '0xa5D2d45228ee2E3A18AB122B2cE84997d008f4Eb', codeSha256: '2113ebc4aa3bd22b80e44e075eea137aabdfb67438883b9e3ee14e5cb6f5e06e', codeBytes: 24525 },61  roleStore: { address: '0x3c3d99FD298f679DBC2CEcd132b4eC4d0F5e6e72', codeSha256: 'd50d6f1e6c1b751b9c8a90a26e7a1d04939f281b9a0ee322fecd7c8d50d19de1', codeBytes: 2342 },62  eventEmitter: { address: '0xC8ee91A54287DB53897056e12D9819156D3822Fb', codeSha256: '29e899318a0b8cbc8cce0cbae83f88efcfb72970d34bf1fed7bb2898a0e8db78', codeBytes: 7998 },63  /** wrapped native token the execution fee is paid in (DataStore key WNT) */64  wnt: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1',65  /** read-only dry run of the latest order (SimulationRouter.simulateExecuteLatestOrder); not on the signing path */66  simulationRouter: { address: '0xaD3051cB1aE3a86b335f12A9a41BD4d995a137ea', codeSha256: 'ff09bf6a840bc854949cb03f7604488a6fcac20576e65850940fec11d7ba82d4', codeBytes: 6994 },67} as const;68export const GMX_PINNED_CONTRACTS = ['exchangeRouter', 'router', 'orderVault', 'dataStore', 'reader', 'orderHandler', 'roleStore', 'eventEmitter'] as const;69export type GmxPinnedContract = (typeof GMX_PINNED_CONTRACTS)[number];7071/** RoleStore role keys = keccak256(abi.encode(<name>)) (role/Role.sol) */72export const GMX_ROLE_KEYS = {73  CONTROLLER: '0x97adf037b2472f4a6a9825eff7d2dd45e37f2dc308df2a260d6a72af4189a65b',74  ROUTER_PLUGIN: '0xc82e6cc76072f8edb32d42796e58e13ab6e145524eb6b36c073be82f20d410f3',75  ORDER_KEEPER: '0x40a07f8f0fc57fcf18b093d96362a8e661eaac7b7e6edbf66f242111f83a6794',76} as const;77/** the roles the pinned contracts must still hold for an order to be creatable through them: read on-chain before every preparation */78export const GMX_REQUIRED_ROLES: readonly { holder: GmxPinnedContract; role: keyof typeof GMX_ROLE_KEYS; why: string }[] = [79  { holder: 'exchangeRouter', role: 'CONTROLLER', why: 'the ExchangeRouter calls OrderHandler.createOrder / cancelOrder (onlyController)' },80  { holder: 'exchangeRouter', role: 'ROUTER_PLUGIN', why: 'the ExchangeRouter calls Router.pluginTransfer (onlyRouterPlugin)' },81  { holder: 'orderHandler', role: 'CONTROLLER', why: 'the OrderHandler writes orders to the DataStore and moves the OrderVault (onlyController)' },82];83/** DataStore keys = keccak256(abi.encode(<name>)) (data/Keys.sol), values read 2026-11-11 in the comment */84export const GMX_DATASTORE_KEYS = {85  REQUEST_EXPIRATION_TIME: '0xf664ba0f4e5389df44705cd100171228b74c4f050a9f00bd873e95d25c49a273', // 30086  INCREASE_ORDER_GAS_LIMIT: '0x983e0a7f5307213e84497f2543331fe5e404db14ddf98f98dc956e0ee3ab6875', // 3 000 00087  DECREASE_ORDER_GAS_LIMIT: '0xfce7a3444b72c2d30987163c1f2e6c00cffa03bf0b6ca2f077e8db006b4cae8b', // 3 000 00088  ESTIMATED_GAS_FEE_BASE_AMOUNT_V2_1: '0x39288f227e5db9a793e9f4afb15aa22b77dbb7e410ffc973c816a19a6ed921cd', // 133 63789  ESTIMATED_GAS_FEE_PER_ORACLE_PRICE: '0xf95915378e4358fb5f51ae0fd75853a15a29a978eb14b73d5c5b7d69d3b9fccc', // 338 67990  ESTIMATED_GAS_FEE_MULTIPLIER_FACTOR: '0xce135f2a886cf6d862269f215b1e64498fa09cb04f90b771b163399df2a82b81', // 1e3091} as const;92/** a DataStore key or a role key by name: keccak256(abi.encode(name)) */93export const gmxKey = (name: string): Hex => keccak256(encodeAbiParameters([{ type: 'string' }], [name]));94/** documented and read on-chain 2026-11-11 (DataStore REQUEST_EXPIRATION_TIME): a market order can be cancelled by its account after this */95export const GMX_REQUEST_EXPIRATION_SEC = 300;96/** oracle prices of a position order without a swap path (GasUtils.estimateOrderOraclePriceCount) */97export const GMX_ORACLE_PRICE_COUNT = 3n;9899export interface GmxGasParams { /** INCREASE_ORDER_GAS_LIMIT or DECREASE_ORDER_GAS_LIMIT */ orderGasLimit: bigint; baseAmount: bigint; perOraclePrice: bigint; multiplierFactor: bigint }100/** `GasUtils.adjustGasLimitForEstimate` of a position order without swap path or callback: the gas the contracts price the execution at */101export const gmxExecutionGas = (p: GmxGasParams): bigint => p.baseAmount + p.perOraclePrice * GMX_ORACLE_PRICE_COUNT + (p.orderGasLimit * p.multiplierFactor) / 10n ** 30n;102/**103 * The execution fee this wallet sends: the contracts require `tx.gasprice × gmxExecutionGas` at creation (else104 * `InsufficientExecutionFee`); the wallet sends that gas at 1.5 × the current base fee plus its own tip, so that a base fee105 * that rises by up to half before inclusion does not make the creation revert. The keeper keeps what its execution cost; the106 * rest comes back to the wallet (`receiver` on execution, `cancellationReceiver` on cancellation — both the wallet).107 */108export const gmxExecutionFee = (executionGas: bigint, baseFeePerGas: bigint, tip: bigint): bigint => executionGas * ((baseFeePerGas * 3n) / 2n + tip);109110export interface GmxMarketPin {111  /** GM market token = the market's identifier */112  readonly market: string; readonly name: string;113  /** read adapter instrument id (`gmx:<GMX API symbol>`) */114  readonly instrument: string;115  readonly indexToken: string; readonly indexDecimals: number;116  /** symbol the signer prices the index with (Coinbase + Kraken) */117  readonly priceSymbol: string;118  readonly longToken: string; readonly shortToken: string;119  /** collateral tokens accepted by THIS wallet for the market (a subset of the market's long/short tokens) */120  readonly collateral: readonly { readonly token: string; readonly symbol: string; readonly decimals: number; readonly priceSymbol: string }[];121}122const USDC = { token: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', symbol: 'USDC', decimals: 6, priceSymbol: 'USDC' } as const;123/** Markets this wallet trades. Each row was read back from `Reader.getMarket(dataStore, market)` on both RPC endpoints on 2026-11-11; the API reads it again before every order. */124export const GMX_MARKETS: readonly GmxMarketPin[] = [125  { market: '0x47c031236e19d024b42f8AE6780E44A573170703', name: 'BTC/USD [WBTC.b-USDC]', instrument: 'gmx:BTC/USD [BTC-USDC]', indexToken: '0x47904963fc8b2340414262125aF798B9655E58Cd', indexDecimals: 8, priceSymbol: 'BTC',126    longToken: '0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f', shortToken: USDC.token, collateral: [USDC] },127  { market: '0x70d95587d40A2caf56bd97485aB3Eec10Bee6336', name: 'ETH/USD [WETH-USDC]', instrument: 'gmx:ETH/USD [WETH-USDC]', indexToken: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', indexDecimals: 18, priceSymbol: 'ETH',128    longToken: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', shortToken: USDC.token, collateral: [USDC] },129];130const lc = (a: string) => a.toLowerCase();131const same = (a: string, b: string) => lc(a) === lc(b);132export const gmxMarket = (market: string): GmxMarketPin | null => GMX_MARKETS.find((m) => same(m.market, market)) ?? null;133export const gmxMarketByInstrument = (instrument: string): GmxMarketPin | null => GMX_MARKETS.find((m) => m.instrument === instrument) ?? null;134135// ---------------------------------------------------------------------------------------------------------------------------136// ABI (from the verified sources; compared in the tests with real transactions and events)137138const ADDRESSES = [139  { name: 'receiver', type: 'address' }, { name: 'cancellationReceiver', type: 'address' }, { name: 'callbackContract', type: 'address' }, { name: 'uiFeeReceiver', type: 'address' },140  { name: 'market', type: 'address' }, { name: 'initialCollateralToken', type: 'address' }, { name: 'swapPath', type: 'address[]' },141] as const;142const NUMBERS = [143  { name: 'sizeDeltaUsd', type: 'uint256' }, { name: 'initialCollateralDeltaAmount', type: 'uint256' }, { name: 'triggerPrice', type: 'uint256' }, { name: 'acceptablePrice', type: 'uint256' },144  { name: 'executionFee', type: 'uint256' }, { name: 'callbackGasLimit', type: 'uint256' }, { name: 'minOutputAmount', type: 'uint256' }, { name: 'validFromTime', type: 'uint256' },145] as const;146export const GMX_EXCHANGE_ROUTER_ABI = [147  { type: 'function', name: 'multicall', stateMutability: 'payable', inputs: [{ name: 'data', type: 'bytes[]' }], outputs: [{ name: 'results', type: 'bytes[]' }] },148  { type: 'function', name: 'sendWnt', stateMutability: 'payable', inputs: [{ name: 'receiver', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [] },149  { type: 'function', name: 'sendTokens', stateMutability: 'payable', inputs: [{ name: 'token', type: 'address' }, { name: 'receiver', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [] },150  { type: 'function', name: 'cancelOrder', stateMutability: 'payable', inputs: [{ name: 'key', type: 'bytes32' }], outputs: [] },151  { type: 'function', name: 'createOrder', stateMutability: 'payable', outputs: [{ type: 'bytes32' }], inputs: [{ name: 'params', type: 'tuple', components: [152    { name: 'addresses', type: 'tuple', components: ADDRESSES }, { name: 'numbers', type: 'tuple', components: NUMBERS },153    { name: 'orderType', type: 'uint8' }, { name: 'decreasePositionSwapType', type: 'uint8' }, { name: 'isLong', type: 'bool' }, { name: 'shouldUnwrapNativeToken', type: 'bool' },154    { name: 'autoCancel', type: 'bool' }, { name: 'referralCode', type: 'bytes32' }, { name: 'dataList', type: 'bytes32[]' }] }] },155] as const;156157const ORDER_PROPS = { type: 'tuple', components: [158  { name: 'addresses', type: 'tuple', components: [{ name: 'account', type: 'address' }, ...ADDRESSES] },159  { name: 'numbers', type: 'tuple', components: [160    { name: 'orderType', type: 'uint8' }, { name: 'decreasePositionSwapType', type: 'uint8' }, { name: 'sizeDeltaUsd', type: 'uint256' }, { name: 'initialCollateralDeltaAmount', type: 'uint256' },161    { name: 'triggerPrice', type: 'uint256' }, { name: 'acceptablePrice', type: 'uint256' }, { name: 'executionFee', type: 'uint256' }, { name: 'callbackGasLimit', type: 'uint256' },162    { name: 'minOutputAmount', type: 'uint256' }, { name: 'uiFeeFactor', type: 'uint256' }, { name: 'updatedAtTime', type: 'uint256' }, { name: 'validFromTime', type: 'uint256' }, { name: 'srcChainId', type: 'uint256' }] },163  { name: 'flags', type: 'tuple', components: [{ name: 'isLong', type: 'bool' }, { name: 'shouldUnwrapNativeToken', type: 'bool' }, { name: 'isFrozen', type: 'bool' }, { name: 'autoCancel', type: 'bool' }] },164  { name: '_dataList', type: 'bytes32[]' }] } as const;165const POSITION_PROPS = { type: 'tuple', components: [166  { name: 'addresses', type: 'tuple', components: [{ name: 'account', type: 'address' }, { name: 'market', type: 'address' }, { name: 'collateralToken', type: 'address' }] },167  { name: 'numbers', type: 'tuple', components: [168    { name: 'sizeInUsd', type: 'uint256' }, { name: 'sizeInTokens', type: 'uint256' }, { name: 'collateralAmount', type: 'uint256' }, { name: 'pendingImpactAmount', type: 'int256' },169    { name: 'borrowingFactor', type: 'uint256' }, { name: 'fundingFeeAmountPerSize', type: 'uint256' }, { name: 'longTokenClaimableFundingAmountPerSize', type: 'uint256' },170    { name: 'shortTokenClaimableFundingAmountPerSize', type: 'uint256' }, { name: 'increasedAtTime', type: 'uint256' }, { name: 'decreasedAtTime', type: 'uint256' }] },171  { name: 'flags', type: 'tuple', components: [{ name: 'isLong', type: 'bool' }] }] } as const;172const RANGE = [{ name: 'dataStore', type: 'address' }, { name: 'account', type: 'address' }, { name: 'start', type: 'uint256' }, { name: 'end', type: 'uint256' }] as const;173export const GMX_READER_ABI = [174  { type: 'function', name: 'getMarket', stateMutability: 'view', inputs: [{ name: 'dataStore', type: 'address' }, { name: 'key', type: 'address' }],175    outputs: [{ type: 'tuple', components: [{ name: 'marketToken', type: 'address' }, { name: 'indexToken', type: 'address' }, { name: 'longToken', type: 'address' }, { name: 'shortToken', type: 'address' }] }] },176  { type: 'function', name: 'getAccountPositions', stateMutability: 'view', inputs: RANGE, outputs: [{ ...POSITION_PROPS, type: 'tuple[]' }] },177  { type: 'function', name: 'getAccountOrders', stateMutability: 'view', inputs: RANGE, outputs: [{ type: 'tuple[]', components: [{ name: 'orderKey', type: 'bytes32' }, { ...ORDER_PROPS, name: 'order' }] }] },178  { type: 'function', name: 'getOrder', stateMutability: 'view', inputs: [{ name: 'dataStore', type: 'address' }, { name: 'key', type: 'bytes32' }], outputs: [ORDER_PROPS] },179] as const;180export const GMX_ROLE_STORE_ABI = [{ type: 'function', name: 'hasRole', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }, { name: 'roleKey', type: 'bytes32' }], outputs: [{ type: 'bool' }] }] as const;181export const GMX_DATASTORE_ABI = [182  { type: 'function', name: 'getUint', stateMutability: 'view', inputs: [{ name: 'key', type: 'bytes32' }], outputs: [{ type: 'uint256' }] },183  { type: 'function', name: 'getAddress', stateMutability: 'view', inputs: [{ name: 'key', type: 'bytes32' }], outputs: [{ type: 'address' }] },184] as const;185186const kv = (name: string, t: string) => ({ name, type: 'tuple', components: [187  { name: 'items', type: 'tuple[]', components: [{ name: 'key', type: 'string' }, { name: 'value', type: t }] },188  { name: 'arrayItems', type: 'tuple[]', components: [{ name: 'key', type: 'string' }, { name: 'value', type: `${t}[]` }] }] });189const EVENT_DATA = { name: 'eventData', type: 'tuple', components: [kv('addressItems', 'address'), kv('uintItems', 'uint256'), kv('intItems', 'int256'), kv('boolItems', 'bool'), kv('bytes32Items', 'bytes32'), kv('bytesItems', 'bytes'), kv('stringItems', 'string')] };190const EV_HEAD = [{ name: 'msgSender', type: 'address', indexed: false }, { name: 'eventName', type: 'string', indexed: false }, { name: 'eventNameHash', type: 'string', indexed: true }];191/** EventEmitter (event/EventEmitter.sol): every GMX event is one of these three, with a generic key/value payload */192export const GMX_EVENT_EMITTER_ABI = [193  { type: 'event', name: 'EventLog', inputs: [...EV_HEAD, { ...EVENT_DATA, indexed: false }] },194  { type: 'event', name: 'EventLog1', inputs: [...EV_HEAD, { name: 'topic1', type: 'bytes32', indexed: true }, { ...EVENT_DATA, indexed: false }] },195  { type: 'event', name: 'EventLog2', inputs: [...EV_HEAD, { name: 'topic1', type: 'bytes32', indexed: true }, { name: 'topic2', type: 'bytes32', indexed: true }, { ...EVENT_DATA, indexed: false }] },196] as unknown as Abi;197198export const GMX_ORDER_TYPE = { MarketSwap: 0, LimitSwap: 1, MarketIncrease: 2, LimitIncrease: 3, MarketDecrease: 4, LimitDecrease: 5, StopLossDecrease: 6, Liquidation: 7, StopIncrease: 8 } as const;199export const GMX_ORDER_TYPE_NAMES = ['MarketSwap', 'LimitSwap', 'MarketIncrease', 'LimitIncrease', 'MarketDecrease', 'LimitDecrease', 'StopLossDecrease', 'Liquidation', 'StopIncrease'] as const;200201// ---------------------------------------------------------------------------------------------------------------------------202// Exact conversions203204export const GMX_USD_DECIMALS = 30;205const U256_MAX = (1n << 256n) - 1n;206const UINT = /^(0|[1-9]\d{0,77})$/;207/** decimal string → integer × 10^decimals; throws when the value has more decimals than the unit can carry (never rounds) */208export function scaleExact(x: DecStr, decimals: number): bigint {209  const d = D.from(x);210  if (d.m < 0n) throw new Error('negative value');211  if (d.s <= decimals) return d.m * 10n ** BigInt(decimals - d.s);212  const div = 10n ** BigInt(d.s - decimals);213  if (d.m % div !== 0n) throw new Error(`${x} has more than ${decimals} decimals`);214  return d.m / div;215}216/** integer × 10^decimals → canonical decimal string */217export const unscale = (raw: bigint, decimals: number): DecStr => D.str({ m: raw, s: decimals });218export const gmxUsdToRaw = (usd: DecStr): bigint => scaleExact(usd, GMX_USD_DECIMALS);219export const gmxUsdFromRaw = (raw: bigint): DecStr => unscale(raw, GMX_USD_DECIMALS);220/** GMX contract price of an index token: USD per smallest token unit × 10^30 = USD per whole token × 10^(30 − decimals) */221export const gmxPriceDecimals = (indexDecimals: number): number => { if (!Number.isInteger(indexDecimals) || indexDecimals < 0 || indexDecimals > 30) throw new Error('bad token decimals'); return GMX_USD_DECIMALS - indexDecimals; };222export const gmxPriceToRaw = (price: DecStr, indexDecimals: number): bigint => scaleExact(price, gmxPriceDecimals(indexDecimals));223export const gmxPriceFromRaw = (raw: bigint, indexDecimals: number): DecStr => unscale(raw, gmxPriceDecimals(indexDecimals));224/** worst acceptable price from a reference price and a slippage bound: longs open / shorts close ABOVE the reference, the others below; rounded to the unit towards the worse side */225export function gmxAcceptablePrice(reference: DecStr, slippageBps: number, o: { isLong: boolean; increase: boolean; indexDecimals: number }): bigint {226  if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 10_000) throw new Error('slippage out of range');227  const up = o.isLong === o.increase, ref = D.from(reference), k = gmxPriceDecimals(o.indexDecimals);228  const factor = D.from(String(up ? 10_000 + slippageBps : 10_000 - slippageBps));229  const d = D.div(D.mul(ref, factor), D.from('10000'), k, up ? 'up' : 'down');230  return scaleExact(D.str(d), k);231}232233// ---------------------------------------------------------------------------------------------------------------------------234// The intent section and its verification (signer and API apply the same function)235236export const GMX_GAS = { orderMin: 400_000n, orderMax: 6_000_000n, cancelMin: 200_000n, cancelMax: 6_000_000n, approveMin: 30_000n, approveMax: 120_000n } as const;237export interface GmxSection {238  venue: 'gmx-v2'; action: 'increase' | 'decrease' | 'cancel'; authorizationId: string; exchangeRouter: string; router: string; orderVault: string;239  market: string; indexToken: string; collateralToken: string; orderType: 'MarketIncrease' | 'MarketDecrease' | 'None'; isLong: boolean;240  sizeDeltaUsd: string; acceptablePrice: string; triggerPrice: string; executionFee: string; approveGas: string; orderKey?: string | undefined;241}242/** the fields of a wallet intent this file reads (structural: the derivatives package does not import the core package) */243export interface GmxIntent {244  network: string; chainRef: string; asset: string; symbol: string; decimals: number; amount: string; from: string; to: string; maxFee: string;245  memo?: string | undefined; swap?: unknown; bridge?: unknown; xswap?: unknown; venueDeposit?: unknown; gmx?: GmxSection | undefined;246  evm?: { nonce: number; gasLimit: string; maxFeePerGas: string; maxPriorityFeePerGas: string } | undefined;247}248export interface GmxCreateOrderParams {249  addresses: { receiver: Hex; cancellationReceiver: Hex; callbackContract: Hex; uiFeeReceiver: Hex; market: Hex; initialCollateralToken: Hex; swapPath: readonly Hex[] };250  numbers: { sizeDeltaUsd: bigint; initialCollateralDeltaAmount: bigint; triggerPrice: bigint; acceptablePrice: bigint; executionFee: bigint; callbackGasLimit: bigint; minOutputAmount: bigint; validFromTime: bigint };251  orderType: number; decreasePositionSwapType: number; isLong: boolean; shouldUnwrapNativeToken: boolean; autoCancel: boolean; referralCode: Hex; dataList: readonly Hex[];252}253export interface GmxPlan {254  action: GmxSection['action']; market: GmxMarketPin | null; collateral: GmxMarketPin['collateral'][number] | null;255  /** collateral base units sent with an increase; 0 otherwise */ amount: bigint;256  sizeDeltaUsd: bigint; acceptablePrice: bigint; executionFee: bigint; isLong: boolean; orderKey: Hex | null;257  gas: bigint; approveGas: bigint; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint; nonce: number;258  /** null for a cancel */ params: GmxCreateOrderParams | null;259}260const uint = (s: string, what: string): bigint => { if (!UINT.test(s)) throw new Error(`malformed ${what}`); const v = BigInt(s); if (v > U256_MAX) throw new Error(`${what} out of range`); return v; };261262/**263 * The fixed part of every order this wallet creates — NOT chosen by the API, not even present in the intent:264 *   receiver = cancellationReceiver = the wallet; callbackContract = 0 and callbackGasLimit = 0 (no third-party code runs);265 *   uiFeeReceiver = 0 (no UI fee); swapPath = [] (the collateral is the market's own token, nothing is swapped);266 *   decreasePositionSwapType = NoSwap; referralCode = 0; dataList = []; validFromTime = 0 and triggerPrice = 0 (market orders);267 *   autoCancel = false (it only applies to LimitDecrease / StopLossDecrease);268 *   shouldUnwrapNativeToken = false, always: token outputs arrive as ERC-20 (a WETH profit as WETH);269 *   minOutputAmount = 0: with an empty swap path nothing is swapped, so for an increase the "output" is the collateral itself270 *     (the check compares the collateral with itself) and for a decrease it is collateral ± PnL − fees, whose price risk is271 *     what `acceptablePrice` bounds; a non-zero value would only add a way for a close to be cancelled.272 */273export function gmxCreateOrderParams(own: string, p: { market: string; collateralToken: string; sizeDeltaUsd: bigint; amount: bigint; acceptablePrice: bigint; executionFee: bigint; increase: boolean; isLong: boolean }): GmxCreateOrderParams {274  return {275    addresses: { receiver: own as Hex, cancellationReceiver: own as Hex, callbackContract: ZERO_ADDRESS, uiFeeReceiver: ZERO_ADDRESS, market: p.market as Hex, initialCollateralToken: p.collateralToken as Hex, swapPath: [] },276    numbers: { sizeDeltaUsd: p.sizeDeltaUsd, initialCollateralDeltaAmount: p.increase ? p.amount : 0n, triggerPrice: 0n, acceptablePrice: p.acceptablePrice, executionFee: p.executionFee, callbackGasLimit: 0n, minOutputAmount: 0n, validFromTime: 0n },277    orderType: p.increase ? GMX_ORDER_TYPE.MarketIncrease : GMX_ORDER_TYPE.MarketDecrease, decreasePositionSwapType: 0, isLong: p.isLong, shouldUnwrapNativeToken: false, autoCancel: false,278    referralCode: ZERO_BYTES32 as Hex, dataList: [],279  };280}281282/** Check every field of a GMX intent against the pinned tables. `own` is the wallet's own address, from the caller's own knowledge. Throws with the reason. */283export function verifyGmxIntent(i: GmxIntent, own: string): GmxPlan {284  const g = i.gmx, e = i.evm;285  if (!g || !e) throw new Error('not a GMX intent');286  if (i.swap || i.bridge || i.xswap || i.venueDeposit || i.memo !== undefined) throw new Error('a GMX order is neither a swap, a bridge, a deposit nor a transfer with a memo');287  if (g.venue !== 'gmx-v2') throw new Error('unknown venue');288  if (i.network !== GMX_PINS.network || i.chainRef !== String(GMX_PINS.chainId)) throw new Error('GMX orders are made on Arbitrum One only');289  if (!/^0x[0-9a-fA-F]{40}$/.test(own) || i.from !== own) throw new Error('a GMX order is created from the wallet\'s own address only');290  if (g.exchangeRouter !== GMX_PINS.exchangeRouter.address || i.to !== GMX_PINS.exchangeRouter.address) throw new Error('the destination is not the pinned GMX ExchangeRouter');291  if (g.router !== GMX_PINS.router.address) throw new Error('the token spender is not the pinned GMX Router');292  if (g.orderVault !== GMX_PINS.orderVault.address) throw new Error('the vault is not the pinned GMX OrderVault');293  const amount = uint(i.amount, 'amount'), size = uint(g.sizeDeltaUsd, 'size'), price = uint(g.acceptablePrice, 'acceptable price'), fee = uint(g.executionFee, 'execution fee'), trigger = uint(g.triggerPrice, 'trigger price');294  const gas = uint(e.gasLimit, 'gas limit'), approveGas = uint(g.approveGas, 'approval gas'), maxFeePerGas = uint(e.maxFeePerGas, 'max fee per gas'), tip = uint(e.maxPriorityFeePerGas, 'priority fee');295  if (!Number.isInteger(e.nonce) || e.nonce < 0) throw new Error('bad nonce');296  if (tip > maxFeePerGas) throw new Error('priority fee above max fee');297  if (trigger !== 0n) throw new Error('market orders carry no trigger price');298  // what may leave the wallet in the native coin: gas of both transactions and the execution fee299  if ((gas + approveGas) * maxFeePerGas + fee > uint(i.maxFee, 'fee ceiling')) throw new Error('gas and execution fee exceed the approved maximum');300  const base = { amount, sizeDeltaUsd: size, acceptablePrice: price, executionFee: fee, isLong: g.isLong, gas, approveGas, maxFeePerGas, maxPriorityFeePerGas: tip, nonce: e.nonce };301302  if (g.action === 'cancel') {303    if (g.orderType !== 'None' || !g.orderKey || !/^0x[0-9a-f]{64}$/.test(g.orderKey) || g.orderKey === ZERO_BYTES32) throw new Error('a cancellation names the order key and nothing else');304    if (g.market !== ZERO_ADDRESS || g.indexToken !== ZERO_ADDRESS || g.collateralToken !== ZERO_ADDRESS || g.isLong || size !== 0n || price !== 0n || fee !== 0n || approveGas !== 0n) throw new Error('a cancellation carries no order field');305    if (i.asset !== 'native' || i.symbol !== 'ETH' || i.decimals !== 18 || amount !== 0n) throw new Error('a cancellation moves no asset');306    if (gas < GMX_GAS.cancelMin || gas > GMX_GAS.cancelMax) throw new Error('cancellation gas limit out of range');307    return { ...base, action: 'cancel', market: null, collateral: null, orderKey: g.orderKey as Hex, params: null };308  }309  if (g.orderKey !== undefined) throw new Error('only a cancellation names an order key');310  const m = gmxMarket(g.market);311  if (!m || g.market !== m.market) throw new Error('this market is not in the wallet\'s pinned GMX market table');312  if (g.indexToken !== m.indexToken) throw new Error('the index token is not the one of the pinned market');313  const c = m.collateral.find((x) => x.token === g.collateralToken);314  if (!c) throw new Error('this collateral token is not accepted for the market');315  if (i.asset !== c.token || i.symbol !== c.symbol || i.decimals !== c.decimals) throw new Error('the intent asset is not the collateral token');316  if (size === 0n) throw new Error('the size must be positive');317  if (price === 0n) throw new Error('the acceptable price must be positive');318  if (fee === 0n) throw new Error('the execution fee must be positive');319  if (gas < GMX_GAS.orderMin || gas > GMX_GAS.orderMax) throw new Error('order gas limit out of range');320  const increase = g.action === 'increase';321  if (increase) {322    if (g.orderType !== 'MarketIncrease') throw new Error('an increase is a MarketIncrease order');323    if (amount === 0n) throw new Error('an increase sends collateral');324    if (approveGas !== 0n && (approveGas < GMX_GAS.approveMin || approveGas > GMX_GAS.approveMax)) throw new Error('approval gas out of range');325  } else {326    if (g.orderType !== 'MarketDecrease') throw new Error('a decrease is a MarketDecrease order');327    if (amount !== 0n) throw new Error('a decrease sends no collateral');328    if (approveGas !== 0n) throw new Error('a decrease needs no token approval');329  }330  const params = gmxCreateOrderParams(own, { market: m.market, collateralToken: c.token, sizeDeltaUsd: size, amount, acceptablePrice: price, executionFee: fee, increase, isLong: g.isLong });331  return { ...base, action: g.action, market: m, collateral: c, orderKey: null, params };332}333334/** calldata of the one ExchangeRouter call of a plan */335export function gmxCalldata(plan: GmxPlan): Hex {336  const enc = (functionName: 'sendWnt' | 'sendTokens' | 'createOrder' | 'cancelOrder' | 'multicall', args: unknown[]) => encodeFunctionData({ abi: GMX_EXCHANGE_ROUTER_ABI, functionName, args } as never);337  if (plan.action === 'cancel') return enc('cancelOrder', [plan.orderKey]);338  const calls: Hex[] = [enc('sendWnt', [GMX_PINS.orderVault.address, plan.executionFee])];339  if (plan.action === 'increase') calls.push(enc('sendTokens', [plan.collateral!.token, GMX_PINS.orderVault.address, plan.amount]));340  calls.push(enc('createOrder', [plan.params]));341  return enc('multicall', [calls]);342}343344export interface GmxDecodedCall { fn: string; args: readonly unknown[] }345/** Decode an ExchangeRouter call (a multicall is expanded). Refuses bytes that do not re-encode to themselves (trailing data, non-canonical encoding). */346export function decodeGmxCalldata(data: string): { fn: string; calls: GmxDecodedCall[] } {347  const one = (d: Hex): GmxDecodedCall => {348    const x = decodeFunctionData({ abi: GMX_EXCHANGE_ROUTER_ABI, data: d });349    if (lc(encodeFunctionData({ abi: GMX_EXCHANGE_ROUTER_ABI, functionName: x.functionName, args: x.args } as never)) !== lc(d)) throw new Error('calldata is not canonical');350    return { fn: x.functionName, args: (x.args ?? []) as readonly unknown[] };351  };352  const top = one(data as Hex);353  if (top.fn !== 'multicall') return { fn: top.fn, calls: [top] };354  return { fn: 'multicall', calls: (top.args[0] as Hex[]).map(one) };355}356357/**358 * The transactions the signer signs for a GMX intent: an exact-amount approval of the pinned Router when asked, then the one359 * ExchangeRouter call. Built from the intent alone, then READ BACK: the bytes are decoded again and every value-moving field360 * is compared with the plan, so that an encoding mistake here cannot produce a signature.361 */362export function buildGmxTxs(i: GmxIntent, own: string): { plan: GmxPlan; approve: TransactionSerializableEIP1559 | null; main: TransactionSerializableEIP1559 } {363  const plan = verifyGmxIntent(i, own), data = gmxCalldata(plan), back = decodeGmxCalldata(data);364  const bad = (): never => { throw new Error('the GMX transaction does not read back as expected'); };365  const vault = GMX_PINS.orderVault.address;366  if (plan.action === 'cancel') { if (back.fn !== 'cancelOrder' || back.calls.length !== 1 || back.calls[0]!.args[0] !== plan.orderKey) bad(); }367  else {368    const names = back.calls.map((c) => c.fn).join(',');369    if (back.fn !== 'multicall' || names !== (plan.action === 'increase' ? 'sendWnt,sendTokens,createOrder' : 'sendWnt,createOrder')) bad();370    const w = back.calls[0]!.args; if (w[0] !== vault || w[1] !== plan.executionFee) bad();371    if (plan.action === 'increase') { const t = back.calls[1]!.args; if (t[0] !== plan.collateral!.token || t[1] !== vault || t[2] !== plan.amount) bad(); }372    const p = back.calls[back.calls.length - 1]!.args[0] as GmxCreateOrderParams, a = p.addresses, n = p.numbers;373    if (!same(a.receiver, own) || !same(a.cancellationReceiver, own) || a.callbackContract !== ZERO_ADDRESS || a.uiFeeReceiver !== ZERO_ADDRESS || a.market !== plan.market!.market || a.initialCollateralToken !== plan.collateral!.token || a.swapPath.length !== 0374      || n.sizeDeltaUsd !== plan.sizeDeltaUsd || n.initialCollateralDeltaAmount !== plan.amount || n.triggerPrice !== 0n || n.acceptablePrice !== plan.acceptablePrice || n.executionFee !== plan.executionFee || n.callbackGasLimit !== 0n375      || n.minOutputAmount !== 0n || n.validFromTime !== 0n || p.orderType !== (plan.action === 'increase' ? 2 : 4) || p.decreasePositionSwapType !== 0 || p.isLong !== plan.isLong || p.shouldUnwrapNativeToken !== false || p.autoCancel !== false376      || p.referralCode !== ZERO_BYTES32 || p.dataList.length !== 0) bad();377  }378  const common = { type: 'eip1559' as const, chainId: GMX_PINS.chainId, maxFeePerGas: plan.maxFeePerGas, maxPriorityFeePerGas: plan.maxPriorityFeePerGas };379  const approve = plan.approveGas === 0n ? null : { ...common, nonce: plan.nonce, gas: plan.approveGas, to: plan.collateral!.token as Hex, value: 0n,380    data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [GMX_PINS.router.address as Hex, plan.amount] }) }; // exact amount, never unlimited; the spender is the Router381  const main = { ...common, nonce: plan.nonce + (approve ? 1 : 0), gas: plan.gas, to: GMX_PINS.exchangeRouter.address as Hex, value: plan.action === 'cancel' ? 0n : plan.executionFee, data };382  return { plan, approve, main };383}384385// ---------------------------------------------------------------------------------------------------------------------------386// Events387388export interface GmxEvent { eventName: string; msgSender: string; topic1: string | null; topic2: string | null; fields: Record<string, unknown> }389/** Decode one log of the pinned EventEmitter into its name and a flat key → value map. Returns null for a log that is not one of its three events. */390export function decodeGmxEvent(log: { address: string; topics: readonly string[]; data: string }): GmxEvent | null {391  if (!same(log.address, GMX_PINS.eventEmitter.address)) return null;392  let ev: { eventName: string; args: Record<string, unknown> };393  try { ev = decodeEventLog({ abi: GMX_EVENT_EMITTER_ABI, data: log.data as Hex, topics: log.topics as [Hex, ...Hex[]] }) as never; } catch { return null; }394  const d = ev.args.eventData as Record<string, { items: { key: string; value: unknown }[]; arrayItems: { key: string; value: unknown }[] }>;395  const fields: Record<string, unknown> = {};396  for (const group of Object.values(d)) { for (const it of group.items) fields[it.key] = it.value; for (const it of group.arrayItems) fields[it.key] = it.value; }397  return { eventName: String(ev.args.eventName), msgSender: String(ev.args.msgSender), topic1: (ev.args.topic1 as string | undefined) ?? null, topic2: (ev.args.topic2 as string | undefined) ?? null, fields };398}399/** the order key of an order event (`OrderCreated`, `OrderExecuted`, `OrderCancelled`, `OrderFrozen`, `OrderUpdated`) */400export const gmxEventKey = (e: GmxEvent): string | null => (typeof e.fields.key === 'string' ? lc(e.fields.key) : e.topic1 ? lc(e.topic1) : null);401402/** Compare an `OrderCreated` event with the plan that was approved, field by field. Returns the differences (empty = identical). */403export function gmxOrderCreatedDiff(e: GmxEvent, plan: GmxPlan, own: string): string[] {404  const f = e.fields, out: string[] = [], p = plan.params;405  if (e.eventName !== 'OrderCreated' || !p) return ['not an OrderCreated event of a create plan'];406  const eq = (name: string, got: unknown, want: unknown) => { const g = typeof got === 'string' ? lc(got) : got, w = typeof want === 'string' ? lc(want) : want; if (g !== w) out.push(`${name}: ${String(got)} ≠ ${String(want)}`); };407  eq('account', f.account, own); eq('receiver', f.receiver, own); eq('cancellationReceiver', f.cancellationReceiver, own); eq('callbackContract', f.callbackContract, ZERO_ADDRESS); eq('uiFeeReceiver', f.uiFeeReceiver, ZERO_ADDRESS);408  eq('market', f.market, p.addresses.market); eq('initialCollateralToken', f.initialCollateralToken, p.addresses.initialCollateralToken);409  if (!Array.isArray(f.swapPath) || f.swapPath.length !== 0) out.push('swapPath is not empty');410  eq('orderType', f.orderType, BigInt(p.orderType)); eq('decreasePositionSwapType', f.decreasePositionSwapType, 0n); eq('sizeDeltaUsd', f.sizeDeltaUsd, p.numbers.sizeDeltaUsd);411  eq('initialCollateralDeltaAmount', f.initialCollateralDeltaAmount, p.numbers.initialCollateralDeltaAmount); eq('triggerPrice', f.triggerPrice, 0n); eq('acceptablePrice', f.acceptablePrice, p.numbers.acceptablePrice);412  eq('executionFee', f.executionFee, p.numbers.executionFee); eq('callbackGasLimit', f.callbackGasLimit, 0n); eq('minOutputAmount', f.minOutputAmount, 0n); eq('uiFeeFactor', f.uiFeeFactor, 0n);413  eq('validFromTime', f.validFromTime, 0n); eq('srcChainId', f.srcChainId, 0n); eq('isLong', f.isLong, p.isLong); eq('shouldUnwrapNativeToken', f.shouldUnwrapNativeToken, false); eq('autoCancel', f.autoCancel, false);414  if (!Array.isArray(f.dataList) || f.dataList.length !== 0) out.push('dataList is not empty');415  return out;416}417418// ---------------------------------------------------------------------------------------------------------------------------419// Venue policy: the authorization the owner approves once with a passkey, stored and enforced by the signer420421export const GMX_POLICY_BOUNDS = { maxLeverage: 20, maxOrdersPerDay: 100, minPriceBandBps: 10, maxPriceBandBps: 1000, maxMarkets: 8, maxDays: 90,422  /** hard ceiling of the execution fee of one order whatever the authorization says: 0.002 ETH */ maxExecutionFeeWei: 2_000_000_000_000_000n,423  /** decreases and cancellations signed per UTC day (they reduce risk; each still costs gas and an execution fee) */ reduceActionsPerDay: 100 } as const;424const enc = new TextEncoder();425const ADDRESS_LC = /^0x[0-9a-f]{40}$/;426const PosUsd = DecStrSchema.refine((x) => !x.startsWith('-') && /[1-9]/.test(x) && (x.split('.')[1]?.length ?? 0) <= GMX_USD_DECIMALS, 'a positive USD amount');427428export const GmxPolicySchema = z.strictObject({429  /** GM market addresses (EIP-55), each in the pinned market table */430  allowedMarkets: z.array(z.string().regex(/^0x[0-9a-fA-F]{40}$/)).min(1).max(GMX_POLICY_BOUNDS.maxMarkets),431  /** an increase is refused when sizeDeltaUsd > maxLeverage × the collateral sent, valued at the signer's OWN price */432  maxLeverage: z.number().int().min(1).max(GMX_POLICY_BOUNDS.maxLeverage),433  /** largest sizeDeltaUsd of one increase, in USD */434  maxOrderSizeUsd: PosUsd,435  /** increases signed per UTC day */436  maxOrdersPerDay: z.number().int().min(1).max(GMX_POLICY_BOUNDS.maxOrdersPerDay),437  /** the acceptable price must be within this band of the signer's OWN price of the index asset */438  maxPriceDeviationBps: z.number().int().min(GMX_POLICY_BOUNDS.minPriceBandBps).max(GMX_POLICY_BOUNDS.maxPriceBandBps),439  /** largest execution fee of one order, wei */440  maxExecutionFeeWei: z.string().regex(UINT),441});442export type GmxPolicy = z.infer<typeof GmxPolicySchema>;443444export const GmxAuthorizationSchema = z.strictObject({445  v: z.literal(1), authorizationId: z.uuid(), keyId: z.string().min(8).max(80),446  venue: z.literal('gmx'), network: z.literal('arbitrum'),447  /** the account IS the wallet's own address (lower case): GMX has no venue account */448  account: z.string().regex(ADDRESS_LC),449  /** one passkey per order; nothing else exists for GMX */450  mode: z.literal('MANUAL_LIVE'),451  /** the reviewed contract set (GMX_PINS.id) and its entry point */452  contracts: z.string().min(4).max(60), exchangeRouter: z.string().regex(/^0x[0-9a-fA-F]{40}$/),453  policy: GmxPolicySchema,454  createdAt: z.number().int().positive(), expiresAt: z.number().int().positive(),455  /** SHA-256 of the jurisdiction acknowledgement text that was shown to the owner */456  ackSha256: z.string().regex(/^[0-9a-f]{64}$/),457});458export type GmxAuthorization = z.infer<typeof GmxAuthorizationSchema>;459460export function gmxAuthorizationBytes(a: GmxAuthorization): Uint8Array {461  const x = GmxAuthorizationSchema.parse(a), p = x.policy;462  const fields: [string, string][] = [463    ['v', '1'], ['authorizationId', x.authorizationId], ['keyId', x.keyId], ['venue', x.venue], ['network', x.network], ['account', x.account], ['mode', x.mode], ['contracts', x.contracts], ['exchangeRouter', x.exchangeRouter],464    ['policy.allowedMarkets', [...p.allowedMarkets].sort().join(',')], ['policy.maxLeverage', String(p.maxLeverage)], ['policy.maxOrderSizeUsd', D.str(D.from(p.maxOrderSizeUsd))], ['policy.maxOrdersPerDay', String(p.maxOrdersPerDay)],465    ['policy.maxPriceDeviationBps', String(p.maxPriceDeviationBps)], ['policy.maxExecutionFeeWei', p.maxExecutionFeeWei],466    ['createdAt', String(x.createdAt)], ['expiresAt', String(x.expiresAt)], ['ackSha256', x.ackSha256],467  ];468  const parts: Uint8Array[] = [enc.encode('SPW-GMX-AUTH-v1'), new Uint8Array([0])];469  for (const [k, v] of fields) { const kb = enc.encode(k), vb = enc.encode(v), head = new Uint8Array(6); new DataView(head.buffer).setUint16(0, kb.length); new DataView(head.buffer).setUint32(2, vb.length); parts.push(head, kb, vb); }470  const out = new Uint8Array(parts.reduce((n, q) => n + q.length, 0));471  let at = 0; for (const q of parts) { out.set(q, at); at += q.length; }472  return out;473}474export const gmxAuthorizationHash = (a: GmxAuthorization): Uint8Array => sha256(gmxAuthorizationBytes(a));475export const gmxAuthorizationHashHex = (a: GmxAuthorization): string => hex(gmxAuthorizationHash(a));476477/** Structural validity at approval time. The signer additionally checks the key id, that the account is ITS OWN address, the acknowledgement hash and the passkey. */478export function validateGmxAuthorization(a: GmxAuthorization, nowSec: number): void {479  if (a.contracts !== GMX_PINS.id || a.exchangeRouter !== GMX_PINS.exchangeRouter.address) throw new Error('the authorization names another GMX contract set than the one this build reviewed');480  if (new Set(a.policy.allowedMarkets.map(lc)).size !== a.policy.allowedMarkets.length) throw new Error('duplicate market');481  for (const m of a.policy.allowedMarkets) { const pin = gmxMarket(m); if (!pin || pin.market !== m) throw new Error(`${m} is not in the pinned GMX market table`); }482  if (BigInt(a.policy.maxExecutionFeeWei) === 0n || BigInt(a.policy.maxExecutionFeeWei) > GMX_POLICY_BOUNDS.maxExecutionFeeWei) throw new Error('execution fee ceiling out of range');483  gmxUsdToRaw(a.policy.maxOrderSizeUsd);484  if (a.expiresAt <= nowSec) throw new Error('authorization already expired');485  if (a.createdAt > nowSec + 120 || a.createdAt < nowSec - 600) throw new Error('authorization creation time is not current');486  if (a.expiresAt - a.createdAt > GMX_POLICY_BOUNDS.maxDays * 86400) throw new Error(`an authorization lasts at most ${GMX_POLICY_BOUNDS.maxDays} days`);487}488489/**490 * The venue policy applied to one plan, with the signer's own prices (USD per whole unit, as numbers from its two exchanges).491 * Returns null when the plan fits, otherwise the reason. A cancellation is bound by nothing here: it can only bring an order's492 * collateral back to the wallet.493 */494export function gmxPolicyVerdict(policy: GmxPolicy, plan: GmxPlan, px: { index: number; collateral: number } | null): string | null {495  if (plan.action === 'cancel') return null;496  const m = plan.market!, c = plan.collateral!;497  if (!policy.allowedMarkets.some((x) => x === m.market)) return `${m.name} is not allowed by the authorization`;498  if (plan.executionFee > BigInt(policy.maxExecutionFeeWei) || plan.executionFee > GMX_POLICY_BOUNDS.maxExecutionFeeWei) return `execution fee ${plan.executionFee} wei is above the authorized ${policy.maxExecutionFeeWei}`;499  if (!px || !(px.index > 0) || !(px.collateral > 0) || !Number.isFinite(px.index) || !Number.isFinite(px.collateral)) return 'no price';500  // the acceptable price against the signer's own price of the index asset, both in the contract's price unit501  const k = gmxPriceDecimals(m.indexDecimals), mid = scaleExact(px.index.toFixed(Math.min(8, k)), k);502  const gap = plan.acceptablePrice > mid ? plan.acceptablePrice - mid : mid - plan.acceptablePrice;503  if (gap * 10_000n > BigInt(policy.maxPriceDeviationBps) * mid) return `acceptable price ${gmxPriceFromRaw(plan.acceptablePrice, m.indexDecimals)} is more than ${policy.maxPriceDeviationBps} bps away from the signer's own ${m.priceSymbol} price ${gmxPriceFromRaw(mid, m.indexDecimals)}`;504  if (plan.action === 'increase') {505    if (plan.sizeDeltaUsd > gmxUsdToRaw(policy.maxOrderSizeUsd)) return `order size ${gmxUsdFromRaw(plan.sizeDeltaUsd)} USD is above the authorized ${policy.maxOrderSizeUsd}`;506    // leverage = size ÷ value of the collateral sent, at the signer's own price of the collateral token (8 decimals)507    const collPx = scaleExact(px.collateral.toFixed(8), 8);508    const collateralUsd = plan.amount * collPx * 10n ** BigInt(GMX_USD_DECIMALS - 8 - c.decimals);509    if (plan.sizeDeltaUsd > BigInt(policy.maxLeverage) * collateralUsd) return `leverage ${D.str(D.div(D.from(plan.sizeDeltaUsd), D.from(collateralUsd === 0n ? 1n : collateralUsd), 2, 'up'))}× is above the authorized ${policy.maxLeverage}×`;510  }511  return null;512}513
Files main
Menu

Theme