什么是 Uniswap V4:可定制的Hooks 和卓越的效率

详解 Uniswap V4

什么是 Uniswap V4

在 DeFi 领域,几乎没有人没听说过 Uniswap。自 2017 年推出以来,Uniswap 已经历了三个版本,现在我们期待第四个版本的到来。Uniswap V1 最初是一个基本的自动化做市商(AMM),交易只能在一对中使用 ETH 作为其中一个代币。V2 通过允许任意两个代币之间的直接兑换来改进这一点。V3 引入了集中流动性,让流动性提供者为其资金设定特定的价格区间,从而提高了系统的效率。

Uniswap V4 引入了一种名为“hooks”的新功能,这是一种智能合约,允许开发者在交易的不同阶段自定义池的行为。这为动态费用、链上限价单以及将大额交易分散到时间上的系统等多种可能性打开了大门。

底层架构也得到了改进,所有池现在都 housed 在一个单一的智能合约中(单例模式),使系统更高效且使用成本更低。通过 hooks 和这种新设置,Uniswap V4 提供了更多的灵活性、速度和安全性,以便在不同池之间自定义和处理交易。

1. 新特性

1.1 单例池

新架构中最显著的变化是使用 单例模式 来创建池。之前,每个池都是通过工厂模式作为新的智能合约部署的,这使得创建新池和执行兑换的成本显著增加。在 V4 中,所有池都 housed 在同一个合约(PoolManager)中,显著降低了 gas 成本,创建池时 gas 成本降低了 99%。

1.2 Hooks

Uniswap V4 引入了一种名为“hooks”的创新功能,作为可以添加到流动性池的插件。Hook 本质上是一个智能合约,包含在特定事件发生时执行的逻辑。在 Uniswap 中触发 hooks 的事件分为三类:池部署、流动性提供和撤回,以及兑换。这定义了八种不同类型的 hooks,允许高度的自定义。

Hooks 使池能够超越简单的兑换,通过添加限价单、MEV 收益共享和优化流动性提供者收益的工具等高级功能。通过利用这些 hooks,开发者可以在 Uniswap V4 上构建新功能,创造一个更动态和灵活的生态系统,同时保持核心平台的效率。

1.3 闪电记账

另一个特性是“闪电记账(flash accounting)”系统,允许用户通过使用 EIP-1153 瞬态存储高效地将多个操作(如兑换和添加流动性)链接到单个交易中。瞬态存储的工作方式类似于常规存储,但在每个交易结束时自动清除数据。由于数据在每个交易后被擦除,因此不会增加以太坊客户端的存储负担,同时消耗的 gas 量比传统存储操作少多达 20 倍(100 gas)。Uniswap V4 利用该系统在交易过程中以更低的成本进行计算和验证。

闪电记账系统跟踪在交易过程中进出代币的净余额。之前,每个池操作涉及在池之间兑换代币,但现在通过单例架构和闪电记账,在过程结束时,合约只需确保所有余额得到结算。如果没有,交易将完全回滚,确保安全性和效率。这个概念类似于闪电贷,是 Uniswap 更广泛努力降低 gas 成本和提高交易效率的一部分。

在 Uniswap V3 中:

  • ETH 被发送到 ETH/USDC 池合约。
  • 从 ETH/USDC 池中提取 USDC,并转移到 USDC/DAI 池合约。
  • 然后从 USDC/DAI 池中提取 DAI,并发送给用户。

在 Uniswap V4 中:

  • 在 ETH/USDC 池上调用 swap() 函数。
  • 在 USDC/DAI 池上调用 swap() 函数,使用上一个兑换中获得的 USDC 作为输入。
  • 用户通过支付 ETH 并接收 DAI 来结算差额。

因此,可以完全跳过在 USDC 合约上调用 transfer() 的步骤。

这种优化有效地扩展,允许任意数量的跳跃,同时只需进行两次代币转移——一次用于输入代币,一次用于输出代币。

1.4 无限制费用层

Uniswap V4 引入了无限制费用(Unlimited Fee)层,以便流动性池提供更大的灵活性,以适应各种资产和交易策略。每个池可以拥有自定义的费用结构,允许更具针对性的方法来满足不同用户的特定需求。

1.5 原生 ETH

Uniswap 的新版本通过允许与原生 ETH 的直接交易对来改善用户体验,消除了对 Wrapped ETH(WETH)的需求。这种简化使交易过程更加直接,并降低了交易成本。

1.6 流动性费用记账

