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