深入 GMX V2 - 交易与合约
2025-06-04 16:32
Coset
2025-06-04 16:32
订阅此专栏
收藏此文章

这一篇将进一步深入 GMX V2,探索交易流程以及合约。


交易流程

GMX v2 支持市价单和限价单,如果只用链上合约实现,代价会非常高。但是如果放弃一些去中心化,引入一个单独的服务器来监控并触发交易,无论设计难度还是执行代价都会小得多。依据这个思路,GMX 上的交易分为两个步骤。

  1. 创建订单:这个交易是用户调用 router 合约发起的。同时把交易所需的资金转给 router。
  2. 执行订单:中心化的 Keeper 服务器会监控待处理的交易。等待满足执行条件后,keeper 调用 handler 合约执行订单。资金会转移到 Pool 合约中进行处理,用户收到的资金也是从 Pool 合约发出的。

在交易过程中,订单存储是在链上的,Keeper 只起到监控和处理的作用。因此如果 keeper 掉线,订单不会丢失。

上述的交易过程涉及如下几个角色:

  1. execute router:这是整个交易的入口,用户需要调用这个合约发起交易,并先将资产转给这个合约。
  2. order vault:execute router 只负责创建订单。在订单执行之前,需要有个地方存放资金。保存资金的任务由 order vault 负责。在 create order 过程中,用户的资金先转给 router,然后 router 会转给 vault。
  3. order keeper:keeper 会监控所有订单,如果订单到达执行条件 ( 比如 index price 达到限价单的触发价格 ),keeper 就会调用 order handler 合约来执行。
  4. order handler:order handler 负责启动订单的执行,它会将资金从 vault 中取出,发送给交易池(pool)。
  5. 交易池合约:最终由 pool 合约来处理这个订单,它会接收并保存用户的资产,扣取手续费,并将用户应得的资金转账出去。

用实际交易更容易描述这个过程,比如我们要将 ETH 兑换成 USDC,假设当前 ETH 价格是 1 ETH = 2000 USDC,手续费是 0.01%,而且不考虑 price impact。那么每个交易的资金流向可以用下面的图表示:

第一笔交易:用户调用 exchange router 合约,在这里我们将 1 ETH 发送给了 router,然后保存在 vault 中。

第二笔交易: order keeper 调用 order handler 合约,由于这个交易没有什么触发条件,所以在几个块 ( 几秒 ) 之后就执行了。这里 handler 把 1 ETH 从 vault 中提出来发送给交易池,交易池会收取 0.0001 ETH(0.2 usdc) 作为手续费,剩下的兑换为 1999.8 USDC,发送给用户。兑换交易完成。

不过,实际情况还要更复杂一点。由于第二笔交易是 keeper 发起的,那么它就需要支付 Gas 费。Gas 从何而来? 只能是用户自己支出。所以,在实际的交易中,exchange router 会预估 keeper 执行订单需要多少 gas,然后多收取一些 ETH 作为手续费,一并存在 vault 中。执行订单的时候,handler 会把这些 ETH 提取出来,计算出 keeper 实际使用了多少,然后发送对应的 ETH 到 keeper,剩下的 ETH 返还给用户。

我们用另一个例子来描述一下这个过程。这是一个 Deposit 交易,用户向 BTC/USD[WBTC-USDC] 池投入流动性。投入金额是 0.00102705 WBTC 和 83.545478 USDC,最终得到了 80.845803041441716647 GM。

第一笔交易[1],用户向 router 存入了 0.00102705 WBTC 和 83.545478 USDC,这两部分资金是用于添加流动性的。又发送了 0.000038431601 ETH 给 router,这部分就是预付的 keeper 手续费。这些 ETH 被转化成 WETH 然后一并存入了 Vault。

由于没有触发条件,第二笔交易[2]在 3 秒后就执行了。首先 WBTC 和 USDC 被发送给了 pool,Pool 将这些资产添加流动性,并铸造了对应的 GM 发送给用户。在 Pool 合约中,long 和 short token 的 Deposit 是分开处理的,所以资金被发送了两次。对应的,GM 也铸造了两次,这两次交易的手续费分别是 0.00000071 WBTC 和 0.058481 USDC。

另外 handler 将之前存入的 0.000038431601 ETH 也提取出来,一部分 (0.0000334227 WETH) 被兑换成了 ETH,发送给了 mint keeper 作为手续费。剩下的 0.000005008901 WETH 则是找零,兑换成 ETH 并退还给用户。


