Curve V1 第4章 兑换:exchange、get_y 与手续费

跟着 exchange 走一遍 Curve V1 的兑换流程,理解 get_y 如何在 D 不变下反解换出量,以及 Curve 在输出币上收取手续费的细节。

8 分钟阅读
Curve V1 第4章 兑换:exchange、get_y 与手续费

Curve V1 第 4 章:兑换(Swap)

这一章讲 3pool 最高频的操作——稳定币之间互换(比如 DAI 换 USDC)。我们跟着 exchange 函数走一遍完整流程,看清它如何用第 2 章的牛顿法 get_y 算出”能换多少”,以及 Curve V1 在输出币上收手续费的细节。最后用 get_dy/get_dy_underlying 报价和真实 exchange 做两个练习。


目录


1. 一次兑换的宏观流程

你想用 100 万 DAI 换 USDC。池子要回答:

“你给我加 100 万 DAI,为了让不变量 D 保持不变,我该从池子里拿出多少 USDC?”

流程:

  1. 把你加进来的 DAI 算进余额,统一精度成 xp。
  2. D 不变 的约束下,用 get_y 反解 USDC 应剩多少。
  3. 旧 USDC 余额 − 应剩余额 = 你能换走的 dy。
  4. 从 dy 扣手续费。
  5. 转账(收 DAI、出 USDC),更新余额。

灵魂还是那句话——保持 D 不变就是定价规则


2. exchange 的参数

def exchange(i: int128, j: int128, dx: uint256, min_dy: uint256):
  • i:卖出币下标(0=DAI, 1=USDC, 2=USDT)。
  • j:买入币下标。
  • dx:卖出数量(输入币的原始精度,DAI 是 18 位、USDC 是 6 位)。
  • min_dy:最少要换到多少(滑点保护,输出币原始精度)。若实际不足则 revert。

注意 V1 没有 receiver 参数(换出的币直接给 msg.sender),也没有 use_eth(全是 ERC20 稳定币)。比 V2 简单。


3. 代码流程逐步拆解

def exchange(i, j, dx, min_dy):
    rates = RATES
    old_balances = self.balances
    xp = _xp_mem(old_balances)          # 统一精度

    # ① 把输入币转进池子(处理 USDT 这种转账可能扣费的情况)
    transferFrom(coins[i], msg.sender, self, dx)
    # dx_w_fee = 实际收到的数量

    # ② 输入币加进 xp(换算到统一精度)
    x = xp[i] + dx_w_fee · rates[i] / PRECISION

    # ③ 核心:求对手币新余额
    y = get_y(i, j, x, xp)

    # ④ 算换出量 dy,扣手续费
    dy = xp[j] - y - 1                   # -1 向下取整偏向池子
    dy_fee = dy · self.fee / FEE_DENOMINATOR
    dy = (dy - dy_fee) · PRECISION / rates[j]   # 扣费 + 换回真实精度
    assert dy >= min_dy, "Exchange resulted in fewer coins than expected"

    # ⑤ 更新余额(admin_fee 部分留给协议)
    dy_admin_fee = dy_fee · admin_fee / FEE_DENOMINATOR / rates[j]...
    self.balances[i] = old_balances[i] + dx_w_fee
    self.balances[j] = old_balances[j] - dy - dy_admin_fee

    # ⑥ 把输出币转给用户
    transfer(coins[j], msg.sender, dy)

注意 dx_w_fee 的处理:像 USDT 这种”转账可能被收费”的代币,合约用”转账前后余额差”来确定真正收到了多少,避免被欺骗。


4. 核心:get_y 反解对手币余额

这就是第 2 章第 10 节讲的 get_y:固定 D、固定其它币,对 y(对手币新余额)解二次方程,牛顿迭代:

y = (y² + c) / (2y + b - D)
  • 输入:i(输入币)、j(输出币)、x(输入币加 dx 后的新余额)、当前 xp
  • 内部先 get_D 拿到当前 D,再迭代求 y。
  • 输出:对手币 j 应该剩的余额 y。

然后 dy = xp[j] - y - 1,就是换出量(统一精度,待扣费、待换回真实精度)。

记住分工:get_D 求不变量(高次、初值 Σx),get_y 在 D 固定下求某币余额(二次、初值 D)。每次 swap 内部其实调了一次 get_D + 一次 get_y。


5. 手续费收在输出币上

这是 Curve V1 一个值得注意的细节(见第 3 章的对比表):

Curve V1 的 swap 手续费收在”输出币”(token out)上。 (Uniswap 是收在输入币上。)

代码体现:

dy = xp[j] - y - 1            # 毛换出量
dy_fee = dy · fee / FEE_DENOMINATOR   # 对"输出量"收费
dy = (dy - dy_fee) ...        # 用户实得 = 毛量 - 费

手续费的一部分(admin_fee 比例)归协议,其余留在池子里归 LP。这笔留存让池子的 D 缓慢增长,从而推高 virtual_price——这就是 LP 的收益来源。


6. 案例:手动模拟 100 万 DAI → USDC

设 3pool 平衡,三种币各约 100 万(统一精度后 xp ≈ [1e6, 1e6, 1e6],单位百万、省略 1e18),A 很大(如 2000),fee = 0.0001(0.01%,3pool 历史费率约 1bp)。

第一步:DAI 加进去。 卖 100 万 DAI,xp[0]: 1,000,000 → 2,000,000。

