永续合约 03 - GMX 协议深度解析

GMX 不走 AMM 做市的路子, 直接拿 Oracle 报价成交, LP 池作为交易者的对手方. 本文介绍 GMX v1/v2 的 GLP/GM 池架构, Oracle 定价与零滑点机制, Funding Rate 实现, 以及 LP 承担 counterparty 风险时的收益模型.

一、目录

# 章节 内容
术语表 GMX 核心术语与概念
GMX 概述: 为什么重要 Oracle 型永续的核心设计理念
GLP / GM Pool: 流动性池 LP 池结构与资产组成
零滑点交易 (Oracle Pricing) 预言机定价机制与零滑点原理
开仓/平仓流程: Two-step Execution 两步执行的开仓与平仓流程
Funding Rate 在 GMX 中的实现 GMX 特有的资金费率机制
OI 限制与风险管理 未平仓量上限与风控策略
LP 的风险与收益 流动性提供者的收益来源与风险
GMX 合约架构概览 核心合约结构与调用关系
十一 Go: 读取 GMX 数据 用 Go 读取链上 GMX 数据实战
十二 与 CEX 永续对比表 GMX 与中心化交易所永续对比
十三 小结 + 下一步 总结与后续学习路径

二、术语表

2.1 GMX 核心概念

术语 英文 含义
GLP GLP (GMX Liquidity Provider) v1 的多资产流动性池代币, LP 存入资产获得 GLP
GM GM Token v2 的隔离市场池代币, 每个交易对一个独立池
零滑点 Zero Slippage 交易按 Oracle 价格执行, 没有 AMM 曲线滑点
Price Impact Price Impact Fee v2 引入的基于 OI 偏斜的动态费用, 替代 AMM 滑点的保护机制
Long/Short OI Long/Short Open Interest GMX 特有: 交易者单侧持仓的总名义价值. 因为 LP 池统一做对手方, 两侧不需要相等 (区别于订单簿中 long OI = short OI 的恒等关系, 见永续合约 FAQ Q22)
OI 偏斜 OI Imbalance / Skew abs(Long OI - Short OI), 即 LP 池承担的净方向性敞口
Two-step Execution Two-step Execution 用户提交请求 → keeper 执行, 防止 Oracle 抢跑
Keeper Keeper 监控链上请求并触发执行的链下机器人
Borrow Fee Borrow Fee 向 LP 池借入资产的利率, v1 中替代 funding rate
Target Weight Target Weight GLP 池中每种资产的目标占比

2.2 角色

术语 英文 含义
Trader Trader 开仓做多/做空的交易者
LP Liquidity Provider 向 GLP/GM 池存入资产, 赚手续费但承担方向性风险
Liquidator Liquidator 监控并执行清算的链上参与者

三、GMX 概述: 为什么重要

3.1 Oracle 型永续的开创者

DeFi 永续合约有三种主流模型 (详见 永续合约机制详解 §6 三种定价模型):

  • Oracle 型 (GMX): Chainlink 喂价, LP 池做对手方, 零滑点
  • 订单簿型 (dYdX, Hyperliquid): 做市商报价撮合, 接近 CEX
  • vAMM 型 (Perpetual Protocol): 虚拟 AMM 曲线定价, 纯链上

GMX 的核心创新在于: 用 Chainlink Oracle 的价格直接作为成交价, LP 池作为所有交易者的统一对手方. 这意味着:

  • 无论你交易 10 USDC 还是 1,000,000 USDC, 成交价格都是 Oracle 报价 (v1)
  • 没有 AMM 滑点, 没有订单簿深度问题
  • 代价是: LP 池承担所有交易者的反向 PnL, 且有 OI 上限

3.2 GMX v1 vs v2 的演进

维度 GMX v1 GMX v2
上线时间 2021 年 9 月 2023 年 8 月
流动性池 GLP (单一多资产池) GM tokens (每个市场隔离池)
定价 纯 Oracle 价格, 零滑点 Oracle + price impact fee
Funding Rate 无 (只有 borrow fee) 有, 基于 OI 偏斜
支持资产 ETH, BTC, LINK, UNI + 稳定币 更多市场, 可快速上新
风险隔离 无 (所有市场共享 GLP) 有 (每个市场独立池)
OI 平衡激励 price impact + funding 双重激励

v2 解决了 v1 的几个核心问题:

  1. 风险隔离: v1 中 LINK 暴涨的风险由整个 GLP 承担; v2 中每个市场独立
  2. OI 平衡: v1 没有机制激励多空平衡; v2 通过 price impact 和 funding rate 双重引导
  3. 可扩展性: v1 上新资产需要修改 GLP 组成; v2 只需创建新的 GM pool

3.3 部署

GMX 部署在两条链上:

  • Arbitrum (主要): 绝大部分 TVL 和交易量
  • Avalanche (辅助): 较小规模

选择 Arbitrum 的原因: 低 gas, 快确认, EVM 兼容, 适合高频交易场景.


四、GLP / GM Pool: 流动性池

4.1 GMX v1: GLP 池

GLP 是 GMX v1 的核心: 一个包含多种资产的流动性池:

GLP 池结构: 多资产 + 目标权重 GLP Pool Total ~$400M TVL (peak $700M+) ETH 30% BTC 25% USDC 25% USDT LINK Trader 手续费 + 亏损 → GLP 池 → LP 收益 Trader 盈利时 → 从 GLP 池提取 → LP 亏损 GLP 持有者 = 所有交易者的统一对手方 (类似赌场庄家) LP 存入 ETH/BTC/USDC mint GLP Trader 做多/做空 Oracle 价格成交 LP 提供流动性赚手续费, 但承担 Trader 盈利时的对手方亏损

GLP 是单边流动性 (Single-sided Liquidity):

1
2
3
4
5
6
7
8
9
10
与 Uniswap 的区别:
Uniswap: 必须同时存入两种资产, 按 50:50 比例 (双边流动性)
GLP: 存入任意单个资产即可 (单边流动性)

原因: GLP 不做价格发现, 价格由 Chainlink Oracle 提供
池子只是 "赔付资金池", 不是 "定价池", 所以不需要凑交易对

注意: 虽然你存的是单个资产, 但 GLP 代表整个池子的份额
存入纯 ETH → 拿到 GLP → 实际持有 ETH+BTC+USDC+... 的混合敞口
赎回时可以选择取回任意一种池中资产 (不一定是你当初存的那个)

GLP 的工作方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
LP 存入 ETH → Vault 按 Oracle 价格计算 USD 价值 → mint 等值 GLP
LP 赎回 GLP → burn GLP → 按比例取回池中资产

GLP price (GLP价格) = (池中所有资产 USD 总价值) / GLP total supply (GLP总供应量)

收益来源:
1. 交易手续费 (swap fee + margin trading fee) → 70% 给 GLP
2. Borrow fee (杠杆交易者付的借贷费)
3. 交易者的亏损 (直接进入池子)

风险:
1. 交易者大规模盈利 → 从池子提取利润 → GLP 贬值
2. 池中资产价格下跌 → GLP 价格下跌 (持有 ETH/BTC 敞口)

4.2 目标权重 (Target Weight)

GLP 池对每种资产有一个目标权重 (target weight). 存入低于目标权重的资产收取更低的手续费, 高于目标权重的收取更高手续费:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
目标权重示例 (Arbitrum):
ETH: 30%
BTC: 25%
USDC: 25%
USDT: 5%
DAI: 5%
LINK: 5%
UNI: 5%

如果当前 ETH 占比只有 20% (低于目标 30%):
→ 存入 ETH: 手续费 0.2% (优惠)
→ 取出 ETH: 手续费 0.5% (惩罚)

如果当前 ETH 占比已达 35% (高于目标 30%):
→ 存入 ETH: 手续费 0.5% (惩罚)
→ 取出 ETH: 手续费 0.2% (优惠)

这个机制引导 GLP 池组成趋向目标权重.

4.3 GMX v2: GM 隔离池

v2 用 GM tokens 替代 GLP, 核心区别是 每个交易对有独立的流动性池:

GLP (v1) vs GM (v2): 流动性隔离 v1: GLP (单一池) ETH + BTC + USDC + LINK + UNI + ... 所有市场共享同一个池 LINK 暴涨 → 整个池受影响 风险传染! 演进 v2: GM (隔离池) GM:ETH-USDC ETH long + USDC short GM:BTC-USDC BTC long + USDC short GM:ARB-USDC ARB long + USDC short GM:SOL-USDC SOL long + USDC short 各池风险独立, 互不影响 v2 GM 池 = 每个市场有 long token (如 ETH) + short token (如 USDC)

GM Pool 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GM:ETH-USDC 池:
Long token = ETH (做多方需要的底层资产)
Short token = USDC (做空方的抵押品)

LP 存入资产:
- 存 ETH → mint GM (按 Oracle 价格计算 USD 价值)
- 存 USDC → mint GM
- 可以同时存两者

