写了个合约,想在"ETH 跌破 2000 美元"时自动清算。

于是你需要一个价格。而链上没有价格。

不只是价格——链上什么外部事实都没有。你的合约不能上网、不能读系统时钟、不能掷骰子、不能知道今天是星期几。它能看到的,只有链自己的状态。

这不是功能没做,是做不了:下面第一节会说明,只要允许合约读外部世界,共识当场就崩了。

一、链的封闭性是确定性的代价

一个合约不能发 HTTP 请求,不能读系统时钟,不能生成随机数。这不是没做,是不能做:

第 21 讲的前提:每个全节点必须【重新执行】每一笔交易,
                并得到【逐字节相同】的结果。

如果 EVM 里有一条 "读取 api.example.com" 指令:
   ⟹ 不同节点在不同时刻读到不同的值
   ⟹ 状态根不一致
   ⟹ ⚠️ 共识直接崩溃

打个比方

链像一间必须让所有人算出同一个答案的考场

正因如此,题目里不能出现"看看今天的天气"这种条件——每个人看到的天气都不一样,答案就对不上了。这不是考场保守,是"所有人答案必须一致"这个要求本身,把一切随时间、随位置变化的东西都排除在外了。

所以链的封闭性是确定性的必然结果,不是一个可以靠工程努力去掉的限制。 而绝大多数有用的合约又必须知道外部事实——价格、比赛结果、某笔电汇是否到账。

⚠️ 于是预言机的处境很尴尬:它要往一个"不许有外部输入"的系统里塞外部输入。 后面所有方案的复杂度都来自这个矛盾,而没有一个方案能真正消除它——只能把"该信谁"这个问题挪个位置。

预言机问题:如何把"外部世界的一个事实"变成"共识内的一条数据"。

这一讲和第 30 讲是同构的。 跨链桥要把"另一条链上发生了什么"引进来,预言机要把"链外世界发生了什么"引进来——两者都是在往一个自洽的密码学系统里,注入一个它无法自行验证的断言。 所以判据也是同一把尺子:谁能撒谎,撒谎的代价是什么。

二、为什么这个问题原则上无法被"解决"

链能够独立验证的东西,只有三类:

① 一个签名是否对应某个公钥        (第 7 讲)
② 一段数据的哈希是否等于某个值    (第 4 讲)
③ 自己的历史状态                  (第 12 讲)

现在看这句话:“ETH 现在是 3000 美元。”

⚠️ 它不属于上面任何一类。 它不是一个数学陈述,没有证明;它是一个关于世界的经验断言。链能验证的只是"某个公钥为这句话签了名",而签名只证明谁说的,不证明说得对

这个区别值得和第 28 讲摆在一起看:

能不能被证明正确 安全性来自
Rollup 状态转移 ——有效性证明是数学命题(第 28 讲) 密码学
预言机报价 ⚠️ 不能——它是经验断言 只能来自经济激励

结论:预言机不能被设计成"正确",只能被设计成"作恶不划算"。

一切预言机设计,本质上都是在回答同一个问题: 怎样让撒谎的成本高于撒谎的收益。

⚠️ 而这句话有一个直接推论:当被保护的资金规模超过某个数额时,撒谎就重新变得划算了。预言机的安全性有金额上限,而这个上限极少被写在文档里。

三、按信任假设分类

用第 30 讲那把尺子过一遍:

类型 谁能撒谎 撒谎的代价 你的钱依赖什么
单一签名者 那一个私钥的持有者 ⚠️ 几乎为零——名誉而已 纯信任
N-of-M 委员会 任意 N 个节点合谋 名誉 + 合约里可能的保证金 合谋的难度
质押 + 罚没 合谋方 可被链上没收的保证金 保证金 vs 可窃金额
乐观预言机 提交者,但可被挑战 提交时的押金会被没收 有人在盯 + 争议期够长
链内价格源(TWAP) 任何愿意付操纵成本的人 可以精确计算(第四节) 池子深度
零知识证明的链下计算 不能对计算过程撒谎 —— ⚠️ 但仍能对输入撒谎

最后一行是最容易被营销话术糊弄的地方:

"我们用 ZK 证明了数据的正确性" —— 这句话几乎总是被误读。

ZK 能证明的是:【给定输入 x,我确实按程序 f 算出了 f(x)】。
⚠️ 它【不能】证明 x 本身是真的。

⟹ 一个 ZK 预言机可以无懈可击地证明
      "我正确地转发了那个 API 返回的数字",
   而那个 API 返回的数字可以是任意编的。