累积的费用在修改流动性时作为信用(credit)使用。当流动性增加时,费用收入会转化为该位置的额外流动性。相反,减少流动性会自动触发任何未领取的费用收入的提取。

在创建流动性时,可以提供一个可选参数,称为 salt。salt 用于区分同一池中具有相同范围的位置,这对于简化费用记账可能很有用。如果两个用户在 PoolManager 中共享相同的范围和状态,则集成合约必须小心处理费用管理,以避免冲突。

1.7 订阅者

所有者现在可以为其位置分配一个订阅者。每当位置的流动性或所有权发生变化时,订阅者合约将收到通知。此功能使得质押和流动性挖掘无需用户转移其 ERC-721 代币。

1.8 ERC-6909

Uniswap V4 集成了 ERC-6909,以提高代币申索(claim)和赎回(redemption)的 gas 效率。

ERC-6909 是一种轻量级、优化 gas 的标准,旨在从单个合约管理多个 ERC-20 代币。它提供了一种简化的替代方案,取代了更复杂的 ERC-1155 多代币标准。

用户可以将其代币保留在合约中,而不是在 PoolManager 中进出转移 ERC-20 代币,并接收代表其申索的 ERC-6909 代币。当用户需要在未来的交互中进行支付时,他们可以简单地销毁其申索代币,而不是进行实际的 ERC-20 转移。

传统的 ERC-20 代币转移需要外部智能合约调用,这会产生比内部记账更高的 gas 开销。这些外部合约通常在其 transfer 函数中包含自己的自定义逻辑,例如 USDC 的阻止地址列表,进一步增加了 gas 成本。

1.9 动态费用

Uniswap V4 引入了对动态费用的支持,使池能够上下调整其费用。与其他 AMM 可能强制执行预定义逻辑以调整费用不同,V4 完全灵活地进行费用计算,允许开发者定义自己的逻辑。费用更新的频率也可以自定义,从每次兑换或区块更新到任何任意的时间表,例如每周、每月甚至每年。动态费用是一种直接归属于流动性提供者的兑换费用。它们也不同于协议费用和 hook 费用。池使用动态费用的决定在创建时做出,之后无法更改。这意味着一旦池设置为使用动态费用,该选择就是永久的。

2. 架构

与池逻辑相关的大多数操作都是通过调用 Pool 库来管理的。库的功能类似于合约,但只在固定地址上部署一次,并通过 DELEGATECALL 反复访问。将核心逻辑集中在库中可以提高可读性,并确保系统的一致性。

2.1 仓库结构

所有合约都位于 v4-core/src 文件夹中。

请注意,用于测试的辅助合约存储在 src 文件夹内的 v4-core/src/test 子文件夹中。任何新的测试辅助合约应添加到此处,而所有 Foundry 测试都位于 v4-core/test 文件夹中。

src/
----interfaces/
     | IPoolManager.sol
     | ...
 ----libraries/
     | Position.sol
     | Pool.sol
     | ...
----test
----PoolManager.sol
...
test/
----libraries/
    | Position.t.sol
    | Pool.t.sol

2.2 结构体

在进入核心合约之前,先解释一些在代码库中出现的常见结构体:

  • PoolKey (PoolManager.sol): 该结构体用于 PoolManager 合约中,存储区分一个池与另一个池的基本信息。它包括一对中的两个代币、LP 费用、刻度间隔和一个Hook数组。这允许两个池具有相同的代币和费用,但不同的Hook,使它们完全独立于 Uniswap V3 中的池。PoolKey 结构体被哈希以生成唯一的池 ID,用于区分每个池。与将池费用限制为 0.05%、0.3% 和 1% 的 Uniswap V3 不同,Uniswap V4 没有此类限制,允许具有相同配置但不同费用的无数池。
struct PoolKey {
    /// @notice 池的低位货币,按数值排序
    Currency currency0;
    /// @notice 池的高位货币,按数值排序
    Currency currency1;
    /// @notice 池的 LP 费用,最高为 1_000_000。如果最高位为 1,则池具有动态费用,必须等于 0x800000
    uint24 fee;
    /// @notice 涉及位置的刻度必须是刻度间隔的倍数
    int24 tickSpacing;
    /// @notice 池的Hook
    IHooks hooks;
}
  • Slot0 (libraries/Pool.sol): 该结构体包含池的费用信息,与包含预言机信息和重入锁的 Uniswap V3 不同。该结构体包括 sqrtPriceX96 和当前刻度,这些在 V3 中也存在,但现在还包含 LP 费用和协议费用,以百分之一的 bip 表示,这通过治理在 V4 中启用。
  • State (libraries/Pool.sol): 该结构体表示池的状态,包括所有位置、来自 Slot0 结构体的费用信息、当前流动性、累积的 LP 费用、交叉的刻度以及每个单独初始化的刻度的数据。
    struct State {
    Slot0 slot0;
    uint256 feeGrowthGlobal0X128;
    uint256 feeGrowthGlobal1X128;
    uint128 liquidity;
    mapping(int24 tick => TickInfo) ticks;
    mapping(int16 wordPos => uint256) tickBitmap;
    mapping(bytes32 positionKey => Position.State) positions;
    }

    2.3 PoolManager.sol