交易发生时:
- Trader 做多 ETH: 从池中 "借" ETH 的上涨收益
- Trader 做空 ETH: 从池中 "借" USDC (做空盈利时)

GM price (GM价格) = (池中 long token (多头代币) 价值 + short token (空头代币) 价值) / GM total supply (GM总供应量)

v2 的隔离设计意味着: LP 可以选择性地只为 ETH-USDC 市场提供流动性, 不必承担 LINK 或其他小币种的风险.

4.4 GM 池保证金选择: long token 还是 short token?

GM 池有两种代币 (如 ETH + USDC), 做多做空都可以选择存入哪种作为保证金:

做多 ETH 保证金用 ETH 保证金用 USDC
上涨时 赚更多 (仓位盈利 + 保证金本身涨) 只赚仓位盈利
下跌时 亏更多 (仓位亏损 + 保证金缩水) 亏损更可控
清算线 更高 (更容易被清算) 更低 (更安全)
适合 极度看涨, 愿意承担更大风险 稳健做多

用 ETH 做保证金做多 = “杠杆 on 杠杆”, 风险和收益都被放大.
大多数人的做法: 做多/做空都存 USDC, 用稳定币做保证金更可控.

long/short token 是固定规则, 不需要 “定义”:

1
2
3
4
5
6
7
8
9
GM 池命名: GM:[波动资产]-[稳定币]
→ 波动资产 = long token, 稳定币 = short token
→ GM:ETH-USDC → long=ETH, short=USDC
→ GM:BTC-USDC → long=BTC, short=USDC

GMX v2 不支持双波动资产交易对 (如 ETC/BNB):
原因: 两个都在波动 → 需追踪两个 Oracle → LP 风险无法有效对冲
对比: CEX (Binance) 支持, 因为订单簿不需要 LP 池兜底
变通: 想赌 ETC 涨 BNB 跌 → 开两个仓: 做多 ETC/USDC + 做空 BNB/USDC

4.5 GMX 的杠杆本质: 记账式差价合约 (CFD)

GMX 的杠杆不是真的借币, 而是纯记账:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Alice 10x 做多 ETH, 保证金 $1,000:

错误理解: Alice 借了 $9,000 → 买入 $10,000 ETH → 持有 ETH
❌ 没有任何 ETH 被借出或买入

GMX 实际做法:
1. Alice 的 $1,000 USDC 转入 Vault (transferFrom, 链上不可逆)
2. 合约记录一条 position (仓位):
{ size: $10,000, collateral: $1,000, entryPrice: $2,000, isLong: true }
3. 完事. 没有 ETH 移动, 只是一条记录.

平仓时:
Oracle 报价 $2,200 (+10%)
PnL (盈亏) = ($2,200 - $2,000) / $2,000 × $10,000 = +$1,000
Alice 取走: $1,000 (保证金) + $1,000 (盈利) = $2,000
→ 盈利从池子支出

Oracle 报价 $1,900 (-5%)
PnL = ($1,900 - $2,000) / $2,000 × $10,000 = -$500
Alice 取走: $1,000 - $500 = $500
→ 亏损留在池子, 成为 LP 收益

本质: LP 池和交易者之间的差价合约 (CFD, Contract for Difference)

1
2
3
传统 CFD (IG Group 等):   平台报价, 你和平台对赌
GMX CFD: Oracle 报价, 你和 LP 池对赌
→ Oracle 替代平台报价, 智能合约替代平台结算

池子的 Reserve (预留) 机制:

1
2
3
4
5
6
7
8
9
10
Alice 做多 $10,000 ETH:
池子必须预留 $10,000 等值的 ETH, 不允许被取走

为什么预留 ETH 而不是 USDC?
ETH 从 $2,000 涨到 $4,000 → Alice 盈利 $10,000
如果池子只留了 $10,000 USDC → 不够赔
但如果留了 5 ETH → 现在值 $20,000 → 够赔

→ 预留 ETH 天然对冲做多盈利
→ 这就是 GM 池分 long token (ETH) + short token (USDC) 的原因

与其他杠杆方式对比:

维度 GMX (池子记账) Aave 借贷杠杆 dYdX (订单簿)
对手方 LP 池 借贷池 其他交易者
资产移动 无, 纯记账 真的借出 ETH 真的撮合成交
价格来源 Oracle 市场价 (去 DEX 买) 订单簿成交价
杠杆上限 50x ~3-5x (受抵押率限制) 20x
LP 风险 方向性亏损 坏账风险 无 (非 LP 对赌)

4.6 GMX 核心能力栈

GMX 核心能力栈 1. Oracle 定价 (Chainlink) ← 不需要撮合, 直接拿外部价格 2. Vault 记账 ← 存保证金, 记录 position 3. 差价结算 (CFD) ← 按 Oracle 价差算盈亏 核心 4. 风控层: Keeper 清算 ← 保障机制, 防止穿仓 5. Swap (附属功能) ← 池子里有多种币, 顺便提供兑换 核心 = 1+2+3 (Oracle + 记账 + 差价结算) 清算 = 保障层, 不是核心功能 | Swap = 副产品, 不是核心业务 类比: 银行核心 = 存贷款, 银行也提供换汇 = 附属功能

五、零滑点交易 (Oracle Pricing)

5.1 v1: 纯 Oracle 定价

GMX v1 的交易完全按 Chainlink Oracle 的报价执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户做多 ETH, 开仓 $100,000:
Oracle 报价: ETH = $2,000
成交价: $2,000 (无论仓位多大)
买入数量: 100,000 / 2,000 = 50 ETH (名义)

对比 Uniswap V3:
如果在 AMM 上买 $100,000 的 ETH:
→ 滑点可能 0.1% ~ 1% (取决于流动性深度)
→ 实际均价可能是 $2,005 ~ $2,020

对比订单簿 (dYdX):
如果市场深度不够:
→ 可能需要吃掉多个价格层级
→ 大单仍有市场冲击成本

这就是 GMX 对大户最有吸引力的地方: 交易 $1M 和交易 $100 的价格完全一样.

GMX Swap vs Uniswap: GMX 的核心优势是杠杆交易, 不是现货兑换. 详见 永续合约 FAQ Q24.

5.2 v2: Price Impact 机制

v1 的零滑点有个问题: 如果所有人都做多, LP 池承担巨大的方向性风险. v2 引入了 price impact fee 来解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Price Impact = 基于 OI 偏斜的动态费用

当前 OI 状态:
Long OI: $50M
Short OI: $30M
偏斜: Long 偏多 $20M

用户想再做多 $1M:
→ 加剧偏斜 → price impact 为正 (需要额外付费)
→ 相当于: 成交价比 Oracle 价格稍高

用户想做空 $1M:
→ 减少偏斜 → price impact 为负 (获得折扣)
→ 相当于: 成交价比 Oracle 价格稍低

Price Impact 计算 (简化):
impact (价格影响) = (finalDiff (最终偏差)^2 - initialDiff (初始偏差)^2) * impactFactor (影响因子)
其中:
initialDiff = |longOI (交易者做多敞口) - shortOI (交易者做空敞口)| (交易前的偏斜)
finalDiff = |longOI' - shortOI'| (交易后的偏斜)
impactFactor = 协议参数, 每个市场不同
Price Impact: OI 偏斜 → 动态费用 OI 平衡点 Short 偏多 ← → Long 偏多 0 做多 付费 ↑ 做多 折扣 ↓ Long 已经偏多, 再做多 → 加剧偏斜 → 收取更高费用 Short 偏多, 去做多 → 减少偏斜 → 获得折扣 Price Impact 本质: 用经济激励引导 OI 趋向平衡, 保护 LP

5.3 费用结构 (Fee Structure)

GMX 交易费用组成 费用类型 说明 费率 1. Position Fee (仓位手续费) 开仓/平仓时一次性收取 每次开仓或平仓按仓位名义价值收取 → 无论盈亏都要付 v1: 0.1% v2: 0.05-0.07% 2. Price Impact (价格影响费) v2 独有, 基于 OI 偏斜 (见 §3.2) 加剧偏斜 → 付费; 减少偏斜 → 获得折扣 → 本质是 "变相滑点", 保护 LP 动态计算 可正可负 3. Borrow Fee (借贷费) 持仓期间持续计费, 按小时累积 向 LP 池 "借入" 资产的利率 → 持仓越久, 累积越多 (类似贷款利息) factor × OI/pool 4. Funding Fee (资金费率) v2 独有, 多空之间互付 多数方付给少数方, 类似 CEX funding rate → 平衡多空比例 (详见 §5) 基于 OI 偏斜 5. Execution Fee (执行费) 支付给 Keeper 的 gas 补偿 Keeper 代用户执行交易, 需要 gas 费 → 固定金额, 和仓位大小无关 ~$0.1-$0.5 费用分配 v1 分配 仓位手续费 + 借贷费 → 70% GLP | 30% GMX stakers v2 分配 仓位手续费 + 借贷费 → 63% GM LP | 27% GMX | 10% 国库
1
2
3
4
5
6
7
8
Borrow Fee (借贷费) 公式:
borrow_fee_per_hour (每小时借贷费)
= borrowing_factor (借贷因子) × (OI (未平仓合约总量) / pool_size (池子总量))

