Curve Cryptoswap 第6章 移除流动性:按比例与单币两种方式

对比 remove_liquidity(按比例、免费)与 remove_liquidity_one_coin(单币、收失衡费)两条取回路径的数学与代价。

9 分钟阅读
Curve Cryptoswap 第6章 移除流动性:按比例与单币两种方式

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

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


目录


1. 两种移除方式概览

函数取回什么收费吗计算复杂度
remove_liquidity三种币按当前比例各取一部分不收费简单(按比例减法)
remove_liquidity_one_coin只取你指定的一种币收失衡费(比兑换费低)复杂(要解不变量)

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

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


2. remove_liquidity:按比例取,简单又安全

def remove_liquidity(
    _amount,            # 要销毁的 LP 数量
    min_amounts,        # 三种币各自最少要取回多少(滑点保护)
    use_eth, receiver, claim_admin_fees
) -> uint256[N_COINS]:

它的数学非常简单——因为按比例取,根本不需要碰复杂的不变量求解。每种币取回的数量:

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

也就是”你占总 LP 的百分比 × 每种币的余额”。代码(非清空情况):

amount -= 1                        # 向下取整,偏向留在池里的 LP(防舍入攻击)
for i in range(N_COINS):
    d_balances[i] = balances[i] · amount / total_supply
    assert d_balances[i] >= min_amounts[i]    # 滑点保护
    self.balances[i] = balances[i] - d_balances[i]

D 也按同样比例缩小:

# D1 / D0 = (total_supply - amount) / total_supply
self.D = D - D · amount / total_supply

注释里写得很清楚:这种取法”非常安全,不做复杂数学,因为是按平衡比例提取,不收任何费用”。


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

假设池子真实余额:

balances = [USDC 10,000,000, WBTC 150, WETH 3,000]
totalSupply = 1,000,000 LP
你持有 100,000 LP(占 10%)

按比例取回(忽略那个 -1 的取整):

USDC: 10,000,000 · 100,000 / 1,000,000 = 1,000,000 USDC
WBTC: 150        · 100,000 / 1,000,000 = 15 WBTC
WETH: 3,000      · 100,000 / 1,000,000 = 300 WETH

你拿回了池子里每种币的 10%。池子取回后仍保持原来的资产比例,平衡度不变。D 也从原值降到 90%。

注意:你拿回的是当前的资产组合,不是你当初存入的组合。如果你当初只存了 USDC,现在可能取回三种币的混合——这是 AMM 的常态(你的资产在你做 LP 期间被交易者”换来换去”了)。


4. 清空池子的特殊情况

如果 amount == total_supply(最后一个 LP 全部退出):

if amount == total_supply:        # Case 2: 清空
    for i in range(N_COINS):
        d_balances[i] = balances[i]   # 全部取走
        self.balances[i] = 0          # 池子归零
# D 也会变成 0

这种情况直接把所有币给最后的 LP,并把不变量重置为 0。代码里特意把”清空”和”部分提取”分开处理,是为了避免 amount -= 1 那个取整在清空时导致灰尘残留或除法误差


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

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

remove_liquidity等比例取出三种币:

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

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


6. remove_liquidity_one_coin:只取一种币

def remove_liquidity_one_coin(
    token_amount,    # 销毁的 LP 数量
    i,               # 只取第 i 种币
    min_amount,      # 滑点保护
    use_eth, receiver
) -> uint256:        # 返回取到的第 i 种币数量 dy

你只想拿回一种币(比如全换成 USDC)。这等价于:

“先按比例取回三种币,再把其中的 WBTC、WETH 在池子里换成 USDC。”

后半段那个”换”就是隐含的兑换 —— 所以要收费(失衡费,但比直接 swap 略低)。流程:

self._claim_admin_fees()           # 先把管理费结算
dy, D, xp, approx_fee = self._calc_withdraw_one_coin(A_gamma, token_amount, i, ...)
assert dy >= min_amount, "Slippage"
self.balances[i] -= dy             # 只减少第 i 种币
self.burnFrom(msg.sender, token_amount)
self._transfer_out(coins[i], dy, ...)
self.tweak_price(A_gamma, xp, D, 0)  # 改变了平衡 → 触发重锚判断

注意:因为单币提取改变了池子平衡,所以结尾要 tweak_price(而按比例的 remove_liquidity 不需要,因为平衡没变)。


7. calc_withdraw_one_coin 的数学

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

步骤(理解逻辑即可,不必记死):

  1. 算新 D:销毁 token_amount 个 LP,对应 D 要从 D0 降到
    D1 = D0 - D0 · token_amount / totalSupply
    (和按比例移除一样,D 按 LP 比例缩小。)
  2. 在新 D 下,反解第 i 种币的新余额:用数学库 get_y(A, gamma, xp, D1, i)——已知其它币不变、D 变小了,求第 i 种币应该剩多少。
  3. dy(毛)= 旧余额 − 新余额(再换算回真实币单位)。
  4. 扣失衡费:因为这等价于把其它币换成第 i 种币,按 _calc_token_fee 思路收一笔 approx_fee,从 dy 里扣掉。
  5. 返回 dy(净)、新 D、新 xpapprox_fee

关键点:

  • 单币提取的费率基于第 4/5 章那个动态 _fee,所以池子越失衡、提取越偏离比例,费越高。
  • 文档注释明确:单币提取的费用”低于 swap 费用”(因为它只对”偏离比例的那部分”收费,不是对全部金额收费)。

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

