前言
本文是「PancakeSwap V3 深度解析」系列的下篇——合约篇。
上篇讲了 V3 的协议设计:集中流动性、tick 离散化、NFT 仓位、TWAP 预言机,以及 PancakeSwap 在协议层独有的 3 处改动(0.25% 费率档、LMPool 协议级挖矿 hook、FarmBooster + veCAKE)。
本文把这些协议设计落到合约源码:V3 fork 共有的合约设计是怎么实现的、PancakeSwap 在合约层加了哪些东西、那些"咒语级名词"
sqrtPriceX96 / tickBitmap / feeGrowthInside 翻译成人话到底是什么意思。重点说清楚两件事——*
NonfungiblePositionManager 与 Pool 的聚合仓位关系*,以及回调收款模式为什么这么设计。
系列阅读:上篇·协议篇 → 下篇·合约篇(本文)
一、项目结构
PancakeSwap V3 的合约代码分布在几个独立的 project 里,各司其职:
| |
为什么要拆这么散?一句话:v3-core 是绝对不能改、不能升级的部分。它是所有外围合约信任的基石,一旦升级就要重新部署所有池、迁移所有流动性。所以 Uniswap / PancakeSwap 都把 core 锁得死死的(连接口都很 minimal),然后把"用户友好"的逻辑——比如 NFT 化、滑点检查、多跳路由、池初始化——全部下沉到可替换的 periphery 层。
v3-periphery 容易被遗漏,但它承载了普通用户最常打交道的 NonfungiblePositionManager——所有 LP 仓位都是通过它铸造成 NFT
的。Core 的 Pool 本身只认"聚合仓位",根本不关心 NFT。
masterchef-v3 是 PancakeSwap 独有的——Uniswap V3 没有对应模块,激励是另一个独立 repo 的 UniswapV3Staker 外挂合约。
二、合约层次架构
完整的调用层次如下:
PancakeV3Pool 内部维护的核心状态:
| |
每一层的职责一句话总结:
| 层级 | 合约 | 职责 |
|---|---|---|
| 入口 | SmartRouter / MasterChefV3 / QuoterV2 | 用户操作的入口,聚合多池路由、质押、报价 |
| 仓位 | NonfungiblePositionManager | 把 LP 仓位 NFT 化,管理用户的份额与待领手续费 |
| 核心 | PancakeV3Pool | 单个交易对的 AMM 引擎,处理 swap / mint / burn / collect |
| 工厂 | PancakeV3Factory | 创建池、维护 fee tier 与 tickSpacing 映射 |
| 部署 | PancakeV3PoolDeployer | 通过 CREATE2 确定性部署 Pool |
三、与 Uniswap V3 合约的实质差异
PancakeSwap V3 的合约树和 Uniswap V3 在 90% 的代码上一致——同一套 AMM 数学、同一套回调收款模式、同一套 CREATE2 池部署。* 但有几处合约层的实质差异值得专门拎出来*——读源码时碰到 Uniswap 没有的代码,基本都集中在这几处。
3.1 PancakeV3Pool 内部多了 LMPool hook
这是合约层最大的差异。PancakeV3Pool.swap() 内部多了两处挖矿钩子调用——
| |
Uniswap V3 的 UniswapV3Pool.swap() 完全不知道挖矿——这两行 hook 是 PancakeSwap 在 core 里加的协议级改动。代价是每次 swap
多 5-10k gas,收益是挖矿状态与 Pool 状态在同一笔交易里同步更新,没有第三方监听延迟。
3.2 多了 PancakeV3LmPool 合约
每个 PancakeV3Pool 部署时,会通过 LmPoolDeployer 同时部署一个 PancakeV3LmPool 实例,与 Pool 1:1 绑定。这个合约维护:
- 每个 tick 的
rewardGrowthOutside - 全局
rewardGrowthGlobal - 当前 reward rate(每秒释放多少 CAKE)
Uniswap V3 没有对应合约——它的 UniswapV3Staker 是独立合约,每个 NFT 在 stake 时单独记账,Pool 完全不感知。
3.3 MasterChefV3 vs UniswapV3Staker
| UniswapV3Staker | MasterChefV3 | |
|---|---|---|
| 与 Pool 的关系 | 完全外挂,Pool 不感知 | 通过 LmPool 与 Pool 在 swap 路径上同步 |
| 奖励来源 | 任何 ERC20(项目方注入) | 主要是 CAKE,由协议挖矿释放 |
| Boost | 没有 | FarmBooster 1x~2x(基于 veCAKE) |
| Stake 形式 | 把 NFT 锁进合约 | NFT.safeTransferFrom 给 MasterChef |
| 奖励计算 | stake 时记快照,unstake 时算差值 | 每次 swap 同步累加,collect 时算差值 |
差异的根因是上篇讲过的"协议级 hook vs 外挂"——MasterChefV3 能在 swap 路径上同步累加,因为 Pool 主动调用 LmPool;UniswapV3Staker 只能等用户主动操作时才更新。
3.4 FarmBooster + veCAKE 集成
FarmBooster 合约根据用户的 veCAKE 余额计算 1x~2x 的乘数。MasterChefV3.updateLiquidityOperation() 在更新 LP 流动性时会主动调用
FarmBooster,把 raw liquidity 放大成 boostLiquidity。Uniswap V3 没有任何对应合约。
3.5 费率档位常量
| |
注意 Uniswap V3 后来通过治理也加过 0.01% 档,但 0.30% 一直在,PancakeSwap 没有 0.30% 而是 0.25%——这导致两套池的 fee tier 完全不能相互替代。
3.6 命名 / 前缀差异(仅 rename)
合约名都从 UniswapV3* 改为 PancakeV3*,callback 函数名相应改为 pancakeV3MintCallback / pancakeV3SwapCallback /
pancakeV3FlashCallback。这只是 rename,不影响 ABI 兼容性,对理解逻辑没有任何障碍。
理解了这 6 处差异,剩下 90% 的内容与 Uniswap V3 高度相似——后面的章节大部分思路和细节也适用于读 Uniswap V3 的源码。
四、核心合约详解
4.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。
4.2 PancakeV3Pool:核心数据结构
| |
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。换算到普通价格:
| |
第一次读到 sqrtPriceX96 这个名字的时候我也懵过几分钟,把它翻译成"用定点数表示的 √price"立刻就清楚了。
4.3 NonfungiblePositionManager:NFT 化的仓位管理
| |
这里有个关键设计:在 Pool 的 positions 映射里,所有通过同一个 PositionManager 来的用户仓位,会按
(PositionManager 地址, tickLower, tickUpper) 聚合成一个仓位;每个 NFT 只在 PositionManager 自己的
_positions[tokenId] 里记录份额和上次的 feeGrowthInside 快照。
这是 V3 最值得单独拎出来讲的一处设计,下面第六节会展开。
五、核心流程时序
5.1 创建池
| |
值得注意的是,createPool 之后池还没有被初始化——slot0.sqrtPriceX96 = 0,任何 swap / mint 都会 revert。需要 Periphery
的 PoolInitializer.createAndInitializePoolIfNecessary() 来设定初始价格,或者直接调用 Pool.initialize(sqrtPriceX96)
。第一个调用方决定了池的开盘价,也因此开池后的第一笔 mint 通常需要项目方先动手,防止套利者按操控价开池。
5.2 添加流动性(Mint)
| |
这里要重点说一下 V3 标志性的回调收款模式。Pool 是先记账(更新 liquidity、positions)、再回调让调用方付款、最后用
balanceBefore / balanceAfter 校验确实收到钱。
为什么不先收钱再记账?因为先收钱意味着用户必须在调用前把代币 approve 给 Pool,然后 Pool 在 mint 里 transferFrom
用户。这套流程在 V2 也用过,但有两个问题:
- 需要两步交易(approve + mint),用户体验差。
- Pool 必须信任用户授权额度的合理性,而 Pool 是个永生合约,授权一旦给出基本不会撤回——这是钓鱼攻击最爱的入口。
回调模式把支付逻辑外包给调用者(通常是 Periphery 合约),Pool 只在事后校验"我余额是不是真的多了 X"。这样:
- Pool 自己不需要任何 approve,授权链断在 Periphery 处。
- 调用方可以是任何合约,包括 flash mint(借出来再还回去),原生支持闪电贷语义。
- 多步操作可以原子地组合在一次 callback 里(比如 swap-and-mint)。
代价是:Pool 必须能识别"调用者是不是合法的"——这通过 verifyCallback() 完成,本质是再算一次
PoolAddress.computeAddress(...) 比对 msg.sender。
5.3 交换(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 步:从 P=2000 涨到下一个 tick 边界 tick(2050)。这一步用
liquidity=1500计算需要多少 USDC 输入、能换多少 ETH 输出。 - 跨越 2050:LP-B 的上界,流动性减少 500。Pool 调用
ticks.cross(),更新liquidity = 1000。 - 第 2 步:从 P=2050 继续涨,这次只用
liquidity=1000算。如果用户的输入还没花完,继续往上走。 - 跨越 2100:LP-A 的上界,流动性再减 1000,变成 0。这意味着池子在更高价格上没有任何做市了——再想买 ETH 只能换更高价的池或别的路径。
- 如果用户的
amountSpecified还没用完,但liquidity = 0,swap 就在这里停住,剩余的 input 退还。
这就是为什么 V3 的 LP 必须主动管理价格区间:价格穿出区间,你的流动性就"下班"了——不再赚手续费,而且仓位会变成单边币(穿出上界变成纯 token0,穿出下界变成纯 token1)。
跨 tick 的 gas 代价
每跨越一个已初始化的 tick,大约要多花 20k gas(一次 SSTORE 反转 tickBitmap + 读写 ticks.Info)。所以密集做市的池子在大额 swap 时会比稀疏池贵很多。这也是为什么你会在区块浏览器上看到一些 V3 swap 的 gas 飙到 30 万以上——那都是跨了十几个 tick 的大单。
5.4 流动性挖矿(MasterChef V3)
关于"为什么挖矿是协议级 hook 而非外挂",已在上篇协议篇与本篇第三节展开,这里只看合约实现细节。
质押(Deposit):
| |
收割(Harvest):
| |
注意 reward 的算法和手续费是同构的:都是"区间内增长 × 流动性份额",只不过一个累加 fee,一个累加 CAKE。理解了
feeGrowthInside,rewardGrowthInside 就是免费的副产品。
六、聚合仓位设计:V3 的精髓
这是整个 V3 体系里最值得单独拎出来讲的一处。
| |
一个具体例子
假设 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,在用户来交互时一次性算出来。
为什么这么设计?
- 省 gas。Pool 只关心"每个区间总共有多少流动性",不需要知道有几个 LP。无论 1 个还是 1000 个 LP 同区间做市,Pool 端的存储成本完全一样。
- 结算延迟到读取时。每个 NFT 的待领手续费 =
(当前 feeGrowthInside − 上次快照) × liquidity。用户没操作时,合约**完全不需要 **为他更新状态——所有"利息"都是用户下次交互时一次性结算的。这是把"懒计算"用到极致。 - 份额化与可组合性。NFT 化让仓位可以被转让、抵押、再封装(MasterChefV3 直接接收 NFT 来做加权挖矿就是个例子)。如果 Pool 端按用户分账,这一切都做不了。
理解了这个设计,你会发现 V3 的 Mint 流程里"先读 Pool 聚合仓位的 feeGrowthInside 作为快照,再写到 NFT 自己的
_positions“那一步——它就是在记下"我这次进场时世界长什么样”,方便下次出场时算差值。
七、关键设计模式
| 模式 | 应用 | 解决的问题 |
|---|---|---|
| 工厂 + CREATE2 | PoolDeployer 用 salt 确定性部署 Pool | 池地址离线可算,绕开 bytecode 24KB 上限 |
| 回调收款 | Mint / Swap / Flash 都用 callback | Pool 不需要 approve,授权链断在 Periphery,天然支持闪电贷 |
| 重入锁 | slot0.unlocked,塞在热点槽里 | Pool 级互斥,零额外 SLOAD 成本 |
| Tick Bitmap | mapping(int16 => uint256) | 一个 word 覆盖 256 个 tick,O(1) 找下一个已初始化 tick |
| Oracle 累加器 | observations 环形数组 | 记录 tick * time 累加和,任意时间窗口的 TWAP 是减法 |
| Boost | FarmBooster 动态调节 1x~2x | 给 veCAKE 长期锁仓者激励 |
| Fee Growth 全局累加 | feeGrowthGlobalX128 单调递增 | 按"区间内累加差值 × 流动性"分账,延迟结算 |
| 聚合仓位 | PositionManager 在 Pool 端只占一个 position | LP 数量与 Pool 存储成本解耦 |
关于 Oracle 累加器多说一句
observations 数组里存的不是某一时刻的价格,而是 tick 的时间积分 tickCumulative = ∫ tick dt。计算 [t1, t2] 区间的
TWAP,只需要:
| |
两次查询、一次减法、一次除法,就拿到任意窗口的均价。这种"前缀和差分"的 trick 在 DeFi 里被反复使用,记住它你能看懂大半个生态的预言机实现。
八、踩坑提醒 / 常见误区
整理几个我自己读这套合约时被绊到、或者看到别人栽的坑:
tickLower / tickUpper必须是tickSpacing的倍数。每个 fee tier 有自己的tickSpacing(0.01% → 1, 0.05% → 10, 0.25% → 50, 1% → 200)。直接传任意 tick 会 revert。liquidity不是"我存了多少钱",而是一个抽象量,大致是√(x * y)的某种归一化。同样的liquidity在窄区间消耗的代币远少于宽区间。前端报的"$value" 是它根据当前价反算出来的。burn()不会把代币转给你——它只是把流动性从 Pool 里"出账"到你的tokensOwed。要拿到币必须再调一次collect()。这是 V3 一个反直觉的两步设计。- 手续费是单独累加的,不是自动复投。LP 想要复投,要主动
collect之后再increaseLiquidity。不少前端会把这个流程包成一键,但合约层面是两步。 swap的amountSpecified是有符号数:正数表示精确输入(exactIn),负数表示精确输出(exactOut)。- Periphery 上的 deadline / slippage 是 Periphery 加的,Core 没有。直接对着 Pool 调 swap 是没有滑点保护的——这就是为什么大家都通过 Router 走。
- NFT 转给 MasterChefV3 之后,你不能直接 collect 手续费了。手续费的归属逻辑被 MasterChefV3 接管,要通过它的接口去取。
- Pool 的 feeProtocol 是协议费,不是 LP 手续费。它从 swap 收的 fee 里再切一刀(默认为 0,治理可改),只有打开后才会进 protocolFees 累加器。
- PancakeSwap 的 0.25% 档与 Uniswap 的 0.30% 档不互通。如果你做跨 DEX 聚合,这是要单独写映射的。
九、数据流总结
| |
Quoter 的报价机制值得单独提一下:它直接调 Pool.swap(),但在自己的 callback 里 revert 抛出计算结果——既不修改状态也不需要任何代币授权,
白嫖了 Pool 的全部计算逻辑做模拟。这是在 EVM 上做"只读模拟"的标准技巧之一,放在 V3 这种逻辑复杂的池子上特别划算(否则前端要自己重新实现
SwapMath,极易出 bug)。
十、附录:数学速查
| 名词 | 公式 / 说明 |
|---|---|
| tick → price | price = 1.0001 ^ tick |
| price → tick | tick = log(price) / log(1.0001) |
| sqrtPriceX96 | sqrtPriceX96 = sqrt(price) * 2^96(Q64.96 定点数) |
| 真实价格 | price = (sqrtPriceX96 / 2^96) ^ 2 |
| 区间内代币关系 | Δ√P = Δy / L,Δ(1/√P) = Δx / L |
| feeGrowthInside | feeGrowthGlobal − 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,正好放进 uint160。feeGrowth / rewardGrowth 用的是 Q128.128(
uint256)——因为它要承载更长时间的累加,精度需求更高。
结语
PancakeSwap V3 的合约体系并不算"难懂",但很致密——几乎每一处设计都是为了在"集中流动性"这个核心命题下,把 gas 抠到极致、把状态压到最小。如果只看一遍 Pool,你会觉得它复杂;但当你理解了:
- Factory / Deployer 拆分是为了绕开 bytecode 限制;
- Slot0 的压槽是为了把 swap 路径上的 SLOAD 压到 1 次;
- 聚合仓位是为了让 LP 数量与 Pool 存储成本解耦;
- 回调模式是为了让 Pool 不必信任任何调用方却仍能正确收款;
- feeGrowth / tickCumulative 都是同一种"前缀和差分"思想;
- LMPool / MasterChefV3 / FarmBooster 是 PancakeSwap 在 Uniswap V3 之上的协议级延伸;
你会发现整个架构其实非常一致——所有看似奇怪的拆分,都是在回答同一个问题:**怎么在以太坊的 gas 预算下,把一个连续函数的做市逻辑跑起来。 **
下一篇打算单独写 tick / tickBitmap / liquidityNet 是怎么把"集中流动性"在链上落地的,以及 SwapMath 那段循环每一步到底在算什么——把数学和 Solidity 一一对上。
系列阅读:上篇·协议篇 → 下篇·合约篇(本文)
如果这篇有讲不清楚的地方,欢迎在评论区或邮件指出。