从零开始逐行实现 Uniswap V2(含 207 个测试)

zealynx 发布于 2026-04-25 阅读 273

文章围绕“先亲手重建再去 fork”这一观点,深入拆解了 Uniswap V2 的核心架构、常量乘积定价、LP 代币铸造销毁、闪电兑换、TWAP 预言机以及费转税代币/重入等安全细节,并结合 8 个模块和 207 个测试说明如何通过实战理解协议实现与常见漏洞。整体强调:只有真正写过代码、跑过测试,才能看懂 fork 中哪些改动安全、哪些会埋雷。

理解你想要 fork 的协议,最好的方式就是先亲手把它重建一遍。

TL;DR — 快速总结

  • 从零重建 Uniswap V2,是理解一个 fork 会继承什么——好的、坏的,以及那些重要但微妙的设计决策——最快的方法。
  • 这个协议比很多人想象中更小:一个 Factory 合约、一个 Pair 合约(也就是 AMM 本身)、一个 Router,以及一个 Library。全部就这些。
  • 核心数学是 constant product formulax * y = k,每次 swap 都会叠加 0.3% 的手续费,从而让不变量持续上升。
  • 你将实现:reserves 跟踪、LP tokens 的 mint 和 burn、带 K-invariant 检查的 swap 函数、通过 callback 实现的 flash swap、TWAP 价格累加器,以及针对 fee-on-transfer 和 rebasing tokens 的完整安全防护。
  • Zealynx Academy 的 Uniswap V2 模块 会通过 8 个部分、207 个自动化测试带你完成整个构建过程。直到每个测试都通过,你才算真正完成。
  • 最终,你会清楚地理解 fork 到底复制了什么——这才是重点。

为什么在 fork 之前要先从零构建

Uniswap V2 的代码库已经被 fork 了成千上万次。大多数 fork 都带着原团队花了多年才加固掉的 bug。整数溢出保护被删掉。重入保护半失效。破坏不变量的手续费机制。缺失 flash-swap callback。fee-on-transfer token 处理不当,悄悄污染 reserves。

如果你亲手构建过这个协议,这些 bug 就一点也不隐蔽了。它们会变得非常明显,因为你记得自己写过那个本该拦住它们的函数。

真正理解 Uniswap V2——读一个 fork 的 diff,就立刻知道什么是安全的、什么是危险的、什么只是风格差异——最快的方法,就是先从零重建一遍。不是 copy-paste。不是一边看别人打字一边“跟着做”。而是自己把每个函数都写出来,直到测试全部通过。

这正是 Zealynx Academy 的 Uniswap V2 模块 带你完成的事情。


Uniswap V2 架构:真正重要的四个合约

在写任何代码之前,先理解你在构建什么。Uniswap V2 由四个合约加一个小型 library 组成:

UniswapV2Factory

一个工厂,用来部署新的 Pair 合约,每种 token pair 对应一个。Factory 除了已部署 pair 的 registry,以及用于可选协议费用的 feeTo 地址之外,不持有其他状态。当有人调用 createPair(tokenA, tokenB) 时,Factory 会使用 CREATE2 和确定性的 salt 部署一个新的 Pair 合约,因此 pair 的地址可以在任何地方提前计算出来,而不需要去查询 Factory。

UniswapV2Pair(AMM)

这才是真正的 AMM。每个 Pair 在自己的 reserves 中持有两种 ERC-20 token,并允许用户按照 constant product formula 进行 swap。它还会跟踪按时间加权的累计价格(用于 TWAP 预言机)、为流动性提供者 mint 和 burn LP tokens,并通过 callback 模式实现 flash swap。

Pair 合约继承自 UniswapV2ERC20,也就是 LP token 的逻辑——一个标准 ERC-20,外加一个用于免 gas 授权的 permit() 函数(EIP-2612 风格)。

UniswapV2Router

Router 是一个方便使用的封装层。它不保存状态。它位于 Pair 合约前面,对外提供更简单的接口:addLiquidityremoveLiquidityswapExactTokensForTokensswapTokensForExactTokens,以及 ETH 版本。底层上,Router 会计算 amounts,调用 Pair 的 swap()mint()burn() 函数,并强制执行滑点截止时间。