⟹ 信任只是被搬了个位置,没有消失。

乐观预言机(UMA 式)值得单独说一句,因为它和第 27 讲是同一个结构:乐观地接受,留一个争议期,靠押金激励挑战者。它的安全性同样依赖那条第 27 讲的假设——至少有一个诚实且在线的挑战者,而且争议期必须长到足够他行动。

四、链内价格源:操纵成本可以精确计算

⭐ 这一节是考场比方里少见的好消息:有一类"外部事实"其实就在考场内部——链上的成交本身。它不需要任何人转述,因此"操纵它要花多少钱"可以被精确算出来。

链内价格源有一个别的方案没有的优点:攻击成本是可以算出来的。

现货价:一个区块内就能被推到任意位置

在恒定乘积池 x · y = L² 里,价格 p = y / x。把价格推到 k 倍:

   x' = x / √k        y' = y · √k        (乘积不变)

⟹ 攻击者需要投入的 Y 数量:
   ΔY = y · (√k − 1)

代入 k = 2(把价格推到两倍):ΔY = 0.414 · y——只要池子里 Y 储备的 41%。 而且用闪电贷,这笔钱不需要真的拥有:同一个区块内借、操纵、获利、还款。

⚠️ 所以:任何直接读取现货价的合约,都是在把自己的安全性交给"谁愿意在一个区块内付 41% 池子储备"。

TWAP:把成本从"一个区块"抬到"持续若干区块"

时间加权均价的定义:

             ∫ price dt              累加器(t₂) − 累加器(t₁)
   TWAP  =  ───────────   ⟹   =    ─────────────────────────
                Δt                          t₂ − t₁
// Uniswap V2 式的累积价格预言机。
// 关键设计:链上只存一个【累加器】,任何两个时刻的快照相减就得到那段区间的均价。
package oracle

import "math"

// Observation 是累加器在某一时刻的快照。
type Observation struct {
	Timestamp uint64  // 秒
	CumPrice  float64 // Σ price × dt
}

type Accumulator struct {
	lastTime uint64
	price    float64
	cum      float64
}

func NewAccumulator(t uint64, price float64) *Accumulator {
	return &Accumulator{lastTime: t, price: price}
}

// Update 在价格变化时,先把【上一段时间】按旧价格结算进累加器,再换新价格。
// 注意结算用的是旧价格——这正是"时间加权"的含义。
func (a *Accumulator) Update(t uint64, newPrice float64) {
	if t > a.lastTime {
		a.cum += a.price * float64(t-a.lastTime)
		a.lastTime = t
	}
	a.price = newPrice
}

func (a *Accumulator) Snapshot(t uint64) Observation {
	cum := a.cum
	if t > a.lastTime {
		cum += a.price * float64(t-a.lastTime)
	}
	return Observation{Timestamp: t, CumPrice: cum}
}

// TWAP 用两个快照求区间时间加权均价。
func TWAP(a, b Observation) float64 {
	dt := b.Timestamp - a.Timestamp
	if dt == 0 {
		return 0
	}
	return (b.CumPrice - a.CumPrice) / float64(dt)
}

// SpotCostRatio 返回把恒定乘积池的价格推到 k 倍,
// 需要投入的 Y 数量占池中 Y 储备的比例:√k − 1。
func SpotCostRatio(k float64) float64 { return math.Sqrt(k) - 1 }

// TWAPAttackCost 计算:想把长度为 window 秒的 TWAP 推到 k 倍,
// 而攻击者只能维持 hold 秒,那么这段时间里现货价必须被推到多高,
// 以及这需要多少倍的池子储备。
//
// 推导:TWAP = (p₀·(window−hold) + p_m·hold) / window = k·p₀
// ⟹ p_m / p₀ = (k·window − (window − hold)) / hold
func TWAPAttackCost(k, window, hold float64) (spotMultiple, costRatio float64) {
	spotMultiple = (k*window - (window - hold)) / hold
	return spotMultiple, SpotCostRatio(spotMultiple)
}

代入一个 30 分钟窗口的 TWAP,目标是把它推到 2 倍:

攻击者能维持的时长      现货价要被推到      需要的资金(× 池中 Y 储备)
──────────────────────────────────────────────────────────────────
 12 秒(一个区块)        ⭐ 151 倍            ⚠️  11.3 ×
 60 秒(五个区块)            31 倍                4.57 ×
300 秒(五分钟)               7 倍                1.65 ×
600 秒(十分钟)               4 倍                1.00 ×
──────────────────────────────────────────────────────────────────
(对照)直接读现货价,一个区块内                    0.41 ×