初探合约

Gmx V2 部署在 Arbitrum 和 Avalanche 上,这两个链条都是交易速度快,gas 费低。因此 GMX 对 gas 的使用有大手大脚的感觉,风格上也是偏向互联网公司,这一点和 Uniswap 正好相反。

这一点首先体现在,从功能的角度,GMX 划分非常细致,每个模块都单独部署自己的合约,handler,vault 等合约都是单独部署的。甚至针对不同的交易类型合约也是单独部署的,比如 handler 就会分为 deposit handler,withdraw handler,order handler 等,vault,pool 也都是类似,这一点很像互联网行业的微服务模式。

而对于资源类型的合约,则部署在一个地方统一管理,最典型的是 data store[3] 合约,GMX V2 中所有的数据都保存在这个合约中。其它模块通过 key 查询对应的数据,key 包含了 market 的信息,所以各个 market 的数据不会重复。同样,事件抛出也都通过 event emmiter[4] 合约,这个合约定义了统一的接口。然后用 key:value 的结构来兼容不同的数据类型。这种把系统存储和输出统一管理的方式,是微服务模式的常见操作。

这样的设计,开发的时候会非常麻烦,也会非常消耗 gas。不过好处是降低耦合容易管理,修改合约参数甚至升级合约也非常容易。

GMX 的项目结构

GMX V2 项目代码在https://github.com/gmx-io/gmx-synthetics[5],合约代码在 contract 文件夹中。其中包含的主要模块:

模块
类型
功能
data
工具
存储数据,并管理存取数据所需的 Key
event
工具
定义并弹出事件
reader
工具
提供接口,读取各个 market 的数据
utils
工具
存放通用的工具函数
bank
工具
管理 token transfer 相关的功能,偏重存钱这个动作
token
工具
管理 token transfer 相关的功能,偏重转帐这个动作
oracle
工具
获取并缓存代币的价格.
error
工具
定义并处理异常
glv
独立功能
实现 GLV 功能
swap
核心功能
实现 swap 交易
deposit
核心功能
实现 LP 的 Deposit
withdrawal
核心功能
处理 LP 的 withdraw 交易
shift
核心功能
实现 LP 的 shift 交易
order
核心功能
处理永续合约的 increase 和 decrease
liquidation
核心功能
处理清算交易
market
核心功能
交易池
position
核心功能
永续交易的头寸
pricing
核心功能
处理 Price impact 相关的问题
router
交易流程
接受用户的交易请求,这里包含了重要的 createOrder 功能
exchange
交易流程
存放各个交易类型的 handler,这是交易处理的起点
fee
辅助功能
与 Claimable fee 相关,和普通用户关系不大
referral
辅助功能
实现交易奖励功能
gas
辅助功能
处理用户如何支付 keeper 手续费

这里,我们最关心的是「核心功能」类型的模块。GMX V2 有以下几个类型的交易,每种交易都有对应的文件夹:

  • Swap

  • LP:

    • Deposit
    • Withdraw
    • Shift
  • 永续交易 ( 位于 Order 模块 ):

    • increase
    • decrease

每个文件夹内的代码文件大体相同。以 Deposit 为例,其中包含这几个文件:

文件名
功能
DepositEventUtils.sol
生成 Deposit 中的 event
Deposit.sol
定义一些数据类型
DepositStoreUtils.sol
处理存储
DepositUtils.sol
Deposit 交易的起点,包括生成和取消交易
DepositVault.sol
定义 Vault
ExecuteDepositUtils.sol
处理 Deposit 交易的核心代码

其中最为关键的是ExecuteDepositUtils,这里有处理交易的核心逻辑,后面解析交易的时候会重点关注这里。


如何观察 GMX V2

在深入了解 GMX V2 之前,我们还应该学会如何观察 GMX 的池和交易。这样才能理解每个交易背后发生的事情。GMX 主要有 3 个观察渠道:Reader 合约,Event 合约和数据存储合约。


Reader 合约

GMX 专门提供了一个 Reader 合约[6]来读取交易池的数据和状态,它包含了一系列读取状态的函数,包括 GM token 的价格,trader 的订单,LP 头寸的净值等。reader 合约本身并不存储数据,它是通过调用 data store 合约和一些逻辑合约来计算用户想要的值。

