Featured image of post PancakeSwap V3 合约架构深度解析

PancakeSwap V3 合约架构深度解析

从 Factory、Pool、NFT 仓位管理到流动性挖矿,系统拆解 PancakeSwap V3 的合约分层、核心流程与精巧设计。

前言:为什么要再看一次 V3?

如果你只用过 Uniswap / PancakeSwap 的前端,大概率会有这样的体感:V2 时代往池子里"丢两个币"就完事了;到了 V3,要选 fee tier、要拉价格区间、还有"超出区间就不再做市"这种反直觉的提示。

这背后的根本变化叫集中流动性(Concentrated Liquidity):

  • V2x * y = k 把流动性均匀铺在 (0, +∞) 全价格区间。结果是大部分资金都待在 BTC=$1 或 BTC=$10M 这种永远不会成交的地方"陪跑"。
  • V3 让 LP 自己选区间,比如 [USDC=0.999, USDC=1.001]。同样 1 万美元,集中在窄区间里能提供的有效流动性可能是全区间做市的几百倍。资金效率上去了,代价是合约复杂度暴涨。

这套机制落到合约里,就变成了:Pool 要按 tick(离散价格刻度)管理流动性、要在 swap 时逐 tick 跨越、要给每个 LP 仓位记录独立的手续费快照。直接读 V3 的 Pool 合约,很容易在一堆 sqrtPriceX96tickBitmapfeeGrowthInside 里迷失方向。

这篇文章想做的事:把 PancakeSwap V3 的合约按"项目结构 → 层次 → 核心流程 → 设计模式"过一遍,顺手把那些"咒语级名词"翻译成人话,重点说清楚两个最容易被忽略的点——NonfungiblePositionManager 与 Pool 的聚合仓位关系,以及回调模式为什么这么设计

PancakeSwap V3 在 AMM 核心上是 Uniswap V3 的 fork,大部分逻辑是通用的;少数差异(费率档位、原生挖矿、Farm Booster)我会单独标出来。


一、项目结构

PancakeSwap V3 的合约代码分布在几个独立的 project 里,各司其职:

1
2
3
4
5
projects/
├── v3-core/          # 核心池合约(AMM 引擎)
├── v3-periphery/     # 外围合约(NFT 仓位管理、流动性管理)
├── router/           # 路由合约(交易聚合、多跳)
└── masterchef-v3/    # 流动性挖矿合约

为什么要拆这么散?一句话:v3-core 是绝对不能改、不能升级的部分。它是所有外围合约信任的基石,一旦升级就要重新部署所有池、迁移所有流动性。所以 Uniswap / PancakeSwap 都把 core 锁得死死的(连接口都很 minimal),然后把"用户友好"的逻辑——比如 NFT 化、滑点检查、多跳路由、池初始化——全部下沉到可替换的 periphery 层。

v3-periphery 容易被遗漏,但它承载了普通用户最常打交道的 NonfungiblePositionManager——所有 LP 仓位都是通过它铸造成 NFT 的。Core 的 Pool 本身只认"聚合仓位",根本不关心 NFT。


二、合约层次架构

完整的调用层次如下:

PancakeV3Pool 内部维护的核心状态:

1
2
3
4
5
6
7
Slot0:                sqrtPriceX96, tick, observationIndex, feeProtocol, unlocked
ticks:                mapping(int24  => Tick.Info)
tickBitmap:           mapping(int16  => uint256)        // O(1) 找下一个已初始化 tick
positions:            mapping(bytes32 => Position.Info) // 聚合仓位
observations:         Oracle[65535]                      // 历史价格,用于 TWAP
liquidity:            uint128                            // 全局活跃流动性
feeGrowthGlobal0/1:   uint256 (Q128.128)                 // 单调累加的手续费增长

每一层的职责一句话总结:

