T21 · Oracle
链上怎么知道外面的价格?
- 练习的能力
- BuilderProtocol Literacy
- 动手
- 接入一个价格源,记录它的更新频率与偏差阈值,模拟数据过期时你的合约怎么办。
- AI Lab
- 让 AI 分析用现货池价格做预言机的风险,自己算出操纵它需要多少资金。
一个现实问题
你要写一个抵押借贷合约。用户存入代币 X,借出稳定币。
核心逻辑只有一句话:抵押品的价值必须高于借出金额的某个倍数。 写成代码也只有一行:
uint256 collateralValue = amount * getPrice(token) / 1e18;问题全部集中在 getPrice 这三个字上。合约在链上,它没有网络,不能发 HTTP 请求,读不到任何交易所的行情。它只能读链上已经存在的数据。
链上有什么现成的价格?有一个 AMM 池子。 它的两侧储备一相除就是价格,而且完全免费、实时、无需任何第三方。
于是你写下这一行:
function getPrice(address token) public view returns (uint256) {
(uint256 r0, uint256 r1) = pair.getReserves();
return r1 * 1e18 / r0; // 这一行值多少钱?
}现在把 T19 最后那个 Lab 的结论搬过来:把一个恒定乘积池子的价格推到 2 倍,需要投入大约 41% 的一侧储备。
如果这个池子一侧有 100 万,那就是 41 万。听起来不便宜。
但还有一件事你可能没想到:攻击者不需要真的拥有这 41 万。他可以在一笔交易里借出来,操纵价格,从你的合约里借走一笔超额的钱,再把价格推回去,还掉借款。整个过程在同一个区块内完成,本金需求接近于零,成本只剩手续费。
那一行代码,值你合约里的全部资金。
思想实验
把合约想象成一个被关在房间里的人。房间没有窗户,只有门上一道细缝。
他的工作是:根据外面黄金的价格,决定给来访的人多少钱。他自己看不到外面。
有人从门缝递进来一张纸条,上面写着一个数字。
他该不该照着这个数字办事?在照做之前,他必须问出四个问题——而这四个问题,就是这一章的全部内容:
第一,这张纸条是谁写的? 如果任何一个路过的人都能往门缝里塞纸条,那价格就等于「最后一个塞纸条的人说了算」。他必须只认识某几个特定的笔迹。
第二,这张纸条是什么时候写的? 纸条上没有时间,他就无法区分「五秒前的价格」和「三天前的价格」。三天前的价格在剧烈波动时是致命的。
第三,这张纸条和上一张差多少? 如果昨天写的是 100,今天这张写着 10000,那大概率不是行情,是有人在开玩笑——或者更糟,是数据源出了故障。
第四,如果很久没人递纸条进来,他该怎么办? 继续用最后一张?停止工作?还是按最保守的方式处理?
这四个问题没有任何一个有「正确答案」,但每一个都必须有一个明确的答案写在代码里。 没写,就等于选了最危险的那个默认值。
现在回到那个 AMM 池子。它相当于什么?它相当于一张任何人都可以花钱重写的纸条,而且它连时间戳都没有。
你来决定
你的借贷合约需要一个价格。你选哪一个来源?
观察结果
把四种来源摊开对比:
| 来源 | 操纵成本 | 延迟 | 依赖谁 | 主要失效模式 |
|---|---|---|---|---|
| 现货池即时价格 | 极低,可用借来的钱 | 无 | 无 | 单笔交易内被改写 |
| 同池时间加权均价 | 中等,需要持续维持 | 等于窗口长度 | 无 | 剧烈行情下严重滞后 |
| 外部推送价格源 | 高,需攻破链下多方 | 秒级到分钟级 | 第三方合约与运营方 | 停更、延迟、触及边界被夹住 |
| 多源中位数 | 最高 | 取决于最慢的源 | 多个第三方 | 单源失效时的处理逻辑 |
第二列是这张表的重点。把它抽象成一句话:
不存在「安全的价格源」,只存在「操纵成本高于可提取价值的价格源」。
这句话可以直接写成一个比值,它是本章最有用的一个工具:
预言机安全边际 = 操纵这个价格源的成本 / 攻击者能因此提走的最大金额
小于 1 → 这个配置迟早会被攻击,只是时间问题
接近 1 → 行情波动或 Gas 变化就可能让它翻过去
远大于 1 → 暂时安全,但分子分母都会随时间变化注意分母:它不是「协议的总锁仓量」,而是「在这个错误价格下能被提走的最大金额」。 这两个数字可以差很远。一个资产哪怕只占总量的 1%,只要用它做抵押能借出全池的稳定币,分母就是全池。
还有一件事必须现在就说清楚:分子和分母都不是常数。
流动性会迁移,池子会变浅,分子随之下降;协议上调某个资产的抵押率,分母随之上升。一个上线时安全的配置,可能在半年后变得不安全,而期间没有任何一行代码被改过。
建立模型
先看完整的链路,以及每一环会怎么坏:
- 真实市场价格
- 数据提供方
- 链下聚合
- 链上价格合约
- 你的合约的判断
| 环节 | 典型失效 | 你在最后一环能做什么 |
|---|---|---|
| 真实市场 | 流动性枯竭,根本没有公允价 | 熔断,暂停相关操作 |
| 数据提供方 | 某个源宕机或返回错误值 | 依赖聚合层的中位数逻辑 |
| 链下聚合 | 整体停更,或延迟拉长 | 检查时间戳 |
| 链上价格合约 | 被升级、被暂停、返回边界值 | 检查取值范围与合理性 |
| 你的合约 | 没检查就用了 | 这一整章 |
一个价格源有四个必须弄清楚的参数。它们全都是可配置的,不同的源、不同的资产、不同的链上取值可以差很远,所以下面只给量级,具体数字必须你自己去查——这正是 Lab 的第一步:
| 参数 | 含义 | 典型量级 | 为什么你必须知道它 |
|---|---|---|---|
| Heartbeat | 最长多久必须更新一次 | 分钟到小时 | 它决定你的过期阈值该设多大 |
| Deviation | 价格偏离多少就提前更新 | 零点几到几个百分点 | 它决定平静期你读到的价格有多旧 |
| Decimals | 返回值的精度 | 通常 8 或 18 | 精度用错就是几个数量级的错误 |
| 取值边界 | 聚合层允许的最大最小值 | 因资产而异 | 极端行情下会被夹住,返回一个假价 |
最后一行值得展开。有些价格源在设计上带有上下界保护。当真实价格冲出这个范围时,它返回的是边界值,而不是真实价格,而且这个返回在接口上看起来完全正常。 如果你只检查了「时间戳新不新」,你会拿着一个错误的价格继续工作。
现在把四个检查写进代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IPriceSource {
// 返回:价格、这个价格的产生时间
function latest() external view returns (int256 price, uint256 updatedAt);
}
contract PriceGuard {
IPriceSource public source;
uint256 public maxStaleness; // 允许的最大陈旧时间,通常设为 heartbeat 的若干倍
uint256 public minPrice; // 合理区间下界
uint256 public maxPrice; // 合理区间上界
uint256 public maxJumpPercent; // 相对上次可信价格允许的最大跳变,百分比
uint256 private lastGoodPrice;
uint256 private lastGoodAt;
error StalePrice(uint256 age);
error PriceOutOfRange(uint256 price);
error PriceJump(uint256 from, uint256 to);
function readPrice() public view returns (uint256) {
(int256 raw, uint256 updatedAt) = source.latest();
// 检查一:不能是零或负数
if (raw <= 0) revert PriceOutOfRange(0);
uint256 price = uint256(raw);
// 检查二:不能过期
uint256 age = block.timestamp - updatedAt;
if (age > maxStaleness) revert StalePrice(age);
// 检查三:不能贴在聚合层的边界上
if (price <= minPrice || price >= maxPrice) revert PriceOutOfRange(price);
// 检查四:相对上一次可信价格的跳变不能超过阈值
if (lastGoodPrice > 0) {
uint256 hi = price > lastGoodPrice ? price : lastGoodPrice;
uint256 lo = price > lastGoodPrice ? lastGoodPrice : price;
if ((hi - lo) * 100 > lo * maxJumpPercent) revert PriceJump(lastGoodPrice, price);
}
return price;
}
}四个检查里,第三个最常被漏掉,第四个最有争议。
第四个检查的争议在于:真实市场确实会出现单次几十个百分点的跳空。 如果你把跳变阈值设得太紧,你会在最需要价格的那一刻拒绝所有价格,让清算停摆,反而制造坏账。这个参数是一个在「防操纵」和「防停摆」之间的取舍,没有普遍正确的值。
比这四个检查更重要的,是数据不可用时你的合约做什么。这个问题只有三种答案,你必须显式选一种:
| 策略 | 做法 | 适合什么操作 | 代价 |
|---|---|---|---|
| 整体回滚 | 直接 revert,什么都不做 | 借出、提取抵押品 | 清算也会被一起挡住 |
| 降级运行 | 只允许降低风险的操作 | 还款、追加抵押品 | 逻辑复杂,要分类每个入口 |
| 使用回退价格 | 切到备用源或最后可信价 | 清算 | 回退源本身也可能被操纵 |
最容易写错的是第一种:一刀切地 revert。 它看起来最安全,实际上会在价格源出问题的同时冻结清算——而价格源出问题的时刻,往往正是市场剧烈波动、最需要清算的时刻。
正确的做法通常是分类:增加风险的操作必须有新鲜价格才放行,降低风险的操作在没有价格时也应当允许。
它叫什么
把链外信息带到链上的机制。
它不是一个技术难题,而是一个信任问题:链本身可以让所有人验证同一份历史,但没有任何办法让链自己验证「外面的黄金现在多少钱」。这一段信任必须由某个链下的东西提供。
持续向链上写入某个资产价格的合约与其背后的运营体系。
读它的时候至少要拿到两个值:价格和它的产生时间。只返回价格、不返回时间的接口,无法安全使用。
价格源承诺的最长更新间隔。价格没变化时,它也会按这个节奏写一次。
它是你设置过期阈值的依据。阈值设得比心跳短,会在正常情况下频繁误报;设得太长,则等于允许使用陈旧数据。常见做法是设为心跳的若干倍,并且每个资产分别设置。
价格相对上次写入变动超过多少时,提前触发更新。
它和心跳共同决定了链上价格的新鲜度:平静时按心跳更新,波动时按偏差更新。这意味着在价格平稳的时段,你读到的数字可能已经很旧了,而这通常没有问题;真正危险的是波动时的更新是否跟得上。
用一段时间窗口内的累积价格算出的平均值。
它提高的不是准确性,而是操纵成本:攻击者必须把价格维持在错误位置整个窗口,而不是推一下。代价是滞后。窗口长度是安全性和响应速度之间的直接取舍。
通过交易主动改变某个价格来源的读数,从依赖它的合约中获利。
它通常不是漏洞利用,而是完全合法的交易组合:在一笔交易里推动价格、触发目标合约、恢复价格。代码没有 bug,逻辑却被彻底绕过。
检测到异常时主动停止部分功能。
关键设计点是停什么:停掉全部功能会连清算一起停掉;只停增加风险的操作,才能在保护协议的同时让它继续自愈。
动手
先做调查,不写代码。 挑一个链上价格源,挑两个资产:一个主流资产,一个流动性明显较小的资产。填这张表:
| 主流资产 | 小资产 | |
|---|---|---|
| 心跳(承诺的最长更新间隔) | ||
| 偏差阈值 | ||
| 返回值精度 | ||
| 最近 24 小时实际更新了几次 | ||
| 两次更新之间的最长间隔 | ||
| 有没有取值上下界,是多少 |
最后两行必须从链上真实数据统计出来,不要抄文档。文档写的是承诺,链上记录的是实际发生的事。两者对不上的情况并不罕见。
写 PriceGuard,把四个检查全部实现:非正数、过期、越界、跳变。每个检查抛一个不同的错误,方便测试区分。
在 Fork 环境上跑通读取。 确认你拿到的价格和区块浏览器上看到的一致,特别是精度。把 8 位精度当 18 位用,结果会差十个数量级,而代码不会报任何错。
构造四种故障,逐个验证。 用一个可控的桩合约替换价格源:
- 时间戳停在一小时前 → 应触发过期
- 返回 0 → 应触发非正数检查
- 返回上界值 → 应触发越界
- 相对上次翻了 10 倍 → 应触发跳变
四个都能正确区分,你的第一层才算完成。
做那个真正重要的决定:过期时做什么。
给你的合约写四个入口:借出、提取抵押品、还款、清算。为每一个显式指定价格不可用时的行为,并写测试验证:
| 入口 | 价格过期时 | 你的理由 |
|---|---|---|
| 借出 | ||
| 提取抵押品 | ||
| 还款 | ||
| 清算 |
如果你四个格子填的都是「回滚」,回头再想一遍第三和第四行。 不让人还款、不让人清算,是在价格源出问题时把坏账主动做大。
算一次操纵成本。这是整章的核心练习。
回到开头那个用现货池价格的版本,把这笔账完整算一遍:
场景:你的合约用一个池子的即时价格给代币 X 定价
池子一侧深度 D,你的合约里有 L 的稳定币可借
抵押率 c(比如 0.75,即抵押 100 只能借 75)
第 1 步:要让 X 的读数涨到 m 倍,需投入的资金
约等于 D * (sqrt(m) - 1)
第 2 步:攻击者用价值 V 的 X 做抵押,在 m 倍价格下能借出
min(V * m * c, L)
第 3 步:攻击者的净成本
推价格的资金可在同一笔交易内借入并归还,本金需求接近零
实际成本 = 两次兑换的手续费 + Gas + 闪电贷费用
约等于 D * (sqrt(m) - 1) * 0.6% + 其他
第 4 步:净利 = 第 2 步 - 第 3 步 - 抵押品的真实价值
第 5 步:安全边际 = 第 3 步 / 第 2 步用具体数字跑三组:D 取 100 万、1000 万、5000 万,m 取 2,L 取 500 万。
你会发现第一组的安全边际远小于 1。 把这个数字记下来。它不是一个理论值——把它当作一个门槛:任何时候你要引入一个新的价格来源,先算这个比值,小于某个你能接受的倍数就不要上线。
再做一次反向计算:在 L 等于 500 万的前提下,池子深度至少要多少,这个攻击才不划算? 这个数字就是这个资产能被安全列为抵押品的最低流动性要求。
全程测试网与本地 Fork,不接任何真实资金。 真实资金只出现在毕业项目,并且必须先通过第五阶段的安全验收。
AI Lab
分三步问:
第一步:
一个借贷协议用某个恒定乘积池子的即时储备比值作为抵押品价格。
池子一侧深度 D,协议可借出 L。请给出攻击的完整步骤,
以及一个可以代入数字的成本公式。
第二步:
把上面的价格源换成同一个池子的时间加权均价,窗口为 N 个区块。
重新推导操纵成本,说明它相对第一步提高了多少倍,以及这个倍数依赖哪些假设。
第三步:
列出我在第二步的方案里仍然存在的五个风险,
按「造成损失的概率乘以损失规模」排序,每一条给出可检测的信号。第一步几乎所有模型都能答对大概,错误集中在成本计算上:很多回答会说「需要 41 万本金」,忽略了这笔钱可以在同一笔交易内借入并归还,真实成本只有手续费。这个差别是一百倍量级的,直接决定了一个配置到底安不安全。
第二步的常见错误是给出一个笼统的「安全得多」,而不是一个可计算的倍数。追问它:窗口内每个区块攻击者要付出什么、套利者会怎样反向吃掉他。
第三步是这个 Lab 真正的产出。把它列出的风险和你 Lab 第五步的那张表对照,看有没有你没考虑到的入口。
它给的所有具体参数一律不要相信,全部去链上核对。这类数字是模型最容易产生幻觉的地方,而且错了你很难察觉——因为它们看起来都很合理。
AI 说完之后,你必须自己验证
- 它算操纵成本时,有没有考虑资金可以在同一笔交易里借入并归还——只算「需要多少本金」是最大的错误
- 它的成本公式和你在 Lab 里手算的结果是否一致,尤其是那个平方根
- 它有没有把「可提取价值」错算成协议总锁仓量,而不是这个错误价格下实际能借出的金额
- 它给的时间加权均价能把成本提高多少倍,你能不能自己推一遍这个倍数怎么来的
- 它有没有提到出块时间会显著改变窗口内的操纵成本
- 它给的任何具体参数(心跳、阈值、抵押率),你是否去链上核对过,而不是采信它的记忆
真实案例
攻击模式几乎完全一致:在一笔交易里借入大额资金,推高某个池子里抵押资产的价格,用少量该资产作抵押从借贷协议借出远超其真实价值的稳定币,再把价格推回去、归还借款。
合约没有任何 bug。 每一步都是合法调用,每一次转账都成功,每一个检查都通过。被绕过的是「价格是可信的」这个假设。
这是整个行业损失金额最大的一类经济攻击,而它的修复方式通常只需要改一个地址:把价格来源换掉。
协议上线了一个流动性很小的资产作为抵押品。价格源本身没问题,问题在于这个资产的市场深度太小——操纵它在任何地方都很便宜。
事故的形态是:攻击者在市场上直接买高这个资产的价格(真的买,不是操纵某一个池子),用它抵押借走大量主流资产,然后放弃抵押品走人。协议拿到一堆无法变现的抵押品,产生坏账。
教训:预言机安全不只是「价格对不对」,还包括「这个价格背后有多少真实深度」。 一个资产能否作为抵押品,取决于它的市场深度和协议对它的敞口上限,而不只是能否拿到报价。
链拥堵、数据提供方故障、或者某个资产被价格源下线,更新停止了。
如果合约没有检查时间戳,它会拿着最后一个价格继续工作。在市场单边下跌时,这意味着所有该被清算的仓位都看起来很健康——直到价格源恢复,一大批仓位同时穿仓。
这个案例是「必须检查时间戳」这条规则的全部理由。它同时说明为什么过期阈值要按资产分别设置:不同资产的心跳可以差一个数量级。
某个资产的真实价格跌破了聚合层设定的下界。价格源返回的是那个下界值,接口上看起来一切正常:时间戳很新,价格是正数,格式完全正确。
依赖它的协议因此认为抵押品还值那个价,清算没有触发,等到边界被调整时,仓位已经严重资不抵债。
只检查「新不新」是不够的,还要检查「合不合理」。 一个简单有效的手段是把价格源的读数与第二个独立来源比对,偏差超过阈值就熔断——这也是「多源」在实践中最主要的价值,它更多是用来发现异常,而不是用来提高精度。
改一个变量
响应速度提升六倍,清算会更及时,T22 里的坏账风险下降。
操纵成本大致按窗口长度线性下降。更麻烦的是,成本下降的同时,攻击者承受反向套利的时间也变短了——他需要维持错误价格的时间更短,被套利者吃掉的部分更少,所以实际成本的下降幅度比线性还要陡。
窗口长度没有正确答案。它应该由「这个价格保护多少钱」反推:用本章的安全边际公式,先定一个可接受的比值,再倒推窗口。
同样一个「30 个区块」的窗口,时长从 6 分钟变成 12 秒。如果你的代码是按区块数计算窗口的,换链时这个保护会悄无声息地缩水三十倍。
反过来,如果按时间戳计算,窗口时长不变,但窗口内的区块数变多,攻击者需要在更多个区块里维持价格,成本反而可能上升。
这是跨链部署时最容易出事的一类假设。任何和时间有关的参数,换链时都必须重新算一遍,而不是直接复制。
单点故障消失了,而且你获得了一个非常有价值的东西:一个能主动发现异常的信号。
代价是可用性下降——两个源只要有一个抖动,你的协议就会暂停。这在正常运行中会比你预期的更频繁。
所以熔断之后的行为设计比熔断本身更重要:暂停期间必须继续允许还款和追加抵押品,否则你会在市场最紧张的时刻,把唯一能自救的用户挡在门外。
分子(操纵成本)塌下来,分母(可提取价值)不变,安全边际直接跌破 1。
这时候换任何价格源都救不了你——因为问题不在价格从哪里读,而在这个资产本身就很容易被推动。即使用一个完美的价格源,攻击者也可以在真实市场上把它买上去。
唯一有效的手段是限制敞口:给这个资产设一个绝对的借出上限,让「能提走的最大金额」始终小于「推动它的成本」。这是一个参数问题,不是一个数据源问题。
带走的问题
为什么需要 Blockchain?这一章给出了它的边界:链能保证「所有人看到同一份历史」,但没有任何办法自己验证外部世界的事实。预言机就是这条边界上的接口,也是协议里最不「去中心化」的那一段。
谁提供流动性?在这一章,流动性直接决定了操纵成本的分子。一个资产的市场深度,就是它作为价格来源的安全预算。 深度迁走了,你的协议会在没有任何代码变更的情况下变得不安全。
谁承担风险?预言机出问题时,损失落在协议的储备和 LP 身上,而不是数据提供方身上。这个错配值得记住:提供价格的人几乎不承担价格错误的后果。
激励是什么?去问清楚数据提供方为什么要诚实:是质押了会被罚没的资产、是声誉、还是单纯的合约约定。答案决定了你对这个源的信任应该有多深。
本章自测
因为它可以被单笔交易改写,而且改写所需的资金可以在同一笔交易里借入并归还,真实成本只剩手续费。
关键不在于「不准」,而在于「攻击者能选择你读到什么」。一个能被攻击者任意设定的输入,不能用来做任何安全判断。
唯一的例外是:这个价格只影响小到可以忽略的金额,且操纵成本高于全部可提取价值。这种场景在借贷、永续、清算里基本不存在。
不是,这只是起点。至少还要做四件事:
- 检查时间戳,按每个资产单独设置过期阈值。
- 检查取值范围,防止在极端行情下拿到被边界夹住的值。
- 显式决定数据不可用时每个入口的行为,而不是统一 revert。
- 持续监控这个源的实际更新行为,文档上的心跳和链上真实表现可能不一致。
再加一条:你现在依赖了一个你不控制的合约。它的升级、暂停、参数变更、资产下线计划,都要纳入你的运维监控。
提高的原因是攻击者必须维持错误价格整个窗口,而不是推一下就撤。维持期间套利者会持续反向交易,每个区块都在消耗他的资金。粗略地说,成本从「推一次的滑点」变成「推一次的滑点乘以窗口内被套利的轮次」。
代价是滞后:窗口有多长,你的价格就落后多久。在快速下跌时,这个滞后会让清算集体迟到,直接转化成坏账。
所以窗口长度是一个双向的风险取舍,两边都会造成损失,只是形式不同。
它会在价格源出问题的同时冻结清算和还款,而价格源出问题的时刻往往正是市场剧烈波动、最需要清算的时刻。
结果是:坏仓位无法被清算,用户想还款自救也被挡住,等价格恢复时损失已经扩大。
正确的原则是按操作方向分类:增加风险的操作(借出、提取抵押品)必须有新鲜价格;降低风险的操作(还款、追加抵押品)在没有价格时也应放行;清算则需要一个明确的回退策略。
没有标准答案,检查这几件事:
- 每一个用到价格的地方,你能不能说出它的来源、心跳、精度和过期阈值。
- 对每个资产,你算过那个安全边际比值吗?分母是不是「这个错误价格下能提走的最大金额」,而不是总锁仓量。
- 价格源停更、返回边界值、被暂停,三种情况你的每个入口分别怎么办,有没有对应的测试。
- 这些资产的市场深度如果减半,哪个配置会先失效?你有没有在监控里放一个对应的告警。
- 换一条出块速度不同的链部署时,哪些参数必须重算?
第 4 条是最少被做、也最能区分水平的一条:它要求你把预言机安全当成一个会随时间漂移的状态,而不是一个上线时做对一次就完事的配置。
一句话带走
预言机是协议最常见的单点,也是最常见的攻击入口。