Uniswap V2 架构详解:最常被 Fork 的 DeFi 协议
目录
- 一、什么是 AMM(自动做市商)
- 二、AMM 的优势
- 三、AMM 的劣势
- 四、Uniswap V2 的三大核心合约
- 五、核心-外围(Core-Periphery)设计模式
- 六、用 CREATE2 计算池子地址
- 七、为什么不用 EIP-1167 最小代理克隆
- 八、总结
- 九、动手练习项目:MiniSwap 工厂与配对合约骨架
一、什么是 AMM(自动做市商)
1.1 AMM 的基本工作原理
传统交易所(比如币安或纳斯达克)使用”订单簿”撮合买家和卖家:你挂一个买单,等别人挂一个匹配的卖单,交易才能成交。
AMM(Automated Market Maker,自动做市商)完全抛弃了订单簿。它的思路非常简单:
一个智能合约里同时存放两种代币。任何人想取走其中一种代币,只要存入足够多的另一种代币即可——前提是池子里资产的”乘积”不能变小。
也就是说,你不需要等待对手方出现,合约本身就是你的交易对手。交易随时可以发生,价格由数学公式自动决定。
1.2 核心不变量公式
AMM 的灵魂是一条不等式:
x * y ≤ x' * y'
其中:
x、y:交易前池子里两种代币的余额x'、y':交易后池子里两种代币的余额
这条规则的含义是:任何一笔交易之后,两种代币余额的乘积必须不小于交易之前。实际上由于手续费的存在,乘积每次都会略微增大——这正是流动性提供者赚钱的来源。
举个具体例子:池子里有 100 个 ETH 和 100,000 个 USDC,乘积 k = 100 × 100,000 = 10,000,000。如果你想取走 10 个 ETH,你必须存入足够的 USDC,使得交易后 (100 - 10) × (100,000 + Δ) ≥ 10,000,000,解出 Δ ≥ 11,111.11 USDC。也就是说取走 10 个 ETH 至少要付 11,111.11 USDC,而不是按当前”标价” 1 ETH = 1000 USDC 只付 10,000——多出来的部分就是大额交易的价格冲击。
1.3 LP 代币与流动性提供者
池子里的代币是谁存进去的?是流动性提供者(LP, Liquidity Provider)。
- LP 向池子存入两种代币,获得 LP 代币作为凭证,代表自己占整个池子的份额比例。
- 这个机制和 ERC-4626 金库的份额机制非常类似。
- 随着交易不断发生,手续费让乘积
x*y不断变大,LP 手中份额对应的资产价值也随之增长——这就是 LP 的收益来源。
二、AMM 的优势
2.1 没有买卖价差
订单簿市场上买一价和卖一价之间有价差(spread)。AMM 没有这个概念,价格直接由池子里两种资产的比例决定:
price(x) = 池中 y 的数量 / 池中 x 的数量
例子:池子里有 100 ETH 和 200,000 USDC,那么 1 ETH 的报价就是 200,000 / 100 = 2,000 USDC。
注意两点:
- 池子里某种资产越多(越”充裕”),它的价格就越低——完全符合供需规律。
- 这个价格只是边际价格(marginal price),即”交易量趋近于 0 时的价格”。实际成交时,交易量越大,实际均价越差。
2.2 自带价格预言机功能
因为价格是自动算出来的,其他智能合约可以免费读取 AMM 的价格作为预言机数据。
⚠️ 但要特别小心:AMM 的瞬时价格可以被闪电贷操纵。攻击者可以借一大笔钱瞬间把池子价格打偏,欺骗依赖这个价格的协议。所以直接读取现货价格做预言机是危险的,必须配合 TWAP(时间加权平均价格)等防护手段(后续文章会详细讲解)。
2.3 Gas 效率高
订单簿系统需要维护大量的挂单记录、撮合逻辑,链上实现非常昂贵。而 AMM 只需要:
- 持有两种代币
- 按一条简单的数学规则进行转账
状态极少,逻辑极简,所以 Gas 成本低得多。
三、AMM 的劣势
3.1 价格冲击(Price Impact)
在流动性好的传统市场,小额订单几乎不会移动价格。但在 AMM 中,任何一笔交易,无论多小,都会移动价格。这带来了滑点问题,也为下面的三明治攻击创造了条件。
3.2 三明治攻击
因为 AMM 价格变化是完全可预测的,攻击者可以利用这一点夹击受害者的交易:
- 抢跑买入(front run):攻击者看到受害者的买单还在内存池中,抢先买入,推高价格;
- 受害者买入:受害者以更差的价格成交,价格被进一步推高;
- 攻击者卖出(back run):攻击者在高位卖出第一步买的代币,获利离场。
受害者的交易就像三明治中间的那片肉,被攻击者的两笔交易夹在中间,故称”三明治攻击”。
3.3 LP 无法自主定价
订单簿市场中做市商可以自由选择挂单价格。AMM 的 LP 做不到这一点——他们必须严格按照池子当前的代币比例注入资产。
例子:池子里现在有 100 个代币 X 和 200 个代币 Y,新的 LP 注入流动性时,提供的 Y 数量必须是 X 数量的两倍(比如 10 个 X 配 20 个 Y),没有任何议价空间。
3.4 无常损失(详细数值案例)
无常损失(Impermanent Loss)是 LP 面临的最重要风险。我们用文章中的例子一步步算:
初始状态:你持有 1 个 ETH(当时价格 $10)和 10 个 USD 稳定币。
情景 A:什么都不做,分开拿着
- ETH 涨到 $1,000
- 最终价值 = 1 ETH × $1,000 + $10 = $1,010
情景 B:把这两笔资产存入 AMM 池子做 LP
- 初始池子(假设你是唯一 LP):1 ETH × 10 USD,乘积 k = 10
- ETH 价格涨到 $1,000 后,套利者会不断和池子交易,直到池内比例反映市场价。
- 根据恒定乘积,新状态必须满足
x * y = 10且y / x = 1000(池内价格 = 市场价) - 解方程:x = 0.1 ETH,y = 100 USD(验证:0.1 × 100 = 10 ✓,100 / 0.1 = 1000 ✓)
- 最终价值 = 0.1 ETH × $1,000 + $100 = $200
对比结果:
| 策略 | 最终价值 | 相对初始($20)的收益 |
|---|---|---|
| 分开持有 | $1,010 | +$990 |
| 存入 AMM | $200 | +$180 |
做 LP 比单纯持币少赚了 $810——这就是无常损失。直观理解:价格上涨过程中,池子不断把你升值的 ETH 卖出换成稳定币(被套利者买走),你享受不到全部涨幅。
注意:两种情况你都是赚钱的,但 LP 赚得少。只有当手续费收入超过无常损失时,做 LP 才划算。
四、Uniswap V2 的三大核心合约
4.1 工厂合约(Factory)
UniswapV2Factory.sol,职责只有一个:无许可地(permissionless)创建新的交易对合约。
任何人都可以调用工厂为任意两个 ERC-20 代币创建交易池,不需要任何人批准。每对代币只能创建一个池子。
4.2 配对合约(Pair)
UniswapV2Pair.sol,是整个协议的心脏:
- 持有两种 ERC-20 代币的余额;
- 自身也是一个 ERC-20 代币——它继承了 ERC-20,把 LP 份额直接做成代币发给流动性提供者。这是个非常优雅的设计:LP 份额可以转账、可以再质押、可以组合进其他协议。
4.3 路由合约(Router)
UniswapV2Router02.sol 等,位于 periphery(外围)仓库。它不持有资产、不包含核心逻辑,只提供方便交易者的封装函数,比如:
- 多跳路径交换(A → B → C)
- 自动处理 ETH/WETH 包装
- 滑点保护参数(amountOutMin 等)
- 添加/移除流动性的便捷入口
五、核心-外围(Core-Periphery)设计模式
Uniswap V2 把代码分成两个仓库:
- core(核心):Factory + Pair,包含资金和最关键的业务逻辑,代码量尽可能少;
- periphery(外围):Router 等便利合约,不持有资金,可以随时升级替换(事实上 Router01 有 bug 后直接发布了 Router02)。
这种模式的好处:资金所在的核心代码越少,出 bug 的概率越低;外围代码即使有问题也不会直接危及池子里的资产。这是值得所有 DeFi 开发者学习的安全设计哲学。
六、用 CREATE2 计算池子地址
常规做法是在工厂合约里维护一个 mapping:token0 => token1 => pair 地址,但每次查询都要读存储,很费 Gas(一次冷读取 2,100 gas)。
Uniswap V2 的做法更巧妙:用 CREATE2 部署池子,于是池子地址可以离线纯计算出来,根本不需要读存储:
function pairFor(address factory, address tokenA, address tokenB)
internal pure returns (address pair) {
(address token0, address token1) = sortTokens(tokenA, tokenB);
pair = address(uint(keccak256(abi.encodePacked(
hex'ff',
factory, // 部署者(工厂)地址
keccak256(abi.encodePacked(token0, token1)), // salt:两个代币地址排序后哈希
hex'96e8ac4277...init code hash...' // Pair 合约 initcode 的哈希
))));
}
分步解释 CREATE2 地址公式:
hex'ff'是 CREATE2 的固定前缀,用于和 CREATE 区分;factory是部署者地址;- salt 由两个代币地址(先排序,保证 A/B 和 B/A 得到同一个池子)打包哈希得到;
- 最后一项是 Pair 合约创建字节码(initcode)的 keccak256 哈希——它是一个写死的常量。
四项拼接后再做一次 keccak256,截取低 20 字节就是池子地址。注意这是一个 pure 函数——完全不访问链上状态,零存储读取成本。
七、为什么不用 EIP-1167 最小代理克隆
有人会问:每个池子都部署一份完整的 Pair 合约字节码,部署成本很高,为什么不用 EIP-1167 克隆(最小代理)来省部署费?
答案是权衡使用频率:
- 克隆确实能大幅降低一次性的部署成本;
- 但克隆通过
delegatecall转发每一次调用,每笔交易都要多付约 2,600 gas; - 交易池是高频使用的合约,一个活跃池子每天可能有成千上万笔交易;
- 几百笔交易之后,省下的部署费就被 delegatecall 的开销吃光了。
结论:低频部署、高频使用的合约,应该直接部署完整字节码。这是一个经典的 Gas 优化决策案例。
八、总结
- AMM 用
x * y ≤ x' * y'这一条不等式替代了整个订单簿系统; - 优势:无价差、可做预言机、Gas 便宜;劣势:价格冲击、三明治攻击、LP 不能定价、无常损失;
- Uniswap V2 = Factory(造池子)+ Pair(存钱+记账+交换)+ Router(用户友好入口);
- 核心-外围分离、CREATE2 计算地址、不用克隆模式,都是教科书级别的工程决策。
九、动手练习项目:MiniSwap 工厂与配对合约骨架
本篇是架构篇,练习项目聚焦于复刻 Uniswap V2 的架构骨架(不含完整 swap 数学,后续文章的练习会逐步补全),并部署到 Sepolia 测试网验证。
项目目标
实现一个 MiniFactory + MiniPair 双合约系统,体验:工厂模式、CREATE2 确定性地址、LP 份额代币化、核心-外围分离思想。
合约要求
1. MiniPair.sol
- 继承 OpenZeppelin 的
ERC20(名称 “MiniSwap LP”,符号 “MLP”)——池子本身就是 LP 代币; - 状态变量:
address public token0、address public token1、address public factory; - 构造函数中记录
factory = msg.sender; initialize(address _token0, address _token1)函数:只允许 factory 调用(用自定义错误OnlyFactory()),写入两个代币地址——之所以不放在构造函数里,是为了让 initcode 不含参数、保持 init code hash 恒定(CREATE2 的关键);getReserves()view 函数:返回两个代币在本合约的余额(直接用IERC20(token0).balanceOf(address(this)));- 一个最简化的
addLiquidity(uint amount0, uint amount1):用transferFrom把两种代币拉进来,第一次注入时铸造amount0 + amount1个 LP 代币给调用者(简化处理,真实的 sqrt 公式留给后面 mint/burn 篇的练习)。
2. MiniFactory.sol
mapping(address => mapping(address => address)) public getPair;address[] public allPairs;createPair(address tokenA, address tokenB) returns (address pair):- 校验
tokenA != tokenB(自定义错误IdenticalAddresses())、地址非零、池子未创建过(PairExists()); - 对两个地址排序得到 token0 < token1;
- 用 CREATE2 部署:
salt = keccak256(abi.encodePacked(token0, token1)),Solidity 写法new MiniPair{salt: salt}(); - 调用
pair.initialize(token0, token1); - 双向写入
getPair[token0][token1]和getPair[token1][token0],push 进allPairs,发出事件PairCreated(token0, token1, pair, allPairs.length);
- 校验
pairFor(address tokenA, address tokenB) public view returns (address):不读 mapping,纯用 CREATE2 公式计算地址(init code hash 可用keccak256(type(MiniPair).creationCode)获得),然后写一个测试断言它和getPair里存的地址一致——这是本练习的核心验证点。
3. 两个测试代币 TokenA.sol / TokenB.sol:最简 ERC20,构造时给部署者 mint 1,000,000 枚。
测试要求(Foundry)
test_CreatePair:创建池子后getPair双向都能查到;test_Create2AddressMatches:pairFor计算出的地址 ==createPair实际部署的地址(CREATE2 验证);test_RevertWhen_PairExists:重复创建同一对(包括反向顺序 B/A)应 revert;test_AddLiquidity:注入 100 A + 200 B 后,getReserves返回正确,LP 余额为 300。
Sepolia 部署步骤
- 写
Deploy.s.sol:依次部署 TokenA、TokenB、MiniFactory,然后调用createPair; forge script script/Deploy.s.sol --rpc-url $SEPOLIA_RPC --broadcast --verify;- 在 Etherscan 上验证合约后,手动调用
pairFor读函数,确认返回地址和事件里的 pair 地址一致; - approve 两个代币给 Pair,调用
addLiquidity,在 Etherscan 上查看自己收到的 MLP 代币余额。
进阶挑战(可选)
- 把
pairFor的 init code hash 做成immutable,在工厂构造函数里计算一次; - 给 Pair 加一个
skim(address to)函数(把超出账面储备的多余代币转给 to),体会 Uniswap 真实代码里这个函数的用途。
完成后你将拥有一个部署在 Sepolia、地址可离线计算的迷你 DEX 骨架——后续每篇文章的练习都会在它上面继续生长。