该合约管理所有池的状态,确保用户与池之间不存在未结清的代币余额。它通过首先计算任何债务,然后结算它们来实现。该合约中的函数分为两类:

  1. 核心计算方法 — 负责执行关键操作,如 _initialize__swap__modifyPosition__donate_
  2. 结算方法 — 处理计算结果的执行和实际的代币转移,包括 _settle__take__lock_

2.3.1 initialize() - 池创建

为了创建一个池,必须用池 ID 作为键和池状态作为值填充 _pools 映射:

    mapping(PoolId id => Pool.State) internal _pools;

入口点是 PoolManagerinitialize() 函数,它充当池创建的接口。它接受一个 PoolKey 结构体、一个初始价格 (sqrtPriceX96) 和可选的Hook数据,基本上设置池的基本特征。

该函数首先进行输入验证,检查刻度间隔是否在有效范围内,验证两个代币是否正确排序,并确保 PoolKey 结构体中的Hook地址有效,使用 isValidHookAddress()

    function initialize(PoolKey memory key, uint160 sqrtPriceX96, bytes calldata hookData)
        external
        noDelegateCall
        returns (int24 tick)
    {
        // 请参见 TickBitmap.sol 以了解刻度间隔过大可能引发的溢出条件
        if (key.tickSpacing > MAX_TICK_SPACING) TickSpacingTooLarge.selector.revertWith(key.tickSpacing);
        if (key.tickSpacing < MIN_TICK_SPACING) TickSpacingTooSmall.selector.revertWith(key.tickSpacing);
        if (key.currency0 >= key.currency1) {
            CurrenciesOutOfOrderOrEqual.selector.revertWith(
                Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)
            );
    }
        if (!key.hooks.isValidHookAddress(key.fee)) Hooks.HookAddressNotValid.selector.revertWith(address(key.hooks));
        ...

接下来,该函数计算初始流动性提供者费用和协议费用。

    ...
    uint24 lpFee = key.fee.getInitialLPFee();
    uint24 protocolFee = _fetchProtocolFee(key);
    ...

在初始化池之前,该函数调用 beforeInitialize Hook,允许开发人员在池完全初始化之前执行任何自定义逻辑。

    ...
    key.hooks.beforeInitialize(key, sqrtPriceX96, hookData);
    ...

然后检索池 ID 并将其添加到 _pools 映射中。之后,调用 Pool 库中的 initialize() 函数,该函数填充池状态中的 slot0 结构体。

    PoolId id = key.toId();

    tick = _pools[id].initialize(sqrtPriceX96, protocolFee, lpFee);
    ...

libraries/Pool.sol

    function initialize(State storage self, uint160 sqrtPriceX96, uint24 protocolFee, uint24 lpFee)
        internal
        returns (int24 tick)
    {
        if (self.slot0.sqrtPriceX96() != 0) PoolAlreadyInitialized.selector.revertWith();

        tick = TickMath.getTickAtSqrtPrice(sqrtPriceX96);

        self.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(sqrtPriceX96).setTick(tick).setProtocolFee(protocolFee)
            .setLpFee(lpFee);
}

最后,afterInitialize Hook被调用,允许在池创建后执行任意逻辑。

    ...
    key.hooks.afterInitialize(key, sqrtPriceX96, tick, hookData);
    ...

成功后,交易通过发出 Initialize 事件宣布创建一个新池。与 Uniswap V3 不同,后者为每个新创建的池部署一个新合约,Uniswap V4 的过程通过在映射中创建记录显著简化。

PoolManagerInitialize.t.sol 是一个测试套件,演示初始化过程并验证其功能。

2.3.2 swap() - 兑换

来自 PoolManager 的 swap() 函数是执行兑换的入口点。

