Uniswap V3 源码解读

发布于 2026年1月3日
344 分钟阅读

Uniswap V3 源码解读

Uniswap V3 是 Uniswap 协议的第三个版本,进一步优化了流动性提供者 (LP) 和交易者的体验。与前一版本相比,Uniswap V3 引入了一些重要的新特性和改进,主要包括集中流动性 (Concentrated Liquidity)主动市场管理,让流动性提供更加灵活和高效。

1. Uniswap V3 的核心要素和其优势:

1.1 集中流动性

在 Uniswap V2 中,流动性提供者 (LP) 必须在整个价格范围内(0 到 ∞)提供流动性。大多数资金实际上没有被有效利用,因为交易往往集中在特定的价格范围内。Uniswap V3 允许 LPs 在特定的价格区间内提供流动性,这称为“集中流动性”,提高了资金效率。这个功能可以让 LPs 将他们的资金集中在市场最活跃的价格区间,提升了收益率。

1.2 自定义价格范围

LPs 在 V3 中可以为他们的流动性设定特定的价格区间。例如,一个 LP 可以选择只在 1000 到 2000 美元之间的 ETH/USD 价格范围提供流动性。如果 ETH 价格超出这个范围,他们的流动性就不会被用于交易,这有助于降低无常损失(Impermanent Loss)。

1.3多级费用结构

Uniswap V3 引入了多种不同的费用层级 (0.05%、0.3%、1%),供 LPs 选择,以适应不同的市场条件和风险偏好。交易对的波动性越大,LPs 通常会选择更高的费用层,以补偿更大的无常损失风险;对于稳定币对,则可以选择较低的费用层以吸引更多的交易量。

1.4无常损失管理

集中流动性减少了资金在非活跃价格区间的分散,这有助于降低无常损失的风险。然而,LPs 仍然需要密切关注市场变化,主动调整他们的流动性价格范围。Uniswap V3 的这种设计促使 LPs 更加关注市场动向,进行主动管理。

1.5NFT 表示流动性头寸

在 Uniswap V2 中,LP 头寸通过 ERC-20 代币表示,每个流动性池都使用相同的 LP 代币。然而在 V3 中,由于 LPs 可以设置不同的价格范围,流动性头寸是独特的,因此使用不可替代代币 (NFT) 来表示每个 LP 的头寸。每个 NFT 都包含了流动性提供者的自定义价格区间和资金量等信息。

1.6自动路由

Uniswap V3 的交易功能会自动寻找最佳的交易路径,通过不同的交易对来最大化交易效率。例如,当你想要在 ETH 和 USDC 之间进行交易时,Uniswap 可能会通过多个中间交易对来找到最具成本效益的路径,减少滑点并节省交易成本。

1.7资本效率提升

集中流动性让 LPs 在更窄的价格范围内提供流动性,极大地提高了资金的利用效率。在这些区间内,LPs 的资本效率比 Uniswap V2 提高了 4000 倍,意味着 LPs 可以用更少的资本获得与 V2 相同的收益。

1.8主动管理策略

流动性提供者可以根据市场价格的变动主动调整他们的价格区间,从而最大化收益。尽管这需要更高的参与度和市场分析,但它为专业流动性提供者提供了新的套利和管理机会。

2. UniSwap V3 集中流动性算法

2.1 什么是集中流动性(concentrated liquidity)

流动性提供者(LP)的资产不再分布在整个价格曲线(如 0 到 ∞)上,而是可以主动选择存放在一个特定的价格区间

[lowerTick, upperTick]
内。只有当前交易价格进入这个区间时,这部分流动性才会被激活并用于交易。

图片加载失败: imgs/uniswap-v3-intro.pnguniswap-v3-intro

2.2 Uniswap V2 和 V3的区别

图片加载失败: imgs/uniswap-v2-v3-diff.pnguniswap-v2-v3-diff

2.3 计算Slot Price (sqrt price, tick, sqrt x 96)

2.3.1 slot0数据结构 (存在一个slot里节省gas)

struct Slot0 {
    // the current price
    uint160 sqrtPriceX96;
    // the current tick
    int24 tick;
    // the most-recently updated index of the observations array
    uint16 observationIndex;
    // the current maximum number of observations that are being stored
    uint16 observationCardinality;
    // the next maximum number of observations to store, triggered in observations.write
    uint16 observationCardinalityNext;
    // the current protocol fee as a percentage of the swap fee taken on withdrawal
    // represented as an integer denominator (1/x)%
    uint8 feeProtocol;
    // whether the pool is locked
    bool unlocked;
}
/// @inheritdoc IUniswapV3PoolState 
Slot0 public override slot0;

2.3.2 Tick, Price, SqrtX96 之间的关系(重要)

Tick价格的最小刻度。价格公式为

P = 1.0001 ^ i
,其中
i
就是 tick 的索引。每个 tick 对应一个具体的价格。

图片加载失败: imgs/uniswap-v3-price-tick.pnguniswap-v3-price-tick

2.3.3 补充:tickSpacing

在代码中会使用到的tickSpacing创建v3pool,

int24 tickSpacing = feeAmountTickSpacing[fee];
    
feeAmountTickSpacing[500] = 10;
emit FeeAmountEnabled(500, 10);
feeAmountTickSpacing[3000] = 60;
emit FeeAmountEnabled(3000, 60);
feeAmountTickSpacing[10000] = 200;
emit FeeAmountEnabled(10000, 200);

费率等级与 TickSpacing 的匹配

费率 (Fee Tier)典型交易对类型TickSpacing设计逻辑
0.01%极稳定币对 (如 USDC/USDT)1价格波动极小,需要极低的滑点。LP 需要极精细的区间来竞争费用。Gas 成本相对可接受,因为价格很少大幅跳动穿越多个 tick。
0.05%稳定币对 (如 DAI/USDC)10较为稳定,但仍需较低滑点。适当放宽 tickSpacing 以控制极端情况下的 Gas,同时保持足够的流动性精度。
0.30%主流币对 (如 ETH/USDC)60波动性中等。需要平衡滑点和 Gas。较大的 tickSpacing 能防止在剧烈波动时单笔交易产生天价 Gas。
1.00%长尾/高波动币对200波动性极高。流动性通常提供在很宽的价格区间。极大的 tickSpacing 能显著降低 Gas,滑点让步于生存性(防止流动性被高 Gas 耗尽)。
  • V3 规定只有被 tickSpacing 整除的 tick 才允许被初始化,不是所有的 tick 都可以作为流动性区间的边界。
    tickSpacing
    规定,只有索引能被
    tickSpacing
    整除的 tick 才能作为
    lowerTick
    upperTick
    。例如,如果
    tickSpacing = 10
    ,那么可用的边界 tick 就是 ... -20, -10, 0, 10, 20 ...
  • tickSpacing 越大,但会节省跨 tick 操作的 gas。
    • 集中流动性越低:
      • 可选区间变少、变宽
        tickSpacing
        增大,意味着 LP 可以设置边界 tick 的选项急剧减少。LP 只能选择更宽的价格区间来存放流动性。
      • 例如,对于一个波动性不大的稳定币对,如果
        tickSpacing = 1
        ,LP 可以把流动性精确地放在 [1.000, 1.001] 这样极窄的范围内。
      • 如果
        tickSpacing = 100
        ,LP 可能被迫将流动性放在 [1.000, 1.100] 这样宽得多的区间。
      • 流动性“被摊薄”:由于区间变宽**,同样数量的流动性资产会被分散到一个更宽的价格范围**上进行做市。当交易价格落在这个宽区间内时,可用的流动性深度相对于窄区间是更浅的。在任何一个具体的价格点(尤其是两个可用边界 tick 之间的价格点)上,其直接可用的流动性可能变少
    • tick 之间滑点越大:
      • 流动性不连续: 在
        tickSpacing
        较大的设置下,流动性集中分布在少数几个离散的、间距很大的边界 tick 附近。在两个边界 tick 之间的价格区域,流动性可能非常有限甚至为零。
      • 跨越“真空地带”: 当一笔交易需要将价格从一个边界 tick 推动到下一个边界 tick 时,它需要消耗掉前一个 tick 处积累的所有流动性,然后进入一个“流动性薄弱地带”,价格会在这个地带内非常敏感地移动,直到触及下一个有充足流动性的边界 tick。这个推动价格穿越“真空地带”的过程,就会产生较大的滑点。
    • 会节省跨 tick 操作的 Gas:
      • 状态变量更新:在 V3 中,每个可用的边界 tick 都对应着一个链上存储的状态
        tickInfo
        。当价格移动并穿越一个有流动性作为边界的 tick 时,核心合约必须:
        1. 加载这个 tick 的信息。
        2. 更新全局流动性变量(
          liquidityNet
          )。
        3. 有时还需结算该 tick 上累积的费用。 这个过程需要写入存储,消耗大量 Gas。
      • 需要穿越的 tick 数量减少
        tickSpacing
        越大,价格轴上存在的、需要被程序处理的有效 tick(即作为流动性边界的 tick)数量就越少。
        • 一笔同样大小的交易,在
          tickSpacing = 1
          的池子里,可能会穿越几十个甚至上百个有流动性的 tick,触发几十上百次昂贵的状态更新。
        • tickSpacing = 100
          的池子里,它可能只穿越一两个 tick。
      • Gas 消耗与 tick 穿越次数直接正相关。减少穿越次数是节省 Gas 最有效的方式。
  • 相反tickSpacing 越小, 集中流动性越高,gas消耗越高。

Tick Spacing

2.4 集中流动性的方程推导

2.4.1 uniswap-v3-xy-equations

图片加载失败: imgs/uniswap-v3-xy-equations.pnguniswap-v3-xy-equations

2.4.2 uniswap-v3-curve-real-reserves

图片加载失败: imgs/uniswap-v3-curve-real-reserves.pnguniswap-v3-curve-real-reserves

2.4.3 结合前面的公式推导:给定L和P,算x和y(重要)

图片加载失败: imgs/uniswap-v3-xy-amounts.pnguniswap-v3-xy-amounts

很对都是基于这个公式推导,比如_updatePosition中计算 liquidityDelta 流动性需要提供的 token0 数量 amount0 和 token1 数量 amount1

3. UniSwap V3 功能模块解读

3.1 核心合约架构

Uniswap V3 的核心代码主要分为以下几大模块:

图片加载失败: imgs/uniswap-v3-contracts.pnguniswap-v3-contracts

  • Uniswap v3-periphery

    面向用户的接口代码,如头寸管理、swap 路由等功能,Uniswap 的前端界面与 periphery 合约交互,主要包含合约:

    • NonfungiblePositionManager.sol:对应头寸管理功能,包含交易池(又称为流动性池或池子,后文统一用交易池表示)创建以及流动性的添加删除;
    • SwapRouter.sol:对应 swap 路由的功能,包含单交易池 swap 和多交易池 swap。
  • Uniswap v3-core

    Uniswap v3 的核心代码,实现了协议定义的所有功能,外部合约可直接与 core 合约交互,主要包含合约:

    • UniswapV3Factory.sol:工厂合约,用来创建交易池,设置 Owner 和手续费等级;
    • UniswapV3Pool.sol:交易池合约,持有实际的 Token,实现价格和流动性的管理,以及在当前交易池中 swap 的功能。
  • Swap-router-contracts:

    • SwapRouter02.sol 是SwapRouter的升级版

3.2 Factory

(1) Factory如何创建Pool

图片加载失败: imgs/uniswap-v3-factory.pnguniswap-v3-factory

(2) 创建交易池 代码

创建交易池调用的是 UniswapV3Factory 合约的 createPool,参数为:

  • token0:token0 的地址
  • token1 地址:token1 的地址;
  • fee:手续费费率。

代码为:

/// @inheritdoc IUniswapV3Factory
function createPool(
    address token0,
    address token1,
    uint24 fee
) external override noDelegateCall returns (address pool) {
	//1. token排序 和判断
    require(token0 != token1);
    (address token0, address token1) = token0 < token1 ? (token0, token1) : (token1, token0);
    require(token0 != address(0));
    int24 tickSpacing = feeAmountTickSpacing[fee];
    require(tickSpacing != 0);
    require(getPool[token0][token1][fee] == address(0));
    //2.deploy部署池子,并记录在map中
    pool = deploy(address(this), token0, token1, fee, tickSpacing);
    getPool[token0][token1][fee] = pool;
    // populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
    getPool[token1][token0][fee] = pool;
    emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}

然后调用deploy函数:

function deploy(
    address factory,
    address token0,
    address token1,
    uint24 fee,
    int24 tickSpacing
) internal returns (address pool) {
    parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
    pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
    delete parameters;
}

3.3 Swap

3.3.1 Swap 代码算法流程图

图片加载失败: imgs/uniswap-v3-swap-algo.pnguniswap-v3-swap-algo

3.3.2 Liquidity Net 计算下一段的流动性(重要)

图片加载失败: imgs/uniswap-v3-liquidity-net.pnguniswap-v3-liquidity-net

图片加载失败: imgs/uniswapV3_liquidity-net.pnguniswapV3_liquidity-net

tick,positions.liquidity sum, liquidity net 之间的关系

3.3.3 SwapMath(重要)

代码位置:libraries/SwapMath.sol :computeSwapStep() 在swap函数中:调用 SwapMath.computeSwapStep 计算当前步骤的

输入量、输出量、费用和新价格。

(1) 计算下一价格P(结合2.4.3的公式继续推导)

图片加载失败: imgs/uniswap-v3-delta-price.pnguniswap-v3-delta-price

(2) 计算SwapFee & 计算达到目标价格所需最大输入输出量

图片加载失败: imgs/uniswap-v3-swap-fee.pnguniswap-v3-swap-fee

3.3.4 swap 代码

3.3.4.1 代码调用流程图

图片加载失败: imgs/uniswap-v3-swapCall.pnguniswap-v3-swapCall

3.3.4.2 底层函数 UniSwapV3Pool.sol: swap方法

参数为:

  • recipient:交易结束后接收代币的一方。
  • zeroForOne:如果从 token0 交换 token1 则为 true,从 token1 交换 token0 则为 false;
  • amountSpecified:指定的代币数量,指定输入的代币数量(ExactInput)则为正数(用户愿意输入多少代币),指定输出的代币数量(用户希望输出多少代币)则为负数。
  • sqrtPriceLimitX96:限定价格,如果从 token0 交换 token1 则限定价格下限,从 token1 交换 token0 则限定价格上限。
  • data:用于在跨合约调用时传递的数据,通常用于回调机制。

代码为:

/// @inheritdoc IUniswapV3PoolActions
function swap( 
    address recipient,
    bool zeroForOne,
    int256 amountSpecified, 
    uint160 sqrtPriceLimitX96, 
    bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1) {
    require(amountSpecified != 0, 'AS');

    Slot0 memory slot0Start = slot0; // 获取当前价格和tick状态

    require(slot0Start.unlocked, 'LOK'); // 确保池子未锁定(防重入)
    require(
    // 对于 zeroForOne 方向,价格限制必须低于当前价格但高于最小价格
    // 对于 !zeroForOne 方向,价格限制必须高于当前价格但低于最大价格
        zeroForOne
            ? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
            : sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO,
        'SPL'
    );

    slot0.unlocked = false; // 锁定池子,防止重入

    SwapCache memory cache =
        SwapCache({
            liquidityStart: liquidity, // // 初始流动性
            blockTimestamp: _blockTimestamp(), 
            feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4), // 协议费率
            secondsPerLiquidityCumulativeX128: 0, // 用于预言机的时间加权流动性累计值
            tickCumulative: 0,  // 用于预言机的tick累计值
            computedLatestObservation: false // 标记是否已计算最新观测值
        });

    bool exactInput = amountSpecified > 0; //判断是输入还是输出模式
    
    // 1.初始化交换状态,跟踪交换过程中的变化
    SwapState memory state =
        SwapState({
            amountSpecifiedRemaining: amountSpecified, // 剩余需要交换的数量
            amountCalculated: 0,  // 已计算出的数量
            sqrtPriceX96: slot0Start.sqrtPriceX96, // 当前价格
            tick: slot0Start.tick, //// 当前tick
            // 全局费用增长,根据方向选择 token0 或token1 的费用增长。
            feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,  
            protocolFee: 0, // 累积的协议费用
            liquidity: cache.liquidityStart // 当前流动性
        });

    // 2.主循环:逐步处理交换,直到处理完所有数量或达到价格限制。
    while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
        StepComputations memory step;

        step.sqrtPriceStartX96 = state.sqrtPriceX96; //记录步骤开始时的价格。
		
		//查找下一个已初始化的 tick。tick 位图帮助高效找到有流动性的 tick。
        (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
            state.tick,
            tickSpacing,
            zeroForOne
        );
		
        //确保下一个 tick 在最小和最大 tick 范围内
        if (step.tickNext < TickMath.MIN_TICK) {
            step.tickNext = TickMath.MIN_TICK;
        } else if (step.tickNext > TickMath.MAX_TICK) {
            step.tickNext = TickMath.MAX_TICK;
        }

        // 计算下一个 tick 对应的价格
        step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);

        // 调用 SwapMath.computeSwapStep 计算当前步骤的输入量、输出量、费用和新价格。
        // computeSwapStep 使用数学公式计算在恒定乘积曲线下如何交换。
        (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
            state.sqrtPriceX96,
            (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
                ? sqrtPriceLimitX96
                : step.sqrtPriceNextX96,
            state.liquidity,
            state.amountSpecifiedRemaining,
            fee
        );
		//根据精确输入或精确输出模式,更新剩余交换量和计算量。
		//精确输入: amountSpecifiedRemaining 减少(输入量 + 费用),amountCalculated 减少输出量(因为输出为负)。
        if (exactInput) {
            state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
            state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
        } else {
        //精确输出: amountSpecifiedRemaining 增加输出量(因为输出为负),amountCalculated 增加(输入量 + 费用)。
            state.amountSpecifiedRemaining += step.amountOut.toInt256();
            state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
        }

        // 处理协议费用: 如果协议费率大于零,计算协议费用并更新步骤费用和协议费用累积。
        if (cache.feeProtocol > 0) {
            uint256 delta = step.feeAmount / cache.feeProtocol; // 计算协议费用份额
            step.feeAmount -= delta;  // 减少交易费用,保留协议费用
            state.protocolFee += uint128(delta); // 累积协议费用
        }

        //更新全局费用增长: 根据步骤费用和流动性,计算费用增长并累加。
        if (state.liquidity > 0)
            state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity);

        // 如果价格达到下一个 tick 的价格,需要处理 tick 转换。
        if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
            // 如果 tick 已初始化,调用 ticks.cross 方法穿越该 tick 并获取流动性变化量 liquidityNet。
            if (step.initialized) {
                // check for the placeholder value, which we replace with the actual value the first time the swap
                 // 如果需要,计算最新预言机观测值(只计算一次)
                if (!cache.computedLatestObservation) {
                    (cache.tickCumulative, cache.secondsPerLiquidityCumulativeX128) = observations.observeSingle(
                        cache.blockTimestamp,
                        0,
                        slot0Start.tick,
                        slot0Start.observationIndex,
                        cache.liquidityStart,
                        slot0Start.observationCardinality
                    );
                    cache.computedLatestObservation = true;
                }
                 // 跨越tick:更新tick状态并获取流动性变化
                int128 liquidityNet =
                    ticks.cross(
                        step.tickNext,
                        (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
                        (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
                        cache.secondsPerLiquidityCumulativeX128,
                        cache.tickCumulative,
                        cache.blockTimestamp
                    );
                // 对于zeroForOne方向,流动性变化需要取反
                // safe because liquidityNet cannot be type(int128).min
                if (zeroForOne) liquidityNet = -liquidityNet;
				// 更新当前流动性
                state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
            }
			// 更新当前tick
            state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
        } else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
             // 如果价格变化但未到达下一个tick,重新计算tick (i.e. already transitioned ticks), and haven't moved
            state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
        }
    }
	
   // 3. 完成 swap 后,更新 slot0 的状态和全局流动性。
   // 如果tick发生变化,需要更新预言机记录
    if (state.tick != slot0Start.tick) {
        (uint16 observationIndex, uint16 observationCardinality) =
         // 写入新的预言机观测值
            observations.write(
                slot0Start.observationIndex,
                cache.blockTimestamp,
                slot0Start.tick,
                cache.liquidityStart,
                slot0Start.observationCardinality,
                slot0Start.observationCardinalityNext
            );
            // 更新槽位状态
        (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
            state.sqrtPriceX96,
            state.tick,
            observationIndex,
            observationCardinality
        );
    } else {
        // 如果tick未变化,只更新价格
        slot0.sqrtPriceX96 = state.sqrtPriceX96;
    }

    // 如果流动性发生变化,更新存储中的流动性值
    if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity;

    // 4. 更新全局费用增长和协议费用
    // overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees
    if (zeroForOne) {
        feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
        if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee;
    } else {
        feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
        if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee;
    }
	// 5. 计算最终代币变化量
    //zero for one | exact input |
    //用代币0换取代币1 | 且指定输入代币0的数量 | amount0: 用户支付了多少代币0 amount1:  用户获得了多少代币1(负号表示获得)
    //    true     |     true    | amount0 = specified - remaining(>0) 
    //             |             | amount1 = calculated           (<0)
    //用代币1换取代币0 | 且指定输出代币0的数量 | amount0:  用户获得了多少代币0(负号表示获得) amount1: 用户支付了多少代币1
    //    false    |     false   | amount0 = specified - remaining(<0)
    //             |             | amount1 = calculated           (>0)
    //用代币1换取代币0 | 且指定输出代币0的数量 | amount0:   用户获得了多少代币0 amount1: 用户支付了多少代币1
    //    false    |     true    | amount0 = calculated           (>0)
    //             |             | amount1 = specified - remaining(>0)           
    //    true     |    false    | amount0 = calculated           (<0)
    //             |             | amount1 = specified - remaining(<0)
    (amount0, amount1) = zeroForOne == exactInput
        ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
        : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);

    // 6. 执行代币转账和回调
    if (zeroForOne) {
        // 如果是token0 → token1,将token1转账给接收者
        if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
		// 记录当前余额,用于后续检查
        uint256 balance0Before = balance0();
         // 调用回调函数,要求调用者支付token0
        IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
        // 检查余额变化,确保调用者支付了足够的token0
        require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
    } else {
     	// 如果是token1 → token0,将token0转账给接收者
        if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));

        uint256 balance1Before = balance1();
        IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
        // 检查余额变化,确保调用者支付了足够的token1
        require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
    }

    emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick);
    slot0.unlocked = true;
}

整体逻辑由一个 while 循环组成,将 swap 过程分解成多个小步骤,一点点的调整当前的 tick,直到满足用户所需的交易量或者价格触及限定价格(此时会部分成交)。

while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {

使用 `tickBitmap.nextInitializedTickWithinOneWord`` 来找到下一个已初始化的 tick

(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
    state.tick,
    tickSpacing,
    zeroForOne
);

使用 SwapMath.computeSwapStep 进行 tick 内的 swap。这个方法会计算出当前区间可以满足的输入数量 amountIn,如果它比 amountRemaining 要小,我们会说现在的区间不能满足整个交易,因此下一个 sqrtPriceX96 就是当前区间的上界/下界,也就是说,我们消耗完了整个区间的流动性。如果 amountIn 大于 amountRemaining,我们计算的 sqrtPriceX96 仍然在现在区间内。

// compute values to swap to the target tick, price limit, or point where input/output amount is exhausted
(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
    state.sqrtPriceX96,
    (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
        ? sqrtPriceLimitX96
        : step.sqrtPriceNextX96,
    state.liquidity,
    state.amountSpecifiedRemaining,
    fee
);

保存本次交易的 amountIn 和 amountOut:

  • 如果是指定输入代币数量。amountSpecifiedRemaining 表示剩余可用输入代币数量,amountCalculated 表示已输出代币数量(以负数表示);
  • 如果是指定输出代币数量。amountSpecifiedRemaining 表示剩余需要输出的代币数量(初始为负值,因此每次交换后需要加上 step.amountOut,直到为 0),amountCalculated 表示已使用的输入代币数量。
if (exactInput) {
    state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
    state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
} else {
    state.amountSpecifiedRemaining += step.amountOut.toInt256();
    state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
}

如果本次 swap 后的价格达到目标价格,如果该 tick 已经初始化,则通过 ticks.cross 方法穿越该 tick,返回新增的净流动性 liquidityNet 更新可用流动性 state.liquidity,移动当前 tick 到下一个 tick。

如果本次 swap 后的价格达到目标价格,但是又不等于初始价格,即表示此时 swap 结束,使用 swap 后的价格计算最新的 tick 值。

重复上述步骤,直到 swap 完全结束。

IUniswapV3SwapCallback

实现在 periphery 仓库的 SwapRouter.sol 中,负责支付输入的 token。

/// @inheritdoc IUniswapV3SwapCallback
function uniswapV3SwapCallback(
    int256 amount0Delta,
    int256 amount1Delta,
    bytes calldata _data
) external override {
    require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported
    SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData));
    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
    CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);

    (bool isExactInput, uint256 amountToPay) =
        amount0Delta > 0
            ? (tokenIn < tokenOut, uint256(amount0Delta))
            : (tokenOut < tokenIn, uint256(amount1Delta));
    if (isExactInput) {
        pay(tokenIn, data.payer, msg.sender, amountToPay);
    } else {
        // 针对ExactOutput
        // [WETH,fee,USDC,fee,DAI]
        if (data.path.hasMultiplePools()) {
            data.path = data.path.skipToken();
            //这里的msg.sender 是pool合约
            exactOutputInternal(amountToPay, msg.sender, 0, data);
        } else {
            //[USDC,fee,DAI]
            amountInCached = amountToPay;
            tokenIn = tokenOut; // swap in/out because exact output swaps are reversed
            pay(tokenIn, data.payer, msg.sender, amountToPay);
        }
    }
}

至此,完成了整体 swap 流程。

3.3.4.3 指定输入代币数量

SwapRouter、SwapRouter02合约包含了以下四个交换代币的方法:

  • exactInput:多池交换,用户指定输入代币数量,尽可能多地获得输出代币;
  • exactInputSingle:单池交换,用户指定输入代币数量,尽可能多地获得输出代币;
  • exactOutput:多池交换,用户指定输出代币数量,尽可能少地提供输入代币;
  • exactOutputSingle:单池交换,用户指定输出代币数量,尽可能少地提供输入代币。

这里分成"指定输入代币数量"和"指定输出代币数量"分别介绍。

(1)exactInput 方法负责多池交换

指定 swap 路径以及输入代币数量,尽可能多地获得输出代币。

参数如下:

struct ExactInputParams {
    bytes path; // swap 路径,可以解析成一个或多个交易池
    address recipient; //  最终接收输出代币的地址
    uint256 deadline; // 过期的区块号
    uint256 amountIn; // 你想要精确花费的输入代币数量。
    uint256 amountOutMinimum; // 你所能接受的最少的输出代币数量。这是应对滑点的保护措施
}

代码如下:

/// @inheritdoc ISwapRouter
function exactInput(ExactInputParams memory params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountOut)
{
    address payer = msg.sender; // msg.sender pays for the first hop

    while (true) {
        //检查编码的路径中是否包含超过一个的流动性池(即是否还有后续的交换需要执行)。
        bool hasMultiplePools = params.path.hasMultiplePools();

        //执行单次交换(核心)
        //exactInputInternal 返回本次交换实际得到的输出代币数量,并更新到 params.amountIn 中,为下一次循环(如果有)做准备。
        params.amountIn = exactInputInternal(
            params.amountIn,// 本次交换的输入量 后续循环时,它是上一次交换的输出量,作为本次交换的输入量
            hasMultiplePools ? address(this) : params.recipient, //如果还有后续池子 那么本次交换的输出代币应该发送到当前合约的地址 
            0,
            SwapCallbackData({
                path: params.path.getFirstPool(), 
                // 只获取当前第一个池子的路径信息[tokenA,fee,tokenB,fee,tokenC] 因为一次 exactInputInternal 调用只处理一个池子。
                payer: payer //谁应该提供本次交换的输入代币
            })
        );

        // decide whether to continue or terminate
  
        if (hasMultiplePools) {
            payer = address(this); // 从下一个池子开始,支付者是本合约(因为它托管了中间代币)
            //[tokenA,fee,tokenB,fee,tokenC] -> [tokenB,fee,tokenC]
            params.path = params.path.skipToken();   // “消耗”掉路径中已经处理完的第一个代币,移动到下一个池子 
        } else {
        	//[tokenB,fee,tokenC]
            amountOut = params.amountIn;   // 循环结束,最终的输出量就是最后一次交换的输出量
            break;
        }
    }
	//在函数最后,检查实际得到的输出代币数量 amountOut 是否大于等于用户设定的最小值
    require(amountOut >= params.amountOutMinimum, 'Too little received');
}

在多池 swap 中,会按照 swap 路径,拆成多个单池 swap,循环进行,直到路径结束。如果是第一步 swap。payer 为合约调用方,否则 payer 为当前SwapRouter合约。

(2)exactInputSingle方法负责单池交换

指定输入代币数量,尽可能多地获得输出代币。

参数如下,指定了输入代币地址和输出代币地址:

struct ExactInputSingleParams {
    address tokenIn; // 输入代币地址
    address tokenOut; // 输出代币地址
    uint24 fee; // 手续费费率
    address recipient; // 接收者地址
    uint256 deadline; // 过期的区块号
    uint256 amountIn; // 输入代币数量
    uint256 amountOutMinimum; // 最少输出代币数量
    uint160 sqrtPriceLimitX96; // 限定价格,值为0则不限价
}

代码如下:

/// @inheritdoc ISwapRouter
function exactInputSingle(ExactInputSingleParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountOut)
{
    amountOut = exactInputInternal(
        params.amountIn,
        params.recipient,
        params.sqrtPriceLimitX96,
        SwapCallbackData({path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender})
    );
    require(amountOut >= params.amountOutMinimum, 'Too little received');
}
(3)实际调用 exactInputInternal
/// @dev Performs a single exact input swap
function exactInputInternal(
    uint256 amountIn,   // 本次交换要投入的确切输入代币数量
    address recipient,     // 本次交换输出的接收地址
    uint160 sqrtPriceLimitX96, // 价格限制,用于防止过度滑点
    SwapCallbackData memory data  // 回调数据,包含路径和支付者信息
) private returns (uint256 amountOut) {
    // allow swapping to the router address with address 0
    if (recipient == address(0)) recipient = address(this);
	
	//从编码的 path 中解析出本次交换所需的三个核心要素:
    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
	//需要确定交换的方向
    bool zeroForOne = tokenIn < tokenOut;

    (int256 amount0, int256 amount1) =
        getPool(tokenIn, tokenOut, fee).swap( // 获取或创建池子合约,并调用其swap方法
            recipient, // 输出代币的接收者
            zeroForOne,// 交换方向
            amountIn.toInt256(),  // 精确的输入数量
            sqrtPriceLimitX96 == 0 // 计算价格限制:
                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)
                : sqrtPriceLimitX96,
            abi.encode(data) // 传递给池子的回调数据
        );
	//正数表示池子收到该代币,负数表示池子付出该代币。
	//如果是 zeroForOne,池子付出的是 amount1(为负值)。我们取它的负数 -amount1 并将其转换为 uint256,就得到了我们得到的 token1 的正数量。
    //如果是 !zeroForOne,池子付出的是 amount0(为负值)。我们取 -amount0 得到得到的 token0 的正数量。
    return uint256(-(zeroForOne ? amount1 : amount0));
}

重点1: 如果没有指定接收者地址,则默认为当前SwapRouter合约地址。这个目的是在多池交易中,将中间代币保存在SwapRouter合约中。

if (recipient == address(0)) recipient = address(this);

重点2:接着解析出交易路由信息 tokenIn,tokenOut 和 fee。并比较 tokenIn 和 tokenOut 的地址得到 zeroForOne,表示在当前交易池是否是 token0 交换 token1。

  • 这是 Uniswap V3 的一个核心概念。由于池子中的代币是按地址排序的(token0和token1,token0<token1),我们需要确定交换的方向。
  • zeroForOne:
    • 如果为true,表示用token0兑换token1。在这种情况下,tokenIn是token0,tokenOut是token1。
    • 如果为false,表示用token1兑换token0。在这种情况下,tokenIn是token1,tokenOut是token0。
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();

bool zeroForOne = tokenIn < tokenOut;

重点3:价格限制:

  • 如果调用者没有设置限制 (sqrtPriceLimitX96 == 0),则系统会设置一个默认的、极端但可达的限制。
    • 如果是zeroForOne(用token0换token1),token0价格会下降。下限设置为TickMath.MIN_SQRT_RATIO + 1(最小价格 +1,避免溢出等问题)。
    • 如果是!zeroForOne(用token1买token0),token0价格会上升。上限设置为TickMath.MAX_SQRT_RATIO - 1(最大价格 -1)。
  • 如果调用者设置了限制,则使用该限制。
sqrtPriceLimitX96 == 0 // 计算价格限制:
                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)
                : sqrtPriceLimitX96,

最后调用交易池合约的swap方法,获取完成本次交换所需的 amount0 和 amount1,再根据 zeroForOne 返回 amountOut,进一步判断 amountOut 满足最少输出代币数量的要求,完成 swap。

3.3.4.4 指定输出代币数量

(1)exactOutput 方法负责多池交换

指定 swap 路径以及输出代币数量,尽可能少地提供输入代币。与exactInput的正向路径处理不同,exactOutput采用了一种反向处理的方式:

  • exactInput: A → B → C (正向处理)
  • exactOutput: C → B → A (反向处理)

这种设计意味着exactOutput会先执行最后一步交换(得到精确的最终输出),然后逐步向前执行交换,直到得到最初需要的输入代币。

参数如下:

struct ExactOutputParams {
    bytes path; // 交换路径(编码的路径,顺序与 exactInput 相反)
    address recipient; // 接收者地址
    uint256 deadline; // 过期的区块号
    uint256 amountOut; // :期望得到的精确数量的输出代币
    uint256 amountInMaximum; //  愿意花费的最多的输入代币数量
}

代码如下:

/// @inheritdoc ISwapRouter
function exactOutput(ExactOutputParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountIn)
{
    // it's okay that the payer is fixed to msg.sender here, as they're only paying for the "final" exact output
    // swap, which happens first, and subsequent swaps are paid for within nested callback frames
    exactOutputInternal(
        params.amountOut,
        params.recipient,
        0,  // sqrtPriceLimitX96,这里设为0表示不使用价格限制 表示使用默认的价格限制
        SwapCallbackData({path: params.path, payer: msg.sender})  // 使用预编码的多跳路径
    );
 
    amountIn = amountInCached;  // 从全局状态读取 使用全局变量 amountInCached 来传递结果
    require(amountIn <= params.amountInMaximum, 'Too much requested');
    amountInCached = DEFAULT_AMOUNT_IN_CACHED; // 必须重置全局状态 这是为了支持多跳交换的复杂回调机制
}

在多池 swap 中,会按照 swap 路径,拆成多个单池 swap,循环进行,直到路径结束。如果是第一步 swap。payer 为合约调用方,否则 payer 为当前SwapRouter合约。

(2)exactOutputSingle方法负责单池交换

指定输出代币数量,尽可能少地提供输入代币。

参数如下,指定了输入代币地址和输出代币地址:

struct ExactOutputSingleParams {
    address tokenIn; // 输入代币地址
    address tokenOut; // 输出代币地址
    uint24 fee; // 手续费费率
    address recipient; // 接收者地址
    uint256 deadline; // 过期的区块号
    uint256 amountOut; // 输出代币数量
    uint256 amountInMaximum; // 最多输入代币数量
    uint160 sqrtPriceLimitX96; // 限定价格,值为0则不限价
}

代码如下:

/// @inheritdoc ISwapRouter
function exactOutputSingle(ExactOutputSingleParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountIn)
{
    // avoid an SLOAD by using the swap return data // 直接获取返回值
    amountIn = exactOutputInternal(
        params.amountOut,
        params.recipient,
        params.sqrtPriceLimitX96, // 允许用户自定义价格限制
        SwapCallbackData({path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender}) // 现场编码单一路径
    );

    require(amountIn <= params.amountInMaximum, 'Too much requested'); 
    // has to be reset even though we don't use it in the single hop case
    amountInCached = DEFAULT_AMOUNT_IN_CACHED;
}
(3)实际调用 exactOutputInternal
/// @dev Performs a single exact output swap
function exactOutputInternal(
    uint256 amountOut, //期望获得的输出代币数量。
    address recipient,
    uint160 sqrtPriceLimitX96, //价格限制,用于防止过度滑点。
    SwapCallbackData memory data //包含路径和支付者信息的回调数据。
) private returns (uint256 amountIn) {
    // allow swapping to the router address with address 0
    if (recipient == address(0)) recipient = address(this);
	// 这里解码出的顺序是tokenOut和tokenIn,与exactInputInternal中的顺序相反。
	// 这是因为exactOutput的路径是从输出代币到输入代币编码的。
	//DAI -> USDC -> WETH
	//[WETH,3000,USDC,100,DAI]
    (address tokenOut, address tokenIn, uint24 fee) = data.path.decodeFirstPool();

    bool zeroForOne = tokenIn < tokenOut;

	//注意第三个参数是-amountOut.toInt256() exactInputInternal: 传入正的 amountIn exactOutputInternal: 传入负的 amountOut
	//因为在exactOutput模式下,我们指定的是输出量,所以传入一个负值
    (int256 amount0Delta, int256 amount1Delta) =
        getPool(tokenIn, tokenOut, fee).swap(
            recipient,
            zeroForOne,
            -amountOut.toInt256(),
            sqrtPriceLimitX96 == 0
                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)
                : sqrtPriceLimitX96,
            abi.encode(data)
        );

    uint256 amountOutReceived;
    // 如果zeroForOne为true(用token0换token1),则:
  		// 池子收到amount0Delta(正数)的token0,即输入量。
  		// 池子付出amount1Delta(负数)的token1,我们取负数得到正数的输出量。
	// 如果zeroForOne为false(用token1换token0),则:
  		// 池子收到amount1Delta(正数)的token1,即输入量。
  		// 池子付出amount0Delta(负数)的token0,我们取负数得到正数的输出量。
    (amountIn, amountOutReceived) = zeroForOne
        ? (uint256(amount0Delta), uint256(-amount1Delta))
        : (uint256(amount1Delta), uint256(-amount0Delta));

	//返回的输出量(amountOutReceived)应该等于我们指定的amountOut(除非遇到价格限制)
    if (sqrtPriceLimitX96 == 0) require(amountOutReceived == amountOut);
}

从池子返回的amount0Delta和amount1Delta中提取实际的输入量和输出量。注意,由于我们指定的是输出量,所以返回的输入量(amountIn)是池子实际收到的代币数量,而输出量(amountOutReceived)应该等于我们指定的amountOut(除非遇到价格限制)。

3.3.4.5 Swap合约调用总结

图片加载失败: imgs/uniswap-v3-swap-contract-calls.pnguniswap-v3-swap-contract-calls

3.4 流动性

3.4.1 计算流动性的公式:

图片加载失败: imgs/uniswap-v3-liquidity.pnguniswap-v3-liquidity

3.4.2 计算流动性变化量 (重要)

图片加载失败: imgs/uniswap-v3-liquidity-delta.pnguniswap-v3-liquidity-delta

添加流动性会用到:计算流动性数量:根据当前池子的价格、用户指定的区间和期望的存款数量,计算出最优的liquidity值

LiquidityAmounts.getLiquidityForAmounts 中使用计算deltaL

3.4.3 交易池UniswapV3Pool 流动性相关方法

(1) mint

参数为:

  • recipient:头寸接收者地址, 这个地址会被记录为流动性拥有者。
  • tickLower:流动性区间下界
  • tickUpper:流动性区间上界
  • amount:流动性数量
  • data:回调参数

返回值

  • amount0
    : 实际为
    token0
    提供的数量。
  • amount1
    : 实际为
    token1
    提供的数量。

代码为:

/// @inheritdoc IUniswapV3PoolActions
/// @dev noDelegateCall is applied indirectly via _modifyPosition
function mint(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount,
    bytes calldata data
) external override lock returns (uint256 amount0, uint256 amount1) {
    require(amount > 0);
    (, int256 amount0Int, int256 amount1Int) =
        _modifyPosition(
            ModifyPositionParams({
                owner: recipient,
                tickLower: tickLower,
                tickUpper: tickUpper,
                liquidityDelta: int256(amount).toInt128()
            })
        );

    amount0 = uint256(amount0Int);
    amount1 = uint256(amount1Int);

    uint256 balance0Before;
    uint256 balance1Before;
    if (amount0 > 0) balance0Before = balance0();
    if (amount1 > 0) balance1Before = balance1();
    IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);
    //回调完成后会检查交易池合约的对应余额是否发生变化,并且增量应该大于 amount0 和 amount1
    if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), 'M0');
    if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), 'M1');

    emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1);
}

首先调用 _modifyPosition 方法计算添加流动性所需的实际代币数量( amount0 和 amount1),并更新池子的状态(如 tick 信息和流动性映射),其返回的 amount0Int 和 amount1Int 表示 amount 流动性对应的 token0 和 token1 的代币数量。

调用 mint 方法的合约需要实现 IUniswapV3MintCallback 接口完成代币的转入操作:

IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);

IUniswapV3MintCallback 的实现在 periphery 仓库的 LiquidityManagement.sol 中。目的是通知调用方向交易池合约转入 amount0 个 token0 和 amount1 个 token2。

至此完成了流动性的创建。

(2) burn

burn函数只移除流动性,不会转移代币 转移代币是通过collect函数

的参数为流动性区间下界 tickLower,流动性区间上界 tickUpper 和流动性数量 amount,

调用池子合约的burn方法。这个方法会:

  1. 也是调用 _modifyPosition 方法修改当前价格区间的流动性,返回的 amount0Int 和 amount1Int 表示 amount 流动性对应的 token0 和 token1 的代币数量,position 表示用户的头寸信息,在这里主要作用是用来记录待取回代币数量。

  2. 将这些代币的本金部分记录在池子合约中,等待被所有者(即NonfungiblePositionManager合约->collect函数)取走。

  3. 用户可以通过主动调用 collect 方法取出自己头寸信息记录的 tokensOwed0 数量的 token0 和 tokensOwed1 数量对应的 token1。

    function burn(
        int24 tickLower,
        int24 tickUpper,
        uint128 amount
    ) external override lock returns (uint256 amount0, uint256 amount1) {
            (Position.Info storage position, int256 amount0Int, int256 amount1Int) =
                _modifyPosition(
                    ModifyPositionParams({
                        owner: msg.sender,
                        tickLower: tickLower,
                        tickUpper: tickUpper,
                        liquidityDelta: -int256(amount).toInt128()
                    })
                );
            //移除amount是负值
            amount0 = uint256(-amount0Int);
            amount1 = uint256(-amount1Int);
        if (amount0 > 0 || amount1 > 0) {
            (position.tokensOwed0, position.tokensOwed1) = (
                position.tokensOwed0 + uint128(amount0),
                position.tokensOwed1 + uint128(amount1)
            );
        }
    
        emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1);
    }

(3) collect

function collect(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount0Requested,
    uint128 amount1Requested
) external override lock returns (uint128 amount0, uint128 amount1) {
    // we don't need to checkTicks here, because invalid positions will never have non-zero tokensOwed{0,1}
    Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper);

    amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested;
    amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested;

    if (amount0 > 0) {
        position.tokensOwed0 -= amount0;
        TransferHelper.safeTransfer(token0, recipient, amount0);
    }
    if (amount1 > 0) {
        position.tokensOwed1 -= amount1;
        TransferHelper.safeTransfer(token1, recipient, amount1);
    }

    emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1);
}

简单易懂

(4)_modifyPosition

方法是mint和burn的核心方法。用于修改一个流动性头寸, 根据当前价格位置和用户指定的价格区间,计算需要提供或返还的代币数量。

参数如下:

struct ModifyPositionParams {
    // the address that owns the position
    address owner;
    // the lower and upper tick of the position
    int24 tickLower;
    int24 tickUpper;
    // 流动性变化量(正数为增加,负数为减少)
    int128 liquidityDelta;
}

代码如下:

/// @dev Effect some changes to a position
/// @param params the position details and the change to the position's liquidity to effect
/// @return position a storage pointer referencing the position with the given owner and tick range
/// @return amount0 the amount of token0 owed to the pool, negative if the pool should pay the recipient
/// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient
function _modifyPosition(ModifyPositionParams memory params)
    private
    noDelegateCall
    returns (
        Position.Info storage position,
        int256 amount0,
        int256 amount1
    )
{
    checkTicks(params.tickLower, params.tickUpper);

    Slot0 memory _slot0 = slot0; // SLOAD for gas optimization

    position = _updatePosition(
        params.owner,
        params.tickLower,
        params.tickUpper,
        params.liquidityDelta,
        _slot0.tick
    );

    if (params.liquidityDelta != 0) {
    	//根据当前价格位置计算代币数量
        if (_slot0.tick < params.tickLower) {
            // 当前价格在区间下方; 流动性只能通过价格从左到右穿越区间时激活
            // 只需要提供token0
            amount0 = SqrtPriceMath.getAmount0Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        } else if (_slot0.tick < params.tickUpper) {
            // 当前价格在区间内
            uint128 liquidityBefore = liquidity; // SLOAD for gas optimization

            // 写入预言机数据
            (slot0.observationIndex, slot0.observationCardinality) = observations.write(
                _slot0.observationIndex,
                _blockTimestamp(),
                _slot0.tick,
                liquidityBefore,
                _slot0.observationCardinality,
                _slot0.observationCardinalityNext
            );
			// 计算两种代币的数量
            amount0 = SqrtPriceMath.getAmount0Delta(
                _slot0.sqrtPriceX96,
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                _slot0.sqrtPriceX96,
                params.liquidityDelta
            );
			// 更新全局流动性
            liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
        } else {
            //当前价格高于区间; 流动性只能通过价格从右到左穿越区间时激活
            // 只需要提供token1
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        }
    }
}

先通过_updatePosition更新头寸信息,接着分别计算出 liquidityDelta 流动性需要提供的 token0 数量 amount0 和 token1 数量 amount1

这里计算amount0和amount1的实际公式:

情况1:当前价格在区间下方 只使用 amount0

价格轴:
P_current     tickLower     tickUpper
  ↓              ↓              ↓
  |              |              |
  ●─────┐        |              |
        │        |              |
        │        |              |
        └────────┼──────────────┘
                区间
  • 当前价格低于整个流动性区间
  • 整个区间都在当前价格的"右侧"
  • 只有当价格从左到右穿越区间时(价格上升进入区间),流动性才会被激活
  • 在这个过程中,只需要token0,因为:
    • 价格低于区间时,区间内都是token1
    • 价格上升时,提供token0来换取区间内的token1
amount0 = L × (1/√P_lower - 1/√P_upper)
amount1 = 0

情况2:当前价格在区间内 同时使用 amount0 和 amount1

amount0 = L × (1/√P_current - 1/√P_upper)
amount1 = L × (√P_current - √P_lower)

情况3:当前价格在区间上方 只使用 amount1

amount0 = 0
amount1 = L × (√P_upper - √P_lower)
(4.1)_updatePosition 方法
负责更新用户的流动性头寸

包括:

  1. 更新tick点的流动性状态
  2. 计算和更新费用累积
  3. 管理tick位图
  4. 清理不再需要的tick数据

代码如下:

/// @dev Gets and updates a position with the given liquidity delta
/// @param owner the owner of the position
/// @param tickLower the lower tick of the position's tick range
/// @param tickUpper the upper tick of the position's tick range
/// @param tick the current tick, passed to avoid sloads
function _updatePosition(
    address owner,
    int24 tickLower,
    int24 tickUpper,
    int128 liquidityDelta,
    int24 tick
) private returns (Position.Info storage position) {
	//获取头寸和费用数据
    position = positions.get(owner, tickLower, tickUpper);

    uint256 _feeGrowthGlobal0X128 = feeGrowthGlobal0X128; // SLOAD for gas optimization
    uint256 _feeGrowthGlobal1X128 = feeGrowthGlobal1X128; // SLOAD for gas optimization

    //  更新Tick状态(当流动性变化时
    bool flippedLower;
    bool flippedUpper;
    if (liquidityDelta != 0) {
        uint32 time = _blockTimestamp();
        //tickCumulative: 累积tick值(用于计算时间加权平均价格) secondsPerLiquidityCumulativeX128: 累积的每流动性秒数
        //更新预言机相关数据
        (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) =
            observations.observeSingle(
                time,
                0,
                slot0.tick,
                slot0.observationIndex,
                liquidity,
                slot0.observationCardinality
            );
        //返回是否"翻转"(从有流动性变为无流动性,或反之)
        flippedLower = ticks.update(
            tickLower,
            tick,
            liquidityDelta,
            _feeGrowthGlobal0X128,
            _feeGrowthGlobal1X128,
            secondsPerLiquidityCumulativeX128,
            tickCumulative,
            time,
            false,
            maxLiquidityPerTick
        );
        flippedUpper = ticks.update(
            tickUpper,
            tick,
            liquidityDelta,
            _feeGrowthGlobal0X128,
            _feeGrowthGlobal1X128,
            secondsPerLiquidityCumulativeX128,
            tickCumulative,
            time,
            true,
            maxLiquidityPerTick
        );
		//位图管理:
		//当tick从无流动性变为有流动性时,在位图中标记该tick
        //位图用于快速查找下一个活跃的tick点
        if (flippedLower) {
            tickBitmap.flipTick(tickLower, tickSpacing);
        }
        if (flippedUpper) {
            tickBitmap.flipTick(tickUpper, tickSpacing);
        }
    }
	//算该价格区间的累积的流动性手续费
    (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) =
        ticks.getFeeGrowthInside(tickLower, tickUpper, tick, _feeGrowthGlobal0X128, _feeGrowthGlobal1X128);
     // 更新头寸信息
    position.update(liquidityDelta, feeGrowthInside0X128, feeGrowthInside1X128);

    //当tick从有流动性变为无流动性时,从位图中清除该tick (ticks.clear)
    if (liquidityDelta < 0) {
        if (flippedLower) {
            ticks.clear(tickLower);
        }
        if (flippedUpper) {
            ticks.clear(tickUpper);
        }
    }
}

(5) pool合约调用总结

图片加载失败: imgs/uniswap-v3-pool-call-trace.pnguniswap-v3-pool-call-trace

3.4.4 NonfungiblePositionManager 合约(实际交互端)

(1) mint 函数

创建流动性调用的是 NonfungiblePositionManager 合约的 mint。这个函数是用户为池子添加流动性并铸造代表该头寸的 NFT 的主要入口。

参数如下:

struct MintParams {
    address token0; // token0 地址
    address token1; // token1 地址
    uint24 fee; // 费率 token0, token1, fee: 唯一标识一个 V3 Pool。
    int24 tickLower; // 流动性区间下界
    int24 tickUpper; // 流动性区间上界
    uint256 amount0Desired; // 添加流动性中 token0 数量 : 用户愿意提供的最大代币数量。合约会按最优比例计算实际需要存入的数量。
    uint256 amount1Desired; // 添加流动性中 token1 数量
    uint256 amount0Min; // 最小添加 token0 数量: 用户能接受的最少代币存入数量。这是防止前端计算出错或高滑点交易的保护措施。
    uint256 amount1Min; // 最小添加 token1 数量
    address recipient; // 头寸接受者的地址、接收新铸造的 NFT 的地址。
    uint256 deadline; // 交易有效的最后区块时间戳,防止过时交易被意外执行。
}

返回值:

    uint256 tokenId, //nft token id
    uint128 liquidity, //实际增加的流动性
    uint256 amount0,  //从msg.sender 实际转移过来的token0的量来增加以上liquidity
    uint256 amount1 //实际转移过来的token1的量

代码如下:

/// @inheritdoc INonfungiblePositionManager
function mint(MintParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (
        uint256 tokenId,
        uint128 liquidity,
        uint256 amount0,
        uint256 amount1
    )
{
    IUniswapV3Pool pool;
    (liquidity, amount0, amount1, pool) = addLiquidity(
        AddLiquidityParams({
            token0: params.token0,
            token1: params.token1,
            fee: params.fee,
            recipient: address(this),
            tickLower: params.tickLower,
            tickUpper: params.tickUpper,
            amount0Desired: params.amount0Desired,
            amount1Desired: params.amount1Desired,
            amount0Min: params.amount0Min,
            amount1Min: params.amount1Min
        })
    );
	//继承自 ERC721 的标准函数,将 Token ID 为 _nextId 的 NFT 铸造给 params.recipient 地址		                                 
    _mint(params.recipient, (tokenId = _nextId++)); 

    //计算一个在池子合约中唯一标识此特定头寸的键(Key)。这个键由所有者地址、Tick下界和Tick上界共同生成。
    //这里的所有者地址是 address(this),即 NonfungiblePositionManager 合约本身。这是因为所有流动性在池子看来都是由这个管理器合约拥有的。
    //真正的所有者信息存储在管理器的 _positions 映射中。
    bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper);
    //调用池子合约的 positions 映射,获取该头寸的最新信息。这里最关键的是获取当前的 
    //feeGrowthInside0LastX128 和 feeGrowthInside1LastX128。:这两个值记录了截至此刻,在该头寸价格区间内累计的每单位流动性应得的费用总数。
    //记录下这个初始值 (feeGrowthInsideLastX128) 至关重要,用于在未来计算该头寸自创建以来累计应得但尚未领取的费用。
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

    // 缓存池子信息
    //cachePoolKey: 一个内部函数,它将池子地址和其关键参数(token0, token1, fee)存储在一个映射中,
    //并返回一个唯一的 poolId(一个 uint80 的 ID)。
    //节省存储成本。在头寸信息中存储一个 uint80 poolId 比存储整个 pool 地址或 PoolKey 结构体要便宜得多。
    //当需要池子信息时,可以通过这个 poolId 反向查找。
    uint80 poolId =
        cachePoolKey(
            address(pool),
            PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee})
        );
	//存储头寸信息 将新头寸的所有元数据存入管理器合约的 _positions 映射中,以 tokenId 为键。(重要记录了所有计算收益和管理头寸所需的数据)
    _positions[tokenId] = Position({
        nonce: 0,
        operator: address(0),
        poolId: poolId,
        tickLower: params.tickLower,
        tickUpper: params.tickUpper,
        liquidity: liquidity,
        feeGrowthInside0LastX128: feeGrowthInside0LastX128,
        feeGrowthInside1LastX128: feeGrowthInside1LastX128,
        tokensOwed0: 0,
        tokensOwed1: 0
    });

    emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1);
}

梳理下整体逻辑,首先是 addLiquidity 添加流动性,然后调用 _mint 发送凭证(NFT)给头寸接受者,接着计算一个自增的 poolId,跟交易池地址互相索引,最后将所有信息记录到头寸的结构体中。

调用内部的addLiquidity 函数。这个函数会完成一系列繁重的工作:

(2) 内部函数 addLiquidity

核心是计算出 liquidity 然后调用交易池合约 mint方法。

 function addLiquidity(AddLiquidityParams memory params)
        internal
        returns (
            uint128 liquidity,
            uint256 amount0,
            uint256 amount1,
            IUniswapV3Pool pool
        )
    {
        //获取或创建池子:根据 (token0, token1, fee)参数,通过UniswapV3Factory找到或创建对应的 Pool 合约
        PoolAddress.PoolKey memory poolKey =
            PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee});

        pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));

        // 计算流动性数量:根据当前池子的价格、用户指定的区间和期望的存款数量,
        // 计算出最优的liquidity值以
        {
            (uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
            uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(params.tickLower);
            uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(params.tickUpper);

            liquidity = LiquidityAmounts.getLiquidityForAmounts(
                sqrtPriceX96,
                sqrtRatioAX96,
                sqrtRatioBX96,
                params.amount0Desired,
                params.amount1Desired
            );
        }
		//调用池子的mint函数:最终调用 IUniswapV3Pool(pool).mint,将代币真正存入池中,并在指定的 Tick 区间内铸造流动性。
		//池子的mint函数会返回实际的amount0 和 amount1(可能与计算的有细微差别 due to slippage)以及流动性数量liquidity。
        (amount0, amount1) = pool.mint(
            params.recipient,
            params.tickLower,
            params.tickUpper,
            liquidity,
            abi.encode(MintCallbackData({poolKey: poolKey, payer: msg.sender})) 
            //pool会调用uniswapV3Mintcallback将计算好的amount0和amount1从用户地址转移到pool合约
        );

        require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, 'Price slippage check');
    }

LiquidityAmounts.getLiquidityForAmounts

function getLiquidityForAmounts(
    uint160 sqrtRatioX96,
    uint160 sqrtRatioAX96,
    uint160 sqrtRatioBX96,
    uint256 amount0,
    uint256 amount1
) internal pure returns (uint128 liquidity) {
    if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);

    if (sqrtRatioX96 <= sqrtRatioAX96) {
        liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0);
    } else if (sqrtRatioX96 < sqrtRatioBX96) {
        uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0);
        uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1);

        liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1;
    } else {
        liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1);
    }
}

这里计算delta L的公式getLiquidityForAmount0, getLiquidityForAmount1 就是 [上面3.4.2的公式](#3.4.2 计算流动性变化量)

(3) 增加流动性 increaseLiquidity

添加流动性调用的是 NonfungiblePositionManager 合约的 increaseLiquidity。 这个函数是用户为其已有的流动性头寸(NFT)增加流动性的主要方法,区别上面的mint

参数如下:

struct IncreaseLiquidityParams {
    uint256 tokenId; // 头寸 id
    uint256 amount0Desired; // 添加流动性中 token0 数量
    uint256 amount1Desired; // 添加流动性中 token1 数量
    uint256 amount0Min; // 最小添加 token0 数量
    uint256 amount1Min; // 最小添加 token1 数量
    uint256 deadline; // 过期的区块号
}

代码如下:

/// @inheritdoc INonfungiblePositionManager
function increaseLiquidity(IncreaseLiquidityParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (
        uint128 liquidity,
        uint256 amount0,
        uint256 amount1
    )
{
    Position storage position = _positions[params.tokenId]; //通过 params.tokenId 从存储映射 _positions 中获取对应的 头寸信息
    //根据头寸中存储的 poolId(一个压缩的标识符),从另一个映射 _poolIdToPoolKey 中查询出完整的池子密钥信息 poolKey(包含 token0, token1, fee)。
    //这是在 mint 时缓存的,现在用于重新定位到正确的池子合约。
    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId];

    IUniswapV3Pool pool;
    (liquidity, amount0, amount1, pool) = addLiquidity(
        AddLiquidityParams({
            token0: poolKey.token0,
            token1: poolKey.token1,
            fee: poolKey.fee, 
            tickLower: position.tickLower, // 使用已有头寸的区间
            tickUpper: position.tickUpper, // 使用已有头寸的区间
            amount0Desired: params.amount0Desired,
            amount1Desired: params.amount1Desired,
            amount0Min: params.amount0Min,
            amount1Min: params.amount1Min,
            recipient: address(this)
        })
    );
     //计算该头寸在池子合约中的键 positionKey。
    bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper);

    //查询池子合约,获取该头寸当前的 feeGrowthInside0LastX128 和 feeGrowthInside1LastX128。
    //这个值反映了到此刻为止,该头寸区间内累计的每单位流动性费用总和。
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

    //关键步骤:结算未领取的费用
    position.tokensOwed0 += uint128(
    	//将费用增长因子差值乘以头寸原有的流动性数量,再除以 Q128(一个固定点数精度常量),得到应累加的费用代币数量。
        FullMath.mulDiv(
            feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, //计算出自上次记录以来,费用增长因子的差值(ΔfeeGrowth)。
            position.liquidity,
            FixedPoint128.Q128
        )
    );
    position.tokensOwed1 += uint128(
        FullMath.mulDiv(
            feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,//计算出自上次记录以来,费用增长因子的差值(ΔfeeGrowth)。
            position.liquidity,
            FixedPoint128.Q128
        )
    );

    position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
    position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    position.liquidity += liquidity;

    emit IncreaseLiquidity(params.tokenId, liquidity, amount0, amount1);
}

整体逻辑跟 mint 类似,先从 tokeinId 拿到头寸,然后 addLiquidity 添加流动性,返回添加成功的流动性 liquidity,所消耗的 amount0 和 amount1,以及交易池合约 pool。根据 pool 对象里的最新头寸信息,更新头寸状态。

  • 为什么必须现在结算?

    因为接下来就要增加头寸的流动性

    position.liquidity += liquidity。

​ 如果不先结算旧流动性产生的费用,那么新添加的流动性也会参与到未来的费用计算中。如果现在不结算,那么旧流动性应得的费用就会错误地与新流动性共 享。这一步确保了在改变流动性数量之前,先将此前累计的收益准确无误地记录到tokensOwed中。

(4) 减少流动性 decreaseLiquidity

减少流动性调用的是 NonfungiblePositionManager 合约的 decreaseLiquidity

参数如下:

struct DecreaseLiquidityParams {
    uint256 tokenId; // 头寸 id
    uint128 liquidity; // 减少流动性数量
    uint256 amount0Min; // 最小减少 token0 数量 用户能接受的最少返还的 token0 和 token1 数量(包含本金+费用)
    uint256 amount1Min; // 最小减少 token1 数量
    uint256 deadline; // 过期的区块号
}

代码如下:

/// @inheritdoc INonfungiblePositionManager
function decreaseLiquidity(DecreaseLiquidityParams calldata params)
    external
    payable
    override
    isAuthorizedForToken(params.tokenId)
    checkDeadline(params.deadline)
    returns (uint256 amount0, uint256 amount1)
{
    require(params.liquidity > 0); // 确保要移除的流动性大于0
    Position storage position = _positions[params.tokenId];  // 获取存储中的头寸信息

    uint128 positionLiquidity = position.liquidity;
    require(positionLiquidity >= params.liquidity);  // 确保头寸的流动性足够被移除

    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; //根据头寸中存储的 poolId 找到对应的 poolKey
    //使用工厂合约地址和 poolKey 计算出目标池子的合约地址,并创建接口实例 pool。
    IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
    (amount0, amount1) = pool.burn(position.tickLower, position.tickUpper, params.liquidity);
    // 滑点检查:确保实际返还的本金数量大于用户设定的最小值。
    // 如果因为价格变动导致返还的代币过少,交易将回滚并抛出 'Price slippage check' 错误。
    require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, 'Price slippage check');
    //获取最新的费用增长因子:
    bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper);
    // this is now updated to the current transaction
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

    //关键步骤:结算所有应得资产
    position.tokensOwed0 +=
        uint128(amount0) +
        uint128(
            FullMath.mulDiv(
                feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
                positionLiquidity,
                FixedPoint128.Q128
            )
        );
    position.tokensOwed1 +=
        uint128(amount1) +
        uint128(
            FullMath.mulDiv(
                feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
                positionLiquidity,
                FixedPoint128.Q128
            )
        );
    //更新费用快照:将头寸的费用增长因子记录更新为最新的查询值
    position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
    position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    // 更新头寸流动性:从总流动性中减去移除的部分。
    position.liquidity = positionLiquidity - params.liquidity;

    emit DecreaseLiquidity(params.tokenId, params.liquidity, amount0, amount1);
}

跟 increaseLiquidity 是反向操作,核心逻辑是调用交易池合约的 burn方法。

(5) 领取token collect

取出待领取代币调用的是 NonfungiblePositionManager 合约的 collect

  • 功能:从一个由 tokenId标识的头寸中提取最多指定数量的待领取代币(token0 和 token1)。
  • 调用者:NFT 的所有者或被授权的操作员(通过 isAuthorizedForToken 修饰符检查)。
  • 核心作用:首先更新头寸的待领取代币数量(如果有流动性,则先结算自上次操作以来的费用),然后从池子中提取指定数量的代币,并更新头寸的待领取代币余额。

参数如下:

struct CollectParams {
    uint256 tokenId; // 头寸 id
    address recipient; // 接收者地址
    uint128 amount0Max; // 最大 token0 数量
    uint128 amount1Max; // 最大 token1 数量
}

代码如下:

/// @inheritdoc INonfungiblePositionManager
function collect(CollectParams calldata params)
    external
    payable
    override
    isAuthorizedForToken(params.tokenId)
    returns (uint256 amount0, uint256 amount1)
{
    require(params.amount0Max > 0 || params.amount1Max > 0);
    // allow collecting to the nft position manager address with address 0
    address recipient = params.recipient == address(0) ? address(this) : params.recipient;

    Position storage position = _positions[params.tokenId];//从存储中获取头寸信息

    //通过 poolId 获取池子的密钥,然后计算出池子的合约地址。
    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId];

    IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
    //将头寸中当前记录的待领取代币数量缓存到局部变量 tokensOwed0 和 tokensOwed1。
    (uint128 tokensOwed0, uint128 tokensOwed1) = (position.tokensOwed0, position.tokensOwed1);

    //如果头寸还有流动性,则更新费用信息:
    if (position.liquidity > 0) {
    	//传入流动性为0。这是一个技巧,目的是触发池子更新该头寸的费用增长因子(因为 burn 函数会更新头寸的 tokensOwed 和费用增长因子)。
    	//这是因为 V3 只在mint和burn时才更新头寸状态,而collect方法可能在swap之后被调用,可能会导致头寸状态不是最新的。
        pool.burn(position.tickLower, position.tickUpper, 0);
        //查询池子获取最新的 feeGrowthInside0LastX128 和 feeGrowthInside1LastX128。
        (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) =
            pool.positions(PositionKey.compute(address(this), position.tickLower, position.tickUpper));
		//计算自上次更新以来,头寸的流动性新产生的费用,并将其加到缓存的 tokensOwed0 和 tokensOwed1 上。
        tokensOwed0 += uint128(
            FullMath.mulDiv(
                feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );
        tokensOwed1 += uint128(
            FullMath.mulDiv(
                feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );
		//更新头寸存储中的费用增长因子为最新值
        position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
        position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    }

    // 计算实际要提取的数量:如果用户指定的最大提取量(params.amount0Max 或 params.amount1Max)大于待领取的代币数量,
    // 则提取全部待领取的代币;否则提取指定的最大数量。
    (uint128 amount0Collect, uint128 amount1Collect) =
        (
            params.amount0Max > tokensOwed0 ? tokensOwed0 : params.amount0Max,
            params.amount1Max > tokensOwed1 ? tokensOwed1 : params.amount1Max
        );

    // 从池子中提取代币:池子的 `collect` 方法会返回实际提取的数量。
    (amount0, amount1) = pool.collect(
        recipient,
        position.tickLower,
        position.tickUpper,
        amount0Collect,
        amount1Collect
    );
	//更新头寸的待领取代币余额:
    //注意:这里减去的是 amount0Collect 和 amount1Collect(即请求提取的数量),而不是实际提取的数量 amount0 和 amount1。
    //注释解释:由于池子核心合约中的向下取整,实际提取的数量可能会比请求的少几个 wei,但这里仍然按请求的数量来减少待领取余额。
    //这样做的目的是为了确保待领取余额能够被完全清零(否则可能会留下一点点余额无法提取)。
    //实际上,因为我们在计算可提取数量时已经 capped by tokensOwed,所以 amount0Collect 和 amount1Collect 不会超过 tokensOwed,
    //因此减法不会下溢。
    (position.tokensOwed0, position.tokensOwed1) = (tokensOwed0 - amount0Collect, tokensOwed1 - amount1Collect);

    emit Collect(params.tokenId, recipient, amount0Collect, amount1Collect);
}

(6) NonfungiblePositionManager总结:

图片加载失败: imgs/uniswap-v3-position-manager.pnguniswap-v3-position-manager

3.5 flash 类似 v2

function flash(
    address recipient,
    uint256 amount0,
    uint256 amount1,
    bytes calldata data
) external override lock noDelegateCall {
    uint128 _liquidity = liquidity;
    require(_liquidity > 0, 'L');
	//手续费 = 借款金额 × 费率(向上取整,防止四舍五入导致手续费不足)
    uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e6);
    uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e6);
    //记录当前余额
    uint256 balance0Before = balance0();
    uint256 balance1Before = balance1();
	//发放借款
    if (amount0 > 0) TransferHelper.safeTransfer(token0, recipient, amount0);
    if (amount1 > 0) TransferHelper.safeTransfer(token1, recipient, amount1);
	//调用借款者的回调函数,让他们有机会使用借款并准备还款
    IUniswapV3FlashCallback(msg.sender).uniswapV3FlashCallback(fee0, fee1, data);

    uint256 balance0After = balance0();
    uint256 balance1After = balance1();
	//检查还款是否足够(本金 + 手续费)
    require(balance0Before.add(fee0) <= balance0After, 'F0');
    require(balance1Before.add(fee1) <= balance1After, 'F1');
	
    // sub is safe because we know balanceAfter is gt balanceBefore by at least fee
    //实际支付金额 = 还款后余额 - 还款前余额
    uint256 paid0 = balance0After - balance0Before;
    uint256 paid1 = balance1After - balance1Before;
	//这里有两个层次的手续费:
	//协议手续费:部分给协议(如果启用)
	//流动性提供者手续费:剩余部分给LP
    if (paid0 > 0) {
        uint8 feeProtocol0 = slot0.feeProtocol % 16;  // 获取低4位(token0协议费率)
        uint256 fees0 = feeProtocol0 == 0 ? 0 : paid0 / feeProtocol0;
        if (uint128(fees0) > 0) protocolFees.token0 += uint128(fees0);
        feeGrowthGlobal0X128 += FullMath.mulDiv(paid0 - fees0, FixedPoint128.Q128, _liquidity);  // LP手续费
    }
    if (paid1 > 0) {
        uint8 feeProtocol1 = slot0.feeProtocol >> 4;
        uint256 fees1 = feeProtocol1 == 0 ? 0 : paid1 / feeProtocol1;
        if (uint128(fees1) > 0) protocolFees.token1 += uint128(fees1);
        feeGrowthGlobal1X128 += FullMath.mulDiv(paid1 - fees1, FixedPoint128.Q128, _liquidity);
    }

    emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1);
}

手续费分为两部分:

  • 协议手续费:给协议国库(如果启用)
  • 流动性提供者手续费:按比例分配给所有 LP

4. UniswapV3 额外要点

4.1 TickBitmap

Tick Bitmap 是 Uniswap V3 用来高效存储和管理已初始化 tick 的数据结构。

  1. 高效存储:使用位图压缩存储,大幅降低 Gas 成本
  2. 快速查找:通过位运算实现 O(1) 复杂度的 tick 查找
  3. 灵活管理:支持任意 tickSpacing 的流动性分布
  4. 空间优化:只存储已初始化的 tick,避免全量存储

用于在Pool合约Swap方法的while循环中,寻找下一个tick位图。

//查找下一个已初始化的 tick。tick 位图帮助高效找到有流动性的 tick。
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
    state.tick,
    tickSpacing,
    zeroForOne
);

4.1.1 TickBitmap结构

  • 每个
    tick
    对应 bitmap 中的一个位(bit)
  • bit = 1
    表示该 tick 已初始化(有流动性)
  • bit = 0
    表示未初始化
  • 每 256 个 tick 组成一个 word(uint256)
  • 每个 word 是一个 uint256(256 位),管理 256 个连续的 tick:

4.1.2 什么是 wordPos 和 bitPos?

  • wordPos:tick 所在的 word(字)的索引 (int16) ->从-2^15 到 2^15-1
  • bitPos:tick 在 word 中具体的位位置 (uint8) -> 256bits
  • tick: (int24) 用上面两个结合

4.1.3 Tick bitmap 图解

图片加载失败: imgs/uniswap-v3-tick-bitmap.pnguniswap-v3-tick-bitmap

4.1.4 如何计算下一个tick

![uniswap-v3-next-tick (2)](imgs/uniswap-v3-next-tick (2).png)

最高有效位(Most Significant Bit, MSB) 指的是一个二进制数中值最高的位,即最左边的位。

4.2 Fee Algorithm

4.2.1 如何计算Fee

图片加载失败: imgs/uniswap-v3-calc-fee.pnguniswap-v3-calc-fee

4.2.2 如何计算Fee Growth

图片加载失败: imgs/uniswap-v3-fee-growth.pnguniswap-v3-fee-growth

fee growth graph

4.2.3 如何计算在position内的费用增长(Fee growth inside ticks)

图片加载失败: imgs/uniswap-v3-fee-growth-inside.pnguniswap-v3-fee-growth-inside

Fee Growth Inside 图

4.2.4 如何计算在价格区间外增长的费用 (Fee growth outside ticks)

图片加载失败: imgs/uniswap-v3-fee-growth-outside.pnguniswap-v3-fee-growth-outside

4.2.4.1 Fee growth below

图片加载失败: imgs/uniswap-v3-fee-growth-below.pnguniswap-v3-fee-growth-below

4.2.4.2 Fee growth above

图片加载失败: imgs/uniswap-v3-fee-growth-above.pnguniswap-v3-fee-growth-above

4.2.5 总结计算Position内的fee

图片加载失败: imgs/uniswap-v3-fee-position.pnguniswap-v3-fee-position

4.2.6 代码总结

Position.update最终算一个position 的fee:

uint128 tokensOwed0 =
    uint128(
        FullMath.mulDiv(
            feeGrowthInside0X128 - _self.feeGrowthInside0LastX128,
            _self.liquidity,
            FixedPoint128.Q128
        )
    );
uint128 tokensOwed1 =
    uint128(
        FullMath.mulDiv(
            feeGrowthInside1X128 - _self.feeGrowthInside1LastX128,
            _self.liquidity,
            FixedPoint128.Q128
        )
    );

其实就是公式:

图片加载失败: imgs/uniswap-v3-FeeofAposition.pngFeeofAposition

4.3 Twap Price Oracle

4.3.1 使用几何平均和累加tick和计算TWAP

图片加载失败: imgs/uniswap-v3-twap.pnguniswap-v3-twap

4.3.2 推导V3的Twap(Px=1/py)

图片加载失败: imgs/uniswap-v3-twap-x-y.pnguniswap-v3-twap-x-y

4.3.3 代码

Code walkthrough

4.4 及时流动性改变情况导致的结果

图片加载失败: imgs/uniswap-v3-jit.pnguniswap-v3-jit

最后更新: 2026年1月3日

评论区

发表评论

请先 登录 后再发表评论

请遵守相关法律法规,文明发言。评论将在审核后显示。

评论

还没有评论,来做第一个评论者吧!