lucifer

大家好,我是 Lucifer。之前写RWA的时候,我把预言机比作”信使”:链下世界的玉米价格、黄金报价,得靠一位信使穿过链上链下那堵墙,把消息递进来。当时一笔带过,今天把这位信使请出来,当面问三个问题:你带来的消息是哪来的?可信吗?有没有人塞你红包?

你可能觉得这是技术宅的闲磕牙。还真不是。DeFi 里几乎所有要命的时刻,背后都挂着同一个问题——“现在价格是多少”。借贷协议靠价格算抵押品价值、触发清算;稳定币靠价格守锚定;永续合约靠价格结算资金费率。价格对,天下太平;价格被污染 10 分钟,几个亿的真金白银就会换主人。2022 年 Mango Markets 一夜之间被搬走约 1.14 亿美元——攻击者没黑一行合约代码,他只是把”价格”这个数换掉了。

这篇文章我们就把预言机从头捋一遍:链上为什么看不到外面的价格、为什么不能随便抄个现货价、Chainlink 和 Pyth 各自在忙什么、TWAP 是怎么把操纵变成烧钱大战的,最后照例用两段 Python 模拟把原理跑给你看。放心,全程初中数学。

链上是一间没有窗户的会议室

先说一个让很多新朋友愣住的事实:智能合约上不了网。

不是”不太方便”,是根本做不到。以太坊虚拟机(EVM)的指令表里,压根没有”发个 HTTP 请求”这一项。你想在 Solidity 里写 fetch("https://api.binance.com/price")?编译器直接把代码打回来。

为什么这么绝情?你把链上想象成一间没有窗户、没有电话的会议室:外面发生什么,屋里的人一概不知。想递消息,只有一种办法——把消息写成纸条,从门缝塞进去。这张纸条,就是一笔交易。

这么设计是故意的,因为区块链的命根子是确定性:全网每个节点都要把每笔交易重放一遍,抄出来的结果必须一字不差。假如合约真能实时调 API,A 节点请求时 Binance 报 2000,B 节点晚 3 秒请求报 2001,两个节点算出来的账本从此对不上——共识当场崩盘。所以设计者一刀切:合约只准看屋里的东西,账户余额、合约状态、区块头,仅此而已。

麻烦来了。ETH 现在值多少钱,答案明明在屋外的交易所里,屋里的人却两眼一抹黑。想让他知道,就必须有人把价格写成纸条塞进门缝。干这件事的机器和人,就叫预言机(Oracle)。

预言机:站在链下世界与区块链之间的信使

于是问题从”怎么拿价格”,变成了一个更扎心的难题:递纸条的人,凭什么信他?他要是谎报一个数,屋里所有按这个数办事的协议全部遭殃。预言机的全部技术史,说白了就是一句话:怎么让说谎变得贵到没人干得起。下面按历史顺序,把几种方案挨个盘一遍。

最偷懒的方案:直接抄 DEX 现货价

链上既然没有价格,那链上的 Uniswap 池子不就是现成的市场吗?读一下储备比例,价格 = 储备B / 储备A,零信任、零外部依赖、一个合约调用搞定,看起来完美。

但这条路上埋着 DeFi 史上最经典的坑:现货价可以被一笔交易临时改写。

你琢磨一下 AMM 的定价:价格就是储备比。那”改价格”就等于”砸钱改储备比”——跟往天平一端加砝码是一个道理。你会说,加砝码得用自己的钱啊。是的,在闪电贷出现之前确实是这样。闪电贷允许你在同一笔交易里借出天量资金、用完即还。抵押物?没有。还不上,整笔交易自动回滚,等于什么都没发生过——像开了一家”一分钟租赁行”,卡车白借给你,货运完把车还回来就行。

于是教科书攻击流程诞生了,全程一个区块内完成:

  1. 闪电贷借出巨额 USDC;
  2. 全部砸进某个流动性很差的 ETH/USDC 小池子,现货价瞬间被拉高 3 倍;
  3. 借贷协议读这个池子的现货价给自己的抵押品估值——虚高的价格,虚增的抵押品;
  4. 凭着虚增的抵押品从国库借出真金白银;
  5. 反手砸回池子,归还闪电贷,留下一笔手续费和一地鸡毛。

