Crypto OS
Technical Crypto OS第四阶段 · DeFi Engineering

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%,只要用它做抵押能借出全池的稳定币,分母就是全池。

还有一件事必须现在就说清楚:分子和分母都不是常数。

流动性会迁移,池子会变浅,分子随之下降;协议上调某个资产的抵押率,分母随之上升。一个上线时安全的配置,可能在半年后变得不安全,而期间没有任何一行代码被改过。

建立模型

先看完整的链路,以及每一环会怎么坏:

  1. 真实市场价格
  2. 数据提供方
  3. 链下聚合
  4. 链上价格合约
  5. 你的合约的判断
五个环节,每一环都有独立的失效模式。你只能控制最后一环,但必须为前面四环的失效做好准备。
环节典型失效你在最后一环能做什么
真实市场流动性枯竭,根本没有公允价熔断,暂停相关操作
数据提供方某个源宕机或返回错误值依赖聚合层的中位数逻辑
链下聚合整体停更,或延迟拉长检查时间戳
链上价格合约被升级、被暂停、返回边界值检查取值范围与合理性
你的合约没检查就用了这一整章

一个价格源有四个必须弄清楚的参数。它们全都是可配置的,不同的源、不同的资产、不同的链上取值可以差很远,所以下面只给量级,具体数字必须你自己去查——这正是 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。 它看起来最安全,实际上会在价格源出问题的同时冻结清算——而价格源出问题的时刻,往往正是市场剧烈波动、最需要清算的时刻。

正确的做法通常是分类:增加风险的操作必须有新鲜价格才放行,降低风险的操作在没有价格时也应当允许。

它叫什么

Oracle预言机

把链外信息带到链上的机制。

它不是一个技术难题,而是一个信任问题:链本身可以让所有人验证同一份历史,但没有任何办法让链自己验证「外面的黄金现在多少钱」。这一段信任必须由某个链下的东西提供。

Price Feed价格源

持续向链上写入某个资产价格的合约与其背后的运营体系。

读它的时候至少要拿到两个值:价格和它的产生时间。只返回价格、不返回时间的接口,无法安全使用。

Heartbeat心跳

价格源承诺的最长更新间隔。价格没变化时,它也会按这个节奏写一次。

它是你设置过期阈值的依据。阈值设得比心跳短,会在正常情况下频繁误报;设得太长,则等于允许使用陈旧数据。常见做法是设为心跳的若干倍,并且每个资产分别设置

Deviation Threshold偏差阈值

价格相对上次写入变动超过多少时,提前触发更新。

它和心跳共同决定了链上价格的新鲜度:平静时按心跳更新,波动时按偏差更新。这意味着在价格平稳的时段,你读到的数字可能已经很旧了,而这通常没有问题;真正危险的是波动时的更新是否跟得上。

TWAP时间加权平均价

用一段时间窗口内的累积价格算出的平均值。

它提高的不是准确性,而是操纵成本:攻击者必须把价格维持在错误位置整个窗口,而不是推一下。代价是滞后。窗口长度是安全性和响应速度之间的直接取舍。

Price Manipulation价格操纵

通过交易主动改变某个价格来源的读数,从依赖它的合约中获利。

它通常不是漏洞利用,而是完全合法的交易组合:在一笔交易里推动价格、触发目标合约、恢复价格。代码没有 bug,逻辑却被彻底绕过。

Circuit Breaker熔断

检测到异常时主动停止部分功能。

关键设计点是停什么:停掉全部功能会连清算一起停掉;只停增加风险的操作,才能在保护协议的同时让它继续自愈。

动手

动手接入一个价格源,记录它的更新频率与偏差阈值,模拟数据过期时你的合约怎么办任意测试网 + Fork 环境 + 合约测试框架0 元,全程测试网与本地 Fork

先做调查,不写代码。 挑一个链上价格源,挑两个资产:一个主流资产,一个流动性明显较小的资产。填这张表:

主流资产小资产
心跳(承诺的最长更新间隔)
偏差阈值
返回值精度
最近 24 小时实际更新了几次
两次更新之间的最长间隔
有没有取值上下界,是多少

最后两行必须从链上真实数据统计出来,不要抄文档。文档写的是承诺,链上记录的是实际发生的事。两者对不上的情况并不罕见。