用户与 Router 交互。Pairs 和 Factory 则是更底层的 plumbing。

UniswapV2Library

一个纯 helper library,没有状态。它提供数学计算:把 amount-in 转换成 swap 的 amount-out、计算最优流动性添加量、排序 token 地址。Router 会大量调用它。

这就是整个协议。阅读 原始 Uniswap V2 白皮书,你会看到同样的架构。


核心数学:带手续费变化的 Constant Product

Uniswap V2 的核心是一条公式:x * y = k

x 是 token A 的 reserve。y 是 token B 的 reserve。k 是池子在每次 swap 中都必须保持的不变量。

swap 在概念上如何运作

swap 前:池子里有 100 DAI 和 1 ETH。k = 100 * 1 = 100

用户想用 DAI 换 ETH。他们向池子转入 10 DAI。新的 DAI balance = 110。

为了让 k 仍然等于 100,新的 ETH balance 必须是 100 / 110 = 0.909。因此,用户会得到 1 - 0.909 = 0.091 ETH

0.3% 手续费:实际上合约收到了 10 DAI,但只有 10 * 0.997 = 9.97 DAI 会在 k 的计算中被视为输入。剩下的 0.03 DAI 留在池子里作为手续费,使 k 在每次 swap 时都增加。

因此,随着时间推移,k 会持续上升。每次 swap 都会让池子稍微更富一点。这就是 LP 赚取手续费的方式——随着 k 增长,他们手中 LP tokens 的价值也会随之增长。

这对你的构建为什么重要

错误实现这部分数学,是 fork 被掏空的头号原因。具体陷阱包括:

  • 忘记在 k 检查中考虑手续费。有些 fork 在分子里正确地应用了手续费,但在重新检查不变量时忘了调整。结果就是:不变量可能下降,套利者会把池子掏空。
  • 在错误方向上做整数舍入。Solidity 的整数除法会向零截断。在 swap 中,输出金额必须始终向下舍入(偏向协议)以避免价值泄漏。这个方向错了会让 LP 亏损;另一个方向错了则会导致池子可被掏空。
  • balancereserve 的使用上弄错。balance 是 Pair 中该 token 当前的 ERC-20 balance。reserve 是上一次 sync 时保存的状态。只要有人不通过函数直接把 token 发送到 Pair,它们就会不同步(这种情况很常见——这正是 flash swap 和 optimistic transfers 的工作方式)。

对照 Zealynx Academy 的 8 个部分

Uniswap V2 模块 把构建过程拆成 8 个部分。下面是每一部分的内容,以及你在结束时会学到什么。

第 1 部分:介绍 + 脚手架

你从 starter code 开始:imports、storage 布局、函数签名都有,但函数体都是 TODO。你的任务是在写任何逻辑之前先理解结构。

你会学到:整体架构、每个合约为什么存在,以及它们如何协同工作。

第 2 部分:Library 数学

实现 UniswapV2Libraryquote()getAmountOut()getAmountIn()sortTokens(),以及最优添加流动性的数学。

它的重要性在于:这里的数学会出现在 Pair 的 swap 函数内部。先把这些公式写对,可以避免后面调试 swap。

常见陷阱:getAmountOut 必须使用 amountInWithFee = amountIn * 997,并除以 reserveIn * 1000 + amountInWithFee。任何地方漏掉手续费,池子都会被掏空。

第 3 部分:ERC-20 Pair Token

实现 UniswapV2ERC20——也就是 Pair 继承的 LP token 逻辑。标准 ERC-20 加上用于 EIP-2612 免 gas 授权的 permit(),这需要 EIP-712 domain separator 和 nonce 跟踪。

常见陷阱:permit 签名必须与精确的 EIP-712 typed-data hash 匹配。type string 只差一个字符,所有 permit 都会静默失败。

第 4 部分:Reserves 和 Sync

Pair 会在单个 storage slot 中(packed)跟踪 reserve0reserve1blockTimestampLast,以节省 gas。你需要实现 _update(),在每次操作后同步这些值,以及 sync(),让任何人都能强制刷新 reserve。