首先,进行输入验证:

    function swap(PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
        external
        onlyWhenUnlocked
        noDelegateCall
        returns (BalanceDelta swapDelta)
    {
        if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();
        PoolId id = key.toId();
        Pool.State storage pool = _getPool(id);
        pool.checkPoolInitialized();
       ...

接下来,触发 beforeSwap Hook:

    ...
    BeforeSwapDelta beforeSwapDelta;
    {
        int256 amountToSwap;
        uint24 lpFeeOverride;
        (amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData);
    ...

然后,在 Pool 库的 swap() 函数中执行实际的兑换计算和核心功能:

    1...
    2// 执行兑换,计算协议费用,并发出兑换事件
    3// _swap 是为了避免栈过深错误
    4swapDelta = _swap(
    5    pool,
    6    id,
    7    Pool.SwapParams({
    8        tickSpacing: key.tickSpacing,
    9        zeroForOne: params.zeroForOne,
    10        amountSpecified: amountToSwap,
    11        sqrtPriceLimitX96: params.sqrtPriceLimitX96,
    12        lpFeeOverride: lpFeeOverride
    13    }),
    14    params.zeroForOne ? key.currency0 : key.currency1 // 输入代币
    15);
    16
    17...
    18
    19function _swap(Pool.State storage pool, PoolId id, Pool.SwapParams memory params, Currency inputCurrency)
    20    internal
    21    returns (BalanceDelta)
    22{
    23    (BalanceDelta delta, uint256 amountToProtocol, uint24 swapFee, Pool.SwapResult memory result) =
    24        pool.swap(params);
    25
    26    // 费用在输入货币上
    27    if (amountToProtocol > 0) _updateProtocolFees(inputCurrency, amountToProtocol);
    28
    29    // 在 afterSwap 调用之前发出事件,以确保事件始终按顺序发出
    30    emit Swap(
    31        id,
    32        msg.sender,
    33        delta.amount0(),
    34        delta.amount1(),
    35        result.sqrtPriceX96,
    36        result.liquidity,
    37        result.tick,
    38        swapFee
    39    );
    40
    41    return delta;
    42}

之后,触发 afterSwap Hook:

    1...
    2BalanceDelta hookDelta;
    3(swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);
    4...

最后,应用池余额增量,考虑池内代币的变化:

    1...
    2// 如果Hook没有返回增量的标志,hookDelta 将始终为 0
    3if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
    4
    5_accountPoolBalanceDelta(key, swapDelta, msg.sender);
    6}

与 V3 一样,Pool 库中的 swap() 函数执行计算,直到兑换完成(使用 while 循环)。兑换在以下两种条件下结束:

  1. 兑换输入金额完全耗尽(兑换完成)。
  2. 达到设定的价格限制(滑点限制)。

V4 的关键区别在于,兑换完成后返回一个增量值。该增量表示由于兑换或流动性提供等操作导致的池余额变化。

    1function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
    2    _accountDelta(key.currency0, delta.amount0(), target);
    3    _accountDelta(key.currency1, delta.amount1(), target);
    4}
    5...
    6
    7function _accountDelta(Currency currency, int128 delta, address target) internal {
    8    if (delta == 0) return;
    9
    10    (int256 previous, int256 next) = currency.applyDelta(target, delta);
    11
    12    if (next == 0) {
    13        NonzeroDeltaCount.decrement();
    14    } else if (previous == 0) {
    15        NonzeroDeltaCount.increment();
    16    }
    17}

CurrencyDeltaNonzeroDeltaCount 库在临时存储中管理调用者的货币增量:

    1library CurrencyDelta {
    2    /// @notice 计算给定账户和货币的增量应存储在哪个存储槽
    3    function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
    4        assembly ("memory-safe") {
    5            mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
    6            mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
    7            hashSlot := keccak256(0, 64)
    8        }
    9    }
    10
    11    function getDelta(Currency currency, address target) internal view returns (int256 delta) {
    12        bytes32 hashSlot = _computeSlot(target, currency);
    13        assembly ("memory-safe") {
    14            delta := tload(hashSlot)
    15        }
    16    }
    17
    18    /// @notice 为给定账户和货币应用新的货币增量
    19    /// @return previous 先前的值
    20    /// @return next 修改后的结果
    21    function applyDelta(Currency currency, address target, int128 delta)
    22        internal
    23        returns (int256 previous, int256 next)
    24    {
    25        bytes32 hashSlot = _computeSlot(target, currency);
    26
    27        assembly ("memory-safe") {
    28            previous := tload(hashSlot)
    29        }
    30        next = previous + delta;
    31        assembly ("memory-safe") {
    32            tstore(hashSlot, next)
    33        }
    34    }
    35}
    36
    1library NonzeroDeltaCount {
    2    // 存储非零增量数量的槽。 bytes32(uint256(keccak256("NonzeroDeltaCount")) - 1)
    3    bytes32 internal constant NONZERO_DELTA_COUNT_SLOT =
    4        0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
    5
    6    function read() internal view returns (uint256 count) {
    7        assembly ("memory-safe") {
    8            count := tload(NONZERO_DELTA_COUNT_SLOT)
    9        }
    10    }
    11
    12    function increment() internal {
    13        assembly ("memory-safe") {
    14            let count := tload(NONZERO_DELTA_COUNT_SLOT)
    15            count := add(count, 1)
    16            tstore(NONZERO_DELTA_COUNT_SLOT, count)
    17        }
    18    }
    19
    20    /// @notice 可能会出现下溢。确保通过集成合约进行检查以确保不会发生这种情况。
    21    /// 当前用法确保不会发生这种情况,因为我们在已知边界内调用 decrement(仅限于我们调用 increment 的次数)。
    22    function decrement() internal {
    23        assembly ("memory-safe") {
    24            let count := tload(NONZERO_DELTA_COUNT_SLOT)
    25            count := sub(count, 1)
    26            tstore(NONZERO_DELTA_COUNT_SLOT, count)
    27        }
    28    }
    29}
    30

