剖析DeFi交易产品之UniswapV4:创建池子

2023-11-27 15:19:17 浏览数 (1)

创建池子的底层函数是 PoolManager 合约的 initialize 函数,其代码实现并不复杂,如下所示:

代码语言:javascript复制
function initialize(PoolKey memory key, uint160 sqrtPriceX96, bytes calldata hookData)
    external
    override
    onlyByLocker
    returns (int24 tick)
{
    if (key.fee.isStaticFeeTooLarge()) revert FeeTooLarge();

    // see TickBitmap.sol for overflow conditions that can arise from tick spacing being too large
    if (key.tickSpacing > MAX_TICK_SPACING) revert TickSpacingTooLarge();
    if (key.tickSpacing < MIN_TICK_SPACING) revert TickSpacingTooSmall();
    if (key.currency0 >= key.currency1) revert CurrenciesOutOfOrderOrEqual();
    if (!key.hooks.isValidHookAddress(key.fee)) revert Hooks.HookAddressNotValid(address(key.hooks));

    if (key.hooks.shouldCallBeforeInitialize()) {
        if (key.hooks.beforeInitialize(msg.sender, key, sqrtPriceX96, hookData) != IHooks.beforeInitialize.selector)
        {
            revert Hooks.InvalidHookResponse();
        }
    }

    PoolId id = key.toId();

    uint24 swapFee = key.fee.isDynamicFee() ? _fetchDynamicSwapFee(key) : key.fee.getStaticFee();

    tick = pools[id].initialize(sqrtPriceX96, _fetchProtocolFees(key), _fetchHookFees(key), swapFee);

    if (key.hooks.shouldCallAfterInitialize()) {
        if (
            key.hooks.afterInitialize(msg.sender, key, sqrtPriceX96, tick, hookData)
                != IHooks.afterInitialize.selector
        ) {
            revert Hooks.InvalidHookResponse();
        }
    }

    // On intitalize we emit the key's fee, which tells us all fee settings a pool can have: either a static swap fee or dynamic swap fee and if the hook has enabled swap or withdraw fees.
    emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks);
}

不过,里面有很多信息,我们需要一一拆解才能理解。

先来看入参,有三个:keysqrtPriceX96hookDatakey 指定了一个池子的唯一组成,sqrtPriceX96 是要初始化的根号价格,hookData 是需要传给 hooks 合约的初始化数据。

关于池子的唯一组成,前文我们已经讲过,PoolKey 包含了五个字段:

  • currency0:token0
  • currency1:token1
  • fee:费率
  • tickSpacing:tick 间隔
  • hooks:hooks 地址

currency0currency1 和以前版本的 token0token1 一样,是经过排序的,currency0 为数值较小的代币,currency1 则为数值较大的代币。tickSpacing 和 UniswapV3 的一样,就不再解释了。hooks 是自定义的地址,具体如何实现后面再细说。

fee 则和之前的版本不一样了。UniswapV3 的 fee 只指定了固定的交易费率,但 UniswapV4 的 fee 其实还包含了动态费用、hook 交易费用、hook 提现费用等标志。fee 总共 24 位(bit),前 4 位用来作为不同的标志位,具体解析在 FeeLibrary 里实现,以下是其代码实现:

代码语言:javascript复制
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.20;

library FeeLibrary {
    // 静态费率掩码
    uint24 public constant STATIC_FEE_MASK = 0x0FFFFF;
    // 支持动态费用的标志位
    uint24 public constant DYNAMIC_FEE_FLAG = 0x800000; // 1000
    // 支持hook交易费用的标志位
    uint24 public constant HOOK_SWAP_FEE_FLAG = 0x400000; // 0100
    // 支持hook提现费用的标志位
    uint24 public constant HOOK_WITHDRAW_FEE_FLAG = 0x200000; // 0010

    // 是否支持动态费用
    function isDynamicFee(uint24 self) internal pure returns (bool) {
        return self & DYNAMIC_FEE_FLAG != 0;
    }
    // 是否支持hook交易费用
    function hasHookSwapFee(uint24 self) internal pure returns (bool) {
        return self & HOOK_SWAP_FEE_FLAG != 0;
    }
    // 是否支持hook提现费用
    function hasHookWithdrawFee(uint24 self) internal pure returns (bool) {
        return self & HOOK_WITHDRAW_FEE_FLAG != 0;
    }
    // 静态费率是否超过最大值
    function isStaticFeeTooLarge(uint24 self) internal pure returns (bool) {
        return self & STATIC_FEE_MASK >= 1000000;
    }
    // 获取出静态手续费率
    function getStaticFee(uint24 self) internal pure returns (uint24) {
        return self & STATIC_FEE_MASK;
    }
}

