你写了个合约,想在"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 年那次,预言机正确地报告了一个真实发生过、但不具代表性的价格。每个组件都符合规范,系统整体却错了——因为"价格 = 有代表性的价格"这个假设从来没被写下来。
- 检查清单五问:最坏延迟多少 / 撒谎成本是否始终大于被保护金额 / 数据源是否真的独立 / 极端行情下返回错值比拒绝返回更危险 / 谁能改预言机地址。
思考题
- 为什么 EVM 不能有一条"读取 HTTP"的指令?请从状态根和重执行推导,不要只说"不确定性"。
- “签名只证明谁说的,不证明说得对。“请用这句话解释,为什么增加签名者数量提高的是合谋难度而不是正确性。
- 一个项目说"我们用 ZK 证明预言机数据的真实性”。请指出这句话里被偷换的概念,并说明 ZK 在这里实际能保证什么。
- 恒定乘积池里 X、Y 各 100 万美元。把价格推到 4 倍需要投入多少 Y?如果池子是 1000 万美元呢?两个答案说明了什么?
- 用第四节的公式:10 分钟窗口的 TWAP,攻击者能维持 60 秒,想把 TWAP 推到 1.5 倍,现货价要推到几倍、需要几倍池子储备?
- 为什么跨区块的 TWAP 操纵不能用闪电贷?请说明闪电贷的原子性约束在这里如何起作用。
- TWAP 窗口从 10 分钟加到 2 小时,抗操纵性和滞后各变成什么样?举一个"滞后导致坏账"的具体场景。
- 拉取式预言机的"择时"风险,为什么可以被看作一个免费期权?签名有效期从 60 秒缩短到 5 秒,这个期权的价值怎么变?
- Compound 那次事件中,如果把价格源换成"五个交易所的中位数”,结果会不同吗?如果换成 30 分钟 TWAP 呢?分别说明理由。
- 检查清单第 ④ 条说"返回一个错的值比拒绝返回危险得多"。请设计一个机制,让合约能够识别"当前数据不可信"并安全暂停,并说明这个机制自己引入了什么新风险。