Curve V1 第 4 章:兑换(Swap)
这一章讲 3pool 最高频的操作——稳定币之间互换(比如 DAI 换 USDC)。我们跟着
exchange函数走一遍完整流程,看清它如何用第 2 章的牛顿法get_y算出”能换多少”,以及 Curve V1 在输出币上收手续费的细节。最后用get_dy/get_dy_underlying报价和真实exchange做两个练习。
目录
- 1. 一次兑换的宏观流程
- 2. exchange 的参数
- 3. 代码流程逐步拆解
- 4. 核心:get_y 反解对手币余额
- 5. 手续费收在输出币上
- 6. 案例:手动模拟 100 万 DAI → USDC
- 7. get_dy 与 get_dy_underlying 的区别
- 8. 与 Uniswap 的对比
- 9. 本章小结
- 10. 动手练习
1. 一次兑换的宏观流程
你想用 100 万 DAI 换 USDC。池子要回答:
“你给我加 100 万 DAI,为了让不变量 D 保持不变,我该从池子里拿出多少 USDC?”
流程:
- 把你加进来的 DAI 算进余额,统一精度成 xp。
- 在 D 不变 的约束下,用
get_y反解 USDC 应剩多少。 - 旧 USDC 余额 − 应剩余额 = 你能换走的 dy。
- 从 dy 扣手续费。
- 转账(收 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 V2 | Curve V1 | |
|---|---|---|
| 滑点 | 高 | 低(锚定资产) |
| 手续费收在 | 输入币 | 输出币 |
| 定价 | 解析公式 x·y=k | 牛顿迭代 get_y |
| 适合 | 任意资产 | 锚定资产(稳定币、同价资产) |
核心记忆:Curve V1 = 为稳定币优化的低滑点 swap,靠牛顿迭代定价,费用收在输出端。
9. 本章小结
- 兑换的定价规则是保持 D 不变:输入币加进去后,用
get_y(牛顿迭代解二次方程)反解对手币应剩余额,差值即换出量 dy。 - 流程:转入(处理转账费) → 统一精度 → get_y → 算 dy → 扣费 → 更新余额 → 转出。
dy = xp[j] - y - 1,-1向下取整偏向池子防舍入攻击。- 手续费收在输出币上,一部分(admin_fee)归协议,其余留池归 LP(推高 virtual_price)。
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);
}
思路:
- DAI=coin0, USDC=coin1,所以
i=0, j=1, dx=1e6 * 1e18(100 万 DAI)。 uint256 dy = pool.get_dy_underlying(0, 1, 1e6 * 1e18);- 打印
dy(USDC,6 位小数),断言> 0。 - 把
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;
}
思路:
setUp:deal(DAI, address(this), 1e6 * 1e18);给自己 100 万 DAI,并dai.approve(pool, max)。- 调用
pool.exchange(0, 1, 1e6 * 1e18, 0.999 * 1e6 * 1e6);min_dy = 0.999 * 1e6 * 1e6表示”至少要换到 99.9 万 USDC”(USDC 6 位小数),作为滑点保护。
- 断言
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 的一个独特设计。