Uniswap V2 源码学习第二篇:Factory 合约,Pair 是如何被创建出来的

tomenengr 发布于 2026-05-11 阅读 237

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,没有设置 token0token1

为什么?

因为 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。

当然,UniswapV2LibrarypairFor() 为了离线计算地址,还是会先排序。


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 列表。

链上合约用 getPairallPairs

链下系统常用事件。


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 手续费。

相关文章

0 条评论