你会学到:Uniswap 如何用 uint112 存 reserves、用 uint32 存时间戳,把三个值塞进一个 storage slot。这就是为什么该协议的 reserves 上限是 2^112

第 5 部分:Minting 和 Burning LP Tokens

实现 mint()(添加流动性,接收 LP tokens)和 burn()(销毁 LP tokens,按比例取回 token)。

常见陷阱:第一个流动性提供者会受到特殊处理——他们设定初始价格比,并获得 sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY 个 LP tokens。MINIMUM_LIQUIDITY(通常是 1000 wei)会被永久锁定,以防止首个存入者的通胀攻击。忘掉这一点,你就已经引入了一个已知漏洞。

第 6 部分:Swap 函数

这是协议的核心。你需要实现 swap(amount0Out, amount1Out, to, data)

  1. 验证 amounts 为正且低于 reserves。
  2. 乐观地把 token 转给接收者。
  3. 如果 data.length > 0,在接收者上触发一个 flash swap callback
  4. 读取 balances(不是 reserves——是 balances,因为有人可能已经把 token 直接转进来了)。
  5. 计算手续费调整后的 balances。
  6. 检查 K-invariant:balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000^2
  7. 通过 _update() 更新 reserves。

常见陷阱:K-invariant 检查右侧使用 * 1000^2,以匹配左侧经过手续费调整后的数值。把指数弄错,这个检查就会变得极易被绕过。

第 7 部分:TWAP 价格累加器

每次 _update() 运行时,它都会把 price0CumulativeLastprice1CumulativeLast 按当前价格乘以经过的时间来推进。外部预言机会在两个时间戳读取这些累计值,并用差值来得到 TWAP。

你会学到:Uniswap V2 的预言机不是 push-based feed,而是一个 pull-based accumulator,任何合约都可以查询。这种模式后来出现在很多协议中。

第 8 部分:最终构建 + 测试套件

到第 8 部分时,你已经拥有整个协议。你运行完整测试套件。207 个测试。它们会检查:

  • 每次 swap 后 K-invariant 是否保持
  • 重入保护是否已启用
  • flash-swap callback 是否被正确触发
  • mint 和 burn 之后 LP token 记账是否按比例正确
  • fee-on-transfer tokens 是否不会污染 reserves
  • sync 是否能从直接转账的 token 捐赠中恢复
  • permit 签名是否针对 EIP-712 domain 验证通过

直到 207 个全部通过,你才算完成。没有部分得分。


207 个测试究竟在测试什么

测试套件不是走过场。每个测试都对应一个在多年 Uniswap V2 fork 中被利用过的真实 bug 类别。下面是一些检查项的样例:

数学正确性

1function test_swap_preserves_k_invariant() public {
2    uint256 kBefore = reserve0 * reserve1;
3    pair.swap(0, 1 ether, bob, "");
4    uint256 kAfter = reserve0 * reserve1;
5    assertGe(kAfter, kBefore); // K 只能增长
6}

重入

1function test_swap_blocks_reentrancy() public {
2    // 恶意 token 在 transfer 期间回调进入 swap
3    // 预期 revert
4}

flash-swap callback 调用

1function test_flashswap_triggers_callback() public {
2    pair.swap(1 ether, 0, address(callback), "trigger");
3    // 预期 callback.uniswapV2Call 被以正确参数调用
4}

首个存款者攻击

1function test_firstDepositor_cannot_manipulate_ratio() public {
2    // 尝试先存入 1 wei 并设置不公平比率应该失败
3    // 因为 MINIMUM_LIQUIDITY 被锁定
4}

fee-on-transfer token 处理

1function test_swap_handles_fee_on_transfer_tokens() public {
2    // 会在 transfer 时扣费的 token 不应破坏 K
3}

如果你没有处理这些情况中的任何一种,测试就会失败,并准确告诉你哪个不变量被破坏了。你不需要单独的调试器——测试本身就会引导你修复问题。


构建时的常见陷阱

我们在 Academy 的 Uniswap V2 模块前三位完成者身上看到的典型模式:

swap() 上缺少 reentrancy lock。swap 函数会触发 callback,这意味着攻击者可以在 swap 完成前回调进入 Pair。没有 lock modifier,攻击者就能把池子掏空。测试套件会抓住这个问题,但前提是你记得这个 modifier 的存在。