例: borrowing_factor = 0.00001, OI = $50M, pool = $100M
→ 每小时费率 = 0.00001 × (50M / 100M) = 0.000005 = 0.0005%
→ 持仓 1 天 = 0.0005% × 24 = 0.012%
→ 持仓 1 年 ≈ 4.38%

六、开仓/平仓流程: Two-step Execution

6.1 为什么需要两步执行

直觉上, 用户提交交易 → 立刻成交, 很简单. 但在 Oracle 型协议中, 这会导致严重的抢跑问题:

为什么需要 Two-step Execution 问题: 单步执行 (假设性) 1. ETH=$2000 2. 攻击者看到 Oracle 3. 抢先开多 @ $2000 4. Oracle→$2050 攻击者在 Oracle 更新前看到新价格, 抢先按旧价格开仓 → 无风险盈利 2.5% 解决: Two-step Execution Step 1: 用户请求 createIncreasePosition() 等待 Oracle 更新价格 Chainlink feed Step 2: Keeper 执行 executeIncreasePosition() 用户请求时不知道成交价 → Keeper 用请求后的 Oracle 价格执行 → 无法抢跑 时间窗口: 请求后 ~1-30 秒 Keeper 执行, 用执行时刻的 Oracle 价格

6.2 详细流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
开仓 (Long ETH, 10x leverage, $10,000 collateral):

Step 1 - 用户发起请求:
→ 调用 ExchangeRouter.createOrder()
→ 参数: market, collateral token, size, leverage, acceptable price
→ 用户发送 collateral ($10,000 USDC) + execution fee ($0.3)
→ 合约存储 Order 结构体, 不立即执行

Step 2 - Keeper 执行:
→ Keeper 监控到新 Order
→ 获取最新 Chainlink Oracle 价格 (含签名)
→ 调用 OrderHandler.executeOrder()
→ 合约验证:
- Oracle 价格在 acceptable price 范围内?
- 池子有足够流动性?
- OI 上限未超?
→ 验证通过 → 创建 Position, 更新 OI
→ 验证失败 (如价格超出 acceptable) → 取消, 退还 collateral

平仓:
同样两步: createDecreaseOrder → executeDecreaseOrder
→ 计算 PnL (盈亏) = (exit price (平仓价) - entry price (开仓价)) * size (仓位大小) / entry price
→ 盈利 → 从池中转给 trader
→ 亏损 → 从 collateral 中扣除, 归入池中

6.2.1 两步执行为什么能防抢跑: 核心规则

1
2
3
4
5
6
7
8
9
10
11
核心规则:
订单创建时间 = t1
Keeper 执行时使用的 Oracle 价格时间 = t2

t2 必须 >= t1 (永远只能用订单创建之后的价格)

为什么有效:
用户在 t1 提交订单时, t2 时刻的价格还不存在 → 无法预知成交价

单步执行: 成交价 = 提交时的 Oracle (旧价格) → 攻击者能利用信息差
两步执行: 成交价 = 执行时的 Oracle (新价格) → 提交时的价格没用

Keeper 执行流程:

Two-step Execution: Keeper 执行流程 用户 合约 Keeper (链下机器人) createOrder() 存储 Order 记录创建时间 t1 OrderCreated 事件 监听到新订单 用户什么都不用做... Chainlink Data Streams 获取签名价格 (t2 >= t1) executeOrder(签名价格) 合约验证 ✓ Oracle 签名有效? ✓ t2 >= t1? (时间戳规则) ✓ 价格在 acceptable 范围内? Event: 已成交 ✓ 订单取消, 退回保证金 整个过程: 1-30 秒 Keeper 赚 execution fee (~$0.3) 任何人可以跑 Keeper, 实际由 GMX 运营

一句话: 两步执行的本质是把 “定价权” 从用户手中拿走.
用户只能说 “我想交易”, 不能决定 “按什么价格交易”.
价格由 Keeper 执行时刻的 Oracle 决定, 消除了信息优势.

6.3 v2 的 Oracle 改进

GMX v2 使用了 Chainlink Data Streams (而非传统的 on-chain feed), 特点:

  • Off-chain 签名价格: Chainlink 节点签名价格数据, Keeper 将签名提交到合约验证
  • 更高频: 可以获得更精确的时间点价格, 而非等链上 feed 更新
  • 更低延迟: 减少 Oracle 抢跑的时间窗口
1
2
3
4
5
6
7
8
9
10
11
12
// GMX v2 - Oracle price validation (simplified)
struct OraclePrice {
uint256 min; // 最低价 (用于做多平仓, 做空开仓)
uint256 max; // 最高价 (用于做多开仓, 做空平仓)
}

// 使用 min/max 而非单一价格, 对交易者稍微不利, 保护 LP:
// - 做多开仓: 用 max price (买贵一点)
// - 做多平仓: 用 min price (卖便宜一点)
// - 做空开仓: 用 min price (卖便宜一点)
// - 做空平仓: 用 max price (买贵一点)
// 这个 min/max spread 通常很小 (< 0.05%), 但提供了额外的 LP 保护

开仓后能取消吗? 不能. Step 2 执行后只能平仓, 否则会产生 “免费期权” 漏洞. 详见 永续合约 FAQ Q25.


七、Funding Rate 在 GMX 中的实现

7.1 v1: Borrow Fee 替代 Funding Rate

GMX v1 没有传统的 funding rate (多空互付), 而是用 borrow fee 替代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
v1 Borrow Fee 逻辑:
- 所有杠杆交易者 (无论多空) 都要付 borrow fee
- 相当于: 你向 GLP 池 "借" 了资产, 需要付利息
- 费率 = borrowing_rate (借贷利率) * utilization (利用率)

Long 交易者: 借入 ETH 的上涨收益 → 付 ETH borrow fee
Short 交易者: 借入 USDC (用于做空) → 付 USDC borrow fee

borrow_fee_per_hour (每小时借贷费) = (assets_borrowed (已借出资产) / total_pool_amount (池子总量)) * borrowing_factor (借贷因子)

v1 的问题:
- 没有多空平衡激励: 不管 OI 多偏, 费率结构相同
- 多头市场: 所有人做多, 没人做空, LP 承担巨大单边风险
- 没有 funding rate → 没有套利者来平衡多空

7.2 v2: 引入 Funding Rate

GMX v2 引入了基于 OI 偏斜的 funding rate:

GMX v2 Funding Rate: 基于 OI 偏斜 Long OI: $80M Short OI: $50M 偏斜 = $30M Long 偏多 → Long 付 funding 给 Short v2 Funding Rate 计算 diffUsd = |longOI - shortOI| = |80M - 50M| = 30M (LP 净敞口) totalOI = longOI + shortOI = 130M (交易者总敞口) fundingRate = (diffUsd / totalOI) * fundingFactor * time 多数方 (Long) 付给少数方 (Short), 与 CEX funding 类似 funding + price impact 双重机制 → 引导 OI 趋向平衡 → 降低 LP 方向性风险

v2 Funding Rate 特点:

  • 多数方付给少数方 (与 CEX 永续一致)
  • 费率与 OI 偏斜成正比: 偏斜越大, funding rate 越高
  • 持续累积, 按秒计算 (不是固定 8h 结算)
  • 与 borrow fee 共存: 交易者同时支付 funding fee + borrow fee
1
2
3
4
5
6
7
8
9
10
11
v2 费用对比:

v1 v2
Borrow Fee: 所有方向都付 所有方向都付 (保留)
Funding Rate: 无 多数方 → 少数方
Price Impact: 无 加剧偏斜付费, 减少偏斜折扣

v2 的三重平衡机制:
1. Price Impact: 开仓时的一次性费用 → 阻止偏斜加剧
2. Funding Rate: 持仓期间的持续费用 → 激励多数方平仓
3. Borrow Fee: 持仓成本 → 抑制过度杠杆

八、OI 限制与风险管理

8.1 单侧 OI 上限

GMX 对每个市场设置 OI 上限, 防止 LP 池承担过大的方向性风险:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ETH-USDC 市场 OI 上限示例:
Long OI Cap: $200M (做多上限)
Short OI Cap: $200M (做空上限)

为什么需要上限?
假设 LP 池 (GLP/GM) 有 $500M 资产, 其中 ETH 占 $150M
如果 Long OI = $300M, 且 ETH 涨 50%:
Trader 盈利 = $300M * 50% = $150M
→ 等于 LP 池中 ETH 部分的全部价值!
LP 池会严重亏损

OI 上限确保: 即使极端行情, LP 池不会被掏空

实际运营参数 (GMX v2 Arbitrum, 近似):
ETH-USDC:
Long OI Cap: ~$200M
Short OI Cap: ~$200M
Pool Size: ~$300M