在这些库中,previous 代表一种货币的先前余额,而 next 是新的结果(previous + delta)。如果修改后的值等于零,则用户的 delta 会减少,从而通过 settle() 函数有效地在池中结算余额。相反,如果先前的余额为零,用户则通过 take() 函数从池中获得相应的货币。

2.3.3 modifyLiquidity() - 流动性提供

与兑换的工作方式类似,提供流动性是通过 modifyLiquidity() 函数完成的。

该函数以输入验证开始:

function modifyLiquidity(
    PoolKey memory key,
    IPoolManager.ModifyLiquidityParams memory params,
    bytes calldata hookData
) external onlyWhenUnlocked noDelegateCall returns (BalanceDelta callerDelta, BalanceDelta feesAccrued) {
    PoolId id = key.toId();
    {
        Pool.State storage pool = _getPool(id);
        pool.checkPoolInitialized();
        ...

接下来,触发 beforeModifyLiquidity Hook:

...
key.hooks.beforeModifyLiquidity(key, params, hookData);
...

提供流动性的核心计算在池库的 modifyLiquidity() 函数中处理。该函数激活(翻转)特定的刻度,返回并更新流动性提供者(LP)的累计费用,并返回 delta。

1...
2BalanceDelta principalDelta;
3(principalDelta, feesAccrued) = pool.modifyLiquidity(
4    Pool.ModifyLiquidityParams({
5        owner: msg.sender,
6        tickLower: params.tickLower,
7        tickUpper: params.tickUpper,
8        liquidityDelta: params.liquidityDelta.toInt128(),
9        tickSpacing: key.tickSpacing,
10        salt: params.salt
11    })
12);
13
14// fee delta 和 principal delta 都归属于调用者
15callerDelta = principalDelta + feesAccrued;
16...

触发 afterModifyLiquidity Hook,并发出 ModifyLiquidity 事件:

...
// 事件在 afterModifyLiquidity 调用之前发出,以确保事件总是按顺序发出
emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta, params.salt);

BalanceDelta hookDelta;
(callerDelta, hookDelta) = key.hooks.afterModifyLiquidity(key, params, callerDelta, feesAccrued, hookData);
...

最后,与 swap() 函数类似,delta 通过 _accountPoolBalanceDelta() 函数进行核算,并通过 settle()take() 函数结算。

...
// 如果Hook没有标志能够返回 delta,则 hookDelta 始终为 0
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));

_accountPoolBalanceDelta(key, callerDelta, msg.sender);
}

2.3.4 settle() - 结清用户的债务

settle() 函数是在用户偿还欠池的款项时调用的。该函数在用户将欠款转移给 PoolManager 之后调用。合约然后检查以前保存的货币储备,并将其与新余额进行比较,以验证所欠金额是否已完全偿还。

该函数通过 CurrencyReserves 库从瞬态存储中检索同步的货币和储备。然后获取当前余额,计算两者之间的 delta,并使用 _accountDelta() 更新池的状态。

function settle() external payable onlyWhenUnlocked returns (uint256) {
    return _settle(msg.sender);
}

...