getAmountOut 中手续费算错。公式是 (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997)。分子里漏掉 997,池子就会在每次 swap 中泄露价值。

忘记销毁 MINIMUM_LIQUIDITY。第一个流动性提供者必须获得 sqrt(k) - 1000 个 LP tokens,其中 1000 个 LP tokens 会被销毁(发送到 address 0)。跳过这一步,首个存入者通胀攻击就会生效。

错误地打包 reserves。uint112 的 reserve0、uint112 的 reserve1,再加上 uint32 的 blockTimestampLast,必须总共正好 256 位。改用 uint256 会让每次 swap 的 gas 成本翻倍,并失去与依赖该 packed slot 的下游协议的兼容性。

在错误的位置使用 balance 而不是 reserve(或反过来)。swap 函数使用 balance 来检测直接转账。K-invariant 检查左侧使用 balance(不是 reserve),右侧使用 reserve。把它们混淆,检查要么总是通过,要么总是失败。

这些问题每一个都会被特定测试捕捉到。测试失败会准确告诉你哪里出错了。这就是 test-driven 方法有效的原因——你不需要在开始前就是 Uniswap 专家。你只需要关注哪里失败了。


完成之后你会理解什么

当所有 207 个测试都通过时,你可以回答这样的问题:

  • 为什么 Uniswap V2 用 uint112 存 reserves,而不是 uint256?(Packing + uint224 用于累计价格数学。)
  • 如果有人不调用函数,而是直接把 token 转到 Pair,会发生什么?(额外的 balance 可以通过调用 sync() 被吸收,或在下一次 swap 的 K 检查中被利用。)
  • 为什么 flash-swap callback 要在 balance 检查之前触发?(这样借款人就可以先使用这些 token,再把它们放回去,并在同一笔交易中原子性地完成 swap。)
  • TWAP 预言机如何防止短期操纵?(通过使用跨时间区间的累加器,短暂的价格尖峰会被平均掉。)
  • 为什么第一个流动性提供者要锁定 MINIMUM_LIQUIDITY?(为了防止首个存入者捐出极少量 token 来操纵价格比,并窃取后续存款。)

这就是一个真正理解协议的 builder 会说的话。不是读过白皮书的人,而是写过代码、失败过测试、理解过原因,并交付过可运行版本的人。


从哪里开始

打开 Zealynx Academy 并开始 Uniswap V2 模块。第一个部分会直接在浏览器中加载——无需设置,无需 clone 命令,也无需 forge install。测试套件会在浏览器中针对你的代码运行。编译由环境处理。

按自己的节奏推进。大多数 builder 会在大约一周的专注学习中完成全部 8 个部分。如果你卡住了,github.com/ZealynxSecurity/zealynx-academy-feedback 这个反馈仓库就是你可以提问的地方。

当测试通过时,你就在浏览器里拥有了一个完整的 Uniswap V2,并且端到端都理解了。你已经准备好认真思考 fork 会改什么、会保留什么,以及哪些边界情况对你的设计很重要。

完成构建后,下一步自然是进入 Shadow Arena,回顾一个真实的、过去的 Uniswap V2 fork 审计竞赛——Basin、ElasticSwap 或 Velodrome。现在你已经构建过原版,fork 的偏差就会变得很明显。那才是最深层的学习发生的地方。


Zealynx Academy 是 Ethereum Security QF Round 的一部分

给关心 Ethereum 公益资金支持的读者补充说明:Zealynx Academy 已被纳入由 TheDAO Security Fund 的 500 ETH matching pool 支持的 Giveth Ethereum Security QF round。该轮次运行时间为 2026 年 4 月 21 日至 5 月 12 日。相比少数大额捐赠,来自许多支持者的小额捐赠能解锁更多 matching。如果你认同 Academy 让 Web3 builder 真正理解他们所交付协议这一使命,那么在轮次结束前捐赠 5 美元,也会产生显著的放大效应。详情和捐赠指南在这里


结论

fork Uniswap V2 只是一行操作。理解它,则需要真正把它构建出来。Zealynx Academy 模块中的 8 个部分,旨在用我们所知最快的方式帮你获得这种理解:带引导的 starter code、逐行编写,以及 207 个测试,精确指出你哪里写错了。

