Uniswap V2 第2章 兑换:amountOut 公式、手续费与 pair.swap

推导 Uniswap V2 含 0.3% 手续费的 getAmountOut/getAmountIn 公式,讲解多跳路由、pair.swap 如何守住 k,以及滑点的来源。

6 分钟阅读
Uniswap V2 第2章 兑换:amountOut 公式、手续费与 pair.swap

Uniswap V2 第 2 章:兑换(Swap)

这一章把”换多少”算到底:推导含 0.3% 手续费的 getAmountOut 公式、它的反函数 getAmountIn、多跳路由怎么连起来,以及底层 pair.swap 如何用一行不等式守住 x·y=k。最后做两个真实交易练习。


目录


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 WETHreserveOut = 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,返回一个数组 amountsamounts[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. 本章小结

  1. 两类兑换:定输入 swapExactTokensForTokens / 定输出 swapTokensForExactTokens,背后是互为反函数的 getAmountOut/getAmountIn
  2. getAmountOut 含 0.3% 费:amountOut = amountIn·997·reserveOut / (reserveIn·1000 + amountIn·997)
  3. 手续费收在输入币、留在池子里让 k 略增,归 LP。
  4. getAmountIn 是反解,末尾 +1 向上取整偏向池子。
  5. 多跳用 getAmountsOut/In 逐跳计算,每跳多一次费和滑点。
  6. 底层 pair.swap 用一行 K 不等式守住定价(乐观转账 + 末尾校验,是 flash swap 的基础)。
  7. 滑点随交易规模上升,用 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);
}

思路:

  1. 构造 path = [WETH, DAI, MKR]
  2. amountIn = 1e18amountOutMin = 1(练习里放宽,实战要算滑点)。
  3. 调用并打印返回的 amounts[0/1/2](各跳数额)。
  4. 断言 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);

思路:

  1. 同样 path = [WETH, DAI, MKR]
  2. amountOut = 0.1e18amountInMax = 1e18
  3. 调用后断言 mkr.balanceOf(user) == amountOut(精确买到)。
  4. 打印 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、以及每对代币唯一池子的保证。

💬 评论