GMX 不走 AMM 做市的路子, 直接拿 Oracle 报价成交, LP 池作为交易者的对手方. 本文介绍 GMX v1/v2 的 GLP/GM 池架构, Oracle 定价与零滑点机制, Funding Rate 实现, 以及 LP 承担 counterparty 风险时的收益模型.
一、目录
二、术语表
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 的几个核心问题:
- 风险隔离: v1 中 LINK 暴涨的风险由整个 GLP 承担; v2 中每个市场独立
- OI 平衡: v1 没有机制激励多空平衡; v2 通过 price impact 和 funding rate 双重引导
- 可扩展性: 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 是单边流动性 (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 → 存入 ETH: 手续费 0.2 → 取出 ETH: 手续费 0.5
如果当前 ETH 占比已达 35 → 存入 ETH: 手续费 0.5 → 取出 ETH: 手续费 0.2
这个机制引导 GLP 池组成趋向目标权重.
|
4.3 GMX v2: GM 隔离池
v2 用 GM tokens 替代 GLP, 核心区别是 每个交易对有独立的流动性池:
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 核心能力栈
五、零滑点交易 (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 = 协议参数, 每个市场不同
|
5.3 费用结构 (Fee Structure)
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 型协议中, 这会导致严重的抢跑问题:
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 执行流程:
一句话: 两步执行的本质是把 “定价权” 从用户手中拿走.
用户只能说 “我想交易”, 不能决定 “按什么价格交易”.
价格由 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:
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,000 的 ETH → 但可用只有 $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 收益来源
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 核心合约
10.2 v2 核心合约
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" )
var ( vaultAddr = common.HexToAddress("0x489ee077994B6658eAfA855C308275EAd8097C4A") glpManagerAddr = common.HexToAddress("0x3963FfC9dff443c2A94f21b129D429891E32ec18") readerAddr = common.HexToAddress("0x2b43c90D1B727cEe1Df34925bcd5Ace52Ec37694") )
var ( wethAddr = common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") wbtcAddr = common.HexToAddress("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f") usdcAddr = common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831") )
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) }
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) }
return decimal.NewFromBigInt(values[0].(*big.Int), -30), nil }
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() { client, err := ethclient.Dial("https://arb1.arbitrum.io/rpc") if err != nil { log.Fatal(err) } defer client.Close()
aum, err := readGLPAum(client) if err != nil { log.Fatal("readGLPAum: ", err) } fmt.Printf("GLP AUM: $%s\n\n", aum.StringFixed(2))
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" )
var ( dataStoreAddr = common.HexToAddress("0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8") readerV2Addr = common.HexToAddress("0x60a0fF4cDaF0f6D496d71e0bC0fFa86FE8E6B23c") )
var ( ethUsdcMarket = common.HexToAddress("0x70d95587d40A2caf56bd97485aB3Eec10Bee6336") )
type MarketInfo struct { MarketToken common.Address IndexToken common.Address LongToken common.Address ShortToken common.Address }
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 }
func hashString(s string) common.Hash { stringTy, _ := abi.NewType("string", "", nil) encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s) return crypto.Keccak256Hash(encoded) }
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()
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())
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") )
func readOpenInterest(client *ethclient.Client, token common.Address, isLong bool) (decimal.Decimal, error) { funcName := "globalShortSizes" if isLong { funcName = "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) }
return decimal.NewFromBigInt(values[0].(*big.Int), -30), nil }
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) }
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()
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)) }
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") )
func hashString(s string) common.Hash { stringTy, _ := abi.NewType("string", "", nil) encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s) return crypto.Keccak256Hash(encoded) }
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) }
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) }
return decimal.NewFromBigInt(values[0].(*big.Int), -18), nil }
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"` })
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()
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)) }
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" )
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) avgPrice := decimal.NewFromInt(2000) markPrice := decimal.NewFromInt(2100)
longPnL := calculatePnL(size, avgPrice, markPrice, true) shortPnL := calculatePnL(size, avgPrice, markPrice, false)
fmt.Printf("Long PnL: $%s\n", longPnL.StringFixed(2)) 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" )
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) sizeInTokens := decimal.NewFromInt(50) markPrice := decimal.NewFromInt(2100)
longPnL := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, true) shortPnL := calculateV2PnL(sizeInUsd, sizeInTokens, markPrice, false)
fmt.Printf("v2 Long PnL: $%s\n", longPnL.StringFixed(2)) 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" )
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 isLiquidatable( size, collateral, avgPrice, markPrice decimal.Decimal, isLong bool, marginFeeBasisPoints int64, // 通常 10 (0.1%) ) bool { pnl := calculatePnL(size, avgPrice, markPrice, isLong) remaining := collateral.Add(pnl)
marginFee := size.Mul(decimal.NewFromInt(marginFeeBasisPoints)).Div(decimal.NewFromInt(10000)) remaining = remaining.Sub(marginFee)
liquidationFee := decimal.NewFromInt(5) return remaining.LessThan(liquidationFee) }
func main() { size := decimal.NewFromInt(100_000) collateral := decimal.NewFromInt(1_000) avgPrice := decimal.NewFromInt(2000)
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) } }
|
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") )
func hashString(s string) common.Hash { stringTy, _ := abi.NewType("string", "", nil) encoded, _ := abi.Arguments{{Type: stringTy}}.Pack(s) return crypto.Keccak256Hash(encoded) }
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 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) }
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) }
return decimal.NewFromBigInt(values[0].(*big.Int), -18), nil }
func main() { sizeInUsd := decimal.NewFromInt(100_000) sizeInTokens := decimal.NewFromInt(50) collateralUsd := decimal.NewFromInt(1_500) minCollateralFactor := decimal.NewFromFloat(0.01) pendingFees := decimal.NewFromInt(50)
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) } }
|
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 关键权衡
13.3 下一步
- dYdX 演进之路: 从 StarkEx 到 Cosmos appchain, 链下撮合 + 做市商的订单簿模型. 与 GMX 的 Oracle 模型形成对比.
- 永续合约中的 MEV: 深入分析 GMX 的 Oracle 抢跑问题, 以及 two-step execution 如何缓解但不完全消除 MEV.