深入 GMX V2 - Swap 交易
2025-06-07 05:12
Coset
2025-06-07 05:12
Coset
2025-06-07 05:12
订阅此专栏
收藏此文章

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 交易,这个交易做了三件事情:

  1. 呼叫转账函数,将用户资产转入到 order handler
  2. 在交易金额中附带一点 eth,作为付给 keeper 的手续费
  3. 调用 exchange router(contracts/router/ExchangeRouter.sol) 合约 的 createOrder() 函数

createOrder() 这个函数不仅仅是  swap 的入口,也是永续交易的入口。在 GMX 中,Order 特指 swap 和永续交易的过程,而 LP 的 Deposit 和 Withdraw 交易不叫 Order。

createOrder() 函数不会做任何处理,而是马上调用 contracts/order/OrderUtils.sol 的 createOrder() 函数,这是创建订单的主函数,它主要做了如下操作:

  1. 调用 AccountUtils.validateAccount 验证用户账户,这里只验证了是否是空地址。
  2. 设置优惠码。
  3. 设置 Vault 中的金额以及 Keeper 手续费信息,这里相当于对 Vault 的收入进行记账。
  4. 验证 Swap 路径。这里会验证以下内容:
    • 路径长度不能过长
    • 交易池开启了 swap
    • 交易池的 long/short token 不能一样。
  5. 生成订单对象并设置属性。
  6. 验证接收地址,接收地址不能为空。
  7. 处理 gas limit,这里会根据最新的 ETH 价格,验证 UI 发起交易时收取的 gas 费够不够,如果不够就再收一些。
  8. 更新自动取消列表。
  9. 抛出创建订单事件。

这里面一些值得注意的点:

  • 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 交易和永续的加仓减仓,因此他们的执行都在这里。这个函数主要做下列事情:

  1. 从存储中删除这个订单 ( 之所以敢于在一开始就删除,是因为如果后面出错,交易会回滚,订单信息也会恢复 )。
  2. 进行一些验证:
    1. 是否是空订单 ( 地址为 0 地址,或者金额为 0)。
    2. 验证触发价格。
    3. 验证是否超时。
  3. 更新池的 Position price impact,Funding fee,Borrowing fee
  4. 运行 processOrder() 函数,开始执行订单
  5. 验证 market 的 token 余额
  6. 从自动取消列表中删除这个订单
  7. 弹出 OrderExecuted 事件
  8. 调用订单执行的回调函数
  9. 支付执行费用,没有用尽的部分找零

需要注意的点:

  • 第 3 步更新了交易池的数值,比如 position price impact,fee。这个操作对于这次交易毫无意义。之所以在这里更新交易池的参数,是为了尽一切可能让池保持在最新状态。由于这几个状态和永续订单相关,将放在永续订单的部分讲解。
  • 第 8 步,在创建订单时,可以设置一个回调函数。订单成功完成后,就会调用这个函数。执行时有一些点需要注意:
    • 执行之前会检查剩下多少 gas 费,如果剩下的 gas 费用不足以执行回调函数,会导致整个交易回滚。
    • 执行回调函数部分被 try catch 保护。如果运行回调函数时出现了错误,不会回滚,只会抛出 AfterOrderExecutionError 事件。

在第 4 步,由 processOrder() 进行订单的处理。但这个函数只是一个选择器,让不同类型的订单 (increase,decrease,swap) 到不同的函数执行。除此并无其他逻辑。对于 swap 订单,会调用 contracts/order/SwapOrderUtils.sol 的 processOrder() 函数。

SwapOrderUtils.processOrder() 是最后一个准备步骤,内容包括:

  1. 检查交易池地址。
  2. 进行价格预言机时间的验证。
  3. 执行 swap 交易。

执行 swap 交易

执行 swap 交易的代码在 contracts/swap/SwapUtils.sol 的 swap() 函数。这是执行 swap 交易的核心代码。当其它交易类型需要用到 swap 功能的时候都会调用这个函数。如 LP 的 deposit 和 withdraw,以及 increase position。从实际作用上看,这个函数有两个功能:

  1. swap:通过资金池,把一种 token 转化为另一种 token。通俗的说,池中有 long/short 两种 token,用户可以存入一种,等价的得到另一种。在兑换过程中,用户需要支付手续费和 swap price impact。
  2. 转帐:不执行任何 swap,仅仅是利用其中的 transfer 代码,将资金从一个地址转入另一个地址。

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() 开始之前,还有两个关键操作

  • 通过 swapPathMarketFlagKey 将 market 锁定,防止重入。
  • 设定此次循环的 receiver,如果 swap 路径没有走完,receiver 就是下一个交易池,如果已经是路径中最后一步,receiver 就是接收地址。