注意攻击的精髓:协议的代码没有漏洞,被攻击的是”喂给协议的价格”这个输入。而”拉盘-借款-还贷”挤在同一个区块里完成,意味着没有任何人来得及拦——原子性本来是保护我们的特性,在这里成了攻击者的隐身衣。

更偷懒的方案:项目方自己喊价

另一条路的逻辑更朴素:我项目方自己写个脚本,每隔几分钟调一下交易所 API,把价格填进链上合约。很多小项目至今还这么干,毕竟实现成本约等于零。

但成本为零的东西,风险一般是满格的。这个方案的整套家当,都压在一个喇叭上:

  • 喇叭会哑:脚本挂了、API 限流了、云服务器宕机了,链上价格就冻在上一次的数字里。行情剧烈波动时,这个冻结的旧价格会诱发一轮误清算;
  • 喇叭会喊错:运维小哥手滑多敲一个零,2000 美元变成 20000,下一秒清算机器人倾巢而出;
  • 喇叭会撒谎:这是最要命的。管喂价的私钥被盗,或者项目方自己起了歹念——把抵押品价格改高 10 倍,抵押人”集体暴富”,国库被凭空借空,项目方卷款跑路。

一句话总结:中心化喂价的潜台词是”请相信我,我是好人”。而在一个所有设计都围着”不信任任何人”转的行业里,这种架构本身就不及格。我们需要一个说谎代价高到不值的方案。

Chainlink:请一群互相不认识的报价员

Chainlink 的答案是:把”喂价”从项目方的私活,变成一群互相不认识的报价员共同出数。这套网络叫 DON(Decentralized Oracle Network)。以 ETH/USD 价格流(Data Feed)为例,流程是这样的:

  1. 一组相互独立的节点运营商(不少是知名基础设施公司)各自从多个数据源(各大中心化交易所、行情聚合商)取数;
  2. 每个节点对答案签名,提交到链上的聚合合约(Aggregator);
  3. 聚合合约把所有报价做一次聚合——取中位数,并按预设的上下限剔除明显离谱的值;
  4. 中位数成为这一轮(round)的官方答案,任何合约都可以无许可地读取。

为什么取中位数而不是平均数?想象 10 个报价员从低到高站成一排,取站正中间的那位。有人报了个 4 倍的离谱价?他只能被挤到队尾,站在中间的还是那 9 个老实人。所以想污染中位数,攻击者必须买通一半以上的报价员;而平均数不一样——收买一个人报个极端值,就能把平均值拖飞(文末代码模拟会跑给你看)。这就是统计学版本的防弹衣。

链上每个价格流还有两个关键参数,它俩决定了”链上的价格有多新鲜”,你可以在 data.chain.link 上逐个查看。你可以把 Chainlink 想成一位电台播报员,这两个参数就是他的工作守则:

  • 偏差阈值(Deviation Threshold):价格相对上一次上链的值波动超过一定百分比,播报员才开口播一次新的。比如 ETH/USD 在以太坊主网是 0.5%——波动不到 0.5%,他就闭嘴不播,反正没什么可说的,播一次还得花一笔 gas 费。这是为了省钱;
  • 心跳(Heartbeat):哪怕市场一整天风平浪静,最长每隔固定时间(ETH/USD 主网是 1 小时),播报员也必须定时播一次,证明自己还活着,防止”价格流假死没人管”。这是为了保命。

这两个参数是一场拉锯战:阈值越小、心跳越短,价格越新鲜,但上链越勤、gas 越贵。主流交易对定得小(波动大、需求高),长尾资产定得大。但也正因为有”整点报时”这回事,接入方要心里有数:你读到的”最新价”,可能已经是一小时前的旧闻了。行情突变的那一小时里,这个时间差足够让清算逻辑做出完全相反的决策。

Pyth:从”订报纸”改成”到店自取”

