Curve V1 第6章 移除流动性:按比例、按数量与单币三种方式

讲清 Curve V1 三种取回流动性的方式:remove_liquidity(按比例免费)、remove_liquidity_imbalance(按数量收费)、remove_liquidity_one_coin(单币收费)及其数学。

9 分钟阅读
Curve V1 第6章 移除流动性:按比例、按数量与单币三种方式

Curve V1 第 6 章:移除流动性(Remove Liquidity)

取回流动性有三条路:按比例全取(免费)、按指定数量取(收费)、只取一种币(收费)。这一章把三者的数学、为什么一个免费两个收费讲清楚——你会发现它和第 5 章的失衡费是同一枚硬币的两面。


目录


1. 三种移除方式概览

函数取回什么收费吗复杂度
remove_liquidity三种币按当前比例各取一部分❌ 不收简单(比例减法)
remove_liquidity_imbalance你指定每种币要多少✅ 失衡费中(算 D 差)
remove_liquidity_one_coin只取一种币✅ 失衡费高(解不变量)

核心直觉(和第 5 章呼应):

按比例操作 → 不改变池子平衡 → 不收费。 偏离比例操作(指定数量 / 单币)→ 把池子推向失衡 → 收失衡费。


2. remove_liquidity:按比例取,简单免费

def remove_liquidity(_amount, min_amounts):
    for i in range(N_COINS):
        value = balances[i] · _amount / total_supply   # 按 LP 占比取
        assert value >= min_amounts[i]                  # 滑点保护
        balances[i] -= value
        transfer(coins[i], msg.sender, value)
    token.burnFrom(msg.sender, _amount)

数学极简单——按比例取,不碰复杂的不变量求解

取回[i] = balances[i] · _amount / totalSupply

即”你占总 LP 的百分比 × 每种币余额”。不收任何费用,也不需要算 D。


3. 案例:销毁 10% 的 LP 会取回什么

设 3pool 真实余额 [DAI 100万e18, USDC 100万e6, USDT 100万e6]totalSupply = 300万 3CRV,你持有 30 万 3CRV(占 10%)。

DAI : 100万 · 30万/300万 = 10万 DAI
USDC: 100万 · 30万/300万 = 10万 USDC
USDT: 100万 · 30万/300万 = 10万 USDT

你拿回每种币的 10%,池子取回后仍保持原比例,平衡不变。

你拿回的是当前的资产组合(三种币各 10 万),不一定是你当初存入的组合(比如你当初只存了 DAI)——因为做 LP 期间你的资产被交易者换来换去了。


4. 为什么按比例移除不收费

回忆第 5 章:收费是为了惩罚”把池子推向失衡”的操作。

remove_liquidity 等比例取出三种币:

  • 取出后三种币的相对比例完全不变,池子还是一样平衡。
  • 没制造任何”隐含兑换”,套利者无法借此薅羊毛。

既然没制造失衡、没隐含兑换,自然没理由收费。这就是它”安全、简单、免费”的根本原因。


5. remove_liquidity_imbalance:按指定数量取

这个函数让你精确指定每种币要取回多少(而不是按比例)。比如”我就要取 5 万 DAI、3 万 USDC、0 USDT”。

def remove_liquidity_imbalance(amounts, max_burn_amount):
    D0 = get_D_mem(old_balances, A)
    for i: new_balances[i] = old_balances[i] - amounts[i]   # 按你指定的减
    D1 = get_D_mem(new_balances, A)
    # 对每种币算失衡费(和 add_liquidity 同款逻辑)
    for i in range(N_COINS):
        ideal_balance = D1 · old_balances[i] / D0
        difference = |ideal_balance - new_balances[i]|
        fees[i] = _fee · difference / FEE_DENOMINATOR
        new_balances[i] -= fees[i]
    D2 = get_D_mem(new_balances, A)
    # 要销毁的 LP(含失衡费)
    token_amount = (D0 - D2) · token_supply / D0
    token_amount += 1                                 # 向上取整,对取款者不利(防套利)
    assert token_amount <= max_burn_amount, "Slippage screwed you"
    token.burnFrom(msg.sender, token_amount)