这个合约中,函数的输入参数通常包括:

  • dataStore:data store 合约的地址(这里体现了 gmx 各个模块的隔离性。reader 合约只负责读,连数据放哪都不知道,需要用户告诉它)
  • market:要查询哪个 market 的数据
  • key:要查询哪个数据,注意 key 一般包括 market,所以 market 参数和 key 参数通常不会一起出现。
  • 辅助参数:如价格等

Event 合约

Event 合约是了解交易最重要的窗口。GMX 交易的 Event 非常多,达到了事无巨细的程度。通过监听这些事件,可以获取交易的所有数据。绝大部分的数据修改也有对应的 event,都会记录修改量和修改后的新值。

我们可以通过一个交易 (0x98aafbaa46659b5bb9f049807c94b8ec3ec55f9a3eb1855e2d02650793338a9e[7]) 来观察 GMX 是如何处理 log 的。

打开浏览器后,可以看到这个 event 是 0xc8ee91a54287db53897056e12d9819156d3822fb[8]合约抛出的。这个合约就是 event emmiter 合约,GMX V2 中所有的 event 都通过它抛出。

再定位到 21 号 log 观察一下。先看一下它的函数签名和 topic:

event and topic
event and topic

这个 event 名字(EventLog1)代表抛出的是有一个参数的事件。为了做到通用性,event emmiter 一共只定义了三种 event,分别是没有额外参数的,有一个参数的,和有两个参数的。

    event EventLog(
        address msgSender,
        string eventName,
        string indexed eventNameHash,
        EventUtils.EventLogData eventData
    );

    event EventLog1(
        address msgSender,
        string eventName,
        string indexed eventNameHash,
        bytes32 indexed topic1,
        EventUtils.EventLogData eventData
    );

    event EventLog2(
        address msgSender,
        string eventName,
        string indexed eventNameHash,
        bytes32 indexed topic1,
        bytes32 indexed topic2,
        EventUtils.EventLogData eventData
    );

在这些 event 的定义中,第一个 index 是 eventNameHash,代表事件的名字,这个在 data 中可以看到,

第二个就是自定义的 topic1 了,在上面的例子中,值是 0x70d95587d40a2caf56bd97485ab3eec10bee6336,这是交易池的地址。

data 部分比较复杂。幸亏 GMX 的合约已经验证,所以我们直接点击 data 旁边的 ABI 就可以看到解析后的值

event data
event data

Data 的结构非常复杂,数组套数组,还有字符串做 Key,前面说 GMX 的代码非常费 gas 也有这个原因。但好处也非常明显,这种设计让 data 可以兼容各种数据类型,解读也非常方便。

在 data 中就可以看到参数中的 msgSender 了,它的值是 0x64fbD82d9F987baF5A59401c64e823232182E8Ed[9],这是 Withdraw Handler 的地址,也就是说这个 event 是 Withdraw handler 发出的。

然后是 event name 以及 hash,通过这个字符串,可以读到这个事件是 SwapFeesCollected

然后就是 data 的本体了。可以看到 data 有两层,第一层是大小为 7 的数组, 每个元素用来存放一种类型的数据。代码中的定义如下:

    struct EventLogData {
        AddressItems addressItems;
        UintItems uintItems;
        IntItems intItems;
        BoolItems boolItems;
        Bytes32Items bytes32Items;
        BytesItems bytesItems;
        StringItems stringItems;
    }

然后每种类型中,又细分了两个容器,存放单独的值和数组。

    struct AddressItems {
        AddressKeyValue[] items;
        AddressArrayKeyValue[] arrayItems;
    }

这里面最常用的是前者AddressKeyValue[] items。items 也是一个数组,数组元素是一个类,可以用类似 key-value 的结构保存数据。其中 key 是字符串,value 则与 EventLogData 中的 AddressItems 匹配。解析到这一层就能看到每一项的值了。

    struct AddressKeyValue {
        string key;
        address value;
    }

通过这种方式,event emmiter 可以灵活的抛出任何类型的数据,就算 event 的内容需要升级也没问题。


Data store 合约

上面两个函数获得的信息是 GMX 故意向外暴露的。但更底层信息没有提供,要获取一些配置信息,需要读取 Data store 合约。Data store 合约是 GMX V2 的数据库,所有配置和订单数据都存储在这里。

与 Event emmiter 合约类似,store 合约专心做存储,与任何业务逻辑无关。所以它根据数据类型提供了一系列接口,通过输入对应的 key 来读写数据。

read data store
read data store

key 的定义位于 contracts/data/Keys.sol 中,在这个文件中,GMX 通过为每个 key 都设定了一个函数,调用函数并输入参数,就可以得到对应的 key,比如:

    bytes32 public constant OPEN_INTEREST = keccak256(abi.encode("OPEN_INTEREST"));

    function openInterestKey(address market,address collateralToken,bool isLong) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            OPEN_INTEREST,
            market,
            collateralToken,
            isLong
        ));
    }

从这里可以看到 key 是如何编码的。key 包含了要查询的内容 (OPEN_INTEREST),哪个市场 (market),以及查询的参数 (collateralToken,isLong),计算后可以得到一个 hash。比如我们要查询 BTC/USD[WBTC-USDC] 中质押 WBTC 做多的 Open interest 的数量,就可以输入 pool 的地址:'0x47c031236e19d024b42f8AE6780E44A573170703',质押代币的地址:'0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f',以及做多 True,就可以得到一个 key:'0xaaf79028722fd65633cdf994a4a10b0bd349761062506c4bb6a0489956ba5d3f',然后就可以读取值:

read data store
read data store

GMX 中的数字精度

GMX 的数值大多是 int256 和 uint256  类型,日常处理中,还需要经过数字精度的换算才能直接读懂。GMX 的数值可以分为三类:token 数量、USD 价值、token 价格,分别对应不同的数字精度。

token 数量:精度和 token 本身的精度一致。比如 USDC 的 Decimal 是 6,那么 GMX 中 1 USDC 会表示为 10^6 = 1000000;WETH 的 Decimal 是 18,所以 1 WETH 会表示为 1000000000000000000。

USD 价值:GMX 中,所有对 USD 的数值精度是 30,也就是 1 USD 会表示为 1000000000000000000000000000000。因此在查看交易时,如果看到数值名称的末尾带有 USD,就要将这个数字除以 。比如当看到 BorrowingFeeUSD=10871208629470217267549284842,则可知实际数量应该是  USD。

token 价格:我们知道 ,精度也是同理。如果 USDC 的价值是 1,数量也是 1,那么在 GMX 中,它的价格应该表示为: ,也就是: 。由此可知,token 价格的精度是:。下面给几个例子:

  • 从合约中读取 USDC 的价格是 999909608268596350000000,实际值是

  • 从合约中读取 WETH 的价格是 2638532818670330,实际值是

总结

本文介绍了 GMX V2 的交易流程,介绍了合约结构,并详细解释了如何查询 GMX 的数据。下一篇我们将从最简单的 swap 交易开始,介绍 GMX 是如何进行交易的。


往期阅读

Uniswap 系列研究

Zelos 小组产出


参考资料
[1] 

第一笔交易: https://arbiscan.io/tx/0xb5d9e4a2ff3f6e676e5ed5b6dd9802f379757575821c4bbd44e9f02c6a365e9b

[2] 

第二笔交易: https://arbiscan.io/tx/0x21e48ffd72125f56cba13c78e397c9b200ddda873576401c381577b327ff79e1

[3] 

data store: https://arbiscan.io/address/0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8

[4] 

event emmiter: https://arbiscan.io/address/0xC8ee91A54287DB53897056e12D9819156D3822Fb

[5] 

https://github.com/gmx-io/gmx-synthetics: https://github.com/gmx-io/gmx-synthetics

[6] 

Reader 合约: https://arbiscan.io/address/0x0537C767cDAC0726c76Bb89e92904fe28fd02fE1

[7] 

0x98aafbaa46659b5bb9f049807c94b8ec3ec55f9a3eb1855e2d02650793338a9e: https://arbiscan.io/tx/0x98aafbaa46659b5bb9f049807c94b8ec3ec55f9a3eb1855e2d02650793338a9e#eventlog#21

[8] 

0xc8ee91a54287db53897056e12d9819156d3822fb: https://arbiscan.io/address/0xc8ee91a54287db53897056e12d9819156d3822fb

[9] 

0x64fbD82d9F987baF5A59401c64e823232182E8Ed: https://arbiscan.io/address/0x64fbD82d9F987baF5A59401c64e823232182E8Ed


Coset 

致力于促进不同个体之间有效的、深度的交流与协作,激发更多创新和创造。

关注我们的社交媒体,了解更多动态:

Website:https://coset.io/ 

Twitter:https://twitter.com/coset_io

Telegram:https://t.me/coset_io

Youtube:www.youtube.com/@coset_io
Contact:emily@coset.io


【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

Coset
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开