前言:为什么要再看一次 V3?
如果你只用过 Uniswap / PancakeSwap 的前端,大概率会有这样的体感:V2 时代往池子里"丢两个币"就完事了;到了 V3,要选 fee tier、要拉价格区间、还有"超出区间就不再做市"这种反直觉的提示。
这背后的根本变化叫集中流动性(Concentrated Liquidity):
- V2 用
x * y = k把流动性均匀铺在(0, +∞)全价格区间。结果是大部分资金都待在 BTC=$1 或 BTC=$10M 这种永远不会成交的地方"陪跑"。 - V3 让 LP 自己选区间,比如
[USDC=0.999, USDC=1.001]。同样 1 万美元,集中在窄区间里能提供的有效流动性可能是全区间做市的几百倍。资金效率上去了,代价是合约复杂度暴涨。
这套机制落到合约里,就变成了:Pool 要按 tick(离散价格刻度)管理流动性、要在 swap 时逐 tick 跨越、要给每个 LP 仓位记录独立的手续费快照。直接读 V3 的 Pool 合约,很容易在一堆 sqrtPriceX96、tickBitmap、feeGrowthInside 里迷失方向。
这篇文章想做的事:把 PancakeSwap V3 的合约按"项目结构 → 层次 → 核心流程 → 设计模式"过一遍,顺手把那些"咒语级名词"翻译成人话,重点说清楚两个最容易被忽略的点——NonfungiblePositionManager 与 Pool 的聚合仓位关系,以及回调模式为什么这么设计。
PancakeSwap V3 在 AMM 核心上是 Uniswap V3 的 fork,大部分逻辑是通用的;少数差异(费率档位、原生挖矿、Farm Booster)我会单独标出来。
一、项目结构
PancakeSwap V3 的合约代码分布在几个独立的 project 里,各司其职:
| |
为什么要拆这么散?一句话:v3-core 是绝对不能改、不能升级的部分。它是所有外围合约信任的基石,一旦升级就要重新部署所有池、迁移所有流动性。所以 Uniswap / PancakeSwap 都把 core 锁得死死的(连接口都很 minimal),然后把"用户友好"的逻辑——比如 NFT 化、滑点检查、多跳路由、池初始化——全部下沉到可替换的 periphery 层。
v3-periphery 容易被遗漏,但它承载了普通用户最常打交道的 NonfungiblePositionManager——所有 LP 仓位都是通过它铸造成 NFT 的。Core 的 Pool 本身只认"聚合仓位",根本不关心 NFT。
二、合约层次架构
完整的调用层次如下:
PancakeV3Pool 内部维护的核心状态:
| |
每一层的职责一句话总结:
| 层级 | 合约 | 职责 |
|---|---|---|
| 入口 | 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:核心数据结构
| |
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"立刻就清楚了。
3.3 NonfungiblePositionManager:NFT 化的仓位管理
| |
这里有个关键设计:在 Pool 的 positions 映射里,所有通过同一个 PositionManager 来的用户仓位,会按 (PositionManager 地址, tickLower, tickUpper) 聚合成一个仓位;每个 NFT 只在 PositionManager 自己的 _positions[tokenId] 里记录份额和上次的 feeGrowthInside 快照。
这是 V3 最值得单独拎出来讲的一处设计,下面第五节会展开。
四、核心流程时序
4.1 创建池
| |
值得注意的是,createPool 之后池还没有被初始化——slot0.sqrtPriceX96 = 0,任何 swap / mint 都会 revert。需要 Periphery 的 PoolInitializer.createAndInitializePoolIfNecessary() 来设定初始价格,或者直接调用 Pool.initialize(sqrtPriceX96)。第一个调用方决定了池的开盘价,也因此开池后的第一笔 mint 通常需要项目方先动手,防止套利者按操控价开池。
4.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。
4.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 的大单。
4.4 流动性挖矿(MasterChef V3)
PancakeSwap V3 与 Uniswap V3 最显著的差异之一,是原生挖矿支持。Uniswap V3 的官方激励是个外挂合约(Staker),靠着读 NFT 上链事件来分配奖励;PancakeSwap V3 直接在 Pool 内部预留了 hook 调用 LMPool,让挖矿和 swap 在同一笔交易里更新。
质押(Deposit):
| |
收割(Harvest):
| |
注意 reward 的算法和手续费是同构的:都是"区间内增长 × 流动性份额",只不过一个累加 fee,一个累加 CAKE。理解了 feeGrowthInside,rewardGrowthInside 就是免费的副产品。
Boost 机制(PancakeSwap 独有):FarmBooster 根据用户持有的 veCAKE 数量,把有效流动性放大到最高 2 倍。这是个软锁仓激励——你想要更高的挖矿收益,就得长期锁 CAKE。
五、聚合仓位设计: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 累加器。
八、数据流总结
| |
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 都是同一种"前缀和差分"思想;
你会发现整个架构其实非常一致——所有看似奇怪的拆分,都是在回答同一个问题:怎么在以太坊的 gas 预算下,把一个连续函数的做市逻辑跑起来。
下一篇打算单独写 tick / tickBitmap / liquidityNet 是怎么把"集中流动性"在链上落地的,以及 SwapMath 那段循环每一步到底在算什么——把数学和 Solidity 一一对上。
如果这篇有讲不清楚的地方,欢迎在评论区或邮件指出。