完成这个模块后,fork 就会变成一种有意为之的行为。你知道自己在复制什么。你知道自己想改什么。你知道该避免哪些 bug。这就是会交付 forks 的 builder 与会交付 protocols 的 builder 之间的分水岭。

开始构建:academy.zealynx.io/modules/uniswap-v2

公告与完整平台:Zealynx Academy Is Public

支持 QF round:giveth.io/project/zealynx-academy


FAQ

1. 开始 Uniswap V2 模块需要 Solidity 经验吗?

需要,具备基础 Solidity 就够了。你应该理解 functions、mappings、events,以及 storage 如何工作。你不需要 inline assembly 或 gas-optimization 技巧之类的高级技能——这些都会在构建过程中学到。如果你完全是 Solidity 新手,先从 CryptoZombiesCyfrin Updraft 开始,然后再回来。

2. 完成全部 8 个部分要多久?

大多数 builder 会在专注的一周内完成,每个部分花 2-3 小时。第 6 部分(swap 函数)和第 3 部分(ERC-20 + permit)最耗时。第 1、2、4 部分会更快一些。学院私有阶段中最先完成完整模块的前三位 builder,每个人都花了 5-10 天,按自己的节奏完成。

3. 如果某个测试失败了,而我不知道原因怎么办?

测试名称会说明它们在检查什么,revert message 通常会告诉你哪个不变量被破坏了。如果你真的卡住了,可以在 feedback repo 开 issue,附上部分名称、测试名称以及你当前的代码。团队通常会在一天内回复。

4. 这和从 GitHub fork 一样吗?

不一样。fork 会复制最终代码,但不会迫使你思考。Academy 模块会给你 starter 脚手架,但把每个函数体都留空。你自己写逻辑。你遇到 bug。你修复它们。到最后,你拥有的是和 fork 一样的代码,但你也理解了每一行。

5. 这对 Uniswap V3 也适用吗?

Uniswap V3 模块已在路线图上。V3 复杂得多——concentrated liquidity、tick math、hooks——所以 V3 模块会更长,并建立在 V2 基础之上。先做 V2。这是清晰理解 V3 的前提。

6. 完成 Uniswap V2 模块后我该做什么?

进入 Shadow Arena。Basin、ElasticSwap 和 Velodrome 这些目标都是 Uniswap V2 家族的 forks。现在你已经构建过原版,就可以对比 fork 的 diff,准确找出改动了什么,以及为什么重要。这就是安全模式识别开始形成的地方。

7. 我需要运行本地开发环境吗?

不需要。模块在浏览器中运行。编译、测试和反馈都在平台内完成。你不需要安装 Foundry、Hardhat 或其他任何东西。如果你想把代码导出并在自己的环境中运行,也支持——但这不是必须的。


术语表

术语 定义
Automated Market Maker 一种去中心化交易所设计,使用数学公式而不是订单簿来定价交易。Uniswap V2 是经典的 constant-product AMM。
Constant Product Formula Uniswap V2 和许多 AMM 使用的核心定价方程 x * y = k。它确保每笔交易中 token reserves 的乘积保持不变,仅根据手续费进行调整。
Liquidity Pool 一个持有两种 token、可相互兑换的智能合约。在 Uniswap V2 中,每个 Pair 合约都是一个 liquidity pool。
LP Token 代表某个 liquidity pool 份额的 token。在你添加流动性时 mint,在移除流动性时 burn。随着池子赚取手续费而升值。
Flash Swap Uniswap V2 的一个特性:Pair 会在检查 K-invariant 之前先把 token 发送给接收者,只要接收者在同一笔交易中归还,它们就可以临时使用这些 token。
K-Invariant Uniswap V2 在每次 swap 中都要保持的核心不变量 reserve0 * reserve1 >= k。违反它会让攻击者掏空池子。
Uniswap V2 Uniswap AMM 协议的第二个版本,于 2020 年发布。历史上被 fork 次数最多的 DeFi 合约。

查看完整术语表 →

  • 原文链接: zealynx.io/blogs/how-to-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论