function _settle(address recipient) internal returns (uint256 paid) {
    Currency currency = CurrencyReserves.getSyncedCurrency();

    // 如果之前未同步,或同步的货币槽已重置,则期望结清原生货币
    if (currency.isAddressZero()) {
        paid = msg.value;
    } else {
        if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
        // 储备是保证设置的,因为货币和储备总是一起设置
        uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
        uint256 reservesNow = currency.balanceOfSelf();
        paid = reservesNow - reservesBefore;
        CurrencyReserves.resetCurrency();
    }

    _accountDelta(currency, paid.toInt128(), recipient);
}
library CurrencyReserves {
    using CustomRevert for bytes4;

    /// bytes32(uint256(keccak256("ReservesOf")) - 1)
    bytes32 constant RESERVES_OF_SLOT = 0x1e0745a7db1623981f0b2a5d4232364c00787266eb75ad546f190e6cebe9bd95;
    /// bytes32(uint256(keccak256("Currency")) - 1)
    bytes32 constant CURRENCY_SLOT = 0x27e098c505d44ec3574004bca052aabf76bd35004c182099d8c575fb238593b9;

    function getSyncedCurrency() internal view returns (Currency currency) {
        assembly ("memory-safe") {
            currency := tload(CURRENCY_SLOT)
        }
    }

    function resetCurrency() internal {
        assembly ("memory-safe") {
            tstore(CURRENCY_SLOT, 0)
        }
    }

    function syncCurrencyAndReserves(Currency currency, uint256 value) internal {
        assembly ("memory-safe") {
            tstore(CURRENCY_SLOT, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
            tstore(RESERVES_OF_SLOT, value)
        }
    }

    function getSyncedReserves() internal view returns (uint256 value) {
        assembly ("memory-safe") {
            value := tload(RESERVES_OF_SLOT)
        }
    }
}

示例:

池中有 10 ETH,Bob 欠池 1 ETH。getSyncedCurrency() 函数返回 ETH,getSyncedReserves() 检索最后保存的 ETH 储备,这些储备存储在 reservesBefore 变量中。currency.balanceOfSelf() 函数返回当前的储备,在转移后应为 11 ETH。paid 变量将被计算为 11 - 10 = 1 ETH。随后,瞬态存储被重置,1 ETH 被记入池的 delta,从而清除了 Bob 对池的债务。

2.3.5 take() - 转移给用户

take() 函数用于将池欠用户的资金转移。首先,通过使用 _accountDelta() 函数从池的余额中减去 delta 来对资金进行核算。随后,将欠款转移给用户。

function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
    unchecked {
        // negation must be safe as amount is not negative
        _accountDelta(currency, -(amount.toInt128()), msg.sender);
        currency.transfer(to, amount);
    }
}

2.3.6 unlock() - 闪电记账

新架构使用闪电记账,这意味着解锁 PoolManager 的调用者被允许进行余额变更操作(多次流动性修改、兑换等),并且只需在序列结束时执行实际的代币转移。此流程通过 Lock 库进行管理,该库使用瞬态存储来控制函数的解锁。每个池操作都以对 unlock() 函数的初始调用开始,集成者必须在进行任何与池相关的操作(如 swapmodifyLiquiditydonatetakesettlemintburn)之前实现 unlockCallback()

请注意,池初始化可以独立于解锁 PoolManager 的上下文进行。

在解锁过程中,仅跟踪用户(正)或池(负)所欠的净余额,使用 delta 字段表示未结余额。在解锁期间,可以在池内执行多个操作,只要到解锁释放时,累积的增量解决为零。此解锁和调用架构为集成者在与核心代码交互时提供了最大的灵活性。

该函数首先检查函数是否已解锁。然后解锁该函数并在 msg.sender 上调用 unlockCallback(),调用者在回调中执行所有必要的操作,包括通过调用 settle() 来偿还所欠的款项。之后,它检查是否还有任何增量未偿还。如果有,则事务被回滚。最后,该函数再次锁定以确保重入保护。

function unlock(bytes calldata data) external override returns (bytes memory result) {
    if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();

    Lock.unlock();

    // the caller does everything in this callback, including paying what they owe via calls to settle
    result = IUnlockCallback(msg.sender).unlockCallback(data);

    if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
    Lock.lock();
}
library Lock {
    // The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
    bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

    function unlock() internal {
        assembly ("memory-safe") {
            // unlock
            tstore(IS_UNLOCKED_SLOT, true)
        }
    }

    function lock() internal {
        assembly ("memory-safe") {
            tstore(IS_UNLOCKED_SLOT, false)
        }
    }

    function isUnlocked() internal view returns (bool unlocked) {
        assembly ("memory-safe") {
            unlocked := tload(IS_UNLOCKED_SLOT)
        }
    }
}