看第一行和最后一行的差距:从 0.41 倍池子储备,涨到 11.3 倍——操纵成本被抬高了约 27 倍,而且这 11.3 倍不能用闪电贷(闪电贷必须在一个区块内还清,而攻击要跨区块)。

更关键的是第二重代价:价格被推歪的每一秒,套利者都在从攻击者身上赚钱。维持时间越长,被套走的钱越多——所以真实成本比表里更高。

⚠️ 但 TWAP 不是免费的,它的代价是滞后:

真实市场在 10 分钟内暴跌 40%(这是常态,不是异常):
   ⟹ 30 分钟 TWAP 此刻给出的价格明显【高于】真实价格
   ⟹ 借贷协议按这个价格判断,会认为一批实际已经资不抵债的仓位仍然健康
   ⟹ 等 TWAP 跟上来时,坏账已经形成

⟹ ⭐ 窗口越长越抗操纵,也越滞后。这是一条直接的权衡曲线,没有最优解。

⚠️ 还有一条更朴素的ΔY = y(√k − 1) 里的 y 是池子储备。小池子的 TWAP 是纸糊的——窗口再长,乘上一个很小的 y 仍然是个很小的数。

五、推送与拉取:两种交付方式,两种不同的漏洞

推送(push) 拉取(pull)
谁触发更新 预言机网络主动写链 ⭐ 用户在交易里自带一份签名价格
触发条件 偏离阈值,或心跳超时 用时才取
成本 预言机付 Gas,无人用也在更新 用户付,按需
特有风险 ⚠️ 陈旧窗口——两次更新之间,链上的值是过期的 ⚠️ 择时——用户可以挑一个对自己有利的时刻提交

“择时"这个风险值得展开,因为它不直观:

拉取式预言机会签发一串带时间戳的价格,有效期比如 60 秒。
攻击者可以:
   ① 拿到 T 时刻的签名价格,先不用
   ② 观察后续 60 秒的行情
   ③ 如果行情朝有利方向走,就用【那个旧的】价格去清算/开仓

⟹ 这等于给了攻击者一个【免费的期权】,
   而这个期权的价值随有效期长度增加。

防御是把有效期压到极短(几秒),代价是用户体验和失败率上升——又是一条权衡曲线。

六、真实失效模式

事件 时间 涉及金额(约) 失效的是什么
bZx 2020-02 合计约 100 万美元 闪电贷操纵现货价
Harvest Finance 2020-10 约 3,400 万美元 闪电贷操纵 Curve 池内价格
Compound / DAI 2020-11 约 8,900 万美元被清算 预言机没有故障
Mango Markets 2022-10 约 1.1 亿美元 操纵薄盘永续合约的标记价格,用虚高抵押品借空金库

Compound 那一次是这张表里最有教育意义的,因为它不是攻击:

2020 年 11 月,DAI 在某个中心化交易所的价格短时冲到约 1.30 美元。
Compound 的价格源忠实地读到了这个数,并按它清算了一批仓位。

⚠️ 预言机没有被攻破,没有节点撒谎,没有 bug。
   它【正确地】报告了一个【真实发生过】的价格。
   问题在于:那个价格不具代表性
      —— 它来自一个流动性不足的交易场所的短时脱锚。

⭐⭐ 预言机的失效未必是"被攻击”,也可以是它正确地报告了一个错误的东西。

这是整堂课反复出现的那个主题的又一个实例(第 35 讲会系统地讲): 每个组件都在按规范工作,而系统整体的行为是错的—— 因为规范里那个隐含的假设(“价格 = 有代表性的价格”)从来没有被写下来。

Mango 那一次还有一个额外的教训:攻击者事后在治理提案里"投票"决定归还一部分——他用窃取来的代币获得了投票权。第 30 讲的结论在这里重现:这类事件的最终结果,往往由协议之外的因素决定,而那不是协议能保证的东西。

七、设计检查清单

① 这个价格的【最坏情况延迟】是多少?
      推送式:心跳周期 + 偏离阈值
      拉取式:签名有效期
② 撒谎需要多少钱?被保护的资金有多少?
      ⭐ 前者必须【始终】大于后者,否则设计在数学上就是失败的
③ 数据源有几个?它们是否独立?
      ⚠️ 五个数据源都从同一个聚合 API 拿数,等于一个数据源
④ 极端行情下会怎样?
      交易所宕机、价格熔断、流动性枯竭时,预言机返回什么?
      ⭐ 「返回一个错的值」比「拒绝返回」危险得多 ——
         合约应当能识别"数据不可信"并暂停,而不是照单全收
⑤ 谁能升级预言机地址?
      ⚠️ 这经常是整个协议里最中心化的一个开关(第 24 讲)

八、本讲小结

  • 链的封闭性是确定性的必然结果,不是没做:合约若能读外部 API,不同节点会算出不同状态根,共识直接崩溃。
  • 预言机问题和跨链桥(第 30 讲)是同构的——都是往一个自洽的密码学系统里注入它无法自行验证的断言,所以判据也一样:谁能撒谎,代价是什么。
  • ⭐⭐ 链只能验证签名、哈希和自己的历史。“ETH 现在 3000 美元"不属于任何一类——它是经验断言不是数学命题。签名只证明谁说的,不证明说得对。
  • ⭐⭐ 因此预言机不能被设计成"正确”,只能被设计成"作恶不划算"。 直接推论:预言机的安全性有一个金额上限,超过它撒谎就重新划算了——而这个上限极少被写进文档。
  • ⚠️ “用 ZK 证明了数据正确"几乎总是被误读:ZK 能证明"我按程序算对了 f(x)",不能证明 x 是真的。信任只是被搬了位置。
  • 恒定乘积池的操纵成本可以精确计算:推到 k 倍需要 √k − 1 倍的 Y 储备。推到 2 倍只要 41% 池子储备,且闪电贷让这笔钱不需要真的拥有
  • ⭐⭐ TWAP 的作用是把成本从"一个区块"抬到"持续若干区块”:30 分钟窗口、只能维持一个区块时,现货价要被推到 151 倍、需要 11.3 倍池子储备——比现货高约 27 倍,而且用不了闪电贷。另有第二重代价:维持期间套利者持续在攻击者身上获利。
  • ⚠️ TWAP 的代价是滞后:窗口越长越抗操纵、也越跟不上真实暴跌,而滞后在清算场景里直接变成坏账。这是权衡曲线,不是最优解。
  • ⚠️ √k − 1 要乘以池子储备——小池子的 TWAP 是纸糊的,窗口再长也救不回来。
  • 推送式的特有风险是陈旧窗口,拉取式的特有风险是择时:后者等于白送攻击者一个免费期权,期权价值随签名有效期增长。
  • ⭐⭐ 最有教育意义的失效不是被攻击:Compound 2020 年那次,预言机正确地报告了一个真实发生过、但不具代表性的价格。每个组件都符合规范,系统整体却错了——因为"价格 = 有代表性的价格"这个假设从来没被写下来。
  • 检查清单五问:最坏延迟多少 / 撒谎成本是否始终大于被保护金额 / 数据源是否真的独立 / 极端行情下返回错值比拒绝返回更危险 / 谁能改预言机地址。

思考题

  1. 为什么 EVM 不能有一条"读取 HTTP"的指令?请从状态根和重执行推导,不要只说"不确定性"。
  2. “签名只证明谁说的,不证明说得对。“请用这句话解释,为什么增加签名者数量提高的是合谋难度而不是正确性
  3. 一个项目说"我们用 ZK 证明预言机数据的真实性”。请指出这句话里被偷换的概念,并说明 ZK 在这里实际能保证什么。
  4. 恒定乘积池里 X、Y 各 100 万美元。把价格推到 4 倍需要投入多少 Y?如果池子是 1000 万美元呢?两个答案说明了什么?
  5. 用第四节的公式:10 分钟窗口的 TWAP,攻击者能维持 60 秒,想把 TWAP 推到 1.5 倍,现货价要推到几倍、需要几倍池子储备?
  6. 为什么跨区块的 TWAP 操纵不能用闪电贷?请说明闪电贷的原子性约束在这里如何起作用。
  7. TWAP 窗口从 10 分钟加到 2 小时,抗操纵性和滞后各变成什么样?举一个"滞后导致坏账"的具体场景。
  8. 拉取式预言机的"择时"风险,为什么可以被看作一个免费期权?签名有效期从 60 秒缩短到 5 秒,这个期权的价值怎么变?
  9. Compound 那次事件中,如果把价格源换成"五个交易所的中位数”,结果会不同吗?如果换成 30 分钟 TWAP 呢?分别说明理由。
  10. 检查清单第 ④ 条说"返回一个错的值比拒绝返回危险得多"。请设计一个机制,让合约能够识别"当前数据不可信"并安全暂停,并说明这个机制自己引入了什么新风险。