BTC-USDC:
Long OI Cap: ~$150M
Short OI Cap: ~$150M
Pool Size: ~$200M

ARB-USDC:
Long OI Cap: ~$20M
Short OI Cap: ~$20M
Pool Size: ~$30M

8.2 OI 上限与池大小的关系

1
2
3
4
5
6
7
8
9
10
11
12
安全比例 (经验值):
OI Cap (未平仓上限) / Pool Size (池子大小)0.5 ~ 0.8

过高 (OI Cap / Pool = 1.5):
→ 极端行情下 LP 可能亏损 > 池子大小 → 类似 "穿仓"
→ 不安全

过低 (OI Cap / Pool = 0.2):
→ 大量流动性闲置, 资本效率低
→ 交易者无法开足够大的仓位

GMX 通过 governance 动态调整 OI 上限, 权衡安全与效率.

8.3 Reserve 机制

Reserve 和 OI Cap 是两个独立的检查, 都要通过才能开仓:

1
2
3
4
OI Cap:  控制 "规模" → current_OI + new_size <= OI_Cap?
Reserve: 控制 "偿付能力" → 池中可用资产 >= 新仓位需要预留的量?

两者都需要, 任一不满足都拒绝开仓

Reserve 怎么算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GM:ETH-USDC 池:
池中 ETH: 5,000 ETH ($15,000,000 @ $3,000)
池中 USDC: $10,000,000

做多仓位总名义: $12,000,000
→ 需预留: $12,000,000 / $3,000 = 4,000 ETH (锁住, 不能被赎回)
→ 可用 ETH: 5,000 - 4,000 = 1,000 ETH = $3,000,000

Alice 想开多 $5,000,000:
需要额外预留 $5,000,000ETH → 但可用只有 $3,000,000 → ❌ 拒绝

Bob 想开多 $2,000,000:
需要额外预留 $2,000,000 → 可用有 $3,000,000 → ✓ 通过

做空同理, 锁的是 USDC:
做空仓位名义 $8,000,000 → 预留 $8,000,000 USDC
池中 $10,000,000 - $8,000,000 = $2,000,000 USDC 可用

关键问题: 价格可以无限涨, Reserve 够用吗?

做多 reserve 锁的是 ETH (long token), 不是 USDC. 价格涨了, reserve 也跟着涨:

1
2
3
4
5
6
7
8
9
10
11
12
Alice 做多 $100,000 ETH @ $3,000:
Reserve 锁定: $100,000 / $3,000 = 33.3 ETH

ETH 涨到 $30,000 (10 倍):
Alice 盈利 = ($30,000 - $3,000) / $3,000 × $100,000 = $900,000
Reserve 价值 = 33.3 ETH × $30,000 = $999,000
$999,000 > $900,000 → 够赔 ✓

ETH 涨到 $300,000 (100 倍):
Alice 盈利 = $9,900,000
Reserve 价值 = 33.3 × $300,000 = $9,990,000
→ 还是够赔 ✓

数学证明: 无论价格怎么涨, reserve 永远够赔

1
2
3
4
5
6
7
8
9
10
做多仓位, 价格从 entry 涨到 P:
盈利 = size × (P/entry - 1)
Reserve 价值 = (size/entry) × P = size × P/entry

Reserve - 盈利 = size × P/entry - size × (P/entry - 1)
= size
→ 永远等于原始名义价值, 不依赖 P

→ 无论 ETH 涨到多少, 赔完盈利后池子还剩 size 等值的资产
→ 这就是为什么做多锁 ETH, 做空锁 USDC, 天然对冲

做空的情况更简单:

1
2
3
4
做空 reserve 锁 USDC (稳定币, 不波动):
ETH 跌到 0 → 空头最大盈利 = 名义价值 (如 $100,000)
Reserve = $100,000 USDC → 刚好够赔
价格不能跌破 0 → USDC reserve 永远够用

完整的开仓检查链:

1
2
3
4
5
6
用户请求开多 $X:
① OI Cap: current_long_OI + $X <= long_OI_cap?
② Reserve: 可用 long token 价值 >= $X?
Max Leverage: $X / collateral <= max_leverage?
Min Collateral: collateral >= min_collateral_usd?
全部通过 → 允许开仓; 任一失败 → 拒绝, 退回保证金

8.4 GMX v2 清算: 理论开放, 实际集中

1
2
3
v1 清算: 任何人调用 Vault.liquidatePosition() → 一步完成 → 真正 "人人可清算"
v2 清算: 走 Two-step → 执行需要 Chainlink Data Streams 签名 → 签名需付费订阅
→ 实际上只有 Keeper 能执行

极端行情下的风险:

1
2
3
4
5
6
7
ETH 30 分钟跌 30%, 5000 个仓位同时触发清算:

瓶颈 1: Keeper 数量有限 → 逐个提交清算 tx → 排队延迟
瓶颈 2: Arbitrum 区块容量 → gas 飙升 → 更慢
瓶颈 3: Chainlink Data Streams 并发限制 → 签名返回变慢

结果: 清算不及时 → 仓位穿仓 → 保险基金承担 → 不够则 ADL

缓解措施:

1
2
3
4
5
1. 多 Keeper 冗余 (分布不同地理位置, 一组挂了其他继续)
2. 大仓位优先清算 (穿仓损失更大)
3. 保险基金兜底 (~$50M+)
4. ADL 作为最后防线
5. OI Cap 限制同时需要清算的仓位数上限

这是 Two-step 的 trade-off: 防抢跑 (好) ↔ 清算中心化 (代价).
对比: dYdX v4 / Hyperliquid 把清算内置到共识层, 出块时顺便清算, 不需要外部 Keeper.


九、LP 的风险与收益

9.1 LP 收益来源

GLP/GM LP 的收益与风险 收益来源 1. 交易手续费 (Position Fee) ~70% 2. Borrow Fee (借贷利息) 持续 3. 交易者亏损 (直接进入池子) 最大头 4. Swap Fee (现货兑换手续费) 少量 历史 APR: 15% ~ 40% (取决于交易量和 trader PnL) 风险来源 1. 交易者盈利 = LP 亏损 所有 trader 做多且 ETH 暴涨 → LP 巨亏 2. 底层资产价格波动 GLP 持有 ETH/BTC → 暴跌时 GLP 价值下降 3. 智能合约风险 Oracle 故障, 合约漏洞, 治理攻击 核心风险: 方向性风险 LP 本质上是做市商, 赌 trader 长期亏损 历史数据: 长期来看 ~70% 的交易者亏损 → LP 整体盈利 (但短期可能巨亏)

9.2 LP 收益的数学本质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GLP/GM 的收益可以分解为:

GLP 回报 (GLP return) = 底层资产回报 + 手续费收入 + trader 净亏损

分解举例 (一个月):
底层资产涨 5% (ETH/BTC 上涨): +5%
手续费收入: +2%
交易者净亏损: +1.5%
交易者净盈利 (Long ETH 赚钱): -3%
-----------------------------------------
GLP 净回报: +5.5%

关键洞察:
- 如果市场单边暴涨 + 大量多头盈利 → LP 亏损可能超过手续费
- 如果市场震荡 + 交易者频繁交易 → LP 赚手续费 + 交易者亏损, 最好的场景
- 长期: 散户交易者整体亏损是统计规律 → LP 长期正 EV

更多 LP 相关问题:


十、GMX 合约架构概览

10.1 v1 核心合约

GMX v1 合约结构 (Arbitrum) Vault (0x489ee...C4A) 核心资产管理合约, 存放所有 GLP 资产 increasePosition() → 开仓/加仓 decreasePosition() → 减仓/平仓 liquidatePosition() → 清算 swap() → 现货兑换 管理 token whitelist, 费率参数 Router (0xaBBc5...4064) 用户入口, 路由到 Vault approvePlugin(), swap(), increasePosition() PositionRouter (0xb87a4...9868) Two-step execution 的请求管理 createIncreasePosition() → Step 1: 创建请求 executeIncreasePosition() → Step 2: Keeper 执行 cancelIncreasePosition() → 超时取消 OrderBook (0x09f77...2ACB) 限价单管理 createIncreaseOrder() → 创建限价开仓单 executeIncreaseOrder() → Keeper 执行 GlpManager addLiquidity() → 存入资产, mint GLP removeLiquidity() → burn GLP, 取回资产 PriceFeed / FastPriceFeed Chainlink 价格聚合 快速价格更新 (Keeper 提交) Vault = 核心 (所有资产和仓位逻辑集中在一个合约) Router/PositionRouter/OrderBook 都是调用 Vault 的外围合约

10.2 v2 核心合约

