Uniswap V2 源码学习第二篇:Factory 合约,Pair 是如何被创建出来的
Factory 合约拆解
上一篇我们先建立了全局地图:
Factory:创建 Pair,记录 Pair
Pair:保存流动性,执行 mint / burn / swap,发行 LP Token
Router:用户入口,负责路径、转账、ETH/WETH 包装
这一篇正式进入源码。
Factory 是整个 Uniswap V2 世界里的“注册中心 + 合约工厂”。
它不处理交易,不保存流动性,也不会直接参与每一次 swap。
它只做一件非常关键的事:
给任意两个 token 创建一个唯一的 Pair 合约,并把这个 Pair 记录下来。
听起来很简单,但这里面藏着几个非常经典的 Solidity 设计点:
1. token0 / token1 排序
2. 防止重复创建 Pair
3. CREATE2 创建确定性地址
4. initialize 初始化 Pair
5. feeTo / feeToSetter 协议手续费权限
我们一层一层拆。
1. Factory 合约长什么样?
先看一个简化版结构:
contract UniswapV2Factory is IUniswapV2Factory {
address public feeTo;
address public feeToSetter;
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs;
constructor(address _feeToSetter) public {
feeToSetter = _feeToSetter;
}
function allPairsLength() external view returns (uint) {
return allPairs.length;
}
function createPair(address tokenA, address tokenB)
external
returns (address pair)
{
// 核心逻辑
}
function setFeeTo(address _feeTo) external {
require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
feeTo = _feeTo;
}
function setFeeToSetter(address _feeToSetter) external {
require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
feeToSetter = _feeToSetter;
}
}
它的代码非常短。
这也很 Uniswap V2:
Factory 不追求“功能很多”,而是追求“边界清晰”。
Factory 主要就三类状态:
address public feeTo;
address public feeToSetter;
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs;
2. feeTo 和 feeToSetter:协议手续费开关
先看这两个变量:
address public feeTo;
address public feeToSetter;
这两个不是普通交易手续费。
Uniswap V2 里有两层手续费概念:
普通 LP 手续费:每笔 swap 的 0.3%,默认一直存在,留在池子里
协议手续费:protocol fee,默认关闭,开启后协议方会获得一部分 LP 收益
feeTo 就是协议手续费接收地址。
如果:
feeTo == address(0)
说明协议手续费关闭。
如果:
feeTo != address(0)
说明协议手续费开启。
但注意,Factory 本身不会直接收钱。它只是保存 feeTo 地址。
真正使用 feeTo 的地方在 Pair 里:
address feeTo = IUniswapV2Factory(factory).feeTo();
也就是说:
Factory 负责记录手续费接收地址
Pair 负责在 mint / burn 时根据 feeTo 决定是否铸造协议手续费 LP Token
这个后面讲 _mintFee() 时会展开。
feeToSetter 则是有权限修改 feeTo 的地址。
function setFeeTo(address _feeTo) external {
require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
feeTo = _feeTo;
}
这是一种很简单的权限模型:
只有 feeToSetter 可以设置 feeTo
只有 feeToSetter 可以转让 feeToSetter
没有复杂的角色系统,没有 Ownable,没有 AccessControl。
V2 的 core 合约非常克制。
3. getPair:为什么是双层 mapping?
Factory 最核心的数据结构是:
mapping(address => mapping(address => address)) public getPair;
它用来记录:
tokenA + tokenB => pairAddress
例如:
getPair[DAI][USDC] = pairAddress;
getPair[USDC][DAI] = pairAddress;
你可能会问:
明明 Pair 内部已经有 token0 和 token1 了,为什么 Factory 还要两个方向都存?
因为用户查询时不应该关心顺序。
对于用户来说:
DAI/USDC
USDC/DAI
是同一个交易对。
所以 Factory 在创建 Pair 之后会做:
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
这样无论外部传入的 token 顺序是什么,都能查到同一个 Pair。
这是一个小设计,但非常实用。
4. allPairs:为什么还需要数组?
除了 mapping,Factory 还维护一个数组:
address[] public allPairs;
它保存所有已经创建过的 Pair 地址。
配套函数:
function allPairsLength() external view returns (uint) {
return allPairs.length;
}
为什么有了 getPair 还要 allPairs?
因为 mapping 不能被遍历。
如果只有:
mapping(address => mapping(address => address)) public getPair;
你可以查:
某两个 token 有没有 Pair
但你没法在链上直接问:
Factory 一共创建了哪些 Pair?
第 100 个 Pair 是谁?
总共有多少个 Pair?
所以需要数组辅助索引。
这个组合很常见:
mapping(...) public getPair;
address[] public allPairs;
mapping 用于快速查询,array 用于枚举。
5. createPair:Factory 的主菜
终于到核心函数:
function createPair(address tokenA, address tokenB)
external
returns (address pair)
原版逻辑大致是这样:
function createPair(address tokenA, address tokenB)
external
returns (address pair)
{
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
require(
getPair[token0][token1] == address(0),
'UniswapV2: PAIR_EXISTS'
);
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(
0,
add(bytecode, 32),
mload(bytecode),
salt
)
}
IUniswapV2Pair(pair).initialize(token0, token1);
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
这个函数虽然不长,但信息量很密。我们逐段看。
6. 第一步:不能用同一个 token 创建 Pair
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
这个很好理解。
交易对必须是两个不同资产。
下面这些是合法的:
DAI / USDC
WETH / USDC
WBTC / WETH
但这个不合法:
DAI / DAI
因为 AMM 池子的意义是做两种资产之间的兑换和定价。
同一个 token 和自己组成 Pair 没有意义。
7. 第二步:token0 / token1 排序
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
这是 Factory 里最重要的小细节之一。
Uniswap V2 会按照地址大小固定排序:
地址较小的 token 作为 token0
地址较大的 token 作为 token1
为什么要这么做?
因为要保证:
createPair(DAI, USDC)
createPair(USDC, DAI)
最终得到的是同一个逻辑交易对。
如果不排序,系统就可能把它们当成两个不同 Pair。
那就麻烦了。
想象一下,如果存在两个池子:
DAI/USDC Pair A
USDC/DAI Pair B
那流动性会被拆散,价格会混乱,用户也不知道该走哪个池子。
排序的目的就是让 Pair 具有唯一规范形式:
无论用户传 tokenA/tokenB 还是 tokenB/tokenA
内部永远归一化成 token0/token1
这个动作也为后面的 CREATE2 铺路。
8. 第三步:不能是零地址
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
为什么只检查 token0?
因为前面已经排序了。
如果两个地址里有一个是零地址,那么零地址一定是更小的那个,也就是 token0。
所以只需要检查:
token0 != address(0)
就能排除任意一个 token 是零地址的情况。
这就是源码里一个很省的写法。
它没有写:
require(tokenA != address(0));
require(tokenB != address(0));
但效果一样。
9. 第四步:防止重复创建 Pair
require(
getPair[token0][token1] == address(0),
'UniswapV2: PAIR_EXISTS'
);
经过排序之后,只需要检查:
getPair[token0][token1]
是否为空。
如果不为空,说明 Pair 已经存在。
因为创建完成后,Factory 会同时写入:
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
但在创建前,检查规范顺序就够了。
到这里为止,Factory 已经确保:
1. tokenA 和 tokenB 不是同一个地址
2. token0 / token1 顺序稳定
3. 两个 token 都不是零地址
4. Pair 之前没有被创建过
接下来就是真正的部署。
10. CREATE2:为什么 Pair 地址可以提前算出来?
Uniswap V2 创建 Pair 用的是:
create2
而不是普通的 new UniswapV2Pair()。
代码是:
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(
0,
add(bytecode, 32),
mload(bytecode),
salt
)
}
这一段看起来有点硬核,但拆开其实很好懂。
普通 create 的问题
普通合约部署地址大致取决于:
部署者地址 + nonce
也就是说,一个合约地址和 Factory 当前 nonce 有关。
这会带来一个问题:
如果不发链上调用,你很难直接知道某两个 token 的 Pair 地址。
但 Uniswap V2 希望能做到:
只要知道 factory、token0、token1 和 Pair init code hash,就能离线算出 Pair 地址
这样 Router 和 Library 就能更高效地工作。
CREATE2 的地址公式
CREATE2 创建出来的地址取决于:
factory address
salt
init code hash
大致可以理解为:
pair = hash(factory, salt, init_code_hash)
而 Uniswap V2 里的 salt 是:
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
所以 Pair 地址只取决于:
Factory 地址
token0
token1
Pair 合约创建代码 hash
这就非常妙。
只要这些东西确定,Pair 地址就确定。
所以 UniswapV2Library 里可以有一个 pairFor() 函数,直接计算 Pair 地址,而不需要调用 Factory 的 getPair()。
11. assembly create2 到底在干嘛?
我们再看这段:
assembly {
pair := create2(
0,
add(bytecode, 32),
mload(bytecode),
salt
)
}
create2 的四个参数分别是:
1. value:部署合约时附带多少 ETH
2. memory start:合约创建代码在内存里的起始位置
3. memory length:创建代码长度
4. salt:盐值
对应这里:
create2(
0,
add(bytecode, 32),
mload(bytecode),
salt
)
逐个看。
value = 0
0
部署 Pair 不需要发送 ETH。
add(bytecode, 32)
add(bytecode, 32)
Solidity 里的动态 bytes 在内存中结构大概是:
前 32 字节:长度
后面内容:真正的数据
bytecode 指向的是整个 bytes 的起点,也就是长度字段的位置。
真正的创建代码要跳过前 32 字节,所以用:
add(bytecode, 32)
mload(bytecode)
mload(bytecode)
读取 bytes 的长度。
也就是 Pair creation code 的长度。
salt
salt
也就是:
keccak256(abi.encodePacked(token0, token1))
最终 create2 返回新部署的 Pair 地址,赋值给:
pair
这就是源码层面的部署过程。
原版 Factory 没有在 assembly 后面显式写 require(pair != address(0))。在这段逻辑里,前面已经通过 getPair 防止了重复创建;如果底层 create2 真的失败,后续调用 IUniswapV2Pair(pair).initialize(...) 也会让整笔交易失败。
12. initialize:为什么不用 constructor 设置 token0/token1?
Pair 创建完之后,Factory 立刻调用:
IUniswapV2Pair(pair).initialize(token0, token1);
Pair 里的函数是:
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN');
token0 = _token0;
token1 = _token1;
}
这里很值得细品。
Pair 的 constructor 是:
constructor() public {
factory = msg.sender;
}
它只设置了 factory,没有设置 token0 和 token1。
为什么?
因为 Factory 想用统一的 creation code 部署所有 Pair。
如果 token0/token1 作为 constructor 参数参与部署,那么不同 Pair 的部署数据会变化,链下计算 Pair 地址时就必须把 constructor 参数编码也纳入 init code。V2 的方式是:
1. 用固定 creation code 部署 Pair
2. salt 使用 token0/token1
3. 部署完后调用 initialize 设置 token0/token1
这样 Pair 的地址可预测性更清晰。
同时,为了防止别人乱初始化,Pair 限制:
require(msg.sender == factory, 'UniswapV2: FORBIDDEN');
只有 Factory 可以调用 initialize()。
更准确地说,是只有 Pair 记录的那个 factory 可以调用。
而这个 factory 是 constructor 里写死的:
factory = msg.sender;
由于 Pair 是 Factory 部署的,所以 msg.sender 就是 Factory。
13. 写入 mapping:两个方向都存
初始化完成后,Factory 记录 Pair:
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
为什么两个方向都存,前面已经讲过。
这是为了让外部查询更自然。
比如 Router 或前端查 Pair:
factory.getPair(tokenA, tokenB)
不需要关心谁是 token0,谁是 token1。
当然,UniswapV2Library 的 pairFor() 为了离线计算地址,还是会先排序。
14. 加入 allPairs
allPairs.push(pair);
这一步是为了全局枚举。
例如外部可以通过:
allPairs(0)
allPairs(1)
allPairs(2)
遍历所有 Pair。
也可以通过:
allPairsLength()
知道当前一共有多少 Pair。
注意这个设计依然很简单:
mapping 负责查询
array 负责枚举
15. 触发 PairCreated 事件
最后:
emit PairCreated(token0, token1, pair, allPairs.length);
事件定义通常是:
event PairCreated(
address indexed token0,
address indexed token1,
address pair,
uint
);
这个事件很重要。
链下服务、区块浏览器、数据索引器、前端、分析平台,都可以监听这个事件,知道有新的交易对被创建了。
比如 The Graph 这类索引服务,就可以通过事件构建 Pair 列表。
链上合约用 getPair 和 allPairs。
链下系统常用事件。
16. createPair 完整流程复盘
现在我们把整个流程串起来。
用户调用:
factory.createPair(tokenA, tokenB)
Factory 做这些事:
1. 检查 tokenA != tokenB
2. 按地址大小排序得到 token0/token1
3. 检查 token0 不是零地址
4. 检查 Pair 还不存在
5. 取出 UniswapV2Pair creation code
6. 用 token0/token1 计算 salt
7. 用 CREATE2 部署 Pair
8. 调用 Pair.initialize(token0, token1)
9. 写入 getPair[token0][token1]
10. 写入 getPair[token1][token0]
11. push 到 allPairs
12. emit PairCreated
可以画成这样:
createPair(tokenA, tokenB)
│
↓
检查 tokenA != tokenB
│
↓
排序 token0/token1
│
↓
检查零地址和重复 Pair
│
↓
计算 salt = hash(token0, token1)
│
↓
CREATE2 部署 Pair
│
↓
Pair.initialize(token0, token1)
│
↓
记录 getPair + allPairs
│
↓
emit PairCreated
17. Factory 为什么不负责添加流动性?
这是一个很自然的问题:
既然 Factory 创建 Pair,为什么不顺便帮用户添加流动性?
因为 Factory 的职责要保持极简。
Factory 不知道用户想添加多少 token,也不负责转账,不参与定价,不碰用户资产。
这些事情分别属于:
Router:处理用户交互和转账流程
Pair:根据实际收到的 token mint LP Token
Factory 只做:
创建和登记 Pair
这就是 V2 的边界设计。
Factory 不越界,Pair 不依赖 Router,Router 可替换。
18. Factory 和 Router 的关系
Router 在很多地方需要知道 Pair 地址。
它有两种方式:
方式一:直接查 Factory
IUniswapV2Factory(factory).getPair(tokenA, tokenB)
这是链上读取。
方式二:用 Library 直接算
UniswapV2Library.pairFor(factory, tokenA, tokenB)
这是纯计算。
pairFor() 本质上复刻了 CREATE2 的地址公式。
伪代码可以理解为:
pair = address(uint160(uint(
keccak256(
abi.encodePacked(
hex'ff',
factory,
keccak256(abi.encodePacked(token0, token1)),
INIT_CODE_PAIR_HASH
)
)
)));
这里的关键是:
factory
salt = hash(token0, token1)
INIT_CODE_PAIR_HASH
所以如果 Factory 的 createPair() 使用 CREATE2,那么 Library 就能提前算出 Pair 地址。
这也是为什么 INIT_CODE_PAIR_HASH 很重要。
19. INIT_CODE_PAIR_HASH 到底是什么?
INIT_CODE_PAIR_HASH 是 Pair creation code 的 hash。
也就是:
keccak256(type(UniswapV2Pair).creationCode)
Router/Library 在链下或链上计算 Pair 地址时,需要这个 hash。
因为 CREATE2 地址公式中必须包含:
init_code_hash
这有一个很实际的影响:
如果你 fork Uniswap V2,并且修改了 Pair 合约代码,那么 INIT_CODE_PAIR_HASH 会变化。
很多人 fork V2 的时候,最容易踩坑的地方就是:
改了 Pair 代码
但忘了更新 INIT_CODE_PAIR_HASH
结果 Router 算出来的 Pair 地址和 Factory 实际创建出来的 Pair 地址不一致。
表现出来就是:
明明 createPair 成功了
但 Router 找不到正确 Pair
swap / addLiquidity 全部异常
所以 fork V2 时一定要记住:
Pair 合约代码变了,INIT_CODE_PAIR_HASH 就要重新计算
这是工程实践里非常常见的坑。
20. 为什么 Factory 用 CREATE2,而不是让 Pair 地址随机生成?
主要有三个好处。
第一,地址可预测
给定:
factory
token0
token1
init code hash
Pair 地址就确定。
这对 Router、SDK、前端都很友好。
第二,减少链上查询
Library 可以直接算 Pair 地址,不一定非要调用 Factory。
尤其在多跳交易里,Router 需要频繁确定每一跳 Pair 地址。
直接算会更轻。
第三,便于链下系统构建路径
前端、SDK、聚合器可以提前计算各种交易对地址。
这对交易路径搜索非常重要。
21. setFeeTo 和 setFeeToSetter
最后看两个权限函数:
function setFeeTo(address _feeTo) external {
require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
feeTo = _feeTo;
}
function setFeeToSetter(address _feeToSetter) external {
require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
feeToSetter = _feeToSetter;
}
这俩函数很简单,但它们控制的是协议手续费开关。
setFeeTo(address(0)):关闭协议手续费
setFeeTo(nonzero):开启协议手续费,并设置接收地址
setFeeToSetter(newAddress):转让管理权限
注意:
普通 swap 的 0.3% 手续费不靠 setFeeTo 开启
0.3% swap fee 是 Pair 的 swap() 里固定实现的。
feeTo 控制的是协议能否从 LP 收益中分走一部分。
这部分不在 Factory 里实现,而是在 Pair 的 _mintFee() 里实现。
22. Factory 的设计哲学
它不做:
不转 token
不计算价格
不处理 swap
不 mint LP Token
不保存 reserve
不管理用户资产
它只做:
创建 Pair
记录 Pair
管理 feeTo
这是一种非常好的合约边界设计。
因为 Factory 是系统级合约,如果它很复杂,风险会迅速增加。
Uniswap V2 的思路是:
能不放在 Factory 里的逻辑,就不要放在 Factory 里。
这也是为什么真正复杂的东西都在 Pair 里。
小结
这一篇我们完整读了 UniswapV2Factory.sol。
你现在应该能回答这些问题:
1. Factory 是干什么的?
创建 Pair,记录 Pair,管理 feeTo。
2. token0/token1 为什么要排序?
为了保证交易对唯一,并支持确定性地址计算。
3. 为什么 getPair 要存两个方向?
方便外部用任意顺序查询 Pair。
4. 为什么要 allPairs?
mapping 不能遍历,数组用于枚举所有 Pair。
5. 为什么用 CREATE2?
让 Pair 地址可以提前计算。
6. initialize 为什么不放在 constructor?
为了保持 Pair creation code 固定,配合 CREATE2。
7. INIT_CODE_PAIR_HASH 有什么用?
用于根据 CREATE2 公式计算 Pair 地址。
8. feeTo 是什么?
协议手续费接收地址,不是普通 0.3% swap 手续费。