Solidity 预言机入门

一个尴尬的现实

你写了一个很棒的 DeFi 合约,用户存入 ETH,你想按实时价格折算成 USDC 给用户。

然后你发现一件事:智能合约根本不知道 ETH 现在值多少钱。

它不知道自己当前的价格,不知道天气怎么样,不知道某场比赛谁赢了,甚至连当前时间都只能拿到区块时间(还不一定准)。

这不是 bug,是设计如此。以太坊节点必须能独立验证每一笔交易,如果合约可以随便发 HTTP 请求去外面查数据,那不同节点查到的结果可能不一样,共识就崩了。

所以区块链把自己关在了一个沙盒里——安全,但也与世隔绝。

预言机(Oracle)就是打破这堵墙的东西。


预言机到底是什么

别被名字唬住了。它不是什么神秘组件,本质上就是一个中间人

1
链外数据源 → 预言机 → 链上合约

合约想要外部数据时,不是自己去拿,而是”喊一声”,预言机听到后去外面查好,再把结果送回来写到合约的存储里。

[!example] 生活中的类比
就像你(合约)在考场里不能带手机,但你可以举手让监考老师(预言机)帮你查一个数据,老师查完告诉你,你记在卷子上。


为什么不能自己搞个简单方案

很多人第一反应是:我自己写个服务器,定期把价格推到合约里不就行了?

技术上完全可以,而且很多小项目就是这么干的。但问题在于:你让所有人信任了你一个人。

如果你的服务器挂了,合约拿到旧价格,用户被清算。
如果你的私钥泄露,黑客可以推送假价格,掏空协议。
如果你心情不好不想更新了,合约就废了。

这就是为什么 DeFi 协议通常不会用单一数据源,而是用去中心化预言机网络——多个独立节点各自拉取数据,取中位数或加权平均,单个节点出问题不会带偏整体。

目前用得最多的是 Chainlink,后面也会以它为例。


先理清几个概念:

  • Consumer — 你的合约,数据的消费者
  • Oracle Node — 链下的节点,负责执行请求并返回结果
  • Job — 节点上要执行的具体任务(比如”去某个 API 拉数据,提取某个字段”)
  • LINK — Chainlink 的网络代币,用来支付节点服务费

工作流程大概是这样的:

  1. 你的合约调用某个函数,发起数据请求
  2. 请求被发送到 Chainlink 网络
  3. 节点接到请求,去指定的 API 拉取数据
  4. 节点把结果通过回调函数写回你的合约
  5. 你的合约在回调里拿到数据,继续后续逻辑

整个过程是异步的——你发完请求不能立刻拿到结果,得等节点处理完后回调你。


准备

你需要:

  • 测试网 ETH(Sepolia 就行)
  • 测试网 LINK 代币(从 Chainlink Faucet 领取)
  • Hardhat 或 Foundry 开发环境

代码

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/AutomationCompatible.sol";
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";

contract PriceConsumer is ChainlinkClient, AutomationCompatibleInterface {
using Chainlink for Chainlink.Request;

uint256 public ethPrice;
address private oracle;
bytes32 private jobId;
uint256 private fee;

// Sepolia 测试网的配置
constructor() {
_setChainlinkToken(0x779877A7B0D9E8603169DdbD7836e478b4624789); // LINK token
_setChainlinkOracle(0x6090149792dAAeE9D1D568c9f9a6F6B46AA29eFD); // Oracle 地址
jobId = "ca981898927f47f38dc359e805874fe9"; // Job ID
fee = 10 ** 17; // 0.1 LINK
}

// 发起价格请求
function requestPrice() external returns (bytes32 requestId) {
Chainlink.Request memory req = _buildChainlinkRequest(
jobId,
address(this),
this.fulfill.selector
);

// 设置 API 地址(CoinGecko)
req._add(
"get",
"https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
);

// 提取路径:ethereum -> usd
req._add("path", "ethereum,usd");

// 结果乘以 100 取整数
req._addInt("times", 100);

requestId = _sendChainlinkRequest(req, fee);
}

// 回调函数,节点会把结果送到这里
function fulfill(bytes32 _requestId, uint256 _price) external recordChainlinkFulfillment(_requestId) {
ethPrice = _price;
}

// 配合 Chainlink Automation 定期自动更新价格
function checkUpkeep(bytes calldata) external view override returns (bool upkeepNeeded, bytes memory) {
// 每 5 分钟更新一次
upkeepNeeded = (block.timestamp - lastUpdated) > 300;
}

function performUpkeep(bytes calldata) external override {
requestPrice();
}
}