要点:

  • 因为你指定的数量通常不按比例,会把池子推向失衡,所以收失衡费(和 add_liquidity 完全一样的 ideal_balance/difference/_fee 逻辑)。
  • max_burn_amount 是滑点保护:“我最多愿意烧这么多 3CRV 来换这些币”。
  • token_amount += 1 向上取整,让取款者多烧一点点,偏向池子防舍入攻击(和 swap 里 -1 是对称的保护)。

6. remove_liquidity_one_coin:只取一种币

你只想拿回一种币(比如全要 DAI)。这等价于”按比例取回三种币,再把 USDC/USDT 在池子里换成 DAI”——后半段那个”换”就是隐含兑换,要收费

def remove_liquidity_one_coin(_token_amount, i, min_amount):
    dy, dy_fee = self._calc_withdraw_one_coin(_token_amount, i)
    assert dy >= min_amount, "Not enough coins removed"
    self.balances[i] -= (dy + dy_fee · admin_fee / FEE_DENOMINATOR)
    token.burnFrom(msg.sender, _token_amount)
    transfer(coins[i], msg.sender, dy)

核心计算全在 _calc_withdraw_one_coin 里。


7. calc_withdraw_one_coin 的数学

_calc_withdraw_one_coin 要回答:“销毁这么多 LP,全换成第 i 种币,能拿多少 dy?”

def _calc_withdraw_one_coin(_token_amount, i) -> (dy, dy_fee):
    D0 = get_D(xp, A)                                   # 当前 D
    D1 = D0 - _token_amount · D0 / total_supply         # 销毁 LP 后 D 按比例降

    new_y = get_y_D(A, i, xp, D1)                        # 在 D1 下,第 i 币应剩多少
    dy_0 = (xp[i] - new_y) / precisions[i]              # 不含费的取出量

    # 对"偏离比例的那部分"收失衡费
    for j in range(N_COINS):
        if j == i:
            dx_expected = xp[j]·D1/D0 - new_y
        else:
            dx_expected = xp[j] - xp[j]·D1/D0
        xp_reduced[j] -= _fee · dx_expected / FEE_DENOMINATOR

    dy = xp_reduced[i] - get_y_D(A, i, xp_reduced, D1)   # 扣费后再解一次 y
    dy = (dy - 1) / precisions[i]                        # -1 偏向池子,换回真实精度
    return dy, dy_0 - dy                                  # dy_0 - dy 就是失衡费

步骤拆解:

  1. 降 D:销毁 LP,D 从 D0 按比例降到 D1
  2. 解 y:用 get_y_D(和第 4 章 get_y 同款牛顿迭代,只是这里 D 在变)在 D1 下求第 i 币的新余额 new_ydy_0 = xp[i] - new_y不含费的取出量。
  3. 扣失衡费:对每种币算”偏离按比例的程度”dx_expected,按 _fee 收费,得到 xp_reduced
  4. 再解一次 y:在扣费后的 xp_reduced 上再求一次,得到含费后真正能取的 dy
  5. 失衡费 = dy_0 - dy

直觉:单币提取的费用比直接 swap 略低,因为只对”偏离比例的那部分”收费,不是对全部金额收费。


8. 案例:单币提取 vs 按比例提取

接第 3 节,你持有 10% LP,对应约 10万 DAI + 10万 USDC + 10万 USDT(总价值≈30 万美元)。

方式 A:按比例提取(remove_liquidity)

直接拿回 10万 DAI + 10万 USDC + 10万 USDT总价值≈30 万,零手续费。但是三种币的混合。

方式 B:全要 DAI(remove_liquidity_one_coin, i=0)

池子要把你那份的 USDC、USDT(约 20 万)在内部”卖成”DAI:

  • 这 20 万的隐含兑换被收失衡费(比 swap 略低)。
  • 还有一点滑点(提取把池子推歪)。
  • 结果你拿到的 DAI ≈ 30万 − 失衡费 − 滑点,可能是约 29.9x 万 DAI

结论:想省钱、要原始组合 → 方式 A;想要单一币种、愿付一点费用换便利 → 方式 B。金额越大、越偏离比例,方式 B 成本越高。


9. 三个函数对照表

维度remove_liquidityremove_liquidity_imbalanceremove_liquidity_one_coin
你指定什么烧多少 LP每种币要多少烧多少 LP + 要哪种币
取回币种三种按比例你指定的组合单一币种
收费❌ 不收✅ 失衡费✅ 失衡费(<swap)
改变平衡
数学比例减法算 D0/D1/D2 差get_y_D 求解
滑点保护min_amountsmax_burn_amountmin_amount
取整方向偏向池子+1 多烧-1 少取

