GMX V2 中,Swap 交易是最简单的交易类型。从原理上说,它是瞬态交易,这意味着只要兑换完成,交易就完成了,没有后续持仓的问题。所以 GMX 中复杂的 funding/borrowing fee 等复杂逻辑都和它没关系。另一方面,由于所有交易类型都会用到 swap,它在 GMX 中处于特别基础的位置。所以用 Swap 来上手 GMX 研究是最合适的。
GMX 的 SWAP 交易相比其他 DEX 有几个好处。首先 GMX 的主业是永续交易,因此不需要用 swap 交易赚钱,swap 手续费可以做到很低,大概在 0.07% 左右,相比右商优势明显。而 swap 过程是通过预言机获取价格,因此也就没有滑点的问题。
和其它交易一样,swap 的交易过程也分为两步,交易一是用户发起的 create order 交易,这一步会收取用户交易的资金并保存在 vault 中,然后创建一个订单。交易二是 keeper 发起的,这一步会把资金送到交易池中进行兑换,兑换时会收取手续费,并处理 price impact。最后将兑换的资金返还给用户。下面将根据源码介绍每个交易的详细过程。
当用户在界面上发起交易后,界面会生成一个 multicall 交易,这个交易做了三件事情:
createOrder()
函数
createOrder()
这个函数不仅仅是 swap 的入口,也是永续交易的入口。在 GMX 中,Order 特指 swap 和永续交易的过程,而 LP 的 Deposit 和 Withdraw 交易不叫 Order。
createOrder() 函数不会做任何处理,而是马上调用 contracts/order/OrderUtils.sol 的 createOrder() 函数,这是创建订单的主函数,它主要做了如下操作:
这里面一些值得注意的点:
swap 路径: 在发起交易之前,页面 UI 会确定一个 swap 路径。并通过交易的 input 传入到 order 中。后面执行这个交易的时候,就要遵循这个路径。路径的概念很好理解,比如用 BTC 兑换 ETH 没有直接对应的池,就可以通过 BTC/USDC 池,先兑换成 USDC,再通过 ETH/USDC 池,兑换成 ETH,那么这个交易的路径就是[BTC/USDC,ETH/USDC]。
自动取消列表: 如果订单长时间得不到执行 ( 比如价格偏离太远的限价单 ),那么系统不可能长时间留着这些订单,因此需要一个自动取消列表,如果订单超时就取消。
GMX 的代码中,会把交易池(pool)称为 market。本文会延续通常叫法「交易池」。
CreateOrder 交易完成后,很快就会被 Keeper 捕获到。这时候就要看订单的执行条件,对于市价单,订单马上执行。因此在实际的使用中会发现,一旦完成交易签名,几秒钟后订单就完成了。对于限价单,就需要等价格到达触发条件才能执行,如果价格一直不满足条件,订单会超时。
执行订单的交易是由 keeper 调用 contracts/exchange/OrderHandler.sol 合约的 executeOrder()
函数发起的。这个函数会校验一下 gas 的情况,保证执行时有足够的 gas。然后检查订单是否冻结。检查通过后调用 contracts/order/ExecuteOrderUtils.sol 的 executeOrder()。
executeOrder() 是处理订单的主函数。注意 GMX 的订单概念包含了 swap 交易和永续的加仓减仓,因此他们的执行都在这里。这个函数主要做下列事情:
processOrder()
函数,开始执行订单需要注意的点:
在第 4 步,由 processOrder()
进行订单的处理。但这个函数只是一个选择器,让不同类型的订单 (increase,decrease,swap) 到不同的函数执行。除此并无其他逻辑。对于 swap 订单,会调用 contracts/order/SwapOrderUtils.sol 的 processOrder() 函数。
SwapOrderUtils.processOrder()
是最后一个准备步骤,内容包括:
执行 swap 交易的代码在 contracts/swap/SwapUtils.sol 的 swap()
函数。这是执行 swap 交易的核心代码。当其它交易类型需要用到 swap 功能的时候都会调用这个函数。如 LP 的 deposit 和 withdraw,以及 increase position。从实际作用上看,这个函数有两个功能:
swap()
的开始是一个判断,判断了金额是 0 的情况:
if (params.amountIn == 0) {
return (params.tokenIn,params.amountIn);
}
乍一看这个判断没什么用,因为如果金额为 0,明显是空交易,应该在之前的检查中拦截这个订单。但实际上这个判断是为了兼容其他交易使用的。前面说过其它交易类型会引用 swap(),按常理,调用逻辑应该是:如果需要 swap,则调用 swap()。但 GMX 不是这样做的,其它在函数会无脑调用 swap(),然后通过交易金额控制是否交易。用代码说明方便一点:
# 在 Deposit 或者 withdraw 中:
# 正常思路
if tokenIn != tokenOut: # 需要兑换
SwapUtils.swap(amountIn=12345)
# GMX 的思路
amountIn = 0
if tokenIn != tokenOut:
amountIn = 12345
SwapUtils.swap(amountIn) # 如果传入为 0,不会执行
swap() 中第二个 if,判断的是 swapPathMarkets 是否为空,这种用法和上面差不多。如果不指定 swapPathMarkets,swap 就变成了转帐函数,会执行 transfer 然后退出。Deposit 交易在调用 swap() 的时候就会进入到这段逻辑,用来把 vault 中的资金转入到 pool 中。
if (params.swapPathMarkets.length == 0) {
if (address(params.bank) != params.receiver) {
params.bank.transferOut(
params.tokenIn,
params.receiver,
params.amountIn,
params.shouldUnwrapNativeToken
);
return (params.tokenIn,params.amountIn);
}
在经历了两个 if 之后,才真正开始 swap 流程。
资产会先转入起点 market,然后对每个交易路径的每个交易池调用_swap() 函数。
if (address(params.bank) != params.swapPathMarkets[0].marketToken) {
params.bank.transferOut(params.tokenIn, params.swapPathMarkets[0].marketToken, params.amountIn, false);
}
address tokenOut = params.tokenIn;
uint256 outputAmount = params.amountIn;
for (uint256 i; i < params.swapPathMarkets.length; i++) {
Market.Props memory market = params.swapPathMarkets[i];
......
_SwapParams memory _params = _SwapParams(
market,
tokenOut,
outputAmount,
receiver,
i == params.swapPathMarkets.length - 1 ? params.shouldUnwrapNativeToken : false // only convert ETH on the last swap if needed
);
(tokenOut, outputAmount) = _swap(params, _params);
}
另外,在 _swap()
开始之前,还有两个关键操作:
_swap()
是兑换的核心逻辑,它是针对单个交易池的。执行的内容包括:
从这部分开始,介绍 SWAP 交易的一些关键点。
首先是数据修改,GMX 对数据的修改遵循统一的风格:先读取原值,叠加修改量,然后写入,最后抛出一个修改的 event。从抛出的 event 可以很直观的观察到这一点,以这个 Pool amount 的修改为例,抛出的 event 标明了修改的对象(market,token, pool amount),修改量,修改之后的值。
其他数值的修改也是类似结构。对于绝大部分数值,GMX 都定义了对应的 XXXXXupdated 事件,只要值修改,就有对应的事件抛出,可以轻易的追踪他们的变化。
另外,在查看交易 log 的时候,可以看到每个「PoolAmountUpdated」的前面都有事件「VirtualSwapInventoryUpdated」,修改的对象是 VirtualSwapInventory,且数量与 pool amount 相同。GMX V2 的一些交易池会有 Virtual Market ID 这个属性,如果若干个池的 Virtual Market ID 相同,那么它们共享一个 VirtualSwapInventory。而且对于相同 Virtual market ID 的池,long/short token 都是相同的。
设定虚拟池的概念是为了减少用户 price imapct 的支出。在虚拟池中,每个实体池在修改 pool amount 的时候,都会同步修改 VirtualSwapInventory 的数量。这就让 VirtualSwapInventory 保存了所辖实体池 Pool amount 的总和。在实体池计算 price imapct 的时候,看实体池本身的 price imapct 和虚拟池的 price impact 哪个小,然后按照小的向用户收取。
Price impact 考虑的是这笔交易对 pool 中两种 token 平衡性影响。当两种 token 的价值比是 1:1,那么这个池处于理想状态。如果一笔交易让两种 token 的价值偏离 1:1,那么这笔交易的 price Impact 就是负值,此时要从用户手中扣掉一部分资金;如果让两种 token 的价值接近 1:1,这笔交易的 price impact 就是正,此时会奖励用户一部分资金。为此要考虑:
然后计算就很简单了,
这个计算过程用代码表示如下:
uint256 initialDiffUsd = Calc.diff(poolParams.poolUsdForTokenA, poolParams.poolUsdForTokenB);
uint256 nextDiffUsd = Calc.diff(poolParams.nextPoolUsdForTokenA, poolParams.nextPoolUsdForTokenB);
bool hasPositiveImpact = nextDiffUsd < initialDiffUsd;
uint256 deltaDiffUsd = Calc.diff(
applyImpactFactor(initialDiffUsd, impactFactor, impactExponentFactor),
applyImpactFactor(nextDiffUsd, impactFactor, impactExponentFactor)
);
int256 priceImpactUsd = Calc.toSigned(deltaDiffUsd, hasPositiveImpact);
需要注意,上面说的情况是在 initialDiffUsd 和 nextDiffUsd 极性相同的情况下,如果这笔交易让 long 和 short 的价值差极性改变(从 long 比 short 多变成 short 比 long 多),还要做一点特殊处理,这部分代码可以参考 PricingUtils.getPriceImpactUsdForCrossoverRebalance()
函数。
swap 的手续费比较简单,只有 swap fee 和 swap ui fee。其中 swap ui fee 的费率是 0,所以可以忽略。
手续费是根据 swap in token 的数量收取的。比如,如果要将 ETH 兑换成 USDC,价格是 2000,费率是 1%,amount In 是 1 ETH。那么会收取 0.01 ETH 的手续费,amountAfterFees 就等于了 0.99 ETH,用户会收到 1950 USDC。
swap 手续费会分为两份。一个是 feeAmountForPool,比例是 63%,这部分会在交易时,转入到 pool amount 中,作为 LP 的奖励。另一部分是 claimable fee,会存入 ClaimableFeeAmount 中,keeper 会定期收集这些手续费。
在这里我们会查看 swap 一个交易 (0x1461fb50be73e427599ec7b94d468166eb56cc68c0d0309045e70d8e8447fadb[1]) 的 log 来了解 swap 交易的执行。这个交易非常简单,在 WBTC/USDC 池用 10 USDC 兑换了 0.0001185 BTC。
浏览器的 log 默认是未解码的,点击 log data 的 abi 按钮即可解码。
GMX 的所有交易,开始之前都会到 oracle 查询价格,因此会先抛出三个价格查询的 log。WBTC 和 USDC 价格分别是 84344.133928581915 和 1.0000140661491367
OraclePriceUpdate
{
'token': '0x47904963fc8b2340414262125af798b9655e58cd',// Index token
'provider': '0xf4122df7be4ccd46d7397daf2387b3a14e53d967',
'minPrice': 843441339285819150000000000,
'maxPrice': 843441339285819150000000000,
'timestamp': 1744885935
}
OraclePriceUpdate
{
'token': '0xaf88d065e77c8cc2239327c5edb3a432268e5831',// short token, USDC
'provider': '0xf4122df7be4ccd46d7397daf2387b3a14e53d967',
'minPrice': 1000014066149136700000000,
'maxPrice': 1000014066149136700000000,
'timestamp': 1744885935
}
OraclePriceUpdate
{
'token': '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f',// long token, WBTC
'provider': '0xf4122df7be4ccd46d7397daf2387b3a14e53d967',
'minPrice': 843441339285819150000000000,
'maxPrice': 843441339285819150000000000,
'timestamp': 1744885935
}
更新了 long 和 short token 的 CumulativeBorrowingFactor,虽然对 swap 交易没有帮助,但是可以帮助池的状态保持最新。
CumulativeBorrowingFactorUpdated
{'delta': 0,'nextValue': 0,'isLong': True}
CumulativeBorrowingFactorUpdated
{'delta': 0,'nextValue': 0,'isLong': False}
开始交易了,先更新了 ClaimableFeeAmount,这里对应 _swap()
函数的第六步。这笔 swap 交易的手续费费率是 0.05%,所以手续费一共是 10 * 0.00005 = 0.0005 USDC,而 claimable 的比例是 37%,所以 claimable fee 是 0.00185 USDC。因此为 ClaimableFeeAmount 增加了 0.0000185 USDC
ClaimableFeeAmountUpdated
{
'token': '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
'delta': 1850,
'nextValue': 39795697,
'feeType': '7ad0b6f464d338ea140ff9ef891b4a69cf89f107060a105c31bb985d9e532214'
}
然后更新 SwapImpactPoolAmount,因为这个池的 index token 是 WBTC,所以写入值的单位是 WBTC。通过 swapInfo 可知,priceImpactUsd=273400143872137209317992800,也就是 0.0002734001438721372093179928 USD,这个值除以 WBTC 的价格,是 0.00000000032414838 BTC,因此这里应该写入 delta=3 * 10^-9 BTC,但是 BTC 的最小精度是 10^-8,delta 小于最小精度,所以最后写入的值是 0。
SwapImpactPoolAmountUpdated
{
'token': '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f',
'nextValue': 395601171,
'delta': 0
}
更新 USDC 的 Pool amount,这里需要把扣除手续费后剩下的钱和 LP 应得的手续费相加,所以写入的值是 AmountAfterFee + FeeForPool = 9.995 +0.00315 = 9.99815 USDC。这个值也会更新到 VirtualSwapInventory 中。
VirtualSwapInventoryUpdated
{
'nextValue': 44171510182315,
'delta': 9998150,
'isLongToken': False,
'virtualMarketId': 'ba1ff14bf93fbb00b6f43d3ad403cc4c6496c1bb88489075c8b1bc709bde9ebb'
}
PoolAmountUpdated
{
'token': '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
'nextValue': 40368373386460,
'delta': 9998150
}
更新 WBTC 的 pool amount,根据价格,用户可以得到 9.998150/84344.133928581915=0.0001185 WBTC,因此从 Pool amount 中减少了 0.0001185 WBTC。
VirtualSwapInventoryUpdated
{
'nextValue': 52569097085,
'delta': -11850,
'isLongToken': True,
'virtualMarketId': 'ba1ff14bf93fbb00b6f43d3ad403cc4c6496c1bb88489075c8b1bc709bde9ebb'
}
PoolAmountUpdated
{
'token': '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f',
'nextValue': 47902686146,
'delta': -11850
}
输出 swap 结果,包括 swap 金额和手续费。
SwapInfo
{
'receiver': '0x784f8b525d652a83e4ae85e573c31f17f2dbca0d',
'tokenIn': '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
'tokenOut': '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f',
'tokenInPrice': 1000014066149136700000000,
'tokenOutPrice': 843441339285819150000000000,
'amountIn': 10000000,
'amountInAfterFees': 9995000,
'amountOut': 11850,
'priceImpactUsd': 273400143872137209317992800,
'priceImpactAmount': 0,
'tokenInPriceImpactAmount': 0,
'orderKey': '4c7f2fa222579148a82a66b66f6d436cb84160e87c71325b38aa67b6cd6cf65c'
}
SwapFeesCollected
{
'uiFeeReceiver': '0xff00000000000000000000000000000000000001',
'token': '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
'tokenPrice': 1000014066149136700000000,
'feeReceiverAmount': 1850,
'feeAmountForPool': 3150,
'amountAfterFees': 9995000,
'uiFeeReceiverFactor': 0,
'uiFeeAmount': 0,
'tradeKey': '4c7f2fa222579148a82a66b66f6d436cb84160e87c71325b38aa67b6cd6cf65c',
'swapFeeType': '7ad0b6f464d338ea140ff9ef891b4a69cf89f107060a105c31bb985d9e532214'
}
OrderExecuted
{
'account': '0x784f8b525d652a83e4ae85e573c31f17f2dbca0d',
'secondaryOrderType': 0,
'key': '4c7f2fa222579148a82a66b66f6d436cb84160e87c71325b38aa67b6cd6cf65c'
}
交易完成后,还要处理 gas 费。gas 费中一部分直接转帐到 keeper 上,余下的返还。
KeeperExecutionFee
{
'keeper': '0xdd5c59b7c4e8fad38732caffbebd20a61bf9f3fc',
'executionFeeAmount': 45171150000000
}
ExecutionFeeRefund
{
'receiver': '0x784f8b525d652a83e4ae85e573c31f17f2dbca0d',
'refundFeeAmount': 42629797000000
}
本文详细解析了 swap 部分的源码,并通过例子介绍了 swap 过程中金额的变化。下一篇将会介绍 LP 的 Deposit 和 Withdraw 交易。
0x1461fb50be73e427599ec7b94d468166eb56cc68c0d0309045e70d8e8447fadb: https://arbiscan.io/tx/0x1461fb50be73e427599ec7b94d468166eb56cc68c0d0309045e70d8e8447fadb
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
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。