Uniswap V2 交易价格冲击:AMM 兑换结算价的计算

用恒定乘积公式逐步推导一笔 swap 实际能换到多少代币,理解滑点与手续费的数学本质

7 分钟阅读
Uniswap V2 交易价格冲击:AMM 兑换结算价的计算

Uniswap V2 交易价格冲击:AMM 兑换结算价的计算

目录


一、问题的提出:换 X 能拿到多少 Y

当你在 Uniswap 上把 ETH 换成 USDC 时,前端会显示一个”预计获得”的数字。这个数字是怎么算出来的?本文回答的就是这个核心问题:

向 AMM 存入 Δx 个代币 X,最多能取走多少个代币 Y?

理解了这个计算,你就理解了滑点、价格冲击、手续费的全部数学本质。

二、恒定乘积公式

2.1 公式与直觉

AMM 的基础原则:

x × y ≥ k
  • x:池中代币 X 的数量
  • y:池中代币 Y 的数量
  • k:恒定乘积

这个公式天然构造了两种代币之间的反比关系:一种代币被存入(变多),另一种就可以被取出(变少),完美模拟供需——某种东西越稀缺越贵。

y = k / x 画出来是一条双曲线,所有合法的池子状态都必须落在这条曲线上方或曲线上。

2.2 具体示例池

本文通篇使用这个例子:

  • ETH/USDC 交易对
  • 池中有 100 ETH100 USDC(为了数字简单,不代表真实价格)
  • k = 100 × 100 = 10,000

三、合约里的实际校验方式

合约并不是”先算好你能拿多少再给你”,而是采用更通用的校验:

交易前的 k ≤ 交易后的 k

也就是先让你存币、取币,最后检查一下乘积没有变小。只要不变小,交易就合法。这种”先斩后奏再验证”的方式正是 Uniswap V2 支持闪电贷(flash swap)的基础。

四、为什么 25 个 ETH 换不到 25 个 USDC

直觉上池子里 ETH 和 USDC 数量相等(100:100),“汇率”是 1:1,那存 25 ETH 取 25 USDC 行不行?

算一下就知道不行:

项目数值
交易后池中 ETH100 + 25 = 125
交易后池中 USDC100 − 25 = 75
交易后乘积125 × 75 = 9,375
交易前乘积100 × 100 = 10,000

9,375 < 10,000,违反了不变量,AMM 会直接拒绝(revert)这笔交易

这说明:池子比例给出的 1:1 只是边际价格,真实成交时你的大额买入本身会把价格推高。

五、推导正确的兑换数量公式

5.1 公式推导

设存入 Δx,取出 Δy,要求交易后乘积不小于交易前:

(x + Δx)(y − Δy) ≥ x·y

展开求解 Δy:

y − Δy ≥ x·y / (x + Δx)
Δy ≤ y − x·y / (x + Δx)

这就是最多能取走的 Y 数量公式。

5.2 代入示例逐步计算

存入 25 ETH:

  1. 交易后池中 ETH:100 + 25 = 125
  2. 为保持 k = 10,000,池中 USDC 至少要剩:10,000 / 125 = 80
  3. 所以最多取走:100 − 80 = 20 USDC
  4. 验证:125 × 80 = 10,000 ✓

结论:25 ETH 最多换 20 USDC,而不是 25 USDC。

如果你少取了(比如只取 15 USDC),交易也合法,但你吃亏了——没有最大化这次兑换的所得。

5.3 这就是滑点

  • 边际价格说 1 ETH = 1 USDC;
  • 实际成交均价:20 / 25 = 0.8 USDC/ETH;
  • 这 20% 的差距就是这笔交易的价格冲击(滑点)

交易量相对池子越大,滑点越严重,且关系是非线性的(双曲线越走越陡)。这也是为什么大额交易要拆单或者寻找深度更好的池子。

六、加入 0.3% 手续费

6.1 手续费只对存入的代币收取

Uniswap V2 收取 0.3% 手续费,规则是:

  • 手续费只从你存入的代币里扣;
  • 只有 99.7% 的存入量参与兑换计算;
  • 被扣下的 0.3% 留在池子里,使 k 略微变大——这就是 LP 的收入。

6.2 含手续费的完整计算示例

还是存入 25 ETH:

  1. 手续费:25 × 0.3% = 0.075 ETH
  2. 参与兑换的有效数量:25 − 0.075 = 24.925 ETH
  3. 用有效数量算可取出的 USDC:100 − 10,000 / (100 + 24.925) = 100 − 10,000 / 124.925 ≈ 100 − 80.048 ≈ 19.952 USDC
  4. 但注意:那 0.075 ETH 手续费也留在池子里,所以交易后池子实际是 125 ETH × 80.048 USDC ≈ 10,006 > 10,000——乘积变大了,LP 们的份额升值了。

