Curve Cryptoswap 第7章 价格重锚:EMA 预言机与 tweak_price

揭开重锚机制:抗操纵的 EMA 内部预言机、tweak_price 的利润与距离两道闸门、price_scale 小步移动与 xcp_profit 盈亏记录。

16 分钟阅读
Curve Cryptoswap 第7章 价格重锚:EMA 预言机与 tweak_price

Curve Cryptoswap 第 7 章:价格重锚(Price Repeg)

这是整门课的高潮。前面我们一直说”流动性集中在 price_scale 附近,市场价变了就重锚”。这一章彻底揭开重锚的机制:内部价格预言机(EMA)如何抗操纵地跟踪市场价;tweak_price 如何决定何时重锚、移动多少xcp_profit 如何记录盈亏,确保重锚永远不会让 LP 亏本。看完这一章,你就真正理解了 Curve V2 为什么是 DeFi 工程的巅峰之一。


目录


1. 重锚要解决的核心矛盾

Curve V2 把流动性集中在 price_scale 附近来获得低滑点。但这带来一个矛盾:

  • 集中才能低滑点(资本效率高)。
  • 可市场价会动,集中错地方就会让 LP 持续受损。

解决方案是 repeg(重锚):让 price_scale 自动追着市场价走,把流动性”搬”到正确的位置。但搬运本身有代价(第 2 章的 repegging loss),所以必须:

  1. 准确感知市场价(且不能被人为操纵)→ 用 EMA 预言机。
  2. 只在划算时才搬(用赚到的手续费补偿搬运损失)→ 利润闸门。
  3. 每次只搬一小步(损失可控、平滑)→ adjustment_step。
  4. 永远不亏本(搬完要验证 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
1110110·0.2 + 100·0.8 = 22 + 80102.00
2110110·0.2 + 102·0.8 = 22 + 81.6103.60
3110110·0.2 + 103.6·0.8 = 22 + 82.88104.88
49090·0.2 + 104.88·0.8 = 18 + 83.90101.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. 数学与代码的差异

课程特意讲了”数学公式”和”合约实现”的几点差异,值得注意:

  1. 连续 vs 离散:数学上 EMA 是连续积分;代码里是离散递推(每块一次),用 exp(-dt/ma_time) 近似连续衰减。
  2. 定点数运算:合约没有浮点,所有运算用 1e18 定点 + wad_expisqrtgeometric_mean 等定点工具函数,会有舍入。
  3. 价格 cap:纯数学的 EMA 不会有 min(·, 2·price_scale) 这种上限,这是代码为了安全额外加的。
  4. 每块一次 + 用上一笔的价:预言机用的是”上一个区块那笔交易的 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.04allowed_extra_profit = 0adjustment_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 tokenmint_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_timeEMA 时间常数(半衰期 ≈ 0.693·ma_time)预言机更平滑/抗操纵,但跟踪市场更慢
allowed_extra_profit重锚前要攒的额外利润垫(闸门一)重锚更保守,要赚更多才重锚
adjustment_step每次重锚移动 price_scale 的最小步长(闸门二 + 移动量)单步移动更大/触发门槛更高

加上费用相关的三个(mid_feeout_feefee_gamma,第 4 章)和曲线形状的两个(Agamma,第 2/3 章),就构成了 Curve V2 池子的完整调参面板。调好这些参数是 Curve V2 上线一个新池子最核心的工作。


16. 本章小结

  1. 重锚要同时做到:准确感知市场价、只在划算时搬、每次搬一小步、永不亏本
  2. 三个价格:last_prices(瞬时成交价)→ EMA 平滑成 price_oracle(市场真值)→ 触发 price_scale(中心价)移动。
  3. EMA:新EMA = 价·(1-alpha) + 旧EMA·alpha,其中 alpha = exp(-dt/ma_time),间隔越久新价权重越大;每块最多更一次
  4. 半衰期 ≈ 0.693·ma_time,是抗操纵(大 ma_time)和跟踪速度(小 ma_time)的权衡。
  5. 抗操纵三层防御:EMA 平滑 + 价格 cap(min(last_price, 2·price_scale))+ 每块一次。
  6. tweak_price 两道闸门:利润闸门vp·2-1 > xcp_profit + 2·allowed_extra_profit)+ 距离闸门norm > adjustment_step)。
  7. price_scale 朝 oracle 线性插值移动一小步(比例 s = adjustment_step/norm),且移动后还要二次校验 virtual_price 不亏才提交。
  8. xcp_profit 是盈利水位线,重锚只动用富余利润;_claim_admin_fees 通过给协议铸 LP 来分润,不动用真实余额。

17. 动手练习

对应课程的 Price Scale / repeg 相关练习。重锚很难在单笔测试里”自然触发”(需要价格漂移 + 利润累积),所以练习分两层:先做能跑的、再做模拟实验。

练习 1(基础,能跑):读取并对比三个价格

主网分叉,针对 TriCrypto:

  1. 读取 price_scale(0/1)price_oracle(0/1)last_prices(0/1)(若有该 getter)。
  2. 计算 price_oracle / price_scale 的偏离比例,打印出来。
  3. 断言偏离通常很小(比如 < 20%)——印证”平时 oracle 和 scale 贴得很近”。
  4. 读取 virtual_price()xcp_profit(),验证 virtual_price >= 1e18 且两者接近。

练习 2(进阶,模拟重锚):用大额交易推动价格,触发重锚

思路(自己实现):

  1. 记录初始 price_scale(1)(WETH 内部价)和 virtual_price
  2. 制造价格漂移:用 deal 给自己大量 USDC,连续多次 exchange 大额买入 WETH(i=0, j=2),把池子 WETH 价推高。
  3. 推进时间和区块:每笔交易之间用 vm.warp(block.timestamp + 600)vm.roll(block.number + 1) 让 EMA 有机会更新(记住每块只更一次)。
  4. 多轮之后,再读 price_scale(1),观察它是否向上移动了(重锚发生)。
  5. 对比 price_oracle(1)price_scale(1),看 scale 是否在追 oracle。

关键技巧:重锚需要”利润闸门”通过,而你的大额交易会产生手续费抬高 virtual_price,同时把价格推离——两个条件可能同时满足。多试几次交易规模和时间间隔。

练习 3(数学验证,选做):手算一次 EMA + 重锚

  1. 从链上读出当前 price_oracleprice_scalema_time
  2. 假设下一笔交易把 last_price 推到某个值、间隔 dt=600 秒,手算 alpha = exp(-600/ma_time) 和新的 price_oracle
  3. 再用第 10、11 节的公式手算:这次是否满足闸门、price_scale 会移动到哪。
  4. (高级)在 Foundry 里真实执行同样的交易,把合约算出的新 price_oracleprice_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 到底怎么算出现货价”的根本问题。

💬 评论