Curve V1 第 3 章:合约总览(Contract Overview)
第 2 章讲了数学。这一章把视角切到代码:以主网经典的 3pool(DAI/USDC/USDT) 为例,看参数 A 怎么存储和缓慢调整、
_xp怎么把不同精度的稳定币拉到同一把尺子、get_virtual_price和calc_token_amount分别干什么。建立起对合约骨架的整体认识。
目录
- 1. 3pool:最经典的 Curve V1 池子
- 2. 用户视角:你会调用哪些函数
- 3. 参数 A 的存储与缓慢调整(ramp_A)
- 4. 案例:A 在 ramp 过程中的线性插值
- 5. 为什么必须缓慢 ramp 而不能瞬间改
- 6. _xp:把不同精度的稳定币统一
- 7. 案例:USDC 的 6 位小数怎么对齐
- 8. get_virtual_price:LP 份额值多少
- 9. calc_token_amount:预估铸/burn 多少 LP
- 10. 状态变量地图
- 11. 本章小结
- 12. 动手练习
1. 3pool:最经典的 Curve V1 池子
本课程练习基于以太坊主网的 3pool:
- 池子地址:
0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7 - LP token(3CRV):
0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490 - 三种币(下标固定):
coin[0]= DAI(18 位小数)coin[1]= USDC(6 位小数)coin[2]= USDT(6 位小数)
注意它和 Curve V2 TriCrypto 的最大区别:3pool 里三种币都≈1 美元(锚定),所以不需要 price_scale、不需要重锚。这让 V1 比 V2 简单很多。
2. 用户视角:你会调用哪些函数
| 函数 | 作用 | 章节 |
|---|---|---|
exchange(i, j, dx, min_dy) | 用 dx 个 coin[i] 换 coin[j] | 第 4 章 |
get_dy(i, j, dx) | 报价(只读) | 第 4 章 |
add_liquidity(amounts, min_mint) | 存币铸 LP | 第 5 章 |
remove_liquidity(amount, min_amounts) | 按比例取回三种币 | 第 6 章 |
remove_liquidity_imbalance(amounts, max_burn) | 按指定数量取回(要付失衡费) | 第 6 章 |
remove_liquidity_one_coin(amount, i, min) | 只取一种币(要付失衡费) | 第 6 章 |
A() / balances(i) | 读放大系数 / 余额 | 本章 |
get_virtual_price() | 每份 LP 的价值 | 本章 |
calc_token_amount(amounts, deposit) | 预估存/取会铸/burn 多少 LP | 本章 |
3. 参数 A 的存储与缓慢调整(ramp_A)
A 决定曲线形状(第 2 章)。管理员有时要调整 A,但不能瞬间跳变。合约用四个状态变量记录一次”渐变”:
initial_A+initial_A_time:起点 A 值和起点时间。future_A+future_A_time:目标 A 值和目标时间。
读取”当前 A”时(_A()),如果还在渐变区间内,就按时间比例线性插值:
def _A() -> uint256:
t1 = self.future_A_time
A1 = self.future_A
if block.timestamp < t1: # 还在 ramp 中
A0 = self.initial_A
t0 = self.initial_A_time
if A1 > A0: # 升 A
return A0 + (A1 - A0)·(now - t0)/(t1 - t0)
else: # 降 A
return A0 - (A0 - A1)·(now - t0)/(t1 - t0)
else: # ramp 结束
return A1
相关管理函数:
ramp_A(future_A, future_time):发起一次渐变。stop_ramp_A():立即停止渐变,把 A 钉在当前插值出的值。
4. 案例:A 在 ramp 过程中的线性插值
假设管理员要把 A 从 2000 升到 5000,ramp 持续 7 天(604800 秒),t0=0, t1=604800。
第 0 天(now=0):
A = 2000 + (5000-2000)·0/604800 = 2000 ← 起点
第 3.5 天(now=302400,正好一半):
A = 2000 + (5000-2000)·302400/604800 = 2000 + 3000·0.5 = 3500 ← 中点
第 7 天(now≥604800):
A = 5000 ← 终点(ramp 结束,直接返回 future_A)
A 平滑地从 2000 爬到 5000,没有任何跳变。
5. 为什么必须缓慢 ramp 而不能瞬间改
如果 A 瞬间从 2000 跳到 5000:
- 不变量 D 会瞬间改变(同样的余额,不同 A 算出的 D 不同)。
- 池子的报价(get_y 的结果)会瞬间跳动。
- 套利者可以精准夹击这个跳变:在跳变前后各做一笔交易,无风险套走价差,损失由 LP 承担。
缓慢 ramp 让 D 平滑变化,任何瞬间的套利空间都极小,从而保护 LP。这和 Curve V2 里 A/gamma 要 ramp 是同一个道理。
6. _xp:把不同精度的稳定币统一
3pool 里 DAI 是 18 位小数,USDC/USDT 是 6 位小数。直接拿 balances 丢进不变量会出错(合约会以为 1 个 DAI = 1e12 个 USDC)。
_xp() 把所有余额统一到 18 位精度:
def _xp() -> uint256[N_COINS]:
result = RATES # 每种币的换算率
for i in range(N_COINS):
result[i] = result[i] · balances[i] / LENDING_PRECISION
return result
其中 RATES 把每种币乘到统一精度:
- DAI(18 位):乘数相当于 ×1(已经是 18 位)。
- USDC(6 位):乘数 ×1e12(补齐到 18 位)。
- USDT(6 位):乘数 ×1e12。
注意:因为 3pool 全是稳定币(都≈$1),
_xp只做精度对齐,不像 V2 那样还要乘 price_scale。这是 V1 比 V2 简单的又一处体现。
7. 案例:USDC 的 6 位小数怎么对齐
假设 balances = [DAI: 1,000,000e18, USDC: 1,000,000e6, USDT: 1,000,000e6]。
经过 _xp:
xp[0] = 1,000,000e18 · 1 = 1,000,000e18 (DAI 已是 18 位)
xp[1] = 1,000,000e6 · 1e12 = 1,000,000e18 (USDC 补 12 位)
xp[2] = 1,000,000e6 · 1e12 = 1,000,000e18 (USDT 补 12 位)
三者都变成 1,000,000e18,量级一致,可以平等地参与不变量计算。如果不做这步,xp[0] 会比 xp[1]、xp[2] 大 1e12 倍,不变量会严重失真。
8. get_virtual_price:LP 份额值多少
def get_virtual_price() -> uint256:
D = get_D(_xp(), _A())
token_supply = token.totalSupply()
return D · PRECISION / token_supply # = D / LP总量,放大 1e18
含义:每一份 LP(3CRV)当前值多少(以”虚拟美元”计)。
- 池子初始时
virtual_price = 1e18(即 1.0)。 - 之后随着交易手续费累积进池子,D 增长但 LP 总量不变,
virtual_price缓慢上升。 - 它是衡量 LP 真实收益 的核心指标,且理论上只增不减(手续费收入),所以常被用作判断”无损”的基准。
9. calc_token_amount:预估铸/burn 多少 LP
def calc_token_amount(amounts, deposit) -> uint256:
D0 = get_D_mem(balances, A) # 操作前的 D
# 把 amounts 加上(存)或减去(取)
D1 = get_D_mem(new_balances, A) # 操作后的 D
diff = |D1 - D0|
return diff · totalSupply / D0 # 按 D 变化比例估算 LP 数量
用途:前端/集成方在存款或取款前预估会铸造/销毁多少 LP。
⚠️ 注意合约注释明确说:这是简化估算,不计手续费,只看滑点,“用来防抢跑,不用于精确计算”。也就是说,它给的是一个不含失衡费的近似值,真实 add_liquidity 拿到的 LP 会因失衡费而略少(第 5 章)。所以它适合做滑点保护参数(min_mint_amount)的参考,但别把它当成精确结果。
10. 状态变量地图
┌──────────── 曲线形状参数(靠 ramp 缓变) ─────────────┐
│ A 放大系数 → initial_A / future_A (+ 对应 time) │
└──────────────────────────────────────────────────────┘
┌──────────── 余额与规模 ───────────────────────────────┐
│ balances[i] 三种币真实余额(原始精度) │
│ _xp() 统一到 18 位精度的余额(喂给不变量) │
│ D 不变量(≈平衡时总币量,由 get_D 求) │
│ token (3CRV) LP token,totalSupply = LP 总量 │
│ virtual_price D / totalSupply,每份 LP 价值 │
└──────────────────────────────────────────────────────┘
┌──────────── 费用参数 ─────────────────────────────────┐
│ fee 交易手续费率(swap 时收) │
│ admin_fee 手续费里归协议的比例 │
│ 失衡费 = fee · N/(4(N-1)),加/减不平衡流动性时收 │
└──────────────────────────────────────────────────────┘
11. 本章小结
- 经典池是 3pool:coin0=DAI(18位)、coin1=USDC(6位)、coin2=USDT(6位),全锚定 → 无需 price_scale/重锚。
- A 的调整必须 ramp(线性插值缓变),防止 D 突变被套利;
ramp_A/stop_ramp_A管理。 _xp把不同精度的稳定币统一到 18 位(USDC/USDT ×1e12),是不变量计算的前置。get_virtual_price= D/LP总量,衡量每份 LP 价值,理论上只增不减。calc_token_amount预估存/取的 LP 数量,但不含手续费、仅供防抢跑参考,不精确。
12. 动手练习
目标:读出并理解 3pool 的核心状态,建立对合约的直观。
练习:读取 3pool 状态
主网分叉,针对 3pool(0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7):
interface IStableSwap3Pool {
function A() external view returns (uint256);
function balances(uint256 i) external view returns (uint256);
function get_virtual_price() external view returns (uint256);
function calc_token_amount(uint256[3] memory amounts, bool deposit) external view returns (uint256);
}
任务:
- 打印
A()(看真实 A 值)。 - 打印
balances(0/1/2),注意 DAI 是 18 位、USDC/USDT 是 6 位(数值量级差 1e12)。 - 打印
get_virtual_price(),断言>= 1e18。 - 调用
calc_token_amount([1_000_000e18, 0, 0], true),预估”存 100 万 DAI 能铸多少 3CRV”,打印出来。 - (进阶)下一章你会真正
add_liquidity存 100 万 DAI,到时把实际铸到的 LP 和这里的预估值对比——实际应略少,差额就是失衡费。
运行
forge test --evm-version cancun --fork-url $FORK_URL \
--match-path test/CurveV1Overview.t.sol -vvv
下一章(第 4 章 Swap)进入第一个动作:
exchange怎么完成兑换、get_y怎么定价、get_dy怎么报价,以及 Curve V1 在输出币上收手续费的细节。