Curve Cryptoswap 第3章 合约总览:三合约架构与状态变量打包

梳理 Curve V2 的主合约/数学库/视图库三件套,讲解状态变量为何用位运算打包省 gas,以及 A、gamma 为何要缓慢 ramp。

11 分钟阅读
Curve Cryptoswap 第3章 合约总览:三合约架构与状态变量打包

Curve Cryptoswap 第 3 章:合约总览(Contract Overview)

第 2 章我们讲清了数学。这一章把视角切换到代码:要和池子交互需要调用哪些函数?那些数学量(A、gamma、price_scale、D、virtual_price……)在合约里到底长什么样、怎么存?为什么 Curve 要用一堆位运算把它们”打包”在一起?这一章建立起对整个合约骨架的整体认识,后面几章(exchange、add/remove liquidity、repeg)才不会迷路。


目录


1. 合约家族:三个文件各管什么

Curve V2 的一个池子不是单个合约,而是三个合约协作

文件角色职责
CurveTricryptoOptimizedWETH.vy主合约(AMM)用户交互入口:exchange / add_liquidity / remove_liquidity,状态变量存储、repeg 逻辑
CurveTricryptoMathOptimized.vy数学库纯计算:newton_D(求不变量 D)、get_y(求某个币的余额)、get_p(求现货价)、reduction_coefficient
CurveCryptoViews3Optimized.vy视图库只读报价:get_dy(输入 dx 算 dy)、calc_token_amount 等,给前端/合约预估用

为什么要拆三个? 主要是字节码大小限制(单合约有 24KB 上限)。Cryptoswap 的数学极其复杂(三次方程求根、牛顿迭代),把数学和视图拆出去,主合约才放得下。主合约通过 MATH.xxx() 的方式调用数学库。

记忆口诀:主合约管”改状态”,Math 管”算数”,Views 管”看报价”。


2. TriCrypto 池子:USDC / WBTC / WETH

整门课的练习都基于以太坊主网上的这个真实池子:

  • 地址:0x7f86bf177dd4f3494b841a37e810a34dd56c829b
  • 三种币(下标固定):
    • coin[0] = USDC(6 位小数,作为计价基准)
    • coin[1] = WBTC(8 位小数)
    • coin[2] = WETH(18 位小数)

这就是为什么第 2 章说 coin0 是基准、price_scale 只需要存 coin1 和 coin2 两个价格。


3. 用户视角:你会调用哪些函数

把合约当黑盒,普通用户/集成方会用到的主要外部函数:

函数作用后续章节
exchange(i, j, dx, min_dy, use_eth, receiver)用 dx 个 coin[i] 换 coin[j]第 4 章
get_dy(i, j, dx)报价:dx 个 coin[i] 大约能换多少 coin[j](只读)第 4 章
add_liquidity(amounts, min_lp, use_eth, receiver)存入若干币,铸造 LP token第 5 章
remove_liquidity(lp, min_amounts, ...)销毁 LP,按比例取回三种币第 6 章
remove_liquidity_one_coin(lp, i, min_amount, ...)销毁 LP,只取回某一种币第 6 章
price_scale(i) / price_oracle(i)读取内部价格刻度 / 内部预言机价本章 + 第 7 章
A() / gamma()读取当前放大系数 / gamma本章
get_virtual_price()读取每份 LP 的真实价值本章
balances(i) / D() / xcp_profit()读取余额 / 不变量 / 累计盈利本章 + 第 7 章

4. 状态变量打包(packing):为什么要这么做

读 Curve V2 源码第一眼最困惑的就是:明明有 Agammaprice_scale[1]price_scale[2]、各种 fee 参数……但存储里却看不到这么多变量,取而代之的是一堆叫 xxx_packeduint256,配合大量位移 << >> 和掩码 &

原因只有一个:省 gas。

EVM 每个存储槽(storage slot)是 256 位(uint256)。读写一个槽(SLOAD/SSTORE)非常贵。如果 Agamma、两个价格分别各占一个槽,每次交易要读好几个槽。

