Curve Cryptoswap 第 4 章:兑换(Exchange)
这一章讲池子最高频的操作——用一种币换另一种币。我们会跟着
exchange函数走一遍完整流程,看清它如何用第 2 章的不变量算出”能换多少”,再看 V2 独有的动态手续费是怎么随池子失衡程度自动变化的。最后用get_dy报价和真实exchange做两个练习。
目录
- 1. 一次兑换,宏观上发生了什么
- 2. exchange 的函数签名与参数
- 3. 代码流程逐步拆解
- 4. 核心:用 get_y 求”换出后对手币的余额”
- 5. 案例:手动模拟一次 WETH → USDC
- 6. 动态手续费:_fee 怎么算
- 7. 案例:平衡池 vs 失衡池的费率对比
- 8. get_dy:只读报价是怎么回事
- 9. 兑换结束后:顺手触发重锚
- 10. 本章小结
- 11. 动手练习
1. 一次兑换,宏观上发生了什么
你想用 1 个 WETH 换 USDC。池子要回答一个问题:
“你给我加 1 个 WETH,为了让不变量 D 保持不变,我应该从池子里拿出多少 USDC 给你?”
整个过程就是:
- 把”你加进来的币”算进池子余额(转换成统一刻度 xp)。
- 在 D 不变 的约束下,用数学库
get_y反解出”对手币应该剩多少”。 - 池子原来的对手币余额 − 现在应剩的余额 = 你能拿走的
dy。 - 从
dy里扣掉动态手续费。 - 真正转账(收进 WETH,转出 USDC)。
- 顺便更新内部价格、判断是否需要重锚(
tweak_price)。
整个逻辑的灵魂是第 2 步——“保持 D 不变” 就是 AMM 的定价规则。
2. exchange 的函数签名与参数
def exchange(
i: uint256, # 输入币的下标(卖出哪种)
j: uint256, # 输出币的下标(买入哪种)
dx: uint256, # 输入数量
min_dy: uint256, # 最少要换到多少(滑点保护)
use_eth: bool, # 是否用原生 ETH(而非 WETH)
receiver: address # 接收输出币的地址
) -> uint256: # 返回实际换到的数量 dy
对 TriCrypto:i/j 取值 0=USDC、1=WBTC、2=WETH。
min_dy是滑点保护:如果实际能换到的dy小于它,交易 revert。这能防止你在交易被打包前价格剧烈变动(或被三明治攻击)而吃大亏。receiver让你可以把换出的币直接发给别人/别的合约。
3. 代码流程逐步拆解
真正干活的是内部函数 _exchange。按顺序拆:
① 基本校验
assert i != j # 不能自己换自己
assert dx > 0 # 不能换 0
② 读取当前 A、gamma、余额、精度
A_gamma = self._A_gamma() # 注意:可能在 ramp 中,取的是"此刻插值后"的值
xp = self.balances # 三种币真实余额
precisions = unpack(packed_precisions)
③ 把输入币加进余额
y = xp[j] # 对手币(输出币)当前余额,先存着
x0 = xp[i] # 输入币当前余额
xp[i] = x0 + dx # 输入币加上你给的 dx
self.balances[i] = xp[i] # 立刻写回存储(输入币余额增加)
④ 把余额转换成统一刻度 xp(第 2 章的转换公式)
xp[0] *= precisions[0] # coin0 是基准
for k in 1..N:
xp[k] = xp[k] * price_scale[k-1] * precisions[k] / PRECISION
现在 xp 全部是”美元 × 1e18”的统一单位,可以喂给不变量了。
⑤(仅当 A/gamma 正在 ramp 时)重算 D ramp 期间曲线形状在变,要用最新的 A_gamma 重新算一遍 D,保证定价准确。平时跳过这步。
⑥ 求 dy(核心,见第 4 节)
⑦ 扣手续费(见第 6 节),做滑点检查
fee = _fee(xp) * dy / 1e10
dy -= fee
assert dy >= min_dy, "Slippage"
⑧ 更新对手币余额、转账进出
y -= dy
self.balances[j] = y # 对手币余额减少
_transfer_in(...) # 收进输入币
_transfer_out(...) # 转出输出币
⑨ tweak_price:更新内部价、可能重锚(第 7 章详解)。
4. 核心:用 get_y 求”换出后对手币的余额”
这是定价的心脏:
D = self.D # 不变量保持不变
y_out = MATH.get_y(A_gamma[0], A_gamma[1], xp, D, j)
dy = xp[j] - y_out[0] # 旧余额 - 新应剩余额 = 换出量(刻度单位)
xp[j] -= dy
dy -= 1 # 减 1,向下取整,永远不让用户多拿(保护池子)
get_y(A, gamma, xp, D, j) 解决的数学问题是:
已知其它币的余额(xp 里除 j 以外的项,其中输入币 i 已经加上了 dx)和不变量 D,反解 coin[j] 应该等于多少,才能让不变量等式继续成立。
这本质是在解第 2 章那个不变量方程对 xp[j] 的根。因为方程是三次的,get_y 内部用三次方程求根公式(解不出来时退回牛顿迭代 _newton_y)。我们不需要会手推这个优化实现,只要理解它的输入输出含义。
求出 y_out[0](对手币新余额,刻度单位)后:
dy = xp[j] - y_out[0]就是要换出的量(仍是刻度单位)。- 再换算回真实币单位(除掉 price_scale 和 precision):
if j > 0: dy = dy * PRECISION / price_scale[j-1] dy /= prec_j
注意那个
dy -= 1:所有 AMM 都会在取整上偏向池子,宁可让用户少拿 1 个最小单位,也不让用户占池子便宜。这是防止舍入攻击的标准做法。
5. 案例:手动模拟一次 WETH → USDC
我们用简化数字走一遍(忽略 1e18 精度细节,只看逻辑)。假设:
- 池子转换后余额:
xp = [USDC: 10,000,000, WBTC: 9,000,000, WETH: 9,000,000](都换算成美元,回忆第 2 章第 9 节)。 - 不变量 D 已知。
- 你要卖出 1 个 WETH,WETH 内部价
price_scale = 3000。
第一步:输入币加进去。 1 WETH 换算成刻度单位 ≈ 1 × 3000 = 3000 美元,所以 WETH 这一项变成 9,000,000 + 3,000 = 9,003,000。
第二步:调用 get_y 求 USDC 新余额。 在 D 不变的约束下,WETH 多了 3000 美元,USDC 必须减少大约对应的美元价值。因为池子在当前价附近表现接近恒定和(低滑点),所以 USDC 大约要减少略少于 3000 美元(具体取决于曲线弯曲程度)。假设 get_y 算出 USDC 新余额 = 9,997,010。
第三步:算 dy。
dy(刻度) = 旧USDC余额 - 新USDC余额 = 10,000,000 - 9,997,010 = 2,990 美元
USDC 是 coin0(price_scale=1),所以真实 USDC ≈ 2,990 USDC(再除 precision 回到 6 位小数)。
第四步:扣手续费。 假设此时费率 _fee ≈ 0.04%:
fee = 2,990 × 0.0004 ≈ 1.2 USDC
最终到手 dy ≈ 2,990 - 1.2 ≈ 2,988.8 USDC
直观结论:卖 1 个 WETH(市价≈3000 美元)换到约 2989 USDC,差额来自滑点 + 手续费。池子越平衡、交易越小,越接近市价。
6. 动态手续费:_fee 怎么算
这是 V2 相对普通 AMM 的一大特色。Uniswap 是固定费率(如 0.3%),而 Curve V2 的费率随池子失衡程度动态变化:
池子越平衡,费率越低;池子越失衡,费率越高。
为什么这样设计?
- 平衡时交易”对池子有益”(帮助维持平衡),用低费率鼓励。
- 失衡时交易往往是把池子推得更歪(或是套利),用高费率补偿 LP 承担的风险。
合约逻辑(_fee):
fee_params = [mid_fee, out_fee, fee_gamma]
# mid_fee = 最低费率(池子平衡时)
# out_fee = 最高费率(池子极度失衡时)
# fee_gamma = 控制从 mid 到 out 过渡的快慢
f = reduction_coefficient(xp, fee_gamma)
# f = fee_gamma / (fee_gamma + 1 - K)
# K = (x0·x1·…) / (((x0+x1+…)/N)^N) ∈ [0,1],1=完全平衡
fee = (mid_fee · f + out_fee · (1 - f)) / 1e18
理解这三个量:
- K 又是衡量平衡度(和第 2 章的 K0 思路一致,1=平衡,0=失衡)。
- f 是一个权重 ∈ (0, 1]:池子平衡 K=1 → f=1;池子失衡 K→0 → f→
fee_gamma/(fee_gamma+1)(趋小)。 - 最终费率是 mid_fee 和 out_fee 的加权平均:
- f=1(平衡)→ 费率 =
mid_fee(最低)。 - f→0(失衡)→ 费率 →
out_fee(最高)。
- f=1(平衡)→ 费率 =
fee_gamma 决定这条过渡曲线有多陡:小的 fee_gamma 让费率一旦失衡就快速冲向 out_fee。
7. 案例:平衡池 vs 失衡池的费率对比
设 mid_fee = 0.0004(0.04%),out_fee = 0.04(4%),fee_gamma = 0.0005。用两币简化(K 的计算同 K0)。
情况 A:完全平衡 x0 = x1 = 100
K = (100·100) / ((200/2)²) = 10000 / 10000 = 1
f = fee_gamma / (fee_gamma + 1 - K) = 0.0005 / (0.0005 + 1 - 1) = 0.0005/0.0005 = 1
费率 = mid_fee·1 + out_fee·0 = 0.0004 = 0.04% ← 最低费率
情况 B:中度失衡 x0 = 160, x1 = 40
K = (160·40) / ((200/2)²) = 6400 / 10000 = 0.64
f = 0.0005 / (0.0005 + 1 - 0.64) = 0.0005 / 0.3605 ≈ 0.00139
费率 = 0.0004·0.00139 + 0.04·(1 - 0.00139)
≈ 0.00000056 + 0.0399 ≈ 0.0399 ≈ 3.99% ← 几乎冲到最高费率!
注意:池子只是变成 160:40(失衡),费率就从 0.04% 飙升到接近 4%。因为 fee_gamma 很小,过渡极陡。这会强烈惩罚把池子推得更失衡的交易,激励套利者把池子拉回平衡(拉回平衡的方向交易费率低)。
这就是 Curve V2 “动态费用”的威力:它让池子有一种”自我修复”的经济激励。
8. get_dy:只读报价是怎么回事
get_dy(i, j, dx) 在 Views 合约里,是 exchange 的”只读预演版”:它走和 exchange 几乎一样的计算(转换余额 → get_y → 算 dy → 扣动态费),但不改任何状态、不转账,只返回”你大概能换到多少”。
用途:
- 前端展示报价。
- 集成合约在交易前做预估、设置合理的
min_dy。
要注意 get_dy 是预估:真正 exchange 执行时,如果中间池子状态被别人改变了,实际结果可能略有不同——所以仍要靠 min_dy 兜底。
9. 兑换结束后:顺手触发重锚
_exchange 的最后一步:
packed_price_scale = self.tweak_price(A_gamma, xp, 0, y_out[1])
每次交易后,池子都会调用 tweak_price:
- 更新内部价格预言机
price_oracle(EMA)和last_prices。 - 判断是否满足重锚条件,若满足就把
price_scale朝市场价挪一小步。
也就是说,重锚不是单独触发的,而是搭便车在每次交易(和增/减流动性)之后顺手做的。这部分是第 7 章的主角,这里先知道”exchange 的尾巴连着 repeg”即可。
10. 本章小结
- 兑换的定价规则是保持不变量 D 不变:加进输入币后,用
get_y反解对手币应剩多少,差值就是换出量dy。 - 算出的
dy要换算回真实币单位(除 price_scale、precision),并-1向下取整偏向池子。 - V2 的手续费是动态的:
mid_fee(平衡时最低)到out_fee(失衡时最高)之间按平衡度 K 加权,fee_gamma控制过渡陡峭度。 - 动态费用让池子有”自我修复”激励——把池子推歪要付高费,拉回平衡付低费。
get_dy是exchange的只读报价版;min_dy提供滑点保护。- 每次
exchange结尾都会调用tweak_price,把重锚逻辑”搭便车”执行。
11. 动手练习
对应课程的两个 Exchange 练习:先用
get_dy报价,再真正exchange。
练习 1:用 get_dy 报价
目标:在主网分叉上,计算”1 个 WETH 能换多少 USDC”。
interface ITriCrypto {
function get_dy(uint256 i, uint256 j, uint256 dx) external view returns (uint256);
}
思路:
- WETH 是 coin2,USDC 是 coin0,所以
i=2, j=0, dx=1e18。 uint256 dy = pool.get_dy(2, 0, 1e18);console2.log("dy %e", dy);断言dy > 0。- 把结果除以 1e6(USDC 6 位小数)看看是不是约等于当前 ETH 市价(如 ~3000)。
练习 2:真正执行 exchange
目标:把 1 个 WETH 换成 USDC,并验证收到了 USDC。
interface ITriCrypto {
function exchange(
uint256 i, uint256 j, uint256 dx, uint256 min_dy,
bool use_eth, address receiver
) external payable returns (uint256);
}
思路:
setUp里:deal(WETH, address(this), 1e18);给自己发 1 WETH,并weth.approve(pool, type(uint256).max)。- 调用:
pool.exchange({ i: 2, j: 0, dx: 1e18, min_dy: 1, use_eth: false, receiver: address(this) }); - 断言
usdc.balanceOf(address(this)) > 0,并打印出来。
进阶(选做)
- 对比预估与实际:在 exchange 前先
get_dy(2,0,1e18)存起来,exchange 后看实际收到的 USDC,比较两者差异(应该非常接近,差异来自取整和你这笔交易本身改变了池子状态)。 - 观察动态费用:分别模拟”把池子推向失衡”和”把池子拉向平衡”两个方向的小额交易,用
get_dy看有效汇率,间接体会动态费率的差异。 - 滑点保护实验:故意把
min_dy设成一个超大值(比如1e18),观察交易因 “Slippage” 而 revert,理解min_dy的保护作用。
运行
forge test --evm-version cancun --fork-url $FORK_URL \
--match-path test/CurveV2Swap.t.sol -vvv
下一章(第 5 章 Add Liquidity)讲:怎么把币存进池子、铸造 LP token,以及”不按比例存入”时为什么会被收一笔失衡费(imbalance fee)。