Chainlink 是”推”模型:报价员主动把价格送上链,链上常驻一个最新值,谁用谁读——像订报纸,每天准时塞进你家信箱,不管你今天看没看,油墨钱(gas 费)一分不少。Pyth 反过来,是个”拉”模型,而且它把数据源这件事直接做到了源头——publisher 就是交易所和顶级做市商自己(比如 Jump 这类),等于以前是二手贩子转述交易所报价,现在是交易所本人亲自到岗报数。

Pyth 的链上合约平时并不保存实时价格。当你的交易需要价格时,在交易里顺手夹一张价格更新证明(由中继器从 Pyth 网络同步过来),合约验证后即取即用,用完不存——像到店自取,要一次取一次,没人替你囤报纸。这个设计换来两个好处:不用为高频推送烧 gas,而且数据来自一手行情、更新达到亚秒级,比 Chainlink 的整点播报新鲜得多。代价是接入复杂度上升,且价格跟着交易走,适合永续合约这种对时效极度饥饿的场景——还记得永续合约的标记价格吗?多交易所现货取指数,那其实就是预言机最朴素的雏形,Pyth 等于把这个雏形交还给交易所本人来供电。

TWAP:一台骗不动的体重秤

上面两条路线都要”额外搭一套基础设施”。而 Uniswap V3 说:不用麻烦,我自己就能当预言机,办法叫 TWAP(Time-Weighted Average Price,时间加权平均价)。

原理直觉很简单。想象一台体重秤,天天自动帮你记体重,报的是一周平均值。某天晚上你暴饮暴食,重了三斤?没关系,摊到七天里,平均体重几乎纹丝不动。想骗过这台秤,你就得连着吃七天大餐——那可不是”一时嘴馋”,是”生活方式”了,成本完全不在一个量级。

放到合约里,它维护一个累积器,每个区块都做一件事:

1
累积器 += 当前现货价 × 距上个区块的时间

任意时刻想算”过去 N 分钟的均价”,只需要:

1
TWAP = (累积器现在 - 累积器 N 分钟前) / N 分钟

这就是个朴素的积分再除以时间——初中学的”平均速度 = 路程 / 时间”,换了个马甲。但它有一个精妙的性质:短时间的价格尖刺,几乎推不动平均值。攻击者把一个区块的价格拉到 3 倍,这个尖刺只给累积器贡献了”1 个区块 × 3 倍价”的一点增量,摊进 30 分钟窗口,连水花都看不见。想让 TWAP 真的动起来,攻击者必须让虚高价格占住窗口的大部分时间——也就是持续砸钱扛住滑点、硬撑价格。池子越浅砸钱越少?不,池子越浅,维持虚高所需的持续买盘相对越明显,而且你是在跟全世界的套利机器人对线,人家会把你拉的价格瞬间搬平。操纵 TWAP 从”一笔交易的手续费”变成了”一场旷日持久的烧钱大战”,还得打得赢套利大军,性价比归零。

代价是什么?慢。TWAP 是后视镜:它看的是过去 30 分钟的路,不是眼前的悬崖。真实暴跌发生时,30 分钟窗口的均价还挂在半山腰。拿它当清算依据的协议,在瀑布行情里会”看错账”:抵押品实际已经跌成渣,TWAP 显示的估值还岁月静好,清算迟迟不触发,坏账越滚越大。2022 年 5 月 UST 崩盘那几天,不少依赖 TWAP 喂价的协议就吃足了迟钝的亏。安全性的代价从来不会消失,只会换个地方收钱:现货价新鲜但容易被拉,TWAP 抗拉但迟钝——选哪个,取决于你的协议更怕哪一种死法。

案发现场:一个年轻人搬走 1.14 亿美元

理论讲完,讲个真事,当故事听。时间拨回 2022 年 10 月 11 日的晚上,舞台是 Solana 上的借贷协议 Mango Markets,主角是个叫 Avraham Eisenberg 的年轻人。他的表演一共五幕:

第一幕,挑一台好骗的秤。Mango 给抵押品估值,用的是 MNGO——它自家治理代币的现货价。这个币的市场浅得像个水洼,几百美元的单子都能砸出明显水花。

第二幕,左右手互搏拉盘。他准备了两个自己控制的账户,在 MNGO 现货和永续市场里自买自卖:左手 100 美元卖给右手 110,右手再 120 卖回给左手……成交价就这么被”喊”上去了,几美分的币一路喊到接近一美元——十倍级的账面暴涨,实际动用的本金并不大。