PriceGuard,把四个检查全部实现:非正数、过期、越界、跳变。每个检查抛一个不同的错误,方便测试区分。

在 Fork 环境上跑通读取。 确认你拿到的价格和区块浏览器上看到的一致,特别是精度。把 8 位精度当 18 位用,结果会差十个数量级,而代码不会报任何错。

构造四种故障,逐个验证。 用一个可控的桩合约替换价格源:

  1. 时间戳停在一小时前 → 应触发过期
  2. 返回 0 → 应触发非正数检查
  3. 返回上界值 → 应触发越界
  4. 相对上次翻了 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

AI Lab让 AI 分析用现货池价格做预言机的风险,自己算出操纵它需要多少资金Level 2 · AI Copilot

分三步问:

第一步:
一个借贷协议用某个恒定乘积池子的即时储备比值作为抵押品价格。
池子一侧深度 D,协议可借出 L。请给出攻击的完整步骤,
以及一个可以代入数字的成本公式。

第二步:
把上面的价格源换成同一个池子的时间加权均价,窗口为 N 个区块。
重新推导操纵成本,说明它相对第一步提高了多少倍,以及这个倍数依赖哪些假设。

第三步:
列出我在第二步的方案里仍然存在的五个风险,
按「造成损失的概率乘以损失规模」排序,每一条给出可检测的信号。

第一步几乎所有模型都能答对大概,错误集中在成本计算上:很多回答会说「需要 41 万本金」,忽略了这笔钱可以在同一笔交易内借入并归还,真实成本只有手续费。这个差别是一百倍量级的,直接决定了一个配置到底安不安全。

第二步的常见错误是给出一个笼统的「安全得多」,而不是一个可计算的倍数。追问它:窗口内每个区块攻击者要付出什么、套利者会怎样反向吃掉他。

第三步是这个 Lab 真正的产出。把它列出的风险和你 Lab 第五步的那张表对照,看有没有你没考虑到的入口。

它给的所有具体参数一律不要相信,全部去链上核对。这类数字是模型最容易产生幻觉的地方,而且错了你很难察觉——因为它们看起来都很合理。

AI 说完之后,你必须自己验证

  • 它算操纵成本时,有没有考虑资金可以在同一笔交易里借入并归还——只算「需要多少本金」是最大的错误
  • 它的成本公式和你在 Lab 里手算的结果是否一致,尤其是那个平方根
  • 它有没有把「可提取价值」错算成协议总锁仓量,而不是这个错误价格下实际能借出的金额
  • 它给的时间加权均价能把成本提高多少倍,你能不能自己推一遍这个倍数怎么来的
  • 它有没有提到出块时间会显著改变窗口内的操纵成本
  • 它给的任何具体参数(心跳、阈值、抵押率),你是否去链上核对过,而不是采信它的记忆

真实案例

用现货池价格给抵押品定价2020 年至今多次

攻击模式几乎完全一致:在一笔交易里借入大额资金,推高某个池子里抵押资产的价格,用少量该资产作抵押从借贷协议借出远超其真实价值的稳定币,再把价格推回去、归还借款。

合约没有任何 bug。 每一步都是合法调用,每一次转账都成功,每一个检查都通过。被绕过的是「价格是可信的」这个假设。

这是整个行业损失金额最大的一类经济攻击,而它的修复方式通常只需要改一个地址:把价格来源换掉。

浅池资产被列为抵押品多起事故

协议上线了一个流动性很小的资产作为抵押品。价格源本身没问题,问题在于这个资产的市场深度太小——操纵它在任何地方都很便宜。

事故的形态是:攻击者在市场上直接买高这个资产的价格(真的买,不是操纵某一个池子),用它抵押借走大量主流资产,然后放弃抵押品走人。协议拿到一堆无法变现的抵押品,产生坏账。

教训:预言机安全不只是「价格对不对」,还包括「这个价格背后有多少真实深度」。 一个资产能否作为抵押品,取决于它的市场深度和协议对它的敞口上限,而不只是能否拿到报价。

价格源停更,合约继续用陈旧数据多次多条链

链拥堵、数据提供方故障、或者某个资产被价格源下线,更新停止了。