接第 3 节,你持有 10% 的 LP,对应价值约:1,000,000 USDC + 15 WBTC + 300 WETH。假设 WBTC=$60,000、WETH=$3,000,则总价值:

1,000,000 + 15·60,000 + 300·3,000 = 1,000,000 + 900,000 + 900,000 = 2,800,000 美元

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

直接拿回 1,000,000 USDC + 15 WBTC + 300 WETH总价值 ≈ 280 万美元,零手续费。但你拿到的是三种币的混合。

方式 B:全部换成 USDC(remove_liquidity_one_coin, i=0)

池子要把你那份的 WBTC、WETH(共约 180 万美元)在内部”卖成” USDC:

  • 这 180 万美元的隐含兑换会被收失衡费(比如几十个 bp,取决于金额占池子比例和池子状态)。
  • 还会有一点滑点(大额单币提取把池子推歪,get_y 给出的价格不如平衡时好)。
  • 结果你拿到的 USDC ≈ 2,800,000 − 失衡费 − 滑点,可能是约 2,79x,xxx USDC

结论:想省钱 → 按比例提取(方式 A);想要单一币种、愿意付一点费用换便利 → 单币提取(方式 B)。金额越大、池子越失衡,方式 B 的成本越高。


9. 两个函数的对照表

维度remove_liquidityremove_liquidity_one_coin
取回币种三种按比例指定一种
是否收费❌ 不收✅ 失衡费(< swap 费)
是否改变池子平衡
是否触发 repeg是(tweak_price)
数学复杂度低(比例减法)高(newton/get_y 求解)
滑点有(大额时明显)
适用场景想要原始资产组合、省钱想要单一币种、图方便

10. 本章小结

  1. remove_liquidity 按 LP 占比等比例取回三种币:取回[i] = balances[i]·amount/totalSupply,D 同比例缩小。
  2. 不收费、不触发 repeg,因为不改变池子平衡、不制造隐含兑换。
  3. 清空池子(amount == totalSupply)走特殊分支,全部取走并把 D 重置为 0。
  4. remove_liquidity_one_coin 只取一种币,等价于”按比例取 + 把其它币换成目标币”,所以收失衡费(低于 swap 费)并触发 tweak_price
  5. 单币提取靠 _calc_withdraw_one_coin:先按 LP 比例降 D,再用 get_y 反解目标币新余额,差值即毛 dy,扣费后得净 dy。
  6. 大额、池子失衡时,单币提取的费用 + 滑点会明显高于按比例提取。

11. 动手练习

对应课程的两个 Remove Liquidity 练习。先在 setUp 里存入 1000 USDC 拿到一些 LP,再分别用两种方式提取。

公共 setUp

function setUp() public {
    deal(USDC, address(this), 1e3 * 1e6);
    usdc.approve(address(pool), type(uint256).max);
    uint256[3] memory amounts = [uint256(1e3 * 1e6), 0, 0];
    pool.add_liquidity(amounts, 1, false, address(this));
}

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

interface ITriCrypto {
    function remove_liquidity(
        uint256 lp, uint256[3] memory min_amounts,
        bool use_eth, address receiver, bool claim_admin_fees
    ) external returns (uint256[3] memory);
    function balanceOf(address) external view returns (uint256);
}

思路:

  1. uint256 lpBal = pool.balanceOf(address(this));
  2. minAmounts = [1, 1, 1],调用 remove_liquidity(lpBal, minAmounts, false, address(this), false)
  3. 断言 pool.balanceOf(address(this)) == 0(LP 烧光)。
  4. 断言并打印:你应同时收到 USDC、WBTC、WETH 三种币(都 > 0)——即使你当初只存了 USDC!这直观印证了”取回的是当前资产组合”。

小坑:测试合约初始的 WETH 余额可能因 Foundry 行为不为 0,所以验证 WETH 时用 weth.balanceOf(this) > wethBalBefore 而不是 > 0

练习 2:只取回 USDC

interface ITriCrypto {
    function remove_liquidity_one_coin(
        uint256 lp, uint256 i, uint256 min_amount,
        bool use_eth, address receiver
    ) external returns (uint256);
}

思路:

  1. uint256 lpBal = pool.balanceOf(address(this));
  2. 调用 remove_liquidity_one_coin(lpBal, 0, 1, false, address(this))(i=0 即 USDC)。
  3. 断言 pool.balanceOf(address(this)) == 0
  4. 断言只有 USDC > 0,而 WBTC == 0、WETH 没增加(== wethBalBefore)。这印证了”单币提取只拿一种币”。

进阶(推荐)

  • 量化失衡费/滑点:你存入了 1000 USDC,分别用方式 1(按比例)和方式 2(全取 USDC)退出,记录两种方式最终拿回的总美元价值(方式 1 需用 price_scale 把三种币折算成美元)。方式 2 的美元价值应略低,差额 ≈ 失衡费 + 滑点。
  • 用 Views 合约的 calc_withdraw_one_coin(lp, i) 预估单币可取量,和实际 remove_liquidity_one_coin 返回值对比。

运行

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

下一章(第 7 章 Price Repeg)是全课程的高潮:内部价格预言机(EMA)怎么跟踪市场价,tweak_price 怎么决定何时、移动多少 price_scale,以及 xcp_profit 如何记录盈亏、确保重锚永不让 LP 亏本。

💬 评论