GMX v2 合约结构 (简化) ExchangeRouter 用户统一入口 createOrder() → 创建订单 (市价/限价/止损) createDeposit() → LP 存入资产 createWithdrawal() → LP 取出资产 OrderHandler executeOrder() → Keeper 执行订单 Order validation, price impact 计算 DepositHandler / WithdrawalHandler executeDeposit() → Keeper 执行存入 executeWithdrawal() → Keeper 执行取出 DataStore 统一存储所有协议参数 OI caps, funding factors, borrow rates 通过 key-value 存储, 灵活可升级 MarketFactory createMarket() → 创建新的 GM 池 Oracle Chainlink Data Streams 集成 价格签名验证 v2 模块化拆分: 逻辑 (Handler) / 存储 (DataStore) / 定价 (Oracle) 分离

10.3 核心 Solidity 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// GMX v1 - Vault.sol (simplified key functions)
interface IVault {
// 开仓: collateralToken 为抵押品, indexToken 为标的
// isLong: true = 做多, false = 做空
function increasePosition(
address _account,
address _collateralToken,
address _indexToken,
uint256 _sizeDelta, // 增加的仓位大小 (USD, 30 decimals)
bool _isLong
) external;

// 平仓: 减少仓位, 将盈亏结算到 collateral
function decreasePosition(
address _account,
address _collateralToken,
address _indexToken,
uint256 _collateralDelta, // 取出的抵押品
uint256 _sizeDelta, // 减少的仓位大小
bool _isLong,
address _receiver // 接收平仓资金的地址
) external returns (uint256);

// 清算: 任何人都可以调用, 清算保证金不足的仓位
function liquidatePosition(
address _account,
address _collateralToken,
address _indexToken,
bool _isLong,
address _feeReceiver // 清算奖励接收者
) external;

// 读取仓位信息
function getPosition(
address _account,
address _collateralToken,
address _indexToken,
bool _isLong
) external view returns (
uint256 size, // 仓位大小 (USD)
uint256 collateral, // 抵押品 (USD)
uint256 averagePrice, // 平均开仓价
uint256 entryFundingRate, // 开仓时的 cumulative funding rate
uint256 reserveAmount, // 保留的资产数量
int256 realisedPnl, // 已实现盈亏
uint256 lastIncreasedTime // 最后加仓时间
);
}

// GMX v2 - Order creation (simplified)
struct CreateOrderParams {
address receiver;
address callbackContract;
address market; // GM 市场地址
address initialCollateralToken;
OrderType orderType; // MarketIncrease, MarketDecrease, LimitIncrease, etc.
uint256 sizeDeltaUsd; // 仓位变化量 (USD)
uint256 initialCollateralDeltaAmount; // 抵押品数量
uint256 triggerPrice; // 触发价格 (限价单)
uint256 acceptablePrice; // 可接受的最差价格
uint256 executionFee; // 给 Keeper 的 gas 补偿
bool isLong;
bool shouldUnwrapNativeToken;
}

十一、Go: 读取 GMX 数据

11.1 读取 v1 GLP 价格和池子组成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
package main

import (
"context"
"fmt"
"log"
"math/big"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
)

// GMX v1 Arbitrum 合约地址
var (
vaultAddr = common.HexToAddress("0x489ee077994B6658eAfA855C308275EAd8097C4A")
glpManagerAddr = common.HexToAddress("0x3963FfC9dff443c2A94f21b129D429891E32ec18")
readerAddr = common.HexToAddress("0x2b43c90D1B727cEe1Df34925bcd5Ace52Ec37694")
)

// 常用 token 地址 (Arbitrum)
var (
wethAddr = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1")
wbtcAddr = common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f")
usdcAddr = common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831")
)