但这些值其实都用不满 256 位:

  • Agamma 都 ≤ 大约 1e18 级别,128 位就够装。
  • price_scaleprecision、fee 参数等都 ≤ 1e18,64 位也够装。

于是 Curve 把多个小值”塞”进同一个 256 位槽里,一次 SLOAD 就能读出 2~3 个值,省下大量 gas。代价是读写时要做位运算来”拆/装”。

主合约里典型的打包字段:

  • price_scale_packed:把 coin1、coin2 两个价格塞进一个槽。
  • price_oracle_packed / last_prices_packed:同理存预言机价、上次成交价。
  • packed_precisions:三种币的精度乘数。
  • packed_fee_params:mid_fee / out_fee / fee_gamma。
  • packed_rebalancing_params:allowed_extra_profit / adjustment_step / ma_time。
  • future_A_gamma / initial_A_gamma:A 和 gamma 各占 128 位塞进一个槽。

5. 案例:_pack / _unpack 逐位拆解

合约里把”3 个都 ≤ 1e18 的数”塞进一个 uint256 的函数长这样:

def _pack(x: uint256[3]) -> uint256:
    #   | 128 bits | 64 bits | 64 bits |
    #       x[0]      x[1]      x[2]
    return (x[0] << 128) | (x[1] << 64) | x[2]

布局是:最高 128 位放 x[0],中间 64 位放 x[1],最低 64 位放 x[2]。

一步步看打包

假设要打包 [5, 7, 9](用很小的数方便演示,实际是大数):

x[0]=5 左移 128 位 → 5 占据 [255..128] 区段
x[1]=7 左移 64  位 → 7 占据 [127..64]  区段
x[2]=9 不移位      → 9 占据 [63..0]    区段
三者用按位或 | 拼起来,互不干扰(因为各自占的位段不重叠)

解包:用掩码 & 把对应位段抠出来

def _unpack(_packed: uint256) -> uint256[3]:
    return [
        (_packed >> 128) & 18446744073709551615,  # 右移128后取低64位 = x[0]? 
        (_packed >> 64)  & 18446744073709551615,  # = x[1]
         _packed         & 18446744073709551615,  # = x[2]
    ]

其中 18446744073709551615 = 2^64 - 1,二进制是 64 个 1,作为掩码X & (2^64 - 1) 等于”只保留 X 的最低 64 位”。

  • 取 x[2]:直接 packed & (2^64-1) → 抠出最低 64 位 = 9。
  • 取 x[1]:先 packed >> 64 把中间段挪到最低,再 & (2^64-1) → 7。
  • 取 x[0]:先 packed >> 128,再掩码 → 5。

关键直觉:左移 = 把数放到指定位段;按位或 = 拼接;右移 + 掩码 = 把指定位段抠出来。 整个 packing 就是这三招的反复使用。


6. price_scale 的存储:少存一个价格的小技巧

三币池有三种币,但 price_scale 只存了两个(coin1、coin2 的价格),因为:

coin0(USDC)是计价基准,它对自己的价格永远是 1,不需要存。

所以 _pack_prices 只打包 N_COINS - 1 = 2 个价格,每个分到 256 / 2 = 128 位:

PRICE_SIZE = 256 / (N_COINS - 1)   # = 128
PRICE_MASK = 2**PRICE_SIZE - 1     # 128 个 1
  • price_scale_packed 的低 128 位 = coin1(WBTC)价格,高 128 位 = coin2(WETH)价格。
  • 读取时 price_scale(0) 返回 coin1 的价,price_scale(1) 返回 coin2 的价。

这就是第 2 章练习里那个坑的根源price_scale(i) 的下标 i 对应的是 coin[i+1],不是 coin[i]


7. A 与 gamma:为什么要”缓慢调整”(ramping)

A(放大系数)和 gamma 是决定曲线形状的核心参数(回忆第 2 章:A 决定硬度,gamma 决定低滑点窗口宽窄)。