静态费率最大值为 1000000,表示 100% 费用。那么要设置 0.3% 的费率的话那就是 3000,这个精度和 UniswapV3 是一致的。

那如果是要支持静态费率,就假设静态费率为 0.3%,同时又要支持 hook 交易费和提现费,则需要同时设置这两个标志位,那 fee 字段用 16 进制表示的值为 0xC01778。其二进制表示为:11000000000101110111000,前面两个 1 就是两个标志位,后面的 101110111000 其实就是十进制数 3000 的二进制数。

另外,UniswapV3 的费率只能在指定支持的几个费率中选择一个,而 UniswapV4 取消了这个限制,费率完全放开了,由池子的创建者自己去决定要设置多少费率。

回到 initialize 函数,函数声明里还有一个函数修饰器 onlyByLocker,这也是需要展开说明的一个地方。我们先来看这个函数修饰器的代码:

代码语言:javascript复制
modifier onlyByLocker() {
    address locker = Lockers.getCurrentLocker();
    if (msg.sender != locker) revert LockedBy(locker);
    _;
}

它要求调用者需是当前的 locker。要成为 locker,需要调用 PoolManager 合约的 lock() 函数。以下是 lock() 函数的实现:

代码语言:javascript复制
function lock(bytes calldata data) external override returns (bytes memory result) {
    //把调用者添加到locker队列里
    Lockers.push(msg.sender);

    //需在这个回调函数里完成所有事情,包括支付等操作
    result = ILockCallback(msg.sender).lockAcquired(data);

    if (Lockers.length() == 1) {//只有一个locker的情况下,做清理操作
        if (Lockers.nonzeroDeltaCount() != 0) revert CurrencyNotSettled();
        Lockers.clear();
    } else {//不止一个locker的情况下,移出顶部的locker
        Lockers.pop();
    }
}

其中,Lockers 是封装了锁定操作的库合约,push() 函数会把当前调用者添加到锁定者队列里,具体实现用到了 EIP-1153 所引入的 tstore 瞬态存储操作码。具体原理不在这里展开。

而下一步是调用了 msg.sender 的回调函数 lockAcquired(),这一步非常关键,透露出很多信息。首先,这说明了,调用者需是一个合约才行,而不能是一个 EOA 账户。然后,调用者需实现 ILockCallback 接口,该接口只定义了一个函数,就是 lockAcquired() 函数。最后,调用者合约需在 lockAcquired() 函数里实现所有事情,包括完成支付和各种不同的交易场景,其实也包括了调用 initialize 函数。

我的理解,lock() 函数调用者应该是一个路由合约,或不同功能模块用不同的合约实现,比如可以加一个工厂合约用于完成创建池子的操作,但目前 UniswapV4 还没看到关于路由合约或工厂合约的实现,所以具体逻辑不得而知。

总而言之,到了这里,我们就已经知道了,创建池子的调用者需是一个实现了 ILockCallback 接口的合约,先调用 lock() 函数成为 locker,再通过 lockAcquired() 回调函数调其 initialize 函数来完成初始化池子。

回到 initialize 函数的具体实现。前面是一些基本的校验,我们摘出来看一下:

代码语言:javascript复制
// 静态费率不能超过最大值
if (key.fee.isStaticFeeTooLarge()) revert FeeTooLarge();
// tickSpacing需在限定的有效范围内
if (key.tickSpacing > MAX_TICK_SPACING) revert TickSpacingTooLarge();
if (key.tickSpacing < MIN_TICK_SPACING) revert TickSpacingTooSmall();
// currency0需小于currency1
if (key.currency0 >= key.currency1) revert CurrenciesOutOfOrderOrEqual();
// hooks地址需是符合条件的有效地址
if (!key.hooks.isValidHookAddress(key.fee)) revert Hooks.HookAddressNotValid(address(key.hooks));

接着,判断是否需要调用 beforeInitialize 的钩子函数,如下:

代码语言:javascript复制
if (key.hooks.shouldCallBeforeInitialize()) {
    if (key.hooks.beforeInitialize(msg.sender, key, sqrtPriceX96, hookData) != IHooks.beforeInitialize.selector)
    {
        revert Hooks.InvalidHookResponse();
    }
}

钩子函数需返回该函数的 selector

之后的三行代码实现初始化逻辑,代码如下:

代码语言:javascript复制
// 把key转为id
PoolId id = key.toId();
// 读取出交易费率
uint24 swapFee = key.fee.isDynamicFee() ? _fetchDynamicSwapFee(key) : key.fee.getStaticFee();
// 执行实际的初始化操作
tick = pools[id].initialize(sqrtPriceX96, _fetchProtocolFees(key), _fetchHookFees(key), swapFee);