第三幕,让秤认下这个数。Mango 的预言机忠实地读到了这个被拉爆的”市场价格”。于是他手里那一堆 MNGO,估值瞬间膨胀成一笔纸面巨款。

第四幕,拿纸面富贵换真金白银。他拿这些虚增的 MNGO 当抵押品,从 Mango 国库借出 USDC、BTC、SOL 等硬通货,总价值约 1.14 亿美元。

第五幕,谢幕离场。随后任由仓位爆掉,抵押品砸回市场,全剧终。

事后相当魔幻:双方谈判,约 6700 万美元被返还,攻击者一度留下约 4700 万,还公开宣称这是”合法利用了代码允许的规则”。三个月后他在波多黎各被捕,后被定罪——法律层面的拉扯比代码还精彩,但那是另一个故事了。

复盘时有一刀必须切准:Chainlink 没有被黑,Solana 也没有被黑。出事的是 Mango 选秤的眼光——把一个深度极浅的小币现货价,直接当成了金融真值。这也是我想划的重点:在预言机事故里,”秤选错了”远比”报价员集体叛变”更常见。攻破 Chainlink 的节点网络要买通多数节点,成本是天文数字;而选错数源,开两个账户对倒几下就够了。这也是为什么主流借贷协议上架新资产那么谨慎:抵押品规模必须远小于喂价源的流动性,小币要么限制借款上限,要么干脆不给额度。一句话教训:再先进的锁,也拦不住你把假钞当真钱收进金库——喂进系统的那个数,首先得配得上”价格”两个字。

把 Mango 案算成一笔账

五幕剧看完,把它摊开成一张账单。下面按公开报道与法庭文书的口径整理,个别中间量各来源略有出入,取量级看:

环节 数字 备注
初始投入 约 500 万美元 USDC 分进两个自己控制的账户(公开指控文书口径)
拉盘手法 现货与永续市场自买自卖 左手 100 卖给右手 110,几美分一路喊到接近一美元——账面十倍级,真实流通盘纹丝没动
抵押品账面 从约 500 万膨胀成足以覆盖约 1.14 亿借款的”纸面巨款” Mango 的喂价忠实读下了这个被喊出来的价格
借出资产 约 1.14 亿美元 USDC、BTC、SOL 等硬通货,从国库直接搬走
事后 归还约 6700 万,一度留下约 4700 万 2022 年 12 月被捕,2024 年 4 月被定罪,2025 年 5 月定罪又被推翻

算一笔最朴素的账:投入约 500 万,搬走约 1.14 亿——每投入 1 美元,搬走 20 美元开外。这几十倍的”杠杆”不是市场给的,是那台只认现货价的秤给的:秤认下了虚高的价格,纸面抵押立刻兑换成真借款额度。再看谁买单:谈判追回约 6700 万,净窟窿约 4700 万,由 Mango 的偿付能力兜底;MNGO 价格崩塌、协议从此一蹶不振,持有者与做市商一起陪葬。

一句话点题:这约 1.14 亿美元没有破解任何一行代码。喂价源选错——拿低流动性现货价当抵押品定价——等于把秤交给想骗保的人;攻击者没撬任何一把锁,他只是给坏秤喂了个好价钱。

防御清单

理论讲完,把实操层面的防御手段整理成清单,接入任何需要价格的协议时逐条打钩:

  • 别用单一现货价:优先 TWAP(Uniswap V3 内置)或多源聚合,天然免疫单块操纵;
  • 选对秤:喂价来源的流动性必须显著大于抵押品规模;小市值资产的借贷池要限制规模,甚至拒绝接入;
  • 用带聚合的多节点网络:Chainlink 这类中位数聚合方案,并配好偏差阈值和心跳参数,同时清楚自己读到的价格最多”旧”多久;
  • 盯住播报员:把链上喂价与外部参考价实时比对,偏差超阈值就告警甚至熔断。这也是参数化保险的天然触发器——DeFi 保险那篇里提过,有的保单直接写着”Chainlink 喂价偏差超过 10% 触发赔付”,把预言机故障变成可理赔的确定性事件;
  • 交易内自校验:如果用 Pyth,检查返回的置信区间,价格置信度过宽就拒绝交易,宁可不做也不做错。