如果合约没有检查时间戳,它会拿着最后一个价格继续工作。在市场单边下跌时,这意味着所有该被清算的仓位都看起来很健康——直到价格源恢复,一大批仓位同时穿仓。

这个案例是「必须检查时间戳」这条规则的全部理由。它同时说明为什么过期阈值要按资产分别设置:不同资产的心跳可以差一个数量级。

极端行情下价格被边界夹住剧烈波动时段

某个资产的真实价格跌破了聚合层设定的下界。价格源返回的是那个下界值,接口上看起来一切正常:时间戳很新,价格是正数,格式完全正确。

依赖它的协议因此认为抵押品还值那个价,清算没有触发,等到边界被调整时,仓位已经严重资不抵债。

只检查「新不新」是不够的,还要检查「合不合理」。 一个简单有效的手段是把价格源的读数与第二个独立来源比对,偏差超过阈值就熔断——这也是「多源」在实践中最主要的价值,它更多是用来发现异常,而不是用来提高精度。

改一个变量

如果把时间加权均价的窗口从 30 分钟缩短到 5 分钟

响应速度提升六倍,清算会更及时,T22 里的坏账风险下降。

操纵成本大致按窗口长度线性下降。更麻烦的是,成本下降的同时,攻击者承受反向套利的时间也变短了——他需要维持错误价格的时间更短,被套利者吃掉的部分更少,所以实际成本的下降幅度比线性还要陡。

窗口长度没有正确答案。它应该由「这个价格保护多少钱」反推:用本章的安全边际公式,先定一个可接受的比值,再倒推窗口。

如果换到一条出块时间从 12 秒变成 0.4 秒的链

同样一个「30 个区块」的窗口,时长从 6 分钟变成 12 秒。如果你的代码是按区块数计算窗口的,换链时这个保护会悄无声息地缩水三十倍。

反过来,如果按时间戳计算,窗口时长不变,但窗口内的区块数变多,攻击者需要在更多个区块里维持价格,成本反而可能上升。

这是跨链部署时最容易出事的一类假设。任何和时间有关的参数,换链时都必须重新算一遍,而不是直接复制。

如果加入第二个独立价格源,两者偏差超过阈值就熔断

单点故障消失了,而且你获得了一个非常有价值的东西:一个能主动发现异常的信号。

代价是可用性下降——两个源只要有一个抖动,你的协议就会暂停。这在正常运行中会比你预期的更频繁。

所以熔断之后的行为设计比熔断本身更重要:暂停期间必须继续允许还款和追加抵押品,否则你会在市场最紧张的时刻,把唯一能自救的用户挡在门外。

如果抵押品换成一个市场深度只有几十万的资产

分子(操纵成本)塌下来,分母(可提取价值)不变,安全边际直接跌破 1。

这时候换任何价格源都救不了你——因为问题不在价格从哪里读,而在这个资产本身就很容易被推动。即使用一个完美的价格源,攻击者也可以在真实市场上把它买上去。

唯一有效的手段是限制敞口:给这个资产设一个绝对的借出上限,让「能提走的最大金额」始终小于「推动它的成本」。这是一个参数问题,不是一个数据源问题。

带走的问题

2
为什么需要 Blockchain?

为什么需要 Blockchain?这一章给出了它的边界:链能保证「所有人看到同一份历史」,但没有任何办法自己验证外部世界的事实。预言机就是这条边界上的接口,也是协议里最不「去中心化」的那一段。

8
谁提供流动性?

谁提供流动性?在这一章,流动性直接决定了操纵成本的分子。一个资产的市场深度,就是它作为价格来源的安全预算。 深度迁走了,你的协议会在没有任何代码变更的情况下变得不安全。

9
谁承担风险?

谁承担风险?预言机出问题时,损失落在协议的储备和 LP 身上,而不是数据提供方身上。这个错配值得记住:提供价格的人几乎不承担价格错误的后果。

10
激励是什么?

激励是什么?去问清楚数据提供方为什么要诚实:是质押了会被罚没的资产、是声誉、还是单纯的合约约定。答案决定了你对这个源的信任应该有多深。

本章自测

一句话带走

预言机是协议最常见的单点,也是最常见的攻击入口。

做完这一章的动手环节了?勾上它查看全部进度

本页目录