第二步:get_y 求 USDC 新余额。 因为 A 很大、曲线在这段很平(近恒定和),加入的 100 万 DAI 几乎要求 USDC 减少近 100 万。但池子被推向失衡(DAI 占比变高),曲线开始翘,所以 USDC 减少略少于100 万——假设 get_y 算出 USDC 新余额 ≈ 30,000(即减少了 ≈970,000)。

(注:把池子从 1:1:1 推到 2:0.03:1 是极度失衡,真实滑点会比平衡时大很多。这里数字仅作演示。实际中没人会一次性把单边推这么夸张,正因为越失衡滑点越大。)

第三步:算 dy。

dy(毛) = xp[1]旧 - y - 1 = 1,000,000 - 30,000 - 1 ≈ 970,000 USDC

第四步:扣手续费。

dy_fee = 970,000 · 0.0001 = 97 USDC
用户实得 ≈ 970,000 - 97 = 969,903 USDC

直观结论:把巨量单边币砸进小池子,滑点会很显著(这里换出明显少于 100 万)。但如果换的是小额(比如 1000 DAI),在 A=2000 的池子里几乎能 1:1 换到 USDC,仅扣约 1bp 手续费——这正是 Curve 的优势场景。


7. get_dy 与 get_dy_underlying 的区别

3pool 提供两个只读报价函数,容易混淆:

函数输入/输出单位用途
get_dy(i, j, dx)统一精度后的 c-units(用 RATES 换算)内部/精确计算
get_dy_underlying(i, j, dx)底层代币的原始单位(用 PRECISION_MUL)更贴近用户直觉的报价

两者算法几乎一样(都走 get_y + 扣费),区别只在精度换算用的是 RATES 还是 PRECISION_MUL。课程练习里用的是 get_dy_underlying(直接传 100 万 DAI 的原始数量 1e6 · 1e18)。

它们都是预估:真正 exchange 时池子可能已被别人改变,所以仍要靠 min_dy 兜底。


8. 与 Uniswap 的对比

借第 3 章的对比表,聚焦 swap:

Uniswap V2Curve V1
滑点低(锚定资产)
手续费收在输入币输出币
定价解析公式 x·y=k牛顿迭代 get_y
适合任意资产锚定资产(稳定币、同价资产)

核心记忆:Curve V1 = 为稳定币优化的低滑点 swap,靠牛顿迭代定价,费用收在输出端。


9. 本章小结

  1. 兑换的定价规则是保持 D 不变:输入币加进去后,用 get_y(牛顿迭代解二次方程)反解对手币应剩余额,差值即换出量 dy。
  2. 流程:转入(处理转账费) → 统一精度 → get_y → 算 dy → 扣费 → 更新余额 → 转出。
  3. dy = xp[j] - y - 1-1 向下取整偏向池子防舍入攻击。
  4. 手续费收在输出币上,一部分(admin_fee)归协议,其余留池归 LP(推高 virtual_price)。
  5. get_dy(c-units)和 get_dy_underlying(原始单位)都是只读报价,区别在精度换算;都需配合 min_dy 防滑点。

10. 动手练习

对应课程的两个 Swap 练习:先用 get_dy_underlying 报价,再真正 exchange

练习 1:用 get_dy_underlying 报价

目标:算”100 万 DAI 能换多少 USDC”。

interface IStableSwap3Pool {
    function get_dy_underlying(int128 i, int128 j, uint256 dx) external view returns (uint256);
}

思路:

  1. DAI=coin0, USDC=coin1,所以 i=0, j=1, dx=1e6 * 1e18(100 万 DAI)。
  2. uint256 dy = pool.get_dy_underlying(0, 1, 1e6 * 1e18);
  3. 打印 dy(USDC,6 位小数),断言 > 0
  4. dy / 1e6 看一眼——100 万 DAI 大约能换到接近 100 万 USDC(差额来自滑点 + 1bp 手续费)。

练习 2:真正执行 exchange

目标:把 100 万 DAI 换成 USDC。

interface IStableSwap3Pool {
    function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external;
}

思路:

  1. setUpdeal(DAI, address(this), 1e6 * 1e18); 给自己 100 万 DAI,并 dai.approve(pool, max)
  2. 调用 pool.exchange(0, 1, 1e6 * 1e18, 0.999 * 1e6 * 1e6);
    • min_dy = 0.999 * 1e6 * 1e6 表示”至少要换到 99.9 万 USDC”(USDC 6 位小数),作为滑点保护。
  3. 断言 usdc.balanceOf(address(this)) > 0,并打印。

进阶(推荐)

  • 对比预估与实际:exchange 前先 get_dy_underlying 存起来,exchange 后看实际 USDC,比较差异。
  • 小额 vs 大额滑点:分别报价 1000 DAI 和 1000 万 DAI,计算有效汇率 dy/dx,观察大额时偏离 1:1 更多(滑点随金额放大)。
  • 滑点保护实验:把 min_dy 设成一个超大值,观察 exchange 因 “Exchange resulted in fewer coins than expected” 而 revert。

运行

forge test --evm-version cancun --fork-url $FORK_URL \
  --match-path test/CurveV1Swap.t.sol -vvv

下一章(第 5 章 Add Liquidity)讲:怎么存币铸 3CRV,以及”不按比例存入”时为什么会被收失衡费(imbalance fee)——这是 Curve V1 区别于 Uniswap 的一个独特设计。

💬 评论