对比无手续费时的 20 USDC,你少拿了约 0.048 USDC,这正是手续费的代价。

6.3 含手续费的通用公式

Δy ≤ y − x·y / (x + 0.997·Δx)

这正是后面 UniswapV2Library.getAmountOut 的数学原型(合约里用整数运算写成 amountIn * 997 / 1000 的形式以避免小数)。

七、关键结论

  1. AMM 不按”标价”成交,每笔交易都沿着双曲线移动价格;
  2. 最大可取出量公式:Δy = y − xy/(x + Δx),取少了是你的损失;
  3. 手续费只从输入侧扣 0.3%,永久留在池中推高 k,是 LP 收益的来源;
  4. 交易越大滑点越大,且非线性恶化。

八、动手练习项目:链上报价器 QuoteLens

项目目标

亲手实现并验证本文的两个公式(无手续费版 / 0.3% 手续费版),做一个可部署到 Sepolia 的报价 + 模拟成交合约,搭配一个最小池子验证理论值与实际值完全一致。

合约要求

1. SimplePool.sol(最小恒定乘积池)

  • 持有两种自部署的测试 ERC20(TokenX / TokenY,各 mint 1,000,000 给部署者);
  • seed(uint amountX, uint amountY):部署者一次性注入初始流动性(transferFrom 拉入),只允许调用一次;
  • swapXForY(uint amountXIn, uint minYOut)
    • transferFrom 拉入 X;
    • 按含手续费公式计算 amountYOut = reserveY - (reserveX * reserveY) / (reserveX + amountXIn * 997 / 1000)(注意整数运算顺序:先乘后除);
    • 校验 amountYOut >= minYOut,否则自定义错误 SlippageExceeded(uint expected, uint actual)
    • 转出 Y,并在函数末尾断言 新X余额 * 新Y余额 >= 旧X余额 * 旧Y余额(自定义错误 KInvariantViolated())——这是本文第三节”事后校验”思想的直接落地;
  • getReserves() view

2. QuoteLens.sol(纯函数报价库合约)

  • quoteNoFee(uint amountIn, uint reserveIn, uint reserveOut) pure returns (uint):实现 Δy = y − xy/(x+Δx)
  • quoteWithFee(uint amountIn, uint reserveIn, uint reserveOut) pure returns (uint):实现 0.3% 手续费版本;
  • priceImpactBps(uint amountIn, uint reserveIn, uint reserveOut) pure returns (uint):返回这笔交易的价格冲击(基点)。提示:边际价 = reserveOut/reserveIn,成交均价 = amountOut/amountIn,冲击 = (1 − 均价/边际价) × 10000。

测试要求(Foundry)

用本文的数字做断言,让理论和代码互相印证:

  1. 池子 100e18 X / 100e18 Y,quoteNoFee(25e18, ...) 必须精确返回 20e18
  2. quoteWithFee(25e18, ...) 返回值在 19.95e18 ~ 19.96e18 区间(assertApproxEqRel);
  3. swapXForY 实际转出的 Y == quoteWithFee 的报价(报价器与池子逻辑一致性测试);
  4. 直接构造”存 25 取 25”的攻击调用(可写一个恶意测试直接给池子转账后取款)验证 K 校验会 revert;
  5. 模糊测试:testFuzz_KNeverDecreases(uint96 amountIn),任意输入下交易后 k 不减小。

Sepolia 部署与验证步骤

  1. 部署 TokenX、TokenY、SimplePool、QuoteLens;
  2. approve 后调用 seed(100e18, 100e18)
  3. 在 Etherscan 上先调用 quoteWithFee(25e18, 100e18, 100e18) 读到报价 ≈ 19.952e18;
  4. approve 25e18 X,调用 swapXForY(25e18, 19.9e18),对比实际收到的 Y 与第 3 步报价一致;
  5. 再读 getReserves,手算乘积确认 k 从 10,000e36 变成了约 10,006e36——亲眼看到手续费让 k 变大。

进阶挑战(可选)

  • 给 QuoteLens 增加 quoteIn(反向问题:想取出 Δy 至少要存多少 Δx),推导公式 Δx = (x·Δy / (y − Δy)) / 0.997 + 1
  • 连续做 5 笔 5 ETH 的小额 swap,对比一笔 25 ETH 的大额 swap,验证”拆单不能绕过滑点”(结果应几乎相同,差异仅来自手续费的复利效应)。

💬 评论