[!warning] 注意
上面的 Job ID 和 Oracle 地址可能会变动,部署前请到 Chainlink 文档 确认 Sepolia 测试网的最新配置。

这段代码做了什么

  1. 继承 ChainlinkClient,获得发送请求的能力
  2. requestPrice() 构造一个请求,告诉节点去哪个 API 拉数据、怎么解析
  3. 支付 0.1 LINK 作为服务费
  4. 节点执行完后调用 fulfill(),把价格写进 ethPrice
  5. 配合 Automation 可以定期自动更新,不需要手动触发

更简单的方式:价格 Feed

如果你只是要 ETH/USD 的价格,其实不用自己发请求。Chainlink 提供了价格 Feed(Price Feed),已经有节点在持续更新价格了,你直接读就行:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract SimplePriceReader {
AggregatorV3Interface internal priceFeed;

constructor() {
// Sepolia ETH/USD Feed
priceFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306);
}

function getLatestPrice() external view returns (int256) {
(
uint80 roundID,
int256 price,
uint256 startedAt,
uint256 timeStamp,
uint80 answeredInRound
) = priceFeed.latestRoundData();

// 安全检查:数据是否过期
require(timeStamp > block.timestamp - 3600, "Stale price");

return price; // 8 位精度,需要自己处理小数
}
}

这种方式不需要支付 LINK,也不需要等回调,直接 view 函数读取。大部分 DeFi 协议用的就是这种。

[!tip] 精度问题
Chainlink 价格 Feed 通常返回 8 位精度的整数。比如 ETH 价格 $3500.12 会返回 350012000000。用的时候记得除以 1e8


预言机不只是查价格

价格数据只是最常见的场景,预言机还能干很多事:

场景 说明
随机数(VRF) 游戏、NFT 抽奖需要可验证的随机数,Chainlink VRF 链上生成并附带证明
任意 API 请求 查天气、体育比赛结果、航班信息……任何 HTTP API 都能接
跨链通信 CCIP 协议让合约能和其他链交互
自动化执行 Automation 服务可以定时或按条件触发合约函数
函数计算 在链下跑一段自定义代码,把结果返回链上

一些踩过的坑

1. 回调函数的 gas 限制

节点回调你的 fulfill() 函数时,gas 上限是固定的(通常 50 万)。如果你的回调逻辑太重,会直接 revert,钱白花。

做法: 回调里只做最简单的赋值,复杂逻辑拆到另一个函数里后续调用。

2. 测试网和主网配置不同

每个网络的 Oracle 地址、Job ID、LINK 合约地址都不一样。复制粘贴前确认当前网络的配置。

3. 数据延迟

预言机不是实时的。从发请求到拿到结果,测试网可能几十秒,主网看网络拥堵情况。做清算逻辑的时候要考虑这个延迟。

4. 价格操纵攻击

如果你只用一个价格源,攻击者可以在 DEX 上砸盘制造低价,然后从你的协议里套利。

做法: 用 Chainlink 这种聚合多源的 Feed,或者自己取多个预言机的中位数。


下一步

预言机本身不难理解,核心就是合约自己拿不到链外数据,需要可信的中间人帮忙。真正需要花心思的是怎么设计数据源,让协议不被单一故障点拖垮。