示例:

如果用户想在 ETH/USDC 池中进行兑换,将 1 ETH 兑换为 2600 USDC,他们需要调用一个实现 unlockCallback() 的合约,然后触发 unlock() 函数。在回调内部,必须调用 PoolManagerswap() 函数。如果交易未达到设定的滑点限制,则从兑换计算出的增量值可能如下所示(+ 符号表示欠池的金额, 符号表示从池中收到的金额):

ETH 增量 (amount0) | +1
USDC 增量 (amount1) | -2000

接下来,在回调中按顺序执行以下步骤:

  • 1 ETH 从用户转移到 PoolManager,使用 IERC20.transferFrom 函数。
  • 通过 settle() 函数将 amount0 增量结算为 0。
  • 2000 USDC 通过 take() 函数转移给用户,并将 amount1 增量结算为 0。
  • unlock() 函数结束时,合约检查是否有任何未偿还的债务,并确保增量为零。

2.3.7 donate() - 捐赠

该函数允许用户向 LP 捐赠资金,作为提供流动性的激励。与 modifyLiquidity()swap() 函数类似,它首先执行输入验证,然后调用 beforeDonate Hook。之后,它执行 Pool 库的 donate() 函数,将金额添加到累积的池费用(feeGrowthGlobal0X128feeGrowthGlobal1X128),然后通过 _accountPoolBalanceDelta() 计算池中的增量,最后发出 Donate 事件并触发 afterDonate Hook。

function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
    external
    onlyWhenUnlocked
    noDelegateCall
    returns (BalanceDelta delta)
{
    PoolId poolId = key.toId();
    Pool.State storage pool = _getPool(poolId);
    pool.checkPoolInitialized();

    key.hooks.beforeDonate(key, amount0, amount1, hookData);

    delta = pool.donate(amount0, amount1);

    _accountPoolBalanceDelta(key, delta, msg.sender);

    // event is emitted before the afterDonate call to ensure events are always emitted in order
    emit Donate(poolId, msg.sender, amount0, amount1);

    key.hooks.afterDonate(key, amount0, amount1, hookData);
}

3. Hook

如前所述,Hook在 modifyLiquidityswapdonate 等操作的各个阶段被触发。Hook是可以附加到单个池的外部智能合约,允许在池生命周期的特定点进行自定义。每个池可以有一个Hook,但一个Hook可以服务于多个池,在池相关操作期间拦截和修改执行流程。

3.1 Hook的类型:

3.1.1 初始化Hook

  • beforeInitialize:在新池初始化之前调用。
  • afterInitialize:在新池初始化之后调用。
  • 这些Hook允许开发者在池初始化期间添加自定义操作或验证,但只能调用一次。

3.1.2 流动性修改Hook

流动性修改Hook设计得非常细致,以确保安全性。

  • beforeAddLiquidity:在向池添加流动性之前调用。
  • afterAddLiquidity:在添加流动性之后调用。
  • beforeRemoveLiquidity:在从池中移除流动性之前调用。
  • afterRemoveLiquidity:在移除流动性之后调用。

3.1.3 兑换Hook

  • beforeSwap: 在池中执行兑换之前调用。
  • afterSwap: 在兑换执行后调用。

3.1.4 捐赠Hook

  • beforeDonate: 在向池进行捐赠之前调用。
  • afterDonate: 在进行捐赠之后调用。
  • 这些Hook允许自定义向流动性提供者的代币捐赠。

3.2 Hook标志的工作原理:

Hook通过在合约地址中编码标志来指示其功能。PoolManager 检查这些标志以确定在给定池中调用哪个Hook函数。

每个Hook函数,例如 beforeSwap,与特定标志相关联。例如,beforeSwap 函数对应于 BEFORE_SWAP_FLAG,其值为 1 << 7

这些标志表示Hook合约地址中特定比特,其中比特的值(10)指示相应的标志是否启用。例如:

以太坊地址长度为 20 字节(160 位)。考虑以下地址:

0x00000000000000000000000000000000000000C0

其二进制形式为:

0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1100 0000

这个地址的末尾 8 位(1100 0000)表示两个活动标志:

  • 第 7 位被设置为 1,对应于 AFTER_SWAP 标志,这意味着在兑换期间将调用 afterSwap 函数。
  • 第 8 位也被设置为 1,表示 BEFORE_SWAP 标志,因此在兑换期间也将调用 beforeSwap 函数。

PoolManager 在执行期间观察这些标志,并根据地址中设置的比特调用相应的Hook函数。

