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

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

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 里实现,以下是其代码实现:

// 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,这也是需要展开说明的一个地方。我们先来看这个函数修饰器的代码:

modifier onlyByLocker() {
    address locker = Lockers.getCurrentLocker();
    if (msg.sender != locker) revert LockedBy(locker);
    _;
}

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

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 函数的具体实现。前面是一些基本的校验,我们摘出来看一下:

// 静态费率不能超过最大值
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 的钩子函数,如下:

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

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

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

// 把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 里实现的,其实现非常简单,就两行代码,如下所示:

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 类型的变量,如下:

mapping(PoolId id => Pool.State) public pools;

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

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 的有所不同,以下是其具体字段:

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,代码如下:

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() 函数,还剩下最后一段代码如下:

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 钩子函数了。最后发送事件,整个创建池子的流程就完成了。

Subscribe to Keegan小钢
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.