这里面有好几个跟费用相关的函数,有必要说明一下。

isDynamicFee() 就是前面所说的 FeeLibrary 库合约的函数,判断是否设置了支持动态费用的标志位。如果不支持,则通过 getStaticFee() 读取出静态费率;如果支持动态费用,则通过 _fetchDynamicSwapFee() 获取费率。 _fetchDynamicSwapFee() 函数是在抽象合约 Fees 里实现的,其实现非常简单,就两行代码,如下所示:

代码语言:javascript复制
function _fetchDynamicSwapFee(PoolKey memory key) internal view returns (uint24 dynamicSwapFee) {
    dynamicSwapFee = IDynamicFeeManager(address(key.hooks)).getFee(msg.sender, key);
    if (dynamicSwapFee >= MAX_SWAP_FEE) revert FeeTooLarge();
}

可见,其实是调用了 hooks 合约的 getFee() 函数。即是说,要支持动态费用,则 hooks 合约需要实现 IDynamicFeeManager 接口的 getFee() 函数。

_fetchHookFees() 函数也类似,需要 hooks 合约实现 IHookFeeManager 接口的 getHookFees() 函数。不过 getHookFees() 的返回值里其实是由两个费用组合而成的,一个是交易费,一个是提现费。返回值是 24 位,前 12 位是交易费,后 12 位是提现费。

_fetchProtocolFees() 函数则是用于获取协议费,这就和 hooks 合约没有关系了,是由一个实现了 IProtocolFeeController 接口的合约进行管理的。只有合约 owner 可以设置这个合约地址。目前 UniswapV4 还没有提供关于该合约的实现,短期内应该也不会开启收取协议费。

最后,通过调用 pools[id].initialize() 函数完成内部的初始化工作。这里的关键就是 pools 状态变量,新建的池子状态最终其实也是存储在了 pools 里。它是一个 mapping 类型的变量,如下:

代码语言:javascript复制
mapping(PoolId id => Pool.State) public pools;

其 value 存的是一个 Pool.State 对象,这是一个定义在 Pool 库合约里的结构体,具体包含了如下数据:

代码语言:javascript复制
struct State {
    Slot0 slot0;
    uint256 feeGrowthGlobal0X128;
    uint256 feeGrowthGlobal1X128;
    uint128 liquidity;
    mapping(int24 => TickInfo) ticks;
    mapping(int16 => uint256) tickBitmap;
    mapping(bytes32 => Position.Info) positions;
}

如果和 UniswapV3 对比就会发现,其实就是将 UniswapV3Pool 里的大部分状态变量移到了 State 里。另外,slot0 的字段与 UniswapV3Pool 的有所不同,以下是其具体字段:

代码语言:javascript复制
struct Slot0 {
    // the current price
    uint160 sqrtPriceX96;
    // the current tick
    int24 tick;
    uint24 protocolFees;
    uint24 hookFees;
    // used for the swap fee, either static at initialize or dynamic via hook
    uint24 swapFee;
}

可看到,与 UniswapV3Pool 的 Slot0 相比,没有了预言机相关的状态数据。另外,关于费用的字段总共有三个:protocolFeeshookFeesswapFee

pools[id].initialize() 函数的实现是在 Pool 库合约里,其代码逻辑很简单,就是初始化了 slot0,代码如下:

代码语言:javascript复制
function initialize(State storage self, uint160 sqrtPriceX96, uint24 protocolFees, uint24 hookFees, uint24 swapFee)
    internal
    returns (int24 tick)
{
    //当前状态下的根号价格不为0,说明已经初始化过了
    if (self.slot0.sqrtPriceX96 != 0) revert PoolAlreadyInitialized();
    //根据根号价格算出tick
    tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
    //初始化slot0
    self.slot0 = Slot0({
        sqrtPriceX96: sqrtPriceX96,
        tick: tick,
        protocolFees: protocolFees,
        hookFees: hookFees,
        swapFee: swapFee
    });
}

再回到 PoolManager 合约自身的 initialize() 函数,还剩下最后一段代码如下:

代码语言:javascript复制
if (key.hooks.shouldCallAfterInitialize()) {
    if (
        key.hooks.afterInitialize(msg.sender, key, sqrtPriceX96, tick, hookData)
            != IHooks.afterInitialize.selector
    ) {
        revert Hooks.InvalidHookResponse();
    }
}
//发送事件
emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks);

完成了 PoolManager 自身的初始化逻辑之后,就是判断是否需要再调用 hooks 合约的 afterInitialize 钩子函数了。最后发送事件,整个创建池子的流程就完成了。

0 人点赞