Curve Cryptoswap 第 3 章:合约总览(Contract Overview)
第 2 章我们讲清了数学。这一章把视角切换到代码:要和池子交互需要调用哪些函数?那些数学量(A、gamma、price_scale、D、virtual_price……)在合约里到底长什么样、怎么存?为什么 Curve 要用一堆位运算把它们”打包”在一起?这一章建立起对整个合约骨架的整体认识,后面几章(exchange、add/remove liquidity、repeg)才不会迷路。
目录
- 1. 合约家族:三个文件各管什么
- 2. TriCrypto 池子:USDC / WBTC / WETH
- 3. 用户视角:你会调用哪些函数
- 4. 状态变量打包(packing):为什么要这么做
- 5. 案例:_pack / _unpack 逐位拆解
- 6. price_scale 的存储:少存一个价格的小技巧
- 7. A 与 gamma:为什么要”缓慢调整”(ramping)
- 8. 案例:A 在 ramp 过程中的线性插值
- 9. get_virtual_price:LP 份额值多少
- 10. 把所有状态变量串起来看一遍
- 11. 本章小结
- 12. 动手练习
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 源码第一眼最困惑的就是:明明有 A、gamma、price_scale[1]、price_scale[2]、各种 fee 参数……但存储里却看不到这么多变量,取而代之的是一堆叫 xxx_packed 的 uint256,配合大量位移 << >> 和掩码 &。
原因只有一个:省 gas。
EVM 每个存储槽(storage slot)是 256 位(uint256)。读写一个槽(SLOAD/SSTORE)非常贵。如果 A、gamma、两个价格分别各占一个槽,每次交易要读好几个槽。
但这些值其实都用不满 256 位:
A、gamma都 ≤ 大约 1e18 级别,128 位就够装。price_scale、precision、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. 本章小结
- 一个 V2 池子由三个合约协作:主合约(改状态)、Math 库(算数)、Views 库(报价),拆开是为了绕过 24KB 字节码上限。
- 练习池是主网 TriCrypto:coin0=USDC、coin1=WBTC、coin2=WETH。
- 主合约把许多小值打包进 uint256 以省 gas:
_pack用”左移 + 按位或”装,_unpack用”右移 + 掩码”拆。 price_scale只存 N-1 = 2 个价格(coin0 是基准价=1),所以price_scale(i)对应coin[i+1]。A、gamma调整必须 ramp(线性插值缓变),防止参数突变被套利;_A_gamma()按时间比例算当前值。get_virtual_price()给出每份 LP 的真实价值,是 LP 收益和重锚判断的核心指标。
12. 动手练习
目标:把第 2 章练习升级一下——这次不仅算 transformed balances,还要完整读出并理解池子的全部核心状态变量,建立对”合约状态长什么样”的直观。
练习目标
在主网分叉环境下,针对 TriCrypto 池(0x7f86bf177dd4f3494b841a37e810a34dd56c829b)写一个 Foundry 测试,读取并打印以下状态,并对它们做基本断言:
A()和gamma()price_scale(0)(WBTC 内部价)和price_scale(1)(WETH 内部价)price_oracle(0)、price_oracle(1)(EMA 价)balances(0..2)和D()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);
}
解题思路(自己实现)
- 全部
console2.log出来,注意%e科学计数法更易读。 - 验证你对 price_scale 下标的理解:
price_scale(0)应该是一个 6 万量级的数(WBTC≈$60k,结果是6e22这种带 1e18 的形式),price_scale(1)应该是 3 千量级(WETH)。如果反了,说明你下标理解错了。 - 验证 virtual_price ≥ 1e18:断言
get_virtual_price() >= 1e18。因为它从 1.0 开始只增不减(手续费累积),任何成熟的池子都应 > 1e18。 - 验证 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 独有的动态手续费是怎么算的。