管理员有时需要调整它们(比如市场环境变化)。但绝不能瞬间跳变,否则:

  • 不变量 D 会突然改变 → 池子报价瞬间跳动 → 套利者可以精准夹击,让 LP 蒙受损失。

所以 Curve 用 ramping(缓慢爬坡):在一段时间内,把 A、gamma 从旧值线性插值地过渡到新值。合约里存了:

  • initial_A_gamma + initial_A_gamma_time:起点值和起点时间。
  • future_A_gamma + future_A_gamma_time:目标值和目标时间。

读取”当前 A、gamma”时(_A_gamma()),如果还在 ramp 区间内,就按时间比例插值算出此刻的值。


8. 案例:A 在 ramp 过程中的线性插值

合约 _A_gamma() 的插值公式(A 和 gamma 同理):

当前 A = (A0 · (t1 - now) + A1 · (now - t0)) / (t1 - t0)

其中 t0 = 起点时间,t1 = 终点时间,A0 = 起点值,A1 = 终点值,now = 当前时间。

具体算一遍

假设管理员要把 A 从 A0 = 1,000,000 调到 A1 = 4,000,000,ramp 持续 2 天(172800 秒)

  • t0 = 0(起点),t1 = 172800

第 0 秒(now=0):

A = (1,000,000 · (172800-0) + 4,000,000 · (0-0)) / 172800 = 1,000,000  ← 起点

过了 1 天(now = 86400,正好一半):

A = (1,000,000 · (172800-86400) + 4,000,000 · (86400-0)) / 172800
  = (1,000,000 · 86400 + 4,000,000 · 86400) / 172800
  = (1,000,000 + 4,000,000) / 2 = 2,500,000  ← 走到中点

到第 2 天(now = 172800):

A = (1,000,000 · 0 + 4,000,000 · 172800) / 172800 = 4,000,000  ← 终点

可以看到 A 平滑地从 100 万爬到 400 万,没有任何跳变。一旦 now ≥ t1,函数直接返回目标值 A1,ramp 结束。

这种”按时间线性插值”的设计在 DeFi 里很常见(V1 也有),核心目的就是避免参数突变被套利


9. get_virtual_price:LP 份额值多少

get_virtual_price() 返回每一份 LP token 当前值多少(以池子的基础单位计)。回忆第 2 章:

virtual_price = xcp / totalSupply
xcp = 池子在"完全平衡"假设下的恒定乘积价值
  • 池子刚创建时 virtual_price = 1e18(即 1.0)。
  • 之后随着手续费收入累积,xcp 增长,virtual_price 缓慢上升。
  • 它是衡量 LP 真实收益 的核心指标,也是第 7 章判断”能不能重锚”的关键输入(virtual_price · 2 - 1 > xcp_profit + ...)。

注意区分两个相关量:

  • virtual_price当前每份 LP 的价值。
  • xcp_profit累计的盈利水位线(历史峰值附近),用来和 virtual_price 比较,决定是否有富余利润去重锚。

10. 把所有状态变量串起来看一遍

下面这张”地图”把本章和前一章的概念归位,建议反复看:

┌─────────────────────── 曲线形状参数(很少变,靠 ramp 缓变) ──────────────────────┐
│  A          放大系数(硬度)          ─┐                                          │
│  gamma      低滑点窗口宽窄            ─┘ 打包进 future_A_gamma / initial_A_gamma   │
└──────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────── 价格三件套(第 7 章重点) ────────────────────────────────┐
│  price_scale     流动性当前集中在哪个价(内部"中心价")  → price_scale_packed      │
│  price_oracle    EMA 跟踪的"真实市场价"                 → price_oracle_packed     │
│  last_prices     上一笔成交价                           → last_prices_packed      │
└──────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────── 规模与盈利 ──────────────────────────────────────────────┐
│  balances[i]     三种币的真实余额                                                 │
│  D               不变量(池子规模刻度)                                            │
│  virtual_price   每份 LP 当前价值                                                 │
│  xcp_profit      累计盈利水位线(决定能否 repeg)                                   │
│  totalSupply     LP token 总量                                                   │
└──────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────── 费用与重锚参数 ──────────────────────────────────────────┐
│  packed_fee_params         = [mid_fee, out_fee, fee_gamma]   (第 4 章)          │
│  packed_rebalancing_params = [allowed_extra_profit, adjustment_step, ma_time]    │
│                                                              (第 7 章)          │
│  packed_precisions         = 三种币的精度乘数                                      │
└──────────────────────────────────────────────────────────────────────────────┘