代码模拟

照例动手写代码加深理解,两段都是纯标准库,python3 直接跑。

模拟一:10 个报价员里混进一个叛徒

模拟 Chainlink 的聚合逻辑:10 个独立节点各自取数报价,正常报价在真实价格 2000 附近小幅抖动,然后我们让第 7 号节点叛变,报出一个 4 倍的极端价 8000。分别用平均数和中位数聚合,看看谁扛得住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 模拟一:10 个预言机节点喂价,其中 1 个被操纵,平均数 vs 中位数
import random
import statistics

random.seed(42) # 固定随机种子,保证结果可复现

TRUE_PRICE = 2000.0 # 真实价格 2000 USDC

# 10 个独立节点各自取数,正常报价 = 真实价格 ± 少量噪声
quotes = [round(TRUE_PRICE + random.uniform(-8, 8), 2) for _ in range(10)]

# 第 7 号节点被黑(或被收买),报出一个 4 倍的极端价
quotes[6] = 8000.0

print("10 个节点的报价:")
for i, q in enumerate(quotes):
tag = " <-- 被操纵" if i == 6 else ""
print(f" 节点 {i}: {q}{tag}")

mean = statistics.mean(quotes)
median = statistics.median(quotes)

print(f"\n真实价格: {TRUE_PRICE}")
print(f"平均数聚合: {mean:.2f} 偏离真实价格 {(mean/TRUE_PRICE-1)*100:+.2f}%")
print(f"中位数聚合: {median:.2f} 偏离真实价格 {(median/TRUE_PRICE-1)*100:+.2f}%")

