Curve Cryptoswap 第 7 章:价格重锚(Price Repeg)
这是整门课的高潮。前面我们一直说”流动性集中在 price_scale 附近,市场价变了就重锚”。这一章彻底揭开重锚的机制:内部价格预言机(EMA)如何抗操纵地跟踪市场价;
tweak_price如何决定何时重锚、移动多少;xcp_profit如何记录盈亏,确保重锚永远不会让 LP 亏本。看完这一章,你就真正理解了 Curve V2 为什么是 DeFi 工程的巅峰之一。
目录
- 1. 重锚要解决的核心矛盾
- 2. 三个价格:price_scale / last_prices / price_oracle
- 3. EMA 指数移动平均:规则时间间隔
- 4. 案例:一步步算 EMA
- 5. 不规则时间间隔:alpha 随时间动态调整
- 6. 半衰期(half-life):直观理解 ma_time
- 7. 抗操纵设计:价格上限 cap
- 8. 数学与代码的差异
- 9. tweak_price 全流程
- 10. 重锚的两道闸门:利润够不够 + 距离够不够
- 11. price_scale 到底移动多少
- 12. 案例:一次完整的重锚计算
- 13. xcp_profit 如何记录盈亏
- 14. claim_admin_fees:协议怎么分钱
- 15. 三个调参的全局意义
- 16. 本章小结
- 17. 动手练习
1. 重锚要解决的核心矛盾
Curve V2 把流动性集中在 price_scale 附近来获得低滑点。但这带来一个矛盾:
- 集中才能低滑点(资本效率高)。
- 可市场价会动,集中错地方就会让 LP 持续受损。
解决方案是 repeg(重锚):让 price_scale 自动追着市场价走,把流动性”搬”到正确的位置。但搬运本身有代价(第 2 章的 repegging loss),所以必须:
- 准确感知市场价(且不能被人为操纵)→ 用 EMA 预言机。
- 只在划算时才搬(用赚到的手续费补偿搬运损失)→ 利润闸门。
- 每次只搬一小步(损失可控、平滑)→ adjustment_step。
- 永远不亏本(搬完要验证 LP 价值没下降)→ virtual_price 二次校验。
这一章就是把这四件事讲透。
2. 三个价格:price_scale / last_prices / price_oracle
务必区分清楚这三个”价格”,它们是本章的主角:
| 名称 | 含义 | 变化频率 |
|---|---|---|
| last_prices | 池子上一笔成交的现货价(spot price,由 get_p 算出) | 每笔交易 |
| price_oracle | 对 last_prices 做 EMA 平滑后的”内部预言机价”,代表池子认为的”真实市场价” | 平滑跟踪 |
| price_scale | 流动性当前集中在哪个价(“中心价”)。重锚就是移动它 | 很少变(满足条件才动一小步) |
它们的关系是一条传导链:
每笔交易产生 last_prices(瞬时、易波动)
│ 经过 EMA 平滑(抗噪声、抗操纵)
▼
price_oracle(平滑的"市场真值")
│ 当它与 price_scale 偏离够大 & 利润够多时
▼
price_scale 朝 price_oracle 移动一小步(= 重锚)
为什么不直接用 last_prices 来重锚? 因为 last_prices 是单笔成交价,极易被操纵:攻击者用一笔大额交易就能把它打到任意值,骗池子在错误价格重锚。EMA 的平滑正是为了抵抗这种操纵。
3. EMA 指数移动平均:规则时间间隔
EMA(Exponential Moving Average,指数移动平均)是一种”越新的数据权重越大、越旧的数据权重指数衰减”的平均方式。基本递推公式(假设每次间隔固定):
新 EMA = 当前价 · (1 - alpha) + 旧 EMA · alpha
alpha ∈ (0, 1)是”记忆系数”:- alpha 越接近 1 → 越看重历史 → EMA 越平滑、越迟钝。
- alpha 越接近 0 → 越看重当前价 → EMA 越灵敏、越抖动。
Curve 不用固定 alpha,而是用时间算出来(见第 5 节),但先用固定 alpha 理解递推。
4. 案例:一步步算 EMA
设 alpha = 0.8(比较平滑),初始 EMA = 100。然后连续来了几笔成交价:
| 步骤 | 当前成交价 | 计算 | 新 EMA |
|---|---|---|---|
| 起点 | — | — | 100.00 |
| 1 | 110 | 110·0.2 + 100·0.8 = 22 + 80 | 102.00 |
| 2 | 110 | 110·0.2 + 102·0.8 = 22 + 81.6 | 103.60 |
| 3 | 110 | 110·0.2 + 103.6·0.8 = 22 + 82.88 | 104.88 |
| 4 | 90 | 90·0.2 + 104.88·0.8 = 18 + 83.90 | 101.90 |
观察:
- 价格从 100 跳到 110,EMA 没有立刻跳到 110,而是慢慢爬(102 → 103.6 → 104.88)。
- 第 4 步价格突然砸到 90,EMA 也只是温和回落到 101.9,没有剧烈反应。
这正是抗操纵的关键:哪怕攻击者把单笔价格打到 1000,EMA 一次也只吸收 (1-alpha) = 20% 的冲击,且攻击者要持续很多个区块维持高价才能慢慢推动 EMA——成本极高,期间还会被套利者反向收割。
5. 不规则时间间隔:alpha 随时间动态调整
区块链上交易不是等间隔发生的。两笔交易可能隔 12 秒,也可能隔 1 小时。如果用固定 alpha,间隔长短一样对待,就不对了——隔得越久,旧 EMA 应该越”过时”,新价权重应该越大。
Curve 的做法是让 alpha 依赖时间间隔 dt:
alpha = exp( - dt / ma_time )
dt = block.timestamp - last_prices_timestamp(距上次更新过了多久)。ma_time是预言机的”时间常数”(一个可配置参数)。
合约里就是这行(wad_exp 是定点数的 e 指数函数):
alpha = MATH.wad_exp( - (block.timestamp - last_prices_timestamp)·1e18 / ma_time )
理解 alpha = exp(-dt/ma_time):
- dt 很小(交易刚发生过)→ -dt/ma_time ≈ 0 → alpha ≈ exp(0) = 1 → 几乎全保留旧 EMA(新价权重≈0)。合理:刚更新过,没必要大改。
- dt 很大(很久没交易)→ -dt/ma_time 很负 → alpha ≈ 0 → 几乎全采用新价。合理:旧 EMA 太老了,该大幅更新。
新 EMA 公式不变:新EMA = 当前价·(1-alpha) + 旧EMA·alpha,只是 alpha 现在由时间决定。
而且 EMA 每个区块最多更新一次(
if last_prices_timestamp < block.timestamp),防止同一区块内多笔交易反复刷预言机。
6. 半衰期(half-life):直观理解 ma_time
ma_time 这个参数有点抽象,用半衰期来理解最直观:
半衰期 = EMA 把”旧值的影响”衰减到一半所需的时间。
由 alpha = exp(-dt/ma_time) = 0.5 解出:
dt_half = ma_time · ln(2) ≈ 0.693 · ma_time
举例:如果 ma_time 对应半衰期是 600 秒(10 分钟),意思是——价格发生一次持续变化后,大约 10 分钟,EMA 会走完到新价格一半的路程;再过 10 分钟,再走剩下的一半……
- ma_time 大 → 半衰期长 → EMA 迟钝、超级抗操纵,但跟踪市场慢(重锚滞后)。
- ma_time 小 → 半衰期短 → EMA 灵敏、跟踪快,但更容易被操纵。
这是一个安全性 vs 灵敏度的权衡,由治理来设定。
7. 抗操纵设计:价格上限 cap
即便有 EMA,Curve 还加了一道保险——把喂进 EMA 的价格上限锁死:
new EMA = min(last_price, 2 · price_scale)·(1 - alpha) + old EMA·alpha
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 价格被 cap 在 2 倍 price_scale
含义:喂给 EMA 的成交价最多不超过当前 price_scale 的 2 倍。
为什么?假设攻击者用一笔巨额交易把 last_price 打到 100 倍。没有 cap 的话,即使 EMA 只吸收一小部分,长期也可能被推动。有了 cap,单次最多按 “2×price_scale” 计入,攻击者能注入的”毒性价格”被严格封顶,操纵成本进一步飙升。
这是典型的”纵深防御”:EMA 平滑(时间维度)+ 价格 cap(幅度维度)+ 每块一次(频率维度),三层叠加抗操纵。
8. 数学与代码的差异
课程特意讲了”数学公式”和”合约实现”的几点差异,值得注意:
- 连续 vs 离散:数学上 EMA 是连续积分;代码里是离散递推(每块一次),用
exp(-dt/ma_time)近似连续衰减。 - 定点数运算:合约没有浮点,所有运算用 1e18 定点 +
wad_exp、isqrt、geometric_mean等定点工具函数,会有舍入。 - 价格 cap:纯数学的 EMA 不会有
min(·, 2·price_scale)这种上限,这是代码为了安全额外加的。 - 每块一次 + 用上一笔的价:预言机用的是”上一个区块那笔交易的 last_price + 更早记录的 oracle”,避免用当前正在进行的交易价(防自我操纵)。
记住一句话:数学告诉你”想做什么”,代码还要额外处理”怎么安全且省 gas 地做到”。
9. tweak_price 全流程
tweak_price 是重锚的总指挥,每次 exchange / add / remove_one_coin 后都会调用。完整流程:
① 更新 EMA(每块一次)
if last_prices_timestamp < block.timestamp:
alpha = exp(-dt/ma_time)
price_oracle = min(last_price, 2·price_scale)·(1-alpha) + price_oracle·alpha
② 算这笔交易的新现货价
last_prices = get_p(xp, D, A_gamma) # 池子当前现货价
(存入 last_prices_packed,供下次 EMA 用)
③ 更新盈利计数(不带价格调整)
算出当前 virtual_price,更新 xcp_profit(见第 13 节)
④ 第一道闸门:利润够不够?
if virtual_price·2 - 1 > xcp_profit + 2·allowed_extra_profit:
进入重锚评估
else:
不重锚,仅更新 D 和 virtual_price,返回
⑤ 第二道闸门:price_oracle 离 price_scale 够远吗?
norm = price_oracle 与 price_scale 的向量距离
adjustment_step = max(参数step, norm/5)
if norm > adjustment_step:
计算 p_new(price_scale 朝 oracle 移动一步)
⑥ 用 p_new 重算 D 和 virtual_price,做最终校验:
if 新 virtual_price > 1 且 2·新vp - 1 > xcp_profit:
提交重锚(写入新 price_scale、D、virtual_price)
else:
放弃这次重锚(因为会亏)
下面把闸门和移动量讲细。
10. 重锚的两道闸门:利润够不够 + 距离够不够
闸门一:利润闸门
if virtual_price · 2 - 1e18 > xcp_profit + 2 · allowed_extra_profit:
virtual_price:当前每份 LP 真实价值。xcp_profit:累计盈利水位线。allowed_extra_profit:要求的额外安全利润垫。
直觉(设 allowed_extra_profit=0 时):条件变成 virtual_price > (1 + xcp_profit)/2,即当前价值要高过”盈利水位线和起点的中点”。也就是说,必须先攒够利润,才允许动用其中一部分去支付重锚损失。allowed_extra_profit 越大,要求攒的利润越多,重锚越保守。
闸门二:距离闸门
norm = |price_oracle / price_scale - 1| 的向量长度(多币时是欧氏距离)
adjustment_step = max(adjustment_step参数, norm / 5)
if norm > adjustment_step:
才真正计算并移动 price_scale
如果 oracle 和 scale 几乎重合(norm 很小),说明流动性还集中在对的地方,没必要折腾,直接跳过。这避免了无意义的频繁微调(每次微调都有 gas 和 repegging loss)。
11. price_scale 到底移动多少
通过两道闸门后,新的 price_scale 这样算:
p_new = price_scale·(norm - adjustment_step)/norm + price_oracle·adjustment_step/norm
= price_scale·(1 - s) + price_oracle·s 其中 s = adjustment_step / norm
这是 price_scale 和 price_oracle 之间的线性插值,插值比例 s = adjustment_step/norm ∈ (0, 1]:
- price_scale 不会一步跳到 price_oracle,而是朝它移动
s这么大的比例。 s越大移得越多。因为adjustment_step = max(参数, norm/5),所以每步最多移动约norm/5(20%)或参数下限那么多。
多次交易 → 多次小步移动 → price_scale 平滑地逼近市场价。 这就是”重锚是渐进的、不是瞬间的”。
最后的安全校验
算出 p_new 后,合约会用它重新计算 D 和 virtual_price,然后再检查一次:
if 新 virtual_price > 1e18 and 2·新virtual_price - 1e18 > xcp_profit:
提交重锚
else:
放弃(这次移动会让 LP 亏,绝不执行)
这是最后一道保险:哪怕前面闸门都过了,如果实际算出来移动后 LP 价值会跌破安全线,就直接放弃这次重锚。这保证了 repeg 永远不会让 virtual_price 亏到本金以下。
12. 案例:一次完整的重锚计算
简化成两币(只看 WBTC 对 USDC 这一个价格维度),数字为演示用。
初始状态:
price_scale = 60,000(流动性集中在 BTC=$60k)- 经过一段时间,BTC 市场价涨到 $66,000,EMA 慢慢跟上:
price_oracle = 65,000 virtual_price = 1.05(池子赚了 5% 手续费),xcp_profit = 1.04,allowed_extra_profit = 0,adjustment_step 参数 = 0.0001
闸门一(利润):
virtual_price·2 - 1 = 1.05·2 - 1 = 1.10
xcp_profit + 0 = 1.04
1.10 > 1.04 ✅ 通过
闸门二(距离):
ratio = price_oracle / price_scale = 65,000 / 60,000 = 1.0833
norm = |1.0833 - 1| = 0.0833
adjustment_step = max(0.0001, 0.0833/5) = max(0.0001, 0.01667) = 0.01667
norm(0.0833) > adjustment_step(0.01667) ✅ 通过
计算移动:
s = adjustment_step / norm = 0.01667 / 0.0833 = 0.2 (移动 20%)
p_new = price_scale·(1-s) + price_oracle·s
= 60,000·0.8 + 65,000·0.2
= 48,000 + 13,000 = 61,000
price_scale 从 60,000 移到 61,000(朝 65,000 走了 20% 的路)。注意它没有直接跳到 65,000——这是有意的小步。
最终校验: 用 price_scale=61,000 重算 virtual_price,假设得到 1.048,检查 2·1.048 - 1 = 1.096 > xcp_profit(1.04) ✅,提交重锚。
结果: 流动性现在集中在 BTC=$61,000 附近,更接近市场的 $66,000。下一笔交易后会继续往上挪,逐步追上。每一步都用赚来的利润支付了那一点点 repegging loss。
13. xcp_profit 如何记录盈亏
xcp_profit 是池子的”累计盈利倍数”,从 1.0(1e18)开始。它在 tweak_price 里这样更新:
xcp_profit = old_xcp_profit · virtual_price / old_virtual_price
含义:每次池子价值(virtual_price)相对上次增长了多少倍,就把这个增长乘进 xcp_profit。
- 交易手续费让 virtual_price 上涨 → xcp_profit 增长(记录真实盈利)。
- repegging loss 会让 virtual_price 略降 → 但因为有闸门保证”只在利润足够时才重锚”,xcp_profit 整体仍是增长的。
- 合约还有一处断言
assert virtual_price > old_virtual_price, "Loss"(在非 ramp 时),禁止 virtual_price 在非重锚路径上下跌,从代码层面杜绝 LP 被悄悄亏损。
简单说:xcp_profit 是”水位线”,virtual_price 是”当前水位”,重锚只能动用水位高出水位线的那部分富余。
14. claim_admin_fees:协议怎么分钱
Curve 协议要从 LP 的收益里抽一部分作为协议收入(admin fee)。这通过 _claim_admin_fees() 完成(在 add/remove liquidity 时触发)。
核心逻辑:
# 自上次结算以来,池子新赚的利润
fees = (xcp_profit - xcp_profit_a) · ADMIN_FEE / (2 · 1e10)
xcp_profit_a是”上次结算时的盈利水位”。xcp_profit - xcp_profit_a就是这段时间新增的盈利。- 取其中
ADMIN_FEE比例的一半作为协议份额。
然后协议份额不是直接转币,而是给 fee_receiver 铸造相应价值的 LP token(mint_relative):
# 把总供应量按 vprice/(vprice - fees) 的比例放大,相当于稀释出协议的份额
frac = vprice·1e18/(vprice - fees) - 1e18
mint_relative(receiver, frac)
xcp_profit -= fees · 2 # 把已分配的利润从水位里扣掉
直觉:协议拿走的利润,相当于让协议也成为一个 LP,铸造给它对应价值的 LP token,从而稀释出协议应得的份额。结算后更新 xcp_profit_a,作为下次的水位起点。
这种”铸 LP 而非转币”的方式很巧妙:不需要从池子里抽走真实代币(不破坏池子余额/平衡),只是在 LP 份额层面记账。
15. 三个调参的全局意义
到这里,Curve V2 的三个重锚参数就全部出场了,汇总它们各管什么:
| 参数 | 管什么 | 调大的影响 |
|---|---|---|
ma_time | EMA 时间常数(半衰期 ≈ 0.693·ma_time) | 预言机更平滑/抗操纵,但跟踪市场更慢 |
allowed_extra_profit | 重锚前要攒的额外利润垫(闸门一) | 重锚更保守,要赚更多才重锚 |
adjustment_step | 每次重锚移动 price_scale 的最小步长(闸门二 + 移动量) | 单步移动更大/触发门槛更高 |
加上费用相关的三个(mid_fee、out_fee、fee_gamma,第 4 章)和曲线形状的两个(A、gamma,第 2/3 章),就构成了 Curve V2 池子的完整调参面板。调好这些参数是 Curve V2 上线一个新池子最核心的工作。
16. 本章小结
- 重锚要同时做到:准确感知市场价、只在划算时搬、每次搬一小步、永不亏本。
- 三个价格:
last_prices(瞬时成交价)→ EMA 平滑成price_oracle(市场真值)→ 触发price_scale(中心价)移动。 - EMA:
新EMA = 价·(1-alpha) + 旧EMA·alpha,其中alpha = exp(-dt/ma_time),间隔越久新价权重越大;每块最多更一次。 - 半衰期 ≈
0.693·ma_time,是抗操纵(大 ma_time)和跟踪速度(小 ma_time)的权衡。 - 抗操纵三层防御:EMA 平滑 + 价格 cap(
min(last_price, 2·price_scale))+ 每块一次。 tweak_price两道闸门:利润闸门(vp·2-1 > xcp_profit + 2·allowed_extra_profit)+ 距离闸门(norm > adjustment_step)。- price_scale 朝 oracle 线性插值移动一小步(比例
s = adjustment_step/norm),且移动后还要二次校验 virtual_price 不亏才提交。 xcp_profit是盈利水位线,重锚只动用富余利润;_claim_admin_fees通过给协议铸 LP 来分润,不动用真实余额。
17. 动手练习
对应课程的 Price Scale / repeg 相关练习。重锚很难在单笔测试里”自然触发”(需要价格漂移 + 利润累积),所以练习分两层:先做能跑的、再做模拟实验。
练习 1(基础,能跑):读取并对比三个价格
主网分叉,针对 TriCrypto:
- 读取
price_scale(0/1)、price_oracle(0/1)、last_prices(0/1)(若有该 getter)。 - 计算
price_oracle / price_scale的偏离比例,打印出来。 - 断言偏离通常很小(比如 < 20%)——印证”平时 oracle 和 scale 贴得很近”。
- 读取
virtual_price()和xcp_profit(),验证virtual_price >= 1e18且两者接近。
练习 2(进阶,模拟重锚):用大额交易推动价格,触发重锚
思路(自己实现):
- 记录初始
price_scale(1)(WETH 内部价)和virtual_price。 - 制造价格漂移:用
deal给自己大量 USDC,连续多次exchange大额买入 WETH(i=0, j=2),把池子 WETH 价推高。 - 推进时间和区块:每笔交易之间用
vm.warp(block.timestamp + 600)和vm.roll(block.number + 1)让 EMA 有机会更新(记住每块只更一次)。 - 多轮之后,再读
price_scale(1),观察它是否向上移动了(重锚发生)。 - 对比
price_oracle(1)和price_scale(1),看 scale 是否在追 oracle。
关键技巧:重锚需要”利润闸门”通过,而你的大额交易会产生手续费抬高 virtual_price,同时把价格推离——两个条件可能同时满足。多试几次交易规模和时间间隔。
练习 3(数学验证,选做):手算一次 EMA + 重锚
- 从链上读出当前
price_oracle、price_scale、ma_time。 - 假设下一笔交易把
last_price推到某个值、间隔 dt=600 秒,手算alpha = exp(-600/ma_time)和新的price_oracle。 - 再用第 10、11 节的公式手算:这次是否满足闸门、price_scale 会移动到哪。
- (高级)在 Foundry 里真实执行同样的交易,把合约算出的新
price_oracle、price_scale和你手算的对比,验证你的理解。
运行
forge test --evm-version cancun --fork-url $FORK_URL \
--match-path test/CurveV2Repeg.t.sol -vvv
下一章(第 8 章 Footnote)是一个数学附录:用”隐函数求导”这个技巧,统一地推导恒定和、恒定乘积、Curve V1/V2 的现货价 dy/dx——回答”get_p 到底怎么算出现货价”的根本问题。