Uniswap V2 源码总览:Pair、Factory、Router 各自负责什么?
Uniswap V2 源码总览:Pair、Factory、Router 各自的作用
如果你第一次打开 Uniswap V2 的源码,很容易会有一种感觉:
文件不多,函数也不算长,但怎么一进去就有点绕?
这很正常。
Uniswap V2 的难点不在于代码量,而在于它把一个自动做市商系统拆成了几个非常克制的模块。每个合约都只做一小部分事,但它们组合起来,就形成了完整的交易、做市、LP Token、手续费、预言机和闪电兑换系统。
这一篇我们先站在源码地图的高度,看清楚三个核心角色:
Factory:负责创建 Pair
Pair:负责保存流动性、执行交易、发行 LP Token
Router:负责给用户提供友好的入口
一句话概括:
Factory 管“生孩子”,Pair 管“真正干活”,Router 管“帮用户少踩坑”。
1. Uniswap V2 的两套源码仓库
Uniswap V2 主要分成两个仓库:
uniswap-v2-core
uniswap-v2-periphery
名字其实已经把职责说得很清楚了。
core:最核心、最不可替代的部分
core 里最重要的是这几个文件:
UniswapV2Factory.sol
UniswapV2Pair.sol
UniswapV2ERC20.sol
其中:
Factory 负责创建交易对
Pair 负责交易和流动性
UniswapV2ERC20 负责 LP Token 的 ERC20 行为
core 是 Uniswap V2 的心脏。
真正的资产储备、交易公式、手续费逻辑、LP Token 铸造和销毁,都在这里。
periphery:外围辅助合约
periphery 里最重要的是:
UniswapV2Router01.sol
UniswapV2Router02.sol
UniswapV2Library.sol
TransferHelper.sol
它们的定位是“辅助用户更方便地和 core 交互”。
比如用户想用 ETH 买 DAI,底层 Pair 其实不认识原生 ETH,只认识 ERC20。于是 Router 帮你把 ETH 包装成 WETH,再去和 Pair 交互。
所以要记住一个关键点:
Router 很重要,但它不是 Uniswap V2 的核心状态所在。真正保存资产和执行规则的是 Pair。
2. 一次 swap 背后的调用路径
先来看一个用户最熟悉的动作:
用 tokenA 换 tokenB
在真实使用中,用户通常不会直接调用 Pair,而是调用 Router。
大概路径是这样:
User
↓
UniswapV2Router02.swapExactTokensForTokens()
↓
TransferHelper.safeTransferFrom()
↓
tokenA.transferFrom(user, pair)
↓
UniswapV2Pair.swap()
↓
tokenB.transfer(to)
这里有一个非常重要的源码细节:
Pair 的
swap()并不主动从用户那里拉入 token。
也就是说,Pair 不会在 swap() 里调用:
token.transferFrom(user, address(this), amountIn);
而是 Router 先把输入 token 转进 Pair,然后 Pair 在 swap() 里通过余额变化反推用户到底输入了多少。
这也是 Uniswap V2 源码里一个特别经典的设计。
大概是这种思路:
旧 reserve:Pair 认为自己原来有多少 token
当前 balance:Pair 现在实际有多少 token
差值:这次交易输入了多少 token
后面讲 swap() 的时候,我们会看到这段核心代码:
uint amount0In = balance0 > _reserve0 - amount0Out
? balance0 - (_reserve0 - amount0Out)
: 0;
uint amount1In = balance1 > _reserve1 - amount1Out
? balance1 - (_reserve1 - amount1Out)
: 0;
它不是“相信用户传进来的 amountIn”,而是“我自己查余额,自己算”。
DeFi 源码里,这种设计很值得品。
3. Factory:Pair 的工厂和索引表
我们先看 Factory。
Factory 的代码并不复杂,它的核心作用是:
1. 创建 Pair
2. 记录 tokenA-tokenB 对应哪个 Pair
3. 记录所有 Pair
4. 管理协议手续费接收地址 feeTo
5. 管理 feeTo 的设置权限 feeToSetter
你可以把 Factory 想象成一个登记处。
每当有人想创建一个新的交易对,比如:
DAI / USDC
Factory 会检查:
这个交易对是否已经存在?
token 地址是否合法?
token0/token1 应该如何排序?
然后创建 Pair。
Factory 里最核心的状态变量是:
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs;
getPair 是一个双层 mapping。
比如:
getPair[DAI][USDC] = pairAddress;
getPair[USDC][DAI] = pairAddress;
这两个方向都会记录。
这样用户不管用哪个顺序查询,都能找到同一个 Pair。
token0 和 token1 为什么要排序?
Uniswap V2 要求每个 Pair 内部有固定的 token0 和 token1。
假设有两个 token:
DAI = 0x6B...
USDC = 0xA0...
Factory 会按照地址大小排序:
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
这样做有几个好处。
第一,避免重复创建。
如果没有排序,那么:
DAI / USDC
USDC / DAI
可能会被当成两个不同交易对。
但实际上它们应该对应同一个 Pair。
第二,让 Pair 地址可预测。
Uniswap V2 使用 CREATE2 创建 Pair。只要 Factory 地址、token0、token1 和 Pair 的 init code hash 固定,Pair 地址就可以提前算出来。
这就是 UniswapV2Library.pairFor() 能够“不发链上调用”直接算 Pair 地址的原因。
4. Pair:Uniswap V2 真正的核心
如果只能读一个文件,那一定是:
UniswapV2Pair.sol
Pair 是 Uniswap V2 最核心的合约。
它同时承担三个角色:
1. 资金池
2. 交易执行器
3. LP Token 合约
这点非常关键。
很多系统会把这些职责拆成多个合约,但 Uniswap V2 的设计非常紧凑:
contract UniswapV2Pair is IUniswapV2Pair, UniswapV2ERC20
这意味着每一个 Pair 自己就是一个 ERC20。
也就是说:
DAI / USDC Pair 合约本身,就是 DAI-USDC LP Token 合约
WETH / USDC Pair 合约本身,就是 WETH-USDC LP Token 合约
用户添加流动性时,Pair 会给用户 mint LP Token。
用户移除流动性时,用户把 LP Token 转回 Pair,Pair 再 burn 掉这些 LP Token,并返还底层资产。
5. Pair 里最重要的状态变量
Pair 里最基础的几个变量是:
address public factory;
address public token0;
address public token1;
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
逐个看。
factory
address public factory;
记录创建自己的 Factory。
Pair 的构造函数很短:
constructor() public {
factory = msg.sender;
}
这意味着谁创建 Pair,谁就是 factory。
在正常情况下,Pair 只会由 UniswapV2Factory 创建。
token0 和 token1
address public token0;
address public token1;
这两个变量记录该 Pair 对应哪两个 token。
注意,它们不是 constructor 参数里传进来的,而是通过 initialize() 设置:
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN');
token0 = _token0;
token1 = _token1;
}
为什么这样设计?
因为 Factory 使用 CREATE2 创建 Pair 时,Uniswap V2 希望 Pair 的 init code 固定。
如果 constructor 里带不同 token 参数,那么不同交易对的创建参数也会参与地址计算,部署和链下计算都会更复杂。V2 采用的方式是:
先用 CREATE2 创建 Pair
再调用 initialize 设置 token0/token1
并且 initialize() 限制只有 Factory 能调用。
reserve0 和 reserve1
uint112 private reserve0;
uint112 private reserve1;
这是 Pair 记录的储备量。
注意,reserve0/reserve1 不一定永远等于 token 的实时 balanceOf(pair)。
它们代表的是 Pair 上一次同步后的储备状态。
每次 mint()、burn()、swap() 结束时,Pair 会调用 _update() 更新 reserve。
这就是为什么源码里经常会同时出现:
uint112 _reserve0;
uint112 _reserve1;
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
简单说:
reserve 是 Pair 记账用的旧状态
balance 是当前链上真实余额
Uniswap V2 很多逻辑都是围绕这两者的差值展开的。
6. Router:用户友好的外壳
现在看 Router。
Router 最容易被误解成“交易核心”。
但其实 Router 更像一个服务员:
用户说:我想用 100 DAI 换尽可能多的 USDC
Router 说:好的,我帮你算路径、转账、调用 Pair
Router 自己不保存流动性,也不长期保存用户资产。
它主要负责:
1. 计算添加流动性时的合适 token 数量
2. 调用 transferFrom 把 token 转进 Pair
3. 调用 Pair.mint / burn / swap
4. 支持多跳交易
5. 支持 ETH 和 WETH 转换
6. 通过特定函数支持 fee-on-transfer token
V2 里常用的是:
UniswapV2Router02.sol
用户常见操作大多走这里:
addLiquidity()
addLiquidityETH()
removeLiquidity()
removeLiquidityETH()
swapExactTokensForTokens()
swapTokensForExactTokens()
swapExactETHForTokens()
swapExactTokensForETH()
Router02 还额外提供了一组 SupportingFeeOnTransferTokens 结尾的函数,用来兼容转账时会扣费的 token。
Router 的设计思想是:
可以升级和替换,但不应该影响 Pair 里的核心资产和规则。
这也是为什么 periphery 可以有 Router01、Router02,而 core 的 Pair/Factory 才是真正稳定的核心。
7. Library:不存状态,只负责计算
除了 Factory、Pair、Router,还有一个非常重要的库:
UniswapV2Library.sol
它里面有几个关键函数:
sortTokens()
pairFor()
getReserves()
quote()
getAmountOut()
getAmountIn()
getAmountsOut()
getAmountsIn()
这个库不存状态,主要做计算。
比如 getAmountOut() 用来计算:
给定 amountIn、reserveIn、reserveOut,最多能换出多少 token?
核心公式是:
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
这里的 997 / 1000 对应 0.3% 交易手续费。
换句话说:
输入 1000 份 token
只有 997 份参与兑换计算
3 份作为手续费留在池子里
注意这个“留在池子里”很重要。
Uniswap V2 的普通交易手续费不是每笔交易单独转给某个地址,而是直接让池子的资产变多。LP 持有的是池子的份额,所以池子变大,LP 的份额自然更值钱。
如果 Factory 的 feeTo 开启,协议手续费会在后续流动性事件中通过增发 LP Token 的方式提取一部分增长,而不是在每次 swap 时直接转账。这一点后面讲 _mintFee() 时再展开。
这个设计很优雅,省掉了很多复杂记账。
8. 三个用户动作,串起整个系统
从源码学习 Uniswap V2,最好的方式不是按文件顺序,而是按用户行为。
Uniswap V2 最核心的三个动作是:
add liquidity
remove liquidity
swap
分别对应 Pair 里的:
mint()
burn()
swap()
添加流动性:mint
用户添加流动性时,调用路径大概是:
User
↓
Router.addLiquidity()
↓
token0.transferFrom(user, pair)
token1.transferFrom(user, pair)
↓
Pair.mint(to)
Pair 根据新增的 token 数量,给用户 mint LP Token。
移除流动性:burn
用户移除流动性时,调用路径大概是:
User
↓
Router.removeLiquidity()
↓
LP Token transferFrom(user, pair)
↓
Pair.burn(to)
Pair 根据用户交回的 LP Token 比例,返还 token0 和 token1。
交易:swap
用户交易时,调用路径大概是:
User
↓
Router.swapExactTokensForTokens()
↓
输入 token 转到第一个 Pair
↓
Pair.swap()
↓
输出 token 转给用户或下一个 Pair
如果是多跳交易,比如:
DAI → WETH → USDC
Router 会一路调用多个 Pair:
DAI/WETH Pair
↓
WETH/USDC Pair
↓
User
中间资产不会先回到用户手里,而是直接转到下一个 Pair。
这就是为什么 Router 的 _swap() 里会有这段很精妙的逻辑:
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to;
意思是:
如果还没到最后一跳,就把输出 token 发给下一个 Pair
如果已经是最后一跳,就发给用户
这就是多跳交易的核心。
9. 用一张图记住三者关系
可以把 Uniswap V2 理解成这样:
┌────────────────────┐
│ User │
└─────────┬──────────┘
│
│ 调用 add/remove/swap
↓
┌────────────────────┐
│ Router │
│ 用户入口/路径计算 │
└─────────┬──────────┘
│
│ 查询/计算 Pair 地址
↓
┌────────────────────┐
│ Factory │
│ 创建 Pair / 记录表 │
└─────────┬──────────┘
│
│ createPair
↓
┌────────────────────┐
│ Pair │
│ 储备/交易/LP Token │
└────────────────────┘
但实际交易时,Factory 通常不参与每一次 swap。
Factory 更多是在“找 Pair”和“创建 Pair”时用到。
一次普通 swap 更像这样:
User → Router → Pair → User
多跳 swap 更像这样:
User → Router → Pair1 → Pair2 → Pair3 → User
10. 这一篇先建立几个源码直觉
读 Uniswap V2 源码之前,先记住这几个直觉,后面会轻松很多。
第一,Pair 是核心
Router 只是入口,Factory 只是创建器。
真正保存资产、执行 AMM 规则的是 Pair。
第二,Pair 自己就是 LP Token
每个 Pair 继承 UniswapV2ERC20。
所以每个交易对都有自己的 LP Token。
第三,Router 经常先转账,再调用 Pair
比如 swap 和 mint 都是这样。
Pair 很多时候不是主动拉 token,而是检查自己余额变化。
第四,reserve 和 balance 是两套概念
reserve:Pair 上次记录的储备
balance:token 合约里查到的真实余额
Pair 通过它们的差值判断输入、同步状态、更新价格累计。
第五,手续费不是单独记账
0.3% 交易手续费会先留在池子里,体现在池子资产增长中。
LP 持有池子的份额,所以自然获得手续费收益。协议费开启时,协议方会通过 _mintFee() 分享一部分池子增长。
第六,Router 可以替换,Pair 不应随便动
这也是为什么 Uniswap V2 把代码分成:
core
periphery
核心资产逻辑保持极简和稳定,外围功能可以继续扩展。
小结
这一篇我们没有深入每个函数,但已经建立了 Uniswap V2 的源码地图。
可以先把三类合约记成:
Factory:创建 Pair,记录 Pair 地址
Pair:保存资产,执行 mint/burn/swap,发行 LP Token
Router:用户入口,负责转账、路径、多跳、ETH 包装
从下一篇开始,就可以正式进入源码了。
下一篇开始:
UniswapV2Factory.sol
因为 Factory 是所有 Pair 的起点。
我们会重点拆:
function createPair(address tokenA, address tokenB) external returns (address pair)
并把这几个问题讲清楚:
1. token0/token1 为什么要排序?
2. 为什么要防止重复 Pair?
3. CREATE2 怎么创建可预测地址?
4. initialize 为什么不放进 constructor?
5. INIT_CODE_PAIR_HASH 到底有什么用?