Uniswap V2 架构详解:最常被 Fork 的 DeFi 协议

深入理解 AMM 自动做市商原理与 Uniswap V2 的工厂、配对、路由三大合约架构设计

12 分钟阅读
Uniswap V2 架构详解:最常被 Fork 的 DeFi 协议

Uniswap V2 架构详解:最常被 Fork 的 DeFi 协议

目录


一、什么是 AMM(自动做市商)

1.1 AMM 的基本工作原理

传统交易所(比如币安或纳斯达克)使用”订单簿”撮合买家和卖家:你挂一个买单,等别人挂一个匹配的卖单,交易才能成交。

AMM(Automated Market Maker,自动做市商)完全抛弃了订单簿。它的思路非常简单:

一个智能合约里同时存放两种代币。任何人想取走其中一种代币,只要存入足够多的另一种代币即可——前提是池子里资产的”乘积”不能变小。

也就是说,你不需要等待对手方出现,合约本身就是你的交易对手。交易随时可以发生,价格由数学公式自动决定。

1.2 核心不变量公式

AMM 的灵魂是一条不等式:

x * y ≤ x' * y'

其中:

  • xy:交易池子里两种代币的余额
  • 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。

注意两点:

  1. 池子里某种资产越多(越”充裕”),它的价格就越低——完全符合供需规律。
  2. 这个价格只是边际价格(marginal price),即”交易量趋近于 0 时的价格”。实际成交时,交易量越大,实际均价越差。

2.2 自带价格预言机功能

因为价格是自动算出来的,其他智能合约可以免费读取 AMM 的价格作为预言机数据。

⚠️ 但要特别小心:AMM 的瞬时价格可以被闪电贷操纵。攻击者可以借一大笔钱瞬间把池子价格打偏,欺骗依赖这个价格的协议。所以直接读取现货价格做预言机是危险的,必须配合 TWAP(时间加权平均价格)等防护手段(后续文章会详细讲解)。

2.3 Gas 效率高

订单簿系统需要维护大量的挂单记录、撮合逻辑,链上实现非常昂贵。而 AMM 只需要:

  • 持有两种代币
  • 按一条简单的数学规则进行转账

状态极少,逻辑极简,所以 Gas 成本低得多。


三、AMM 的劣势

3.1 价格冲击(Price Impact)

在流动性好的传统市场,小额订单几乎不会移动价格。但在 AMM 中,任何一笔交易,无论多小,都会移动价格。这带来了滑点问题,也为下面的三明治攻击创造了条件。

3.2 三明治攻击

因为 AMM 价格变化是完全可预测的,攻击者可以利用这一点夹击受害者的交易:

  1. 抢跑买入(front run):攻击者看到受害者的买单还在内存池中,抢先买入,推高价格;
  2. 受害者买入:受害者以更差的价格成交,价格被进一步推高;
  3. 攻击者卖出(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 = 10y / 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 地址公式:

  1. hex'ff' 是 CREATE2 的固定前缀,用于和 CREATE 区分;
  2. factory 是部署者地址;
  3. salt 由两个代币地址(先排序,保证 A/B 和 B/A 得到同一个池子)打包哈希得到;
  4. 最后一项是 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 token0address public token1address 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)

  1. test_CreatePair:创建池子后 getPair 双向都能查到;
  2. test_Create2AddressMatchespairFor 计算出的地址 == createPair 实际部署的地址(CREATE2 验证);
  3. test_RevertWhen_PairExists:重复创建同一对(包括反向顺序 B/A)应 revert;
  4. test_AddLiquidity:注入 100 A + 200 B 后,getReserves 返回正确,LP 余额为 300。

Sepolia 部署步骤

  1. Deploy.s.sol:依次部署 TokenA、TokenB、MiniFactory,然后调用 createPair
  2. forge script script/Deploy.s.sol --rpc-url $SEPOLIA_RPC --broadcast --verify
  3. 在 Etherscan 上验证合约后,手动调用 pairFor 读函数,确认返回地址和事件里的 pair 地址一致;
  4. approve 两个代币给 Pair,调用 addLiquidity,在 Etherscan 上查看自己收到的 MLP 代币余额。

进阶挑战(可选)

  • pairFor 的 init code hash 做成 immutable,在工厂构造函数里计算一次;
  • 给 Pair 加一个 skim(address to) 函数(把超出账面储备的多余代币转给 to),体会 Uniswap 真实代码里这个函数的用途。

完成后你将拥有一个部署在 Sepolia、地址可离线计算的迷你 DEX 骨架——后续每篇文章的练习都会在它上面继续生长。

💬 评论