Uniswap V2 第 2 章:兑换(Swap)
这一章把”换多少”算到底:推导含 0.3% 手续费的
getAmountOut公式、它的反函数getAmountIn、多跳路由怎么连起来,以及底层pair.swap如何用一行不等式守住x·y=k。最后做两个真实交易练习。
目录
- 1. 两类兑换:定输入 vs 定输出
- 2. getAmountOut:定输入的精确公式
- 3. 0.3% 手续费藏在哪
- 4. 案例:1 WETH 换 DAI 算到底
- 5. getAmountIn:定输出的反函数
- 6. 多跳路由:getAmountsOut / getAmountsIn
- 7. pair.swap:底层如何守住 k
- 8. 现货价与滑点
- 9. 本章小结
- 10. 动手练习
1. 两类兑换:定输入 vs 定输出
| 函数 | 你固定的是 | 你想优化的是 | 对应 Router 方法 |
|---|---|---|---|
| 定输入 | 卖出量 amountIn | 尽量多换 | swapExactTokensForTokens |
| 定输出 | 想买到的 amountOut | 尽量少花 | swapTokensForExactTokens |
两者背后是一对互为反函数的公式:getAmountOut(已知输入求输出)和 getAmountIn(已知输出求输入)。
2. getAmountOut:定输入的精确公式
设池子储备 reserveIn(输入币)、reserveOut(输出币),你投入 amountIn。不含手续费时,由 x·y=k:
amountOut = reserveOut − k/(reserveIn + amountIn)
= reserveOut · amountIn / (reserveIn + amountIn)
含 0.3% 手续费的真实公式(Uniswap Library 里的 getAmountOut):
amountInWithFee = amountIn · 997
amountOut = (amountInWithFee · reserveOut) / (reserveIn · 1000 + amountInWithFee)
把分子分母都看一眼:相当于把 amountIn 先打 99.7 折(扣掉 0.3% 手续费),再按恒定乘积算输出。
3. 0.3% 手续费藏在哪
Uniswap V2 的每笔 swap 收 0.3% 手续费,收在输入币上。它不是单独转走,而是留在池子里:
- 你投入的
amountIn,只有amountIn × 997/1000真正参与”换出”计算。 - 那 0.3% 留在池子里,使
k略微变大。 - 池子变大的部分按比例归所有 LP——这就是 LP 的收益来源。
所以严格说,swap 之后 k 不是不变,而是略微增大(因为手续费留存)。“恒定乘积”是对不含费部分而言。
4. 案例:1 WETH 换 DAI 算到底
池子 reserveIn = 100 WETH,reserveOut = 300,000 DAI,投入 amountIn = 1 WETH。
amountInWithFee = 1 · 997 = 997(用 0.997 WETH 参与计算)
分子 = 997 · 300,000 = 299,100,000
分母 = 100 · 1000 + 997 = 100,997
amountOut = 299,100,000 / 100,997 ≈ 2,961.47 DAI
对比第 1 章不含费的 2,970.30 DAI,含费后只换到 2,961.47 DAI:
- 差额里约 8.9 DAI 来自 0.3% 手续费。
- 其余来自滑点。
现货价 3,000,实际成交价 2,961.47——手续费 + 滑点共同让你”打了折”。交易越小,越接近现货价。
5. getAmountIn:定输出的反函数
如果你想精确买到 amountOut 个输出币,需要投入多少?把上面公式反解(Library 的 getAmountIn):
amountIn = (reserveIn · amountOut · 1000) / ((reserveOut − amountOut) · 997) + 1
- 末尾
+ 1是向上取整,保证投入足够(宁可多收 1 wei,不让池子吃亏)。 - 注意分母里是
reserveOut − amountOut:你想买的越多,分母越小,所需输入急剧上升(滑点)。想买空池子(amountOut → reserveOut)则需要无穷多输入——再次印证池子掏不空。
6. 多跳路由:getAmountsOut / getAmountsIn
如果没有 WETH↔MKR 的直接池子,但有 WETH↔DAI 和 DAI↔MKR,Router 可以走多跳:path = [WETH, DAI, MKR]。
getAmountsOut(amountIn, path):从头到尾,对每一跳依次调用getAmountOut,返回一个数组amounts,amounts[0]=输入,amounts[最后]=最终输出。getAmountsIn(amountOut, path):从尾到头,对每一跳依次调用getAmountIn,倒推出每一跳所需输入。
每多一跳就多扣一次 0.3% 手续费、多一层滑点,所以路径不是越长越好。
7. pair.swap:底层如何守住 k
Router 算好数额后,真正执行的是 Core 的 pair.swap。它的核心是一行 K 校验(简化):
// 先把输出币乐观地转出去(支持 flash swap,见第 6 章)
// 然后检查新储备的乘积不小于旧乘积(含手续费)
require(
balance0Adjusted · balance1Adjusted >= reserve0 · reserve1 · 1000²
);
// 其中 balanceAdjusted = balance·1000 - amountIn·3 (扣 0.3% 费)
含义:不管你怎么换、换多少,交易后池子的”调整后乘积”不能比交易前小。 这一条不等式就锁死了定价规则——你想多拿输出币,就必须投入足够的输入币让不等式成立,否则交易 revert。Router 算的那些公式,本质就是”恰好让这条不等式成立”的解。
pair.swap是”乐观转账 + 末尾校验”:先把输出转给你,再检查 k。这个顺序是 flash swap(先借后还)的基础。
8. 现货价与滑点
- 现货价(spot price)= reserveOut / reserveIn:无穷小交易的边际价格。
- 成交价:有限交易的平均价,因恒定乘积曲线弯曲而总是比现货价差,差距 = 滑点。
滑点随交易占池子比例放大。给 Router 传 amountOutMin(定输入)或 amountInMax(定输出)就是滑点保护:实际结果超出容忍范围就 revert,防止你在价格剧烈波动或被三明治攻击时吃大亏。
9. 本章小结
- 两类兑换:定输入
swapExactTokensForTokens/ 定输出swapTokensForExactTokens,背后是互为反函数的getAmountOut/getAmountIn。 getAmountOut含 0.3% 费:amountOut = amountIn·997·reserveOut / (reserveIn·1000 + amountIn·997)。- 手续费收在输入币、留在池子里让 k 略增,归 LP。
getAmountIn是反解,末尾+1向上取整偏向池子。- 多跳用
getAmountsOut/In逐跳计算,每跳多一次费和滑点。 - 底层
pair.swap用一行 K 不等式守住定价(乐观转账 + 末尾校验,是 flash swap 的基础)。 - 滑点随交易规模上升,用
amountOutMin/amountInMax做保护。
10. 动手练习
对应课程的两个 Swap 练习:定输入和定输出兑换。
公共 setUp
// 给 user 充值 WETH,并 approve 给 Router
// Router02: 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D
练习 1:swapExactTokensForTokens(定输入)
目标:用 1 WETH,沿路径 [WETH, DAI, MKR] 尽量多换 MKR。
interface IUniswapV2Router02 {
function swapExactTokensForTokens(
uint256 amountIn, uint256 amountOutMin,
address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);
}
思路:
- 构造
path = [WETH, DAI, MKR]。 amountIn = 1e18,amountOutMin = 1(练习里放宽,实战要算滑点)。- 调用并打印返回的
amounts[0/1/2](各跳数额)。 - 断言
mkr.balanceOf(user) >= amountOutMin。
练习 2:swapTokensForExactTokens(定输出)
目标:精确买到 0.1 MKR,最多花 1 WETH。
function swapTokensForExactTokens(
uint256 amountOut, uint256 amountInMax,
address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);
思路:
- 同样
path = [WETH, DAI, MKR]。 amountOut = 0.1e18,amountInMax = 1e18。- 调用后断言
mkr.balanceOf(user) == amountOut(精确买到)。 - 打印
amounts[0](实际花掉的 WETH),体会”定输出”模式下输入是被反推出来的。
进阶
- 交易前先用
getAmountsOut(1e18, path)预估,和实际amounts对比。 - 把
amountOutMin设成一个超大值,观察因滑点保护而 revert。
运行
forge test --evm-version cancun --fork-url $FORK_URL \
--match-path test/UniswapV2Swap.t.sol -vvv
下一章(第 3 章 Create Pool)讲:Factory 怎么用
createPair创建池子、为什么用 CREATE2、以及每对代币唯一池子的保证。