11. 本章小结

  1. 一个 V2 池子由三个合约协作:主合约(改状态)、Math 库(算数)、Views 库(报价),拆开是为了绕过 24KB 字节码上限。
  2. 练习池是主网 TriCrypto:coin0=USDC、coin1=WBTC、coin2=WETH
  3. 主合约把许多小值打包进 uint256 以省 gas:_pack 用”左移 + 按位或”装,_unpack 用”右移 + 掩码”拆。
  4. price_scale 只存 N-1 = 2 个价格(coin0 是基准价=1),所以 price_scale(i) 对应 coin[i+1]
  5. Agamma 调整必须 ramp(线性插值缓变),防止参数突变被套利;_A_gamma() 按时间比例算当前值。
  6. get_virtual_price() 给出每份 LP 的真实价值,是 LP 收益和重锚判断的核心指标。

12. 动手练习

目标:把第 2 章练习升级一下——这次不仅算 transformed balances,还要完整读出并理解池子的全部核心状态变量,建立对”合约状态长什么样”的直观。

练习目标

在主网分叉环境下,针对 TriCrypto 池(0x7f86bf177dd4f3494b841a37e810a34dd56c829b)写一个 Foundry 测试,读取并打印以下状态,并对它们做基本断言:

  1. A()gamma()
  2. price_scale(0)(WBTC 内部价)和 price_scale(1)(WETH 内部价)
  3. price_oracle(0)price_oracle(1)(EMA 价)
  4. balances(0..2)D()
  5. get_virtual_price()xcp_profit()

你需要的接口(自己补全)

interface ITriCrypto {
    function A() external view returns (uint256);
    function gamma() external view returns (uint256);
    function price_scale(uint256 i) external view returns (uint256);
    function price_oracle(uint256 i) external view returns (uint256);
    function balances(uint256 i) external view returns (uint256);
    function D() external view returns (uint256);
    function get_virtual_price() external view returns (uint256);
    function xcp_profit() external view returns (uint256);
    function precisions() external view returns (uint256[3] memory);
}

解题思路(自己实现)

  1. 全部 console2.log 出来,注意 %e 科学计数法更易读。
  2. 验证你对 price_scale 下标的理解price_scale(0) 应该是一个 6 万量级的数(WBTC≈$60k,结果是 6e22 这种带 1e18 的形式),price_scale(1) 应该是 3 千量级(WETH)。如果反了,说明你下标理解错了。
  3. 验证 virtual_price ≥ 1e18:断言 get_virtual_price() >= 1e18。因为它从 1.0 开始只增不减(手续费累积),任何成熟的池子都应 > 1e18。
  4. 验证 price_oracle 与 price_scale 接近:计算两者比值,断言它们相差不超过比如 20%。这印证了第 2 章说的”平时 oracle 和 scale 贴得很近,分离够大才 repeg”。

运行

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

进阶思考(选做)

  • 自己用第 5 节的位运算,手动从 price_scale_packed(如果能读到原始 storage slot)里拆出两个价格,和 price_scale(0/1) 的返回值对比,验证你真的理解了打包。
  • 观察 xcp_profit 的值(应该是 1.0x e18 形式,比如 1.03e18 表示池子历史累计赚了约 3%),思考它和 virtual_price 的关系。

下一章(第 4 章 Exchange)进入第一个真正的”动作”:exchange 函数怎么完成一次兑换,get_dy 怎么报价,以及 V2 独有的动态手续费是怎么算的。

💬 评论