_swap() 是兑换的核心逻辑,它是针对单个交易池的。执行的内容包括:

  1. 验证 tokenIn 是否是 longToken 或者 shortToken。
  2. 验证 market 是否可以 swap (longToken 和 shortToken 不能一样 )。
  3. 从缓存中获取价格
  4. 计算 swap price impact
  5. 计算交易手续费
  6. 处理 Claimable Fee 以及 Claimable Ui Fee
  7. 如果这笔交易有利于池的平衡,奖励 price Impact:
    1. 把 price Impact 加到 swap 输入金额上
    2. 从输出 token 的 Swap Impact pool 扣减 price impact
    3. 如果 price impact 达到了支出限额,从输入 token 的 Swap Impact pool 扣减超出部分的 price impact
    4. 刚才 price impact 加到了 swap 输入金额上,现在要根据新的输入金额,以及价格,计算 swap 最终的输出金额
  8. 如果这笔交易不利于池的平衡,从用户手里扣除 price Impact:
    1. 把 price Impact 加到输入 token 的 price Impact pool
    2. 从 swap 的输入金额扣掉 priceImpactAmount
    3. swap 输出金额也做相应的扣减
  9. 从池中转出资金
  10. 修改输入 token 的 Pool amount。数量为 amountIn + feeAmountForPool,注意这个值已经被 priceImpact 修改过。
  11. 修改输出 token 的 pool amount,数量为 poolAmountOut
  12. 做一些验证
  13. 抛出 SwapInfo 事件和 SwapFeesCollected 事件

Pool amount 的修改

从这部分开始,介绍 SWAP 交易的一些关键点。

首先是数据修改,GMX 对数据的修改遵循统一的风格:先读取原值,叠加修改量,然后写入,最后抛出一个修改的 event。从抛出的 event 可以很直观的观察到这一点,以这个 Pool amount 的修改为例,抛出的 event 标明了修改的对象(market,token, pool amount),修改量,修改之后的值。

pool amount updated
pool amount updated

其他数值的修改也是类似结构。对于绝大部分数值,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 哪个小,然后按照小的向用户收取。


Swap price impact 的计算

Price impact 考虑的是这笔交易对 pool 中两种 token 平衡性影响。当两种 token 的价值比是 1:1,那么这个池处于理想状态。如果一笔交易让两种 token 的价值偏离 1:1,那么这笔交易的 price Impact 就是负值,此时要从用户手中扣掉一部分资金;如果让两种 token 的价值接近 1:1,这笔交易的 price impact 就是正,此时会奖励用户一部分资金。为此要考虑:

  • 当前 pool 中两种 token 的数量
  • 两种 token 的价格
  • 这笔交易的价值

然后计算就很简单了,

  • 首先计算出交易发生前,两种 token 的价值差 initialDiffUsd;
  • 再求出交易之后的价值差 nextDiffUsd。
  • 用 initialDiffUsd 和 nextDiffUsd 计算这笔交易是否让 long/short 的价值差变得更小(hasPositiveImpact),确定 price impact 的方向。
  • 最后计算 initialDiffUsd 和 nextDiffUsd 在乘以系数后的差值,求出 priceImpactUsd。这个值的单位是 USD。

这个计算过程用代码表示如下:

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
}

Pool 状态更新

更新了 long 和 short token 的 CumulativeBorrowingFactor,虽然对 swap 交易没有帮助,但是可以帮助池的状态保持最新。

CumulativeBorrowingFactorUpdated
{'delta': 0,'nextValue': 0,'isLong': True}
CumulativeBorrowingFactorUpdated
{'delta': 0,'nextValue': 0,'isLong': False}

swap 交易

开始交易了,先更新了 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 费。gas 费中一部分直接转帐到 keeper 上,余下的返还。

KeeperExecutionFee
{
   'keeper': '0xdd5c59b7c4e8fad38732caffbebd20a61bf9f3fc',
   'executionFeeAmount': 45171150000000
}
ExecutionFeeRefund
{
   'receiver': '0x784f8b525d652a83e4ae85e573c31f17f2dbca0d',
   'refundFeeAmount': 42629797000000
}

总结

本文详细解析了 swap 部分的源码,并通过例子介绍了 swap 过程中金额的变化。下一篇将会介绍 LP 的 Deposit 和 Withdraw 交易。


往期阅读

Uniswap 系列研究

Zelos 小组产出


参考资料
[1] 

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


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

Coset
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开