10. 本章小结

  1. remove_liquidity 按 LP 占比等比例取回三种币,取回[i]=balances[i]·amount/totalSupply不收费、不破坏平衡
  2. remove_liquidity_imbalance 让你指定每种币的数量,因通常不按比例 → 收失衡费(同 add_liquidity 的 ideal_balance 逻辑),max_burn_amount 保护,销毁量 +1 偏向池子。
  3. remove_liquidity_one_coin 只取一种币,等价于”按比例取 + 把其它币换成目标币” → 收失衡费(低于 swap)。
  4. _calc_withdraw_one_coin:先按 LP 比例降 D,用 get_y_D 求目标币新余额得不含费量,再对偏离部分扣费、二次求解得净 dy。
  5. 三种方式的取整都偏向池子(防舍入攻击):按比例无额外、imbalance +1 多烧、one_coin -1 少取。

11. 动手练习

对应课程的两个 Remove Liquidity 练习。setUp 里先存 100 万 DAI 拿到 3CRV,再分别用两种方式提取。

公共 setUp

function setUp() public {
    deal(DAI, address(this), 1e6 * 1e18);
    dai.approve(address(pool), type(uint256).max);
    uint256[3] memory coins = [uint256(1e6 * 1e18), 0, 0];
    pool.add_liquidity(coins, 1);     // 拿到一些 3CRV
}

练习 1:按比例移除全部 LP

interface IStableSwap3Pool {
    function remove_liquidity(uint256 amount, uint256[3] memory min_amounts) external;
}

思路:

  1. uint256 lpBal = lp.balanceOf(address(this));
  2. minCoins = [1,1,1],调用 pool.remove_liquidity(lpBal, minCoins);
  3. 断言 lp.balanceOf(this) == 0(3CRV 烧光)。
  4. 断言你同时收到 DAI、USDC、USDT 三种币(都比 setUp 前增加)——即使当初只存了 DAI!这印证”取回的是当前组合”。

练习 2:只取回 DAI

interface IStableSwap3Pool {
    function remove_liquidity_one_coin(uint256 token_amount, int128 i, uint256 min_amount) external;
}

思路:

  1. uint256 lpBal = lp.balanceOf(address(this));
  2. 调用 pool.remove_liquidity_one_coin(lpBal, 0, 1);(i=0 即 DAI)。
  3. 断言 lp.balanceOf(this) == 0
  4. 断言只有 DAI 增加,USDC、USDT 保持不变(== setUp 前)。印证”单币提取只拿一种币”。

进阶(推荐)

  • 量化失衡费 + 滑点:你存入 100 万 DAI,分别用方式 1(按比例)和方式 2(全取 DAI)退出,比较两种方式最终拿回的总价值(方式 1 把三种币按≈1 美元相加)。方式 2 应略低,差额 ≈ 失衡费 + 滑点。
  • calc_withdraw_one_coin(lp, 0) 预估单币可取量,和实际返回对比。
  • 试试 remove_liquidity_imbalance:指定取 [5万 DAI, 3万 USDC, 0],观察烧掉的 3CRV 数量,并和 calc_token_amount(..., false) 的预估对比。

运行

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

🎉 Curve V1 课程完结

恭喜走完 Curve V1(StableSwap)核心内容!回顾主线:

  1. 第 2 章 数学:StableSwap 不变量用 A 把恒定和与恒定乘积缝起来,牛顿法求 D 和 y。
  2. 第 3 章 合约总览:A 缓慢 ramp、_xp 统一精度、virtual_price 与 calc_token_amount。
  3. 第 4 章 兑换:保持 D 不变定价、get_y 反解、手续费收在输出币。
  4. 第 5 章 添加流动性:按 D 增量铸 LP、失衡费惩罚不按比例存。
  5. 第 6 章 移除流动性:按比例(免费)/ 按数量(收费)/ 单币(收费)三种方式。

学完 V1 再回看 Curve V2(Cryptoswap),你会发现 V2 = V1 + 「可移动的 price_scale + 自动重锚 + 动态手续费」,核心数学一脉相承。最好的巩固方式就是把每章练习用 Foundry 真正跑一遍。祝你 DeFi 开发顺利!

💬 评论