Curve Cryptoswap 第4章 兑换机制:get_y 定价与动态手续费

跟着 exchange 函数走一遍兑换流程,理解如何用不变量反解换出量,以及随池子失衡程度自动变化的动态手续费。

11 分钟阅读
Curve Cryptoswap 第4章 兑换机制:get_y 定价与动态手续费

Curve Cryptoswap 第 4 章:兑换(Exchange)

这一章讲池子最高频的操作——用一种币换另一种币。我们会跟着 exchange 函数走一遍完整流程,看清它如何用第 2 章的不变量算出”能换多少”,再看 V2 独有的动态手续费是怎么随池子失衡程度自动变化的。最后用 get_dy 报价和真实 exchange 做两个练习。


目录


1. 一次兑换,宏观上发生了什么

你想用 1 个 WETH 换 USDC。池子要回答一个问题:

“你给我加 1 个 WETH,为了让不变量 D 保持不变,我应该从池子里拿出多少 USDC 给你?”

整个过程就是:

  1. 把”你加进来的币”算进池子余额(转换成统一刻度 xp)。
  2. D 不变 的约束下,用数学库 get_y 反解出”对手币应该剩多少”。
  3. 池子原来的对手币余额 − 现在应剩的余额 = 你能拿走的 dy
  4. dy 里扣掉动态手续费
  5. 真正转账(收进 WETH,转出 USDC)。
  6. 顺便更新内部价格、判断是否需要重锚(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(最高)。

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. 本章小结

  1. 兑换的定价规则是保持不变量 D 不变:加进输入币后,用 get_y 反解对手币应剩多少,差值就是换出量 dy
  2. 算出的 dy换算回真实币单位(除 price_scale、precision),并 -1 向下取整偏向池子。
  3. V2 的手续费是动态的mid_fee(平衡时最低)到 out_fee(失衡时最高)之间按平衡度 K 加权,fee_gamma 控制过渡陡峭度。
  4. 动态费用让池子有”自我修复”激励——把池子推歪要付高费,拉回平衡付低费。
  5. get_dyexchange 的只读报价版;min_dy 提供滑点保护。
  6. 每次 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);
}

思路:

  1. WETH 是 coin2,USDC 是 coin0,所以 i=2, j=0, dx=1e18
  2. uint256 dy = pool.get_dy(2, 0, 1e18);
  3. console2.log("dy %e", dy); 断言 dy > 0
  4. 把结果除以 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);
}

思路:

  1. setUp 里:deal(WETH, address(this), 1e18); 给自己发 1 WETH,并 weth.approve(pool, type(uint256).max)
  2. 调用:
    pool.exchange({
        i: 2, j: 0, dx: 1e18, min_dy: 1,
        use_eth: false, receiver: address(this)
    });
  3. 断言 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)

💬 评论