你可以在 这里 找到完整的标志列表。

3.3 完整Hook示例

SwapHook 合约是一个自定义的 Uniswap V4 Hook,用于跟踪多个池的兑换次数。它扩展了 BaseHook 并实现了 beforeSwapafterSwap Hook。

  • beforeSwapCountafterSwapCount 映射用于记录每个池调用Hook的次数。
  • 仅启用了 beforeSwapafterSwap Hook,如 getHookPermissions() 中所指定。
  • 计数器在 beforeSwap()afterSwap() 函数内部递增。
  • 合约使用 PoolId 为每个池单独管理状态,使其能够从单个合约服务多个池。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {BaseHook} from "v4-periphery/src/base/hooks/BaseHook.sol";

import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol";

contract SwapHook is BaseHook {
    using PoolIdLibrary for PoolKey;

    // NOTE: ---------------------------------------------------------
    // 状态变量通常应对每个池唯一
    // 单个Hook合约应能够服务多个池
    // ---------------------------------------------------------------

    mapping(PoolId => uint256 count) public beforeSwapCount;
    mapping(PoolId => uint256 count) public afterSwapCount;

    constructor(IPoolManager _poolManager) BaseHook(_poolManager) {}

    function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
        return Hooks.Permissions({
            beforeInitialize: false,
            afterInitialize: false,
            beforeAddLiquidity: true,
            afterAddLiquidity: false,
            beforeRemoveLiquidity: true,
            afterRemoveLiquidity: false,
            beforeSwap: true,
            afterSwap: true,
            beforeDonate: false,
            afterDonate: false,
            beforeSwapReturnDelta: false,
            afterSwapReturnDelta: false,
            afterAddLiquidityReturnDelta: false,
            afterRemoveLiquidityReturnDelta: false
        });
    }

    // -----------------------------------------------
    // NOTE: 请参见 IHooks.sol 以获取函数文档
    // -----------------------------------------------

    function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata)
        external
        override
        returns (bytes4, BeforeSwapDelta, uint24)
    {
        beforeSwapCount[key.toId()]++;
        return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
    }

    function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata)
        external
        override
        returns (bytes4, int128)
    {
        afterSwapCount[key.toId()]++;
        return (BaseHook.afterSwap.selector, 0);
    }
}

其他Hook示例可以在这里找到。

3.4 Hook示例

Hook可以用于各种用例。在这个 GitHub 页面,有更多关于 Uniswap V4 Hook的示例和资源。 GitHub。一些Hook示例包括,

动态费用调整(波动费用Hook)

市场波动通常会导致流动性提供者的回报降低或出现暂时性损失。动态费用调整Hook通过根据市场条件调整费用来解决这个问题,在波动期间优化回报,并可能解决 LVR 和 MEV 的问题。

该Hook监控资产波动,并相应调整费用结构。在波动期间提高费用有助于保护流动性提供者免受价格波动,鼓励他们留在池中。这个动态机制有助于在市场波动期间有效管理流动性,使所有参与者受益于波动环境。

链上限价单

该Hook允许用户为交易设置价格条件。当市场达到目标价格时,订单会自动触发,在链上执行交易。这种自动化功能使得更具策略性的交易成为可能,无需不断监控,从而简化了零售用户的去中心化交易。

波动性预言机

波动性预言机Hook解决了 DeFi 中缺乏链上波动性指标的问题,为定价衍生品(如期权)提供重要数据。这使得 DeFi 能够提供更先进的产品,使其更接近传统金融市场。

波动性预言机的另一种选择是将其与动态费用Hook结合,因为费用可以基于该预言机提供的波动性。

KYC / 身份验证Hook (Civic, WorldID 等)

KYC/身份验证Hook将 KYC 检查和身份验证集成到平台中,确保只有经过验证的用户才能进行交易。根据监管环境的变化,这可能会变得强制。

此Hook将身份验证集成到智能合约中,要求用户在交易或提供流动性之前完成 KYC。通过这样做,平台保持去中心化,同时确保合规,使机构参与者能够安全地参与 DeFi。

4. 许可证

Uniswap V4 在 商业源许可证 1.1 (BSL 1.1) 下运营,该许可证限制商业使用四年。一旦此期限结束,代码将转为 GPL 并完全开源。

Uniswap 治理和 Uniswap Labs 保留授予例外的能力,为特定用例提供灵活性,同时保持对商业实施的监督。

5. 链接

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Three Sigma
Three Sigma
Three Sigma is a blockchain engineering and auditing firm focused on improving Web3 by working closely with projects in the space.