// readGLPAum 读取 GLP 池 AUM (USD)
// ABI 层返回 *big.Int (30 decimals), 转为 decimal 返回
func readGLPAum(client *ethclient.Client) (decimal.Decimal, error) {
parsed, err := abi.JSON(strings.NewReader(`[{
"inputs": [{"type": "bool"}],
"name": "getAum",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

// maximise = false → 用较保守的价格计算 AUM
data, err := parsed.Pack("getAum", false)
if err != nil {
return decimal.Zero, fmt.Errorf("pack call: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &glpManagerAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call getAum: %w", err)
}

values, err := parsed.Unpack("getAum", result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack getAum: %w", err)
}

// 30 decimals → 人类可读 USD
return decimal.NewFromBigInt(values[0].(*big.Int), -30), nil
}

// readPoolAmounts 读取 Vault 中某 token 的池子数量
// tokenDecimals: ETH=18, BTC=8, USDC=6
func readPoolAmounts(client *ethclient.Client, token common.Address, tokenDecimals int32) (decimal.Decimal, error) {
parsed, err := abi.JSON(strings.NewReader(`[{
"inputs": [{"type": "address"}],
"name": "poolAmounts",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

data, err := parsed.Pack("poolAmounts", token)
if err != nil {
return decimal.Zero, fmt.Errorf("pack call: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &vaultAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call poolAmounts: %w", err)
}

values, err := parsed.Unpack("poolAmounts", result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack: %w", err)
}

return decimal.NewFromBigInt(values[0].(*big.Int), -tokenDecimals), nil
}

func main() {
// 替换为你的 Arbitrum RPC (如 Alchemy, Infura), 公共 RPC 有速率限制
client, err := ethclient.Dial("https://arb1.arbitrum.io/rpc")
if err != nil {
log.Fatal(err)
}
defer client.Close()

// 1. 读取 GLP AUM
aum, err := readGLPAum(client)
if err != nil {
log.Fatal("readGLPAum: ", err)
}
fmt.Printf("GLP AUM: $%s\n\n", aum.StringFixed(2))

// 2. 读取各 token 池子数量 (不同 token 精度不同)
tokens := []struct {
name string
addr common.Address
decimals int32
}{
{"WETH", wethAddr, 18},
{"WBTC", wbtcAddr, 8},
{"USDC", usdcAddr, 6},
}
for _, t := range tokens {
amount, err := readPoolAmounts(client, t.addr, t.decimals)
if err != nil {
log.Printf("readPoolAmounts %s: %v", t.name, err)
continue
}
fmt.Printf("%s pool: %s\n", t.name, amount.StringFixed(4))
}
}

11.2 读取 v2 GM 池价格和组成

v2 的状态存在通用 DataStore 中, 通过 Reader 合约聚合读取. 价格精度从 v1 的 30 decimals 降到 18 decimals.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
package main

import (
"context"
"fmt"
"log"
"math/big"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
)

// GMX v2 Arbitrum 合约地址
var (
dataStoreAddr = common.HexToAddress("0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8")
readerV2Addr = common.HexToAddress("0x60a0fF4cDaF0f6D496d71e0bC0fFa86FE8E6B23c")
)

// GMX v2 market 地址 (每个交易对一个 GM 池)
var (
ethUsdcMarket = common.HexToAddress("0x70d95587d40A2caf56bd97485aB3Eec10Bee6336") // GM:ETH-USDC
)

// MarketInfo v2 市场信息
type MarketInfo struct {
MarketToken common.Address
IndexToken common.Address
LongToken common.Address
ShortToken common.Address
}

// readGMMarketInfo 读取 GM 池的市场信息, 返回真实的 token 地址
func readGMMarketInfo(client *ethclient.Client, market common.Address) (MarketInfo, error) {
readerABI, err := abi.JSON(strings.NewReader(`[{
"inputs": [
{"type": "address", "name": "dataStore"},
{"type": "address", "name": "key"}
],
"name": "getMarket",
"outputs": [{
"type": "tuple",
"components": [
{"type": "address", "name": "marketToken"},
{"type": "address", "name": "indexToken"},
{"type": "address", "name": "longToken"},
{"type": "address", "name": "shortToken"}
]
}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return MarketInfo{}, fmt.Errorf("parse ABI: %w", err)
}

data, err := readerABI.Pack("getMarket", dataStoreAddr, market)
if err != nil {
return MarketInfo{}, fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &readerV2Addr,
Data: data,
}, nil)
if err != nil {
return MarketInfo{}, fmt.Errorf("call getMarket: %w", err)
}

values, err := readerABI.Unpack("getMarket", result)
if err != nil {
return MarketInfo{}, fmt.Errorf("unpack: %w", err)
}

raw := values[0].(struct {
MarketToken common.Address `json:"marketToken"`
IndexToken common.Address `json:"indexToken"`
LongToken common.Address `json:"longToken"`
ShortToken common.Address `json:"shortToken"`
})

return MarketInfo{
MarketToken: raw.MarketToken,
IndexToken: raw.IndexToken,
LongToken: raw.LongToken,
ShortToken: raw.ShortToken,
}, nil
}

// hashString 计算 keccak256(abi.encode(s))
// 匹配 Solidity: bytes32 constant X = keccak256(abi.encode("X"))
func hashString(s string) common.Hash {
stringTy, _ := abi.NewType("string", "", nil)
encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s)
return crypto.Keccak256Hash(encoded)
}

// readGMPoolAmounts 读取 GM 池中 long/short token 的余额
// v2 通过 DataStore 的 key-value 读取, key = keccak256(abi.encode(keccak256(abi.encode("POOL_AMOUNT")), market, token))
func readGMPoolAmounts(client *ethclient.Client, market, token common.Address, tokenDecimals int32) (decimal.Decimal, error) {
dsABI, err := abi.JSON(strings.NewReader(`[{
"inputs": [{"type": "bytes32"}],
"name": "getUint",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

keyHash := hashString("POOL_AMOUNT")
key := crypto.Keccak256Hash(
common.LeftPadBytes(keyHash.Bytes(), 32),
common.LeftPadBytes(market.Bytes(), 32),
common.LeftPadBytes(token.Bytes(), 32),
)

data, err := dsABI.Pack("getUint", key)
if err != nil {
return decimal.Zero, fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &dataStoreAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call getUint: %w", err)
}

values, err := dsABI.Unpack("getUint", result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack: %w", err)
}

return decimal.NewFromBigInt(values[0].(*big.Int), -tokenDecimals), nil
}

func main() {
client, err := ethclient.Dial("https://arb1.arbitrum.io/rpc")
if err != nil {
log.Fatal(err)
}
defer client.Close()

// 1. 读取 GM 池的真实 token 地址 (不要硬编码, 可能是 USDC.e 而非 native USDC)
fmt.Println("=== GM:ETH-USDC Pool ===")
info, err := readGMMarketInfo(client, ethUsdcMarket)
if err != nil {
log.Fatal("readGMMarketInfo: ", err)
}
fmt.Printf("GM Pool: %s\n", ethUsdcMarket.Hex())
fmt.Printf("Index Token: %s\n", info.IndexToken.Hex())
fmt.Printf("Long Token: %s\n", info.LongToken.Hex())
fmt.Printf("Short Token: %s\n", info.ShortToken.Hex())

// 2. 用真实地址查余额 (Long Token 通常是 ETH=18dec, Short Token 通常是 USDC/USDC.e=6dec)
fmt.Println()
tokens := []struct {
name string
addr common.Address
decimals int32
}{
{"Long Token", info.LongToken, 18},
{"Short Token", info.ShortToken, 6},
}
for _, t := range tokens {
amount, err := readGMPoolAmounts(client, ethUsdcMarket, t.addr, t.decimals)
if err != nil {
log.Printf("readGMPoolAmounts %s: %v", t.name, err)
continue
}
fmt.Printf("%s in pool: %s\n", t.name, amount.StringFixed(4))
}
}

v1 vs v2 读取方式对比:
v1 Vault.poolAmounts(token) → 一个调用, 函数名即字段名.
v2 DataStore.getUint(key) → 需要先算 key: keccak256(abi.encode(keccak256(abi.encode("POOL_AMOUNT")), market, token)).

注意 key 编码陷阱: GMX v2 的 key 常量定义为 keccak256(abi.encode("X")), 不是 keccak256("X") (raw bytes).
abi.encode(string) 会加 offset + length 前缀, 两者哈希完全不同. 算错 key 不会 revert, 只会返回 0.

v2 有两种读取模式:

  • Reader 聚合: Reader.getMarket(), Reader.getPosition() — Reader 内部帮你算 key, 参数错会 revert
  • DataStore 直接读: DataStore.getUint(key) — 需要手动构造 key, 适用于 Reader 未封装的字段 (如 pool amount, OI)

11.3 读取 v1 OI 和仓位信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
package main

import (
"context"
"fmt"
"log"
"math/big"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
)

var (
vaultAddr = common.HexToAddress("0x489ee077994B6658eAfA855C308275EAd8097C4A")
wethAddr = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1")
)

// readOpenInterest 读取某个 token 的全局 OI (USD)
func readOpenInterest(client *ethclient.Client, token common.Address, isLong bool) (decimal.Decimal, error) {
funcName := "globalShortSizes"
if isLong {
funcName = "guaranteedUsd" // long OI 通过 guaranteedUsd 间接计算
}

parsed, err := abi.JSON(strings.NewReader(fmt.Sprintf(`[{
"inputs": [{"type": "address"}],
"name": "%s",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`, funcName)))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

data, err := parsed.Pack(funcName, token)
if err != nil {
return decimal.Zero, fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &vaultAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call %s: %w", funcName, err)
}

values, err := parsed.Unpack(funcName, result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack: %w", err)
}

// 30 decimals → USD
return decimal.NewFromBigInt(values[0].(*big.Int), -30), nil
}

// readPosition 读取某个地址的仓位
func readPosition(
client *ethclient.Client,
account, collateralToken, indexToken common.Address,
isLong bool,
) error {
parsed, err := abi.JSON(strings.NewReader(`[{
"inputs": [
{"type": "address"},
{"type": "address"},
{"type": "address"},
{"type": "bool"}
],
"name": "getPosition",
"outputs": [
{"type": "uint256"},
{"type": "uint256"},
{"type": "uint256"},
{"type": "uint256"},
{"type": "uint256"},
{"type": "int256"},
{"type": "uint256"}
],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return fmt.Errorf("parse ABI: %w", err)
}

data, err := parsed.Pack("getPosition", account, collateralToken, indexToken, isLong)
if err != nil {
return fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &vaultAddr,
Data: data,
}, nil)
if err != nil {
return fmt.Errorf("call getPosition: %w", err)
}

values, err := parsed.Unpack("getPosition", result)
if err != nil {
return fmt.Errorf("unpack: %w", err)
}

// 30 decimals → decimal
size := decimal.NewFromBigInt(values[0].(*big.Int), -30)
collateral := decimal.NewFromBigInt(values[1].(*big.Int), -30)
avgPrice := decimal.NewFromBigInt(values[2].(*big.Int), -30)
realisedPnl := decimal.NewFromBigInt(values[5].(*big.Int), -30)

fmt.Printf("Position Size: $%s\n", size.StringFixed(2))
fmt.Printf("Collateral: $%s\n", collateral.StringFixed(2))
fmt.Printf("Average Price: $%s\n", avgPrice.StringFixed(2))
fmt.Printf("Realised PnL: $%s\n", realisedPnl.StringFixed(2))

return nil
}

func main() {
client, err := ethclient.Dial("https://arb1.arbitrum.io/rpc")
if err != nil {
log.Fatal(err)
}
defer client.Close()

// 1. 读取 ETH 的 Long/Short OI
for _, isLong := range []bool{true, false} {
oi, err := readOpenInterest(client, wethAddr, isLong)
if err != nil {
log.Printf("readOpenInterest(long=%v): %v", isLong, err)
continue
}
dir := "Short"
if isLong {
dir = "Long"
}
fmt.Printf("ETH %s OI: $%s\n", dir, oi.StringFixed(2))
}

// 2. 读取某地址的仓位 (替换为实际地址)
account := common.HexToAddress("0x0000000000000000000000000000000000000000")
fmt.Println("\n--- Position ---")
if err := readPosition(client, account, wethAddr, wethAddr, true); err != nil {
log.Printf("readPosition: %v", err)
}
}

11.4 读取 v2 OI 和仓位信息

v2 的 OI 和仓位数据都存在 DataStore 中, 通过 Reader 合约的聚合方法读取.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
package main

import (
"context"
"fmt"
"log"
"math/big"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
)

var (
dataStoreAddr = common.HexToAddress("0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8")
readerV2Addr = common.HexToAddress("0x60a0fF4cDaF0f6D496d71e0bC0fFa86FE8E6B23c")
ethUsdcMarket = common.HexToAddress("0x70d95587d40A2caf56bd97485aB3Eec10Bee6336")
wethAddr = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1")
usdcAddr = common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831")
)

// hashString 计算 keccak256(abi.encode(s))
// 匹配 Solidity: bytes32 constant X = keccak256(abi.encode("X"))
func hashString(s string) common.Hash {
stringTy, _ := abi.NewType("string", "", nil)
encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s)
return crypto.Keccak256Hash(encoded)
}

// readV2OpenInterest 读取 v2 市场的 long/short OI (USD)
// key = keccak256(abi.encode(keccak256(abi.encode("OPEN_INTEREST")), market, collateralToken, isLong))
func readV2OpenInterest(client *ethclient.Client, market common.Address, isLong bool) (decimal.Decimal, error) {
dsABI, err := abi.JSON(strings.NewReader(`[{
"inputs": [{"type": "bytes32"}],
"name": "getUint",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

// long OI 用 long token (WETH), short OI 用 short token (USDC)
collateralToken := usdcAddr
if isLong {
collateralToken = wethAddr
}

keyHash := hashString("OPEN_INTEREST")
isLongBytes := make([]byte, 32)
if isLong {
isLongBytes[31] = 1
}
key := crypto.Keccak256Hash(
keyHash.Bytes(),
common.LeftPadBytes(market.Bytes(), 32),
common.LeftPadBytes(collateralToken.Bytes(), 32),
isLongBytes,
)

data, err := dsABI.Pack("getUint", key)
if err != nil {
return decimal.Zero, fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &dataStoreAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call getUint: %w", err)
}

values, err := dsABI.Unpack("getUint", result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack: %w", err)
}

// 18 decimals → USD
return decimal.NewFromBigInt(values[0].(*big.Int), -18), nil
}

// readV2Position 读取 v2 仓位
// positionKey = keccak256(abi.encode(account, market, collateralToken, isLong))
func readV2Position(
client *ethclient.Client,
account, market, collateralToken common.Address,
isLong bool,
) error {
isLongBytes := make([]byte, 32)
if isLong {
isLongBytes[31] = 1
}
positionKey := crypto.Keccak256Hash(
common.LeftPadBytes(account.Bytes(), 32),
common.LeftPadBytes(market.Bytes(), 32),
common.LeftPadBytes(collateralToken.Bytes(), 32),
isLongBytes,
)

readerABI, err := abi.JSON(strings.NewReader(`[{
"inputs": [
{"type": "address", "name": "dataStore"},
{"type": "bytes32", "name": "key"}
],
"name": "getPosition",
"outputs": [{
"type": "tuple",
"components": [
{"type": "address", "name": "account"},
{"type": "address", "name": "market"},
{"type": "address", "name": "collateralToken"},
{"type": "uint256", "name": "sizeInUsd"},
{"type": "uint256", "name": "sizeInTokens"},
{"type": "uint256", "name": "collateralAmount"},
{"type": "bool", "name": "isLong"}
]
}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return fmt.Errorf("parse ABI: %w", err)
}

data, err := readerABI.Pack("getPosition", dataStoreAddr, positionKey)
if err != nil {
return fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &readerV2Addr,
Data: data,
}, nil)
if err != nil {
return fmt.Errorf("call getPosition: %w", err)
}

values, err := readerABI.Unpack("getPosition", result)
if err != nil {
return fmt.Errorf("unpack: %w", err)
}

pos := values[0].(struct {
Account common.Address `json:"account"`
Market common.Address `json:"market"`
CollateralToken common.Address `json:"collateralToken"`
SizeInUsd *big.Int `json:"sizeInUsd"`
SizeInTokens *big.Int `json:"sizeInTokens"`
CollateralAmount *big.Int `json:"collateralAmount"`
IsLong bool `json:"isLong"`
})

// 18 decimals → decimal
sizeUsd := decimal.NewFromBigInt(pos.SizeInUsd, -18)
sizeTokens := decimal.NewFromBigInt(pos.SizeInTokens, -18)
collateral := decimal.NewFromBigInt(pos.CollateralAmount, -18)

fmt.Printf("v2 Position Size: $%s\n", sizeUsd.StringFixed(2))
fmt.Printf("Size in Tokens: %s\n", sizeTokens.StringFixed(6))
fmt.Printf("Collateral: %s\n", collateral.StringFixed(6))
fmt.Printf("Is Long: %v\n", pos.IsLong)

return nil
}

func main() {
client, err := ethclient.Dial("https://arb1.arbitrum.io/rpc")
if err != nil {
log.Fatal(err)
}
defer client.Close()

// 1. 读取 ETH-USDC 市场的 Long/Short OI
fmt.Println("=== ETH-USDC v2 OI ===")
for _, isLong := range []bool{true, false} {
oi, err := readV2OpenInterest(client, ethUsdcMarket, isLong)
if err != nil {
log.Printf("readV2OpenInterest(long=%v): %v", isLong, err)
continue
}
dir := "Short"
if isLong {
dir = "Long"
}
fmt.Printf("%s OI: $%s\n", dir, oi.StringFixed(2))
}

// 2. 读取仓位 (替换为实际地址)
account := common.HexToAddress("0x0000000000000000000000000000000000000000")
fmt.Println("\n--- v2 Position ---")
if err := readV2Position(client, account, ethUsdcMarket, wethAddr, true); err != nil {
log.Printf("readV2Position: %v", err)
}
}

v1 vs v2 仓位读取对比:

v1 v2
函数 Vault.getPosition(account, collateral, index, isLong) Reader.getPosition(dataStore, positionKey)
仓位标识 4 个参数组合 positionKey = keccak256 编码的 4 参数
价格精度 30 decimals 18 decimals
返回格式 多返回值 (7 个 uint256) 结构体 (typed struct)
OI 读取 Vault.globalShortSizes(token) DataStore.getUint(OPEN_INTEREST key)

11.5 计算 v1 Position PnL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"

"github.com/shopspring/decimal"
)

// calculatePnL 计算仓位的未实现盈亏
// Long: pnl = size * (markPrice - avgPrice) / avgPrice
// Short: pnl = size * (avgPrice - markPrice) / avgPrice
func calculatePnL(size, avgPrice, markPrice decimal.Decimal, isLong bool) decimal.Decimal {
if isLong {
return size.Mul(markPrice.Sub(avgPrice)).Div(avgPrice)
}
return size.Mul(avgPrice.Sub(markPrice)).Div(avgPrice)
}

func main() {
size := decimal.NewFromInt(100_000) // $100,000 仓位
avgPrice := decimal.NewFromInt(2000) // 开仓价 $2,000
markPrice := decimal.NewFromInt(2100) // 当前价 $2,100

longPnL := calculatePnL(size, avgPrice, markPrice, true)
shortPnL := calculatePnL(size, avgPrice, markPrice, false)

// Long PnL = 100000 * (2100 - 2000) / 2000 = $5,000
fmt.Printf("Long PnL: $%s\n", longPnL.StringFixed(2))
// Short PnL = 100000 * (2000 - 2100) / 2000 = -$5,000
fmt.Printf("Short PnL: $%s\n", shortPnL.StringFixed(2))
}

11.6 计算 v2 PnL

v2 的 PnL 计算逻辑相同, 但精度从 30 decimals 改为 18 decimals, 且仓位大小直接存了 sizeInTokens 字段.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"

"github.com/shopspring/decimal"
)

// calculateV2PnL 计算 v2 仓位的未实现盈亏
// v2 存储了 sizeInTokens, 计算更直接:
// Long: pnl = sizeInTokens * markPrice - sizeInUsd
// Short: pnl = sizeInUsd - sizeInTokens * markPrice
func calculateV2PnL(sizeInUsd, sizeInTokens, markPrice decimal.Decimal, isLong bool) decimal.Decimal {
if sizeInTokens.IsZero() {
return decimal.Zero
}
currentValue := sizeInTokens.Mul(markPrice)
if isLong {
return currentValue.Sub(sizeInUsd)
}
return sizeInUsd.Sub(currentValue)
}

func main() {
sizeInUsd := decimal.NewFromInt(100_000) // $100,000
sizeInTokens := decimal.NewFromInt(50) // 50 ETH (开仓价 $2,000)
markPrice := decimal.NewFromInt(2100) // 当前价 $2,100

longPnL := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, true)
shortPnL := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, false)

// Long: 50 * 2100 - 100000 = $5,000
fmt.Printf("v2 Long PnL: $%s\n", longPnL.StringFixed(2))
// Short: 100000 - 50 * 2100 = -$5,000
fmt.Printf("v2 Short PnL: $%s\n", shortPnL.StringFixed(2))
}

v2 PnL 计算更简洁: v1 用 size * priceDelta / avgPrice (需要处理方向和符号),
v2 直接用 sizeInTokens * markPrice - sizeInUsd (多头) 或反过来 (空头).
因为 v2 存了 sizeInTokens, 不需要回算 token 数量.

11.7 监控 v1 清算机会

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import (
"fmt"

"github.com/shopspring/decimal"
)

// calculatePnL 计算仓位的未实现盈亏 (同 §9.5)
func calculatePnL(size, avgPrice, markPrice decimal.Decimal, isLong bool) decimal.Decimal {
if isLong {
return size.Mul(markPrice.Sub(avgPrice)).Div(avgPrice)
}
return size.Mul(avgPrice.Sub(markPrice)).Div(avgPrice)
}

// isLiquidatable 判断仓位是否可以被清算
func isLiquidatable(
size, collateral, avgPrice, markPrice decimal.Decimal,
isLong bool,
marginFeeBasisPoints int64, // 通常 10 (0.1%)
) bool {
// 1. 剩余抵押品 = 抵押品 + PnL (PnL 为负时自动扣减)
pnl := calculatePnL(size, avgPrice, markPrice, isLong)
remaining := collateral.Add(pnl)

// 2. 扣除平仓手续费
marginFee := size.Mul(decimal.NewFromInt(marginFeeBasisPoints)).Div(decimal.NewFromInt(10000))
remaining = remaining.Sub(marginFee)

// 3. GMX v1: remainingCollateral < $5 (固定清算费) 时可清算
liquidationFee := decimal.NewFromInt(5)
return remaining.LessThan(liquidationFee)
}

func main() {
size := decimal.NewFromInt(100_000) // $100,000 ETH 多仓
collateral := decimal.NewFromInt(1_000) // $1,000 抵押品
avgPrice := decimal.NewFromInt(2000) // 开仓价 $2,000

for _, price := range []int64{2000, 1990, 1985, 1980} {
markPrice := decimal.NewFromInt(price)
result := isLiquidatable(size, collateral, avgPrice, markPrice, true, 10)
pnl := calculatePnL(size, avgPrice, markPrice, true)
remaining := collateral.Add(pnl).Sub(size.Mul(decimal.NewFromInt(10)).Div(decimal.NewFromInt(10000)))
fmt.Printf("ETH=$%d → PnL=$%s, remaining=$%s, liquidatable=%v\n",
price, pnl.StringFixed(2), remaining.StringFixed(2), result)
}
// 预期: $2000 安全, $1980 附近触发清算 (亏损 $1000 ≈ 全部抵押品)
}

11.8 监控 v2 清算机会

v2 清算判断和 v1 逻辑不同: v1 用固定 $5 liquidationFee, v2 用 minCollateralFactor (最低抵押因子) — 本质是动态的维持保证金率.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
"context"
"fmt"
"math/big"
"strings"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
)

var (
dataStoreAddr = common.HexToAddress("0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8")
ethUsdcMarket = common.HexToAddress("0x70d95587d40A2caf56bd97485aB3Eec10Bee6336")
)

// hashString 计算 keccak256(abi.encode(s)) (同 §9.2)
func hashString(s string) common.Hash {
stringTy, _ := abi.NewType("string", "", nil)
encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s)
return crypto.Keccak256Hash(encoded)
}

// calculateV2PnL 计算 v2 PnL (同 §9.6)
func calculateV2PnL(sizeInUsd, sizeInTokens, markPrice decimal.Decimal, isLong bool) decimal.Decimal {
if sizeInTokens.IsZero() {
return decimal.Zero
}
currentValue := sizeInTokens.Mul(markPrice)
if isLong {
return currentValue.Sub(sizeInUsd)
}
return sizeInUsd.Sub(currentValue)
}

// isV2Liquidatable 判断 v2 仓位是否可清算
// v2 清算条件: remaining <= minCollateral
// minCollateral = sizeInUsd * minCollateralFactor
// remaining = collateralUsd + pnl - pendingFees
func isV2Liquidatable(
sizeInUsd, sizeInTokens, collateralUsd, markPrice decimal.Decimal,
isLong bool,
minCollateralFactor, pendingFees decimal.Decimal,
) bool {
pnl := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, isLong)
remaining := collateralUsd.Add(pnl).Sub(pendingFees)
minCollateral := sizeInUsd.Mul(minCollateralFactor)
return remaining.LessThanOrEqual(minCollateral)
}

// readMinCollateralFactor 从 DataStore 读取 minCollateralFactor
// key = keccak256(abi.encode(keccak256("MIN_COLLATERAL_FACTOR"), market))
// 返回 decimal (如 0.01 = 1%), 内部 ABI 层仍用 big.Int
func readMinCollateralFactor(client *ethclient.Client, market common.Address) (decimal.Decimal, error) {
dsABI, err := abi.JSON(strings.NewReader(`[{
"inputs": [{"type": "bytes32"}],
"name": "getUint",
"outputs": [{"type": "uint256"}],
"stateMutability": "view",
"type": "function"
}]`))
if err != nil {
return decimal.Zero, fmt.Errorf("parse ABI: %w", err)
}

keyHash := hashString("MIN_COLLATERAL_FACTOR")
key := crypto.Keccak256Hash(
keyHash.Bytes(),
common.LeftPadBytes(market.Bytes(), 32),
)

data, err := dsABI.Pack("getUint", key)
if err != nil {
return decimal.Zero, fmt.Errorf("pack: %w", err)
}

result, err := client.CallContract(context.Background(), ethereum.CallMsg{
To: &dataStoreAddr,
Data: data,
}, nil)
if err != nil {
return decimal.Zero, fmt.Errorf("call: %w", err)
}

values, err := dsABI.Unpack("getUint", result)
if err != nil {
return decimal.Zero, fmt.Errorf("unpack: %w", err)
}

// 18 decimals → decimal (如 0.01e18 → 0.01)
return decimal.NewFromBigInt(values[0].(*big.Int), -18), nil
}

func main() {
// 模拟: 50 ETH 多仓 ($100,000), $1,500 抵押品, minCollateralFactor=1%, 累积费用 $50
sizeInUsd := decimal.NewFromInt(100_000)
sizeInTokens := decimal.NewFromInt(50)
collateralUsd := decimal.NewFromInt(1_500)
minCollateralFactor := decimal.NewFromFloat(0.01) // 1%
pendingFees := decimal.NewFromInt(50)

// minCollateral = 100000 * 1% = $1,000
for _, price := range []int64{2000, 1992, 1990, 1980} {
markPrice := decimal.NewFromInt(price)
result := isV2Liquidatable(sizeInUsd, sizeInTokens, collateralUsd, markPrice, true, minCollateralFactor, pendingFees)
pnl := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, true)
remaining := collateralUsd.Add(pnl).Sub(pendingFees)
fmt.Printf("ETH=$%d → PnL=$%s, remaining=$%s, liquidatable=%v\n",
price, pnl.StringFixed(0), remaining.StringFixed(0), result)
}
// 预期: remaining <= $1,000 (minCollateral) 时触发清算
}

v1 vs v2 清算判断对比:

v1 v2
清算阈值 固定 $5 (liquidationFeeUsd) 动态: sizeInUsd × minCollateralFactor
费用扣除 只扣 margin fee 扣 borrowing + funding + position fee
精度 30 decimals 18 decimals
谁能清算 任何人调 Vault.liquidatePosition() 只有 Keeper (需要 Chainlink Data Streams 签名)

十二、与 CEX 永续对比表

维度 CEX (Binance Perps) GMX v1 GMX v2
定价方式 订单簿撮合 Chainlink Oracle Chainlink Data Streams
滑点 取决于深度 Price Impact (基于偏斜)
对手方 其他交易者 GLP 池 GM 池
Funding Rate 基于 mark-index 偏差 无 (只有 borrow fee) 基于 OI 偏斜
最大杠杆 125x (BTC) 50x (ETH/BTC) 50x (ETH/BTC)
清算引擎 中心化撮合 链上, 任何人可触发 链上, Keeper 执行
KYC 需要 不需要 不需要
资产托管 中心化 (交易所持有) 链上 (Vault 合约) 链上 (GM Pool)
交易速度 ~10ms ~1-30s (two-step) ~1-30s (two-step)
手续费 0.02%/0.05% (maker/taker) 0.1% 0.05-0.07%
OI 限制 几乎无限 有 (受 GLP 大小限制) 有 (受 GM 池大小限制)
上线新市场 交易所决定 需改 GLP 组成 创建新 GM 池即可
透明度 不透明 完全链上可验证 完全链上可验证
宕机风险 交易所维护/拔网线 Arbitrum 网络拥堵 Arbitrum 网络拥堵

选择建议:

  • 大单, 追求零滑点: GMX (尤其 v1)
  • 高频交易, 追求速度: CEX
  • 无 KYC 需求, 链上可验证: GMX
  • 小币种永续: CEX (GMX OI 上限低)

十三、小结 + 下一步

13.1 核心要点回顾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GMX 的核心创新:
1. Oracle 定价: 用 Chainlink 价格直接成交, 消除 AMM 滑点
2. LP 做对手方: GLP/GM 池是所有交易者的统一对手方
3. Two-step execution: 防止 Oracle 抢跑
4. v2 改进: 隔离池 + price impact + funding rate

LP 的核心逻辑:
- 收益 = 手续费 + 借贷费 + 交易者亏损
- 风险 = 交易者盈利 (方向性风险)
- 长期 EV 为正 (因为散户整体亏损)

与其他模型的对比:
- vs 订单簿 (dYdX): GMX 更简单, 无需做市商, 但有 OI 上限
- vs vAMM (Perp Protocol): GMX 用真实资产, 不靠虚拟曲线
- vs CEX: GMX 无 KYC, 链上透明, 但速度慢, 上限低

13.2 关键权衡

Oracle 型永续的核心权衡 零滑点 大单不被 front-run Oracle 价格直接成交 OI 上限 容量受 LP 池限制 不能无限开仓 简单机制 无需做市商 LP 存钱就行 Oracle 依赖 Chainlink 故障 = 协议停摆 Oracle 操纵 = 套利攻击 GMX 的定位 适合: 大户, 低频交易 适合: 追求零滑点 适合: 被动 LP 策略 不适合: 高频, 小币种 不适合: 超大 OI 需求

13.3 下一步

  • dYdX 演进之路: 从 StarkEx 到 Cosmos appchain, 链下撮合 + 做市商的订单簿模型. 与 GMX 的 Oracle 模型形成对比.
  • 永续合约中的 MEV: 深入分析 GMX 的 Oracle 抢跑问题, 以及 two-step execution 如何缓解但不完全消除 MEV.

永续合约 03 - GMX 协议深度解析
https://mritd.com/2025/08/10/perp-gmx/
作者
Kovacs
发布于
2025年8月10日
许可协议