这一篇将进一步深入 GMX V2,探索交易流程以及合约。
GMX v2 支持市价单和限价单,如果只用链上合约实现,代价会非常高。但是如果放弃一些去中心化,引入一个单独的服务器来监控并触发交易,无论设计难度还是执行代价都会小得多。依据这个思路,GMX 上的交易分为两个步骤。
router
合约发起的。同时把交易所需的资金转给 router。handler
合约执行订单。资金会转移到 Pool
合约中进行处理,用户收到的资金也是从 Pool 合约发出的。在交易过程中,订单存储是在链上的,Keeper 只起到监控和处理的作用。因此如果 keeper 掉线,订单不会丢失。
上述的交易过程涉及如下几个角色:
execute router
:这是整个交易的入口,用户需要调用这个合约发起交易,并先将资产转给这个合约。order vault
:execute router 只负责创建订单。在订单执行之前,需要有个地方存放资金。保存资金的任务由 order vault 负责。在 create order 过程中,用户的资金先转给 router,然后 router 会转给 vault。order keeper
:keeper 会监控所有订单,如果订单到达执行条件 ( 比如 index price 达到限价单的触发价格 ),keeper 就会调用 order handler 合约来执行。order handler
:order handler 负责启动订单的执行,它会将资金从 vault 中取出,发送给交易池(pool)。交易池合约
:最终由 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 V2 项目代码在https://github.com/gmx-io/gmx-synthetics[5],合约代码在 contract 文件夹中。其中包含的主要模块:
这里,我们最关心的是「核心功能」类型的模块。GMX V2 有以下几个类型的交易,每种交易都有对应的文件夹:
Swap
LP:
永续交易 ( 位于 Order 模块 ):
每个文件夹内的代码文件大体相同。以 Deposit 为例,其中包含这几个文件:
其中最为关键的是ExecuteDepositUtils
,这里有处理交易的核心逻辑,后面解析交易的时候会重点关注这里。
在深入了解 GMX V2 之前,我们还应该学会如何观察 GMX 的池和交易。这样才能理解每个交易背后发生的事情。GMX 主要有 3 个观察渠道:Reader 合约,Event 合约和数据存储合约。
GMX 专门提供了一个 Reader 合约[6]来读取交易池的数据和状态,它包含了一系列读取状态的函数,包括 GM token 的价格,trader 的订单,LP 头寸的净值等。reader 合约本身并不存储数据,它是通过调用 data store 合约和一些逻辑合约来计算用户想要的值。
这个合约中,函数的输入参数通常包括:
dataStore
:data store 合约的地址(这里体现了 gmx 各个模块的隔离性。reader 合约只负责读,连数据放哪都不知道,需要用户告诉它)market
:要查询哪个 market 的数据key
:要查询哪个数据,注意 key 一般包括 market,所以 market 参数和 key 参数通常不会一起出现。Event 合约是了解交易最重要的窗口。GMX 交易的 Event 非常多,达到了事无巨细的程度。通过监听这些事件,可以获取交易的所有数据。绝大部分的数据修改也有对应的 event,都会记录修改量和修改后的新值。
我们可以通过一个交易 (0x98aafbaa46659b5bb9f049807c94b8ec3ec55f9a3eb1855e2d02650793338a9e[7]) 来观察 GMX 是如何处理 log 的。
打开浏览器后,可以看到这个 event 是 0xc8ee91a54287db53897056e12d9819156d3822fb[8]合约抛出的。这个合约就是 event emmiter 合约,GMX V2 中所有的 event 都通过它抛出。
再定位到 21 号 log 观察一下。先看一下它的函数签名和 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 就可以看到解析后的值
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 的内容需要升级也没问题。
上面两个函数获得的信息是 GMX 故意向外暴露的。但更底层信息没有提供,要获取一些配置信息,需要读取 Data store
合约。Data store 合约是 GMX V2 的数据库,所有配置和订单数据都存储在这里。
与 Event emmiter 合约类似,store 合约专心做存储,与任何业务逻辑无关。所以它根据数据类型提供了一系列接口,通过输入对应的 key 来读写数据。
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',然后就可以读取值:
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 价格的精度是:。下面给几个例子:
本文介绍了 GMX V2 的交易流程,介绍了合约结构,并详细解释了如何查询 GMX 的数据。下一篇我们将从最简单的 swap 交易开始,介绍 GMX 是如何进行交易的。
第一笔交易: 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
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。