运行输出(python3 sim1_median.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
10 个节点的报价:
节点 0: 2002.23
节点 1: 1992.4
节点 2: 1996.4
节点 3: 1995.57
节点 4: 2003.78
节点 5: 2002.83
节点 6: 8000.0 <-- 被操纵
节点 7: 1993.39
节点 8: 1998.75
节点 9: 1992.48

真实价格: 2000.0
平均数聚合: 2597.78 偏离真实价格 +29.89%
中位数聚合: 1997.58 偏离真实价格 -0.12%

10 个节点里有 1 个叛徒:平均数被拖偏近 30%,中位数只偏 0.12%

一目了然:同样是 1/10 的叛徒,平均数被拖偏近 30%(够触发一轮大规模误清算了),中位数只偏了 0.12%,几乎无感。原因在直觉上也说得通:把 8000 塞进排好序的队伍里,它只能站在队尾,中间位置的数还是那 9 个诚实的报价。中位数聚合把”收买一个报价员”的攻击收益打成了零,攻击者只剩一条路:收买一半以上的节点——而节点是相互独立的运营商,让它们合谋的成本,就是 Chainlink 安全性的定价。

模拟二:闪电贷拉盘,TWAP 装睡

模拟第二节讲的闪电贷攻击:一个 10000 ETH / 2000 万 USDC 的池子(现货价 2000),攻击者用闪电贷把价格拉到 3 倍。我们分别看攻击块的现货价和30 分钟窗口的 TWAP,并且做一个对攻击者相当慷慨的设定:让他拉出的 6000 在链上站稳整整一个区块(真实的闪电贷攻击里,拉盘和砸盘在同一笔交易内完成,连这个区块都留不下)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 模拟二:闪电贷单块拉盘 3 倍,spot price vs 30 分钟窗口 TWAP
import math

# 一个 x*y=k 的池子:10000 ETH / 2000 万 USDC,现货价 2000 USDC/ETH
reserve_eth = 10000.0
reserve_usdc = 20_000_000.0


def spot_price(x: float, y: float) -> float:
"""现货价 = USDC 储备 / ETH 储备"""
return y / x


# 攻击者用闪电贷借来巨额 USDC,把价格拉到 3 倍(6000)
target_price = 3 * spot_price(reserve_eth, reserve_usdc)
# 恒定乘积下,买入后新 USDC 储备满足 new_y^2 / k = target_price
k = reserve_eth * reserve_usdc
new_usdc = math.sqrt(target_price * k)
buy_amount = new_usdc - reserve_usdc

print(f"拉盘前现货价: {spot_price(reserve_eth, reserve_usdc):.2f} USDC/ETH")
print(f"把价格拉到 3 倍需要单笔买入: {buy_amount/1e6:.2f} 百万 USDC(闪电贷借的,无需自有资金)")

# TWAP 累积器:每个区块把「现货价 x 区块时长」累加进去,均值 = 累积量差 / 时间差
BLOCK_TIME = 12 # 秒
WINDOW = 30 * 60 # 30 分钟
blocks = int(WINDOW / BLOCK_TIME) + 1 # 覆盖整个窗口

# 攻击前的区块价格都在 2000 附近,最后一个区块是"攻击块",价格 6000
cumulative = 0.0
prices = []
for b in range(blocks):
p = spot_price(reserve_eth, reserve_usdc) if b < blocks - 1 else target_price
prices.append(p)
cumulative += p * BLOCK_TIME

twap = cumulative / (len(prices) * BLOCK_TIME)

print(f"\n攻击块的现货价: {prices[-1]:.2f} USDC/ETH(比正常价高 {prices[-1]/prices[0]-1:+.0%})")
print(f"30 分钟窗口 TWAP: {twap:.2f} USDC/ETH(只比正常价高 {twap/prices[0]-1:+.2%})")

运行输出(python3 sim2_twap.py):

1
2
3
4
5
拉盘前现货价: 2000.00 USDC/ETH
把价格拉到 3 倍需要单笔买入: 14.64 百万 USDC(闪电贷借的,无需自有资金)

攻击块的现货价: 6000.00 USDC/ETH(比正常价高 +200%)
30 分钟窗口 TWAP: 2026.49 USDC/ETH(只比正常价高 +1.32%)

闪电贷拉盘时:现货价飙升 200%,30 分钟 TWAP 只动 1.32%

同一个攻击动作,两种秤给出了两个世界:现货价飙升 200%,借贷协议的抵押品估值跟着原地起飞;TWAP 只动了 1.32%,攻击块那根尖刺被摊进 30 分钟的时间长河里,几乎没溅起水花。顺带注意那笔 1464 万美元的买入——这就是闪电贷存在的意义,攻击者不需要一分钱自有资金,只需要支付手续费。而如果他面对的是 TWAP,想让 30 分钟均价真的涨到 6000,就得让大部分区块都站在虚高价上,一边扛着滑点一边和套利机器人对线,这场战争的军费足够直接买下一个 Mango。

总结

把全文浓缩成三句话:

  1. 链上没有真值,只有别人塞进来的纸条。 智能合约是确定性孤岛,所有外部价格都必须经由预言机写入,而”谁写的、凭什么信”是预言机的核心命题;
  2. 任何单一价格源,要么能被买,要么能被拉。 项目方自己的喇叭可以谎报,小池子现货价可以被闪电贷单块改写,连中位数聚合也只是在提高作恶成本而非消除作恶可能;
  3. 预言机安全的本质,是让操纵的账单大于操纵的收益。 多节点中位数让收买变成”买下一半网络”,TWAP 让改价变成”长期烧钱大战”,多源交叉验证让谎报变得无处遁形——没有银弹,只有不断加高的作恶账单。

学习建议:打开 data.chain.link,挑一个你熟悉的价格流(比如 ETH/USD),盯着它的历史轮次看一会儿,看看偏差阈值和心跳写的是多少,再想想:如果明天市场上演 312 那样的瀑布,这组参数下,链上价格会落后现货多久?纸上得来终觉浅,价格流面前见真章。

最后留个钩子:预言机负责把价格送进来,但什么顺序用这个价格是另一门学问——清算机器人抢跑喂价更新的排序博弈,是 MEV 供应链里最肥美的一环,那片战场我们在《预言机的另一半:MEV 与出块拍卖》里再会。

相关阅读


 评论


博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议

本站使用 Material X 作为主题 。
载入天数...载入时分秒...