层级合约职责
入口SmartRouter / MasterChefV3 / QuoterV2用户操作的入口,聚合多池路由、质押、报价
仓位NonfungiblePositionManager把 LP 仓位 NFT 化,管理用户的份额与待领手续费
核心PancakeV3Pool单个交易对的 AMM 引擎,处理 swap / mint / burn / collect
工厂PancakeV3Factory创建池、维护 fee tier 与 tickSpacing 映射
部署PancakeV3PoolDeployer通过 CREATE2 确定性部署 Pool

三、核心合约详解

3.1 Factory + PoolDeployer:为什么要拆成两个?

V3 把"池的部署"从 Factory 中剥离出来,单独放在 PoolDeployer。这是个工程决定,根源在于以太坊的合约 bytecode 上限(EIP-170,约 24KB)。

PancakeV3Pool 的 bytecode 已经接近上限了。如果 Factory 里直接 new PancakeV3Pool(...),Pool 的完整 bytecode 会被嵌进 Factory 的 bytecode,Factory 自己直接爆体积。拆分后:

  • Factory 只负责:校验参数、记录 getPool 映射、调用 PoolDeployer。
  • PoolDeployer 只负责:把构造参数写入 storage → CREATE2 部署 Pool → Pool 构造函数从 PoolDeployer 读取参数 → 清空 storage。

为什么 Pool 不直接从构造参数取?因为 CREATE2 要求 bytecode 完全一致(地址 = keccak256(0xff, deployer, salt, keccak256(bytecode))),如果把 token 地址塞到构造参数里,每个 Pool 的 bytecode 就不一样了。所以 Pool 的构造函数是无参的,从外部 storage 读——这是一个非常有意思的"曲线救国"。

最终效果:salt = keccak256(token0, token1, fee),Pool 的地址在部署前就可以离线算出。这也是为什么 Periphery 合约里到处看到 PoolAddress.computeAddress(...)——根本不需要查 Factory,直接算就完事了,省一次 SLOAD。

3.2 PancakeV3Pool:核心数据结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 全局热点状态,塞进一个 storage slot
struct Slot0 {
    uint160 sqrtPriceX96;             // 当前价格(平方根表示, Q64.96)
    int24   tick;                     // 当前 tick
    uint16  observationIndex;
    uint16  observationCardinality;
    uint16  observationCardinalityNext;
    uint8   feeProtocol;
    bool    unlocked;                 // 重入锁
}

mapping(int24  => Tick.Info) public ticks;        // 每个 tick 的元信息
mapping(int16  => uint256)   public tickBitmap;   // O(1) 查找下一个已初始化 tick
mapping(bytes32 => Position.Info) public positions; // key = keccak(owner, tickLower, tickUpper)

Slot0 把所有"每次 swap 都要读"的字段塞进了一个 256-bit 槽(160 + 24 + 16 + 16 + 16 + 8 + 8 = 248,刚好压得进去)。这样 swap 路径上读这些字段的成本从 N 次 SLOAD 降到 1 次。这种"压槽"是 V3 抠 gas 的代表手法,几乎每个核心结构体都做了这件事。

Tick 是什么?为什么用 sqrt(price)?

V3 把价格离散化成 tick,关系是:

$$ \text{price}(i) = 1.0001^i, \quad i \in \mathbb{Z} $$

也就是说每个 tick 之间正好差 1 个基点(0.01%)。tick = 0 对应价格 1,tick = 6932 大约对应 2,tick = -6932 大约对应 0.5。tick 是整数,这一点很重要——所有 tick 边界比较都是整数比较,极度便宜。

那为什么 slot0 里存的不是 price,而是 sqrtPriceX96?因为 V3 的核心公式涉及 √price 的差分:

$$ \Delta \sqrt{P} = \frac{\Delta y}{L}, \qquad \Delta \frac{1}{\sqrt{P}} = \frac{\Delta x}{L} $$

其中 L 是流动性。如果存 price,每次 swap 都要做开方,gas 直接劝退。直接存 √price,所有运算都是加减乘除。

X96 是 Q64.96 定点数:整数部分 64 bit,小数部分 96 bit,合起来 160 bit,正好塞进 uint160。换算到普通价格:

1
realPrice = (sqrtPriceX96 / 2**96) ** 2

第一次读到 sqrtPriceX96 这个名字的时候我也懵过几分钟,把它翻译成"用定点数表示的 √price"立刻就清楚了。

3.3 NonfungiblePositionManager:NFT 化的仓位管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Position {
    uint96  nonce;                       // permit nonce
    address operator;                    // 授权操作者
    uint80  poolId;                      // 池 ID(指向 _poolIdToPoolKey)
    int24   tickLower;
    int24   tickUpper;
    uint128 liquidity;                   // 该 NFT 的流动性份额
    uint256 feeGrowthInside0LastX128;    // 上次快照
    uint256 feeGrowthInside1LastX128;
    uint128 tokensOwed0;                 // 待领取的手续费
    uint128 tokensOwed1;
}

这里有个关键设计:在 Pool 的 positions 映射里,所有通过同一个 PositionManager 来的用户仓位,会按 (PositionManager 地址, tickLower, tickUpper) 聚合成一个仓位;每个 NFT 只在 PositionManager 自己的 _positions[tokenId] 里记录份额和上次的 feeGrowthInside 快照。

这是 V3 最值得单独拎出来讲的一处设计,下面第五节会展开。


四、核心流程时序

4.1 创建池

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
用户 → Factory.createPool(tokenA, tokenB, fee)
  ├─ 校验: tokenA != tokenB、fee tier 已启用、池未存在
  ├─ Factory → PoolDeployer.deploy(factory, token0, token1, fee, tickSpacing)
  │     ├─ 写 Parameters 到 storage
  │     ├─ CREATE2 部署 PancakeV3Pool(salt = keccak256(token0, token1, fee))
  │     ├─ Pool 构造函数从 PoolDeployer.parameters() 读取参数
  │     └─ 清除 Parameters
  ├─ 写双向映射 getPool[token0][token1][fee] = pool
  └─ emit PoolCreated

值得注意的是,createPool 之后池还没有被初始化——slot0.sqrtPriceX96 = 0,任何 swap / mint 都会 revert。需要 Periphery 的 PoolInitializer.createAndInitializePoolIfNecessary() 来设定初始价格,或者直接调用 Pool.initialize(sqrtPriceX96)。第一个调用方决定了池的开盘价,也因此开池后的第一笔 mint 通常需要项目方先动手,防止套利者按操控价开池。

4.2 添加流动性(Mint)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
用户 → NonfungiblePositionManager.mint(params)
  ├─ addLiquidity()  [LiquidityManagement.sol]
  │     ├─ 计算 pool 地址(CREATE2)
  │     ├─ 读 pool.slot0() 获取当前价格
  │     ├─ LiquidityAmounts.getLiquidityForAmounts() 算出 liquidity
  │     │
  │     └─ Pool.mint(address(this), tickLower, tickUpper, liquidity, callbackData)
  │           ├─ _modifyPosition() → _updatePosition()
  │           │     └─ 更新 Pool.positions 中的【聚合仓位】
  │           │
  │           └─ callback: pancakeV3MintCallback(amount0, amount1, data)
  │                 ├─ verifyCallback() 校验回调来源
  │                 └─ pay(token, payer=用户, recipient=Pool, amount)
  ├─ _mint(recipient, tokenId++)            // 铸造 ERC721
  ├─ 缓存 poolId(_poolIds + _poolIdToPoolKey)
  ├─ 读 Pool 聚合仓位的 feeGrowthInside 作为快照
  └─ 把 Position 存到 _positions[tokenId]
       └─ emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1)

这里要重点说一下 V3 标志性的回调收款模式。Pool 是先记账(更新 liquiditypositions)、再回调让调用方付款、最后用 balanceBefore / balanceAfter 校验确实收到钱。

为什么不先收钱再记账?因为先收钱意味着用户必须在调用前把代币 approve 给 Pool,然后 Pool 在 mint 里 transferFrom 用户。这套流程在 V2 也用过,但有两个问题:

  1. 需要两步交易(approve + mint),用户体验差。
  2. Pool 必须信任用户授权额度的合理性,而 Pool 是个永生合约,授权一旦给出基本不会撤回——这是钓鱼攻击最爱的入口。

回调模式把支付逻辑外包给调用者(通常是 Periphery 合约),Pool 只在事后校验"我余额是不是真的多了 X"。这样:

  • Pool 自己不需要任何 approve,授权链断在 Periphery 处。
  • 调用方可以是任何合约,包括 flash mint(借出来再还回去),原生支持闪电贷语义。
  • 多步操作可以原子地组合在一次 callback 里(比如 swap-and-mint)。

代价是:Pool 必须能识别"调用者是不是合法的"——这通过 verifyCallback() 完成,本质是再算一次 PoolAddress.computeAddress(...) 比对 msg.sender

4.3 交换(Swap)—— 最核心的路径

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
用户 → SmartRouter.exactInputSingle(params)
  └─ V3SwapRouter.exactInputInternal()
        ├─ 解析 path: tokenIn / tokenOut / fee
        ├─ 确定方向 zeroForOne = tokenIn < tokenOut
        └─ Pool.swap(recipient, zeroForOne, amountSpecified, sqrtPriceLimitX96, data)
              ├─ lock() 重入锁
              ├─ LmPool.accumulateReward()                  // 若开启挖矿
              ├─ while 循环(逐 tick 跨越):
              │     ├─ tickBitmap.nextInitializedTickWithinOneWord() 找下一个 tick
              │     ├─ SwapMath.computeSwapStep() 算本步 amountIn/Out/fee
              │     ├─ 扣协议费,累加 feeGrowthGlobal
              │     ├─ 如果到达 tick 边界:
              │     │     ├─ LmPool.crossLmTick()           // 若开启挖矿
              │     │     └─ ticks.cross() 跨越 tick、更新流动性
              │     └─ 直到 amountSpecified 用尽或撞到价格上限
              ├─ 更新 slot0(sqrtPrice、tick、observation)
              ├─ 更新全局 liquidity
              ├─ 先把 output token 转给 recipient
              ├─ callback: pancakeV3SwapCallback(amount0, amount1, data)
              │     └─ Router 把 input token 打进 Pool
              ├─ 校验 Pool 余额确实增加
              └─ emit Swap

一个具体例子:跨 tick 是怎么发生的?

假设当前 ETH/USDC 池价格 P = 2000,tick = 76012,池里只有两个流动性区间:

  • LP-A: 在 [1900, 2100] 区间,liquidity = 1000
  • LP-B: 在 [1950, 2050] 区间,liquidity = 500

当价格在 2000 时,两个区间都覆盖当前价格,池的活跃流动性 liquidity = 1500

现在有人 swap 卖 USDC 买 ETH,价格上涨。Pool 的 while 循环大致这么走:

  1. 第 1 步:从 P=2000 涨到下一个 tick 边界 tick(2050)。这一步用 liquidity=1500 计算需要多少 USDC 输入、能换多少 ETH 输出。
  2. 跨越 2050:LP-B 的上界,流动性减少 500。Pool 调用 ticks.cross(),更新 liquidity = 1000
  3. 第 2 步:从 P=2050 继续涨,这次只用 liquidity=1000 算。如果用户的输入还没花完,继续往上走。
  4. 跨越 2100:LP-A 的上界,流动性再减 1000,变成 0。这意味着池子在更高价格上没有任何做市了——再想买 ETH 只能换更高价的池或别的路径。
  5. 如果用户的 amountSpecified 还没用完,但 liquidity = 0,swap 就在这里停住,剩余的 input 退还。

这就是为什么 V3 的 LP 必须主动管理价格区间:价格穿出区间,你的流动性就"下班"了——不再赚手续费,而且仓位会变成单边币(穿出上界变成纯 token0,穿出下界变成纯 token1)。

跨 tick 的 gas 代价

每跨越一个已初始化的 tick,大约要多花 20k gas(一次 SSTORE 反转 tickBitmap + 读写 ticks.Info)。所以密集做市的池子在大额 swap 时会比稀疏池贵很多。这也是为什么你会在区块浏览器上看到一些 V3 swap 的 gas 飙到 30 万以上——那都是跨了十几个 tick 的大单。

4.4 流动性挖矿(MasterChef V3)

PancakeSwap V3 与 Uniswap V3 最显著的差异之一,是原生挖矿支持。Uniswap V3 的官方激励是个外挂合约(Staker),靠着读 NFT 上链事件来分配奖励;PancakeSwap V3 直接在 Pool 内部预留了 hook 调用 LMPool,让挖矿和 swap 在同一笔交易里更新。

质押(Deposit):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
用户 → NFT.safeTransferFrom(user, MasterChefV3, tokenId)
  └─ MasterChefV3.onERC721Received()
        ├─ 读 NFT 的 (token0, token1, fee, liquidity, ticks)
        ├─ 找对应的 pid
        ├─ LMPool.accumulateReward()
        ├─ updateLiquidityOperation()
        │     ├─ 更新 position.liquidity
        │     ├─ 通过 FarmBooster 算 boostLiquidity(1x ~ 2x)
        │     └─ LMPool.updatePosition(tickLower, tickUpper, liquidityDelta)
        └─ emit Deposit

收割(Harvest):

1
2
3
4
5
6
用户 → MasterChefV3.harvest(tokenId, to)
  ├─ LMPool.accumulateReward()
  ├─ LMPool.getRewardGrowthInside(tickLower, tickUpper)
  ├─ reward = (rewardGrowthDelta * boostLiquidity) / Q128
  └─ CAKE.safeTransfer(to, reward)

注意 reward 的算法和手续费是同构的:都是"区间内增长 × 流动性份额",只不过一个累加 fee,一个累加 CAKE。理解了 feeGrowthInside,rewardGrowthInside 就是免费的副产品。

Boost 机制(PancakeSwap 独有):FarmBooster 根据用户持有的 veCAKE 数量,把有效流动性放大到最高 2 倍。这是个软锁仓激励——你想要更高的挖矿收益,就得长期锁 CAKE。


五、聚合仓位设计:V3 的精髓

这是整个 V3 体系里最值得单独拎出来讲的一处。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Pool.positions[key]  ——【聚合仓位,全局共享】
┌──────────────────────────────────────────┐
│ key = keccak256(PositionManager 地址,    │
│                 tickLower, tickUpper)    │
│                                          │
│ liquidity        = 所有 NFT 流动性之和   │
│ feeGrowthInside  = 累计手续费增长        │
│ tokensOwed0/1    = (聚合层不实际使用)   │
└──────────────────────────────────────────┘

PositionManager._positions[tokenId]  ——【个体仓位,NFT 私有】
┌──────────────────────────────────────────┐
│ liquidity              = 该 NFT 的份额   │
│ feeGrowthInsideLast    = 上次快照值      │
│ tokensOwed0/1          = 待领取手续费    │
│ poolId → (token0, token1, fee)           │
└──────────────────────────────────────────┘

一个具体例子

假设 Alice 和 Bob 都通过 PositionManager 在同一个区间 [1900, 2100] 做市:

  • Alice mint:liquidity = 100
  • Bob mint:liquidity = 200

Pool 端只看到一个位于该区间的仓位,liquidity = 300。Pool 不知道 Alice 是谁,也不知道 Bob 是谁。

这时发生了一笔 swap,该区间累计产生 9 USDC 手续费。Pool 把这笔费用按"区间内的总流动性"折算成 feeGrowthInside 的增量,假设增量是 +ΔF

Alice 来 collect 时:

  • 她的 feeGrowthInsideLast 是 mint 时的快照。
  • 当前 feeGrowthInside 比她的快照多了 ΔF
  • 她应得 = ΔF × 100 / 2^128 = 3 USDC(对应她占 1/3 的流动性份额)。

Bob 同理拿到 6 USDC。Pool 端没有任何"分账"逻辑——所有的份额结算全部下放到 PositionManager,在用户来交互时一次性算出来。

为什么这么设计?

  1. 省 gas。Pool 只关心"每个区间总共有多少流动性",不需要知道有几个 LP。无论 1 个还是 1000 个 LP 同区间做市,Pool 端的存储成本完全一样。
  2. 结算延迟到读取时。每个 NFT 的待领手续费 = (当前 feeGrowthInside − 上次快照) × liquidity。用户没操作时,合约完全不需要为他更新状态——所有"利息"都是用户下次交互时一次性结算的。这是把"懒计算"用到极致。
  3. 份额化与可组合性。NFT 化让仓位可以被转让、抵押、再封装(MasterChefV3 直接接收 NFT 来做加权挖矿就是个例子)。如果 Pool 端按用户分账,这一切都做不了。

理解了这个设计,你会发现 V3 的 Mint 流程里"先读 Pool 聚合仓位的 feeGrowthInside 作为快照,再写到 NFT 自己的 _positions“那一步——它就是在记下"我这次进场时世界长什么样”,方便下次出场时算差值。


六、关键设计模式

模式应用解决的问题
工厂 + CREATE2PoolDeployer 用 salt 确定性部署 Pool池地址离线可算,绕开 bytecode 24KB 上限
回调收款Mint / Swap / Flash 都用 callbackPool 不需要 approve,授权链断在 Periphery,天然支持闪电贷
重入锁slot0.unlocked,塞在热点槽里Pool 级互斥,零额外 SLOAD 成本
Tick Bitmapmapping(int16 => uint256)一个 word 覆盖 256 个 tick,O(1) 找下一个已初始化 tick
Oracle 累加器observations 环形数组记录 tick * time 累加和,任意时间窗口的 TWAP 是减法
BoostFarmBooster 动态调节 1x~2x给 veCAKE 长期锁仓者激励
Fee Growth 全局累加feeGrowthGlobalX128 单调递增按"区间内累加差值 × 流动性"分账,延迟结算
聚合仓位PositionManager 在 Pool 端只占一个 positionLP 数量与 Pool 存储成本解耦

关于 Oracle 累加器多说一句

observations 数组里存的不是某一时刻的价格,而是 tick 的时间积分 tickCumulative = ∫ tick dt。计算 [t1, t2] 区间的 TWAP,只需要:

1
2
twapTick = (cumulative(t2) - cumulative(t1)) / (t2 - t1)
twapPrice = 1.0001 ** twapTick

两次查询、一次减法、一次除法,就拿到任意窗口的均价。这种"前缀和差分"的 trick 在 DeFi 里被反复使用,记住它你能看懂大半个生态的预言机实现。


七、踩坑提醒 / 常见误区

整理几个我自己读这套合约时被绊到、或者看到别人栽的坑:

  1. tickLower / tickUpper 必须是 tickSpacing 的倍数。每个 fee tier 有自己的 tickSpacing(0.01% → 1, 0.05% → 10, 0.25% → 50, 1% → 200)。直接传任意 tick 会 revert。
  2. liquidity 不是"我存了多少钱",而是一个抽象量,大致是 √(x * y) 的某种归一化。同样的 liquidity 在窄区间消耗的代币远少于宽区间。前端报的"$value" 是它根据当前价反算出来的。
  3. burn() 不会把代币转给你——它只是把流动性从 Pool 里"出账"到你的 tokensOwed。要拿到币必须再调一次 collect()。这是 V3 一个反直觉的两步设计。
  4. 手续费是单独累加的,不是自动复投。LP 想要复投,要主动 collect 之后再 increaseLiquidity。不少前端会把这个流程包成一键,但合约层面是两步。
  5. swapamountSpecified 是有符号数:正数表示精确输入(exactIn),负数表示精确输出(exactOut)。
  6. Periphery 上的 deadline / slippage 是 Periphery 加的,Core 没有。直接对着 Pool 调 swap 是没有滑点保护的——这就是为什么大家都通过 Router 走。
  7. NFT 转给 MasterChefV3 之后,你不能直接 collect 手续费了。手续费的归属逻辑被 MasterChefV3 接管,要通过它的接口去取。
  8. Pool 的 feeProtocol 是协议费,不是 LP 手续费。它从 swap 收的 fee 里再切一刀(默认为 0,治理可改),只有打开后才会进 protocolFees 累加器。

八、数据流总结

1
2
3
4
Token 流  : 用户 → Router → Pool ↔ NonfungiblePositionManager
奖励流    : Receiver → MasterChefV3 → 用户(CAKE)
信息流    : Pool.tick / tickBitmap / position → LMPool → MasterChefV3
报价流    : QuoterV2 → Pool.swap(用 revert 捕获结果,不实际成交)

Quoter 的报价机制值得单独提一下:它直接调 Pool.swap(),但在自己的 callback 里 revert 抛出计算结果——既不修改状态也不需要任何代币授权,白嫖了 Pool 的全部计算逻辑做模拟。这是在 EVM 上做"只读模拟"的标准技巧之一,放在 V3 这种逻辑复杂的池子上特别划算(否则前端要自己重新实现 SwapMath,极易出 bug)。


九、附录:数学速查

名词公式 / 说明
tick → priceprice = 1.0001 ^ tick
price → ticktick = log(price) / log(1.0001)
sqrtPriceX96sqrtPriceX96 = sqrt(price) * 2^96(Q64.96 定点数)
真实价格price = (sqrtPriceX96 / 2^96) ^ 2
区间内代币关系Δ√P = Δy / L,Δ(1/√P) = Δx / L
feeGrowthInsidefeeGrowthGlobal − feeGrowthBelow(tickLower) − feeGrowthAbove(tickUpper)
LP 应得手续费(feeGrowthInside_now − feeGrowthInside_snapshot) × liquidity / 2^128
TWAP(tickCumulative(t2) − tickCumulative(t1)) / (t2 − t1)

Q64.96:整数部分 64 bit、小数部分 96 bit,合计 160 bit,正好放进 uint160feeGrowth / rewardGrowth 用的是 Q128.128(uint256)——因为它要承载更长时间的累加,精度需求更高。


结语

PancakeSwap V3 的合约体系并不算"难懂",但很致密——几乎每一处设计都是为了在"集中流动性"这个核心命题下,把 gas 抠到极致、把状态压到最小。如果只看一遍 Pool,你会觉得它复杂;但当你理解了:

  • Factory / Deployer 拆分是为了绕开 bytecode 限制;
  • Slot0 的压槽是为了把 swap 路径上的 SLOAD 压到 1 次;
  • 聚合仓位是为了让 LP 数量与 Pool 存储成本解耦;
  • 回调模式是为了让 Pool 不必信任任何调用方却仍能正确收款;
  • feeGrowth / tickCumulative 都是同一种"前缀和差分"思想;

你会发现整个架构其实非常一致——所有看似奇怪的拆分,都是在回答同一个问题:怎么在以太坊的 gas 预算下,把一个连续函数的做市逻辑跑起来。

下一篇打算单独写 tick / tickBitmap / liquidityNet 是怎么把"集中流动性"在链上落地的,以及 SwapMath 那段循环每一步到底在算什么——把数学和 Solidity 一一对上。

如果这篇有讲不清楚的地方,欢迎在评论区或邮件指出。

使用 Hugo 构建
主题 StackJimmy 设计