Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
集成与开发阅读约 12 分钟

Chainlink 数据源:安全读取链上价格数据

一份关于在智能合约和链下服务中集成 Chainlink 数据源的实用指南,涵盖过期检查、排序器正常运行时间防护以及可复现的验证。

TL;DR

Chainlink 数据源是由去中心化预言机网络组成的,它们从多个独立来源聚合数据并将结果发布到链上,任何合约都可以通过一次调用读取最新值。本文解释了基于推送的数据源模型,逐步演示了一个使用 AggregatorV3Interface 的可运行 Solidity 消费者,并展示了如何添加过期检查和 L2 排序器正常运行时间防护,以防止你的协议基于不良数据采取行动。文章还涵盖了链下读取、可复现的验证流程,以及数据源与基于拉取的数据流之间的权衡。

假设与前提条件

示例假设使用 EVM 兼容链、Solidity 工具链(Foundry 或 Hardhat),以及访问目标网络的 RPC 端点。你需要打算读取的特定数据源的代理地址;地址是特定于网络的,必须从官方 Chainlink 数据源目录获取,而不是从其他链复制。

对于链下读取,你需要 Node.js 配合 ethers 或 viem,或者 Python 配合 web3.py。对于任何发送交易的脚本,你需要一个有资金的账户;将私钥保存在环境变量中,切勿提交。如果你仍在为目标链选择 RPC 提供商,RPC Assistant 可以帮助你选择一个具体的端点,多链 RPC 端点指南解释了如何跨网络构建访问结构。

基于推送的数据源模型及其更新触发条件

数据源不会在每个价格变动时更新。当心跳间隔过去或观察到的数值移动超过配置的偏差阈值时,DON 会发布新一轮。每次更新都会创建一个带有 ID、答案和更新时间戳的新轮次,聚合器通过标准接口公开最新轮次。

这种设计有两个你必须明确处理的后果。首先,答案可能比当前市场价格旧最多一个心跳间隔,因此任何依赖新鲜度的逻辑都需要过期检查。其次,由于更新是事件驱动的,轮次之间的时间不是恒定的;平静的市场可能会产生较长的间隔,即使数据源是健康的。

你的合约使用的接口是 AggregatorV3Interface,它公开了 latestRoundData、getRoundData、decimals 和 description。decimals 值因数据源而异,并不总是 8,因此切勿硬编码。

从 Solidity 合约读取数据源

下面的消费者读取最新轮次,验证答案为正且新鲜,并将数值归一化为 18 位小数。将 FEED_ADDRESS 替换为你所在网络和资产的代理地址,并将 MAX_STALENESS 设置为根据数据源文档记录的心跳间隔得出的值,而不是猜测。

对 answeredInRound 的检查是一种防御措施:在某些历史聚合器实现中,它可能落后于 roundId,将其视为过期可以避免对不完整的轮次采取行动。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface AggregatorV3Interface {
    function decimals() external view returns (uint8);
    function description() external view returns (string memory);
    function latestRoundData()
        external
        view
        returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}

contract FeedReader {
    AggregatorV3Interface public immutable feed;
    uint256 public immutable maxStaleness;

    error StaleAnswer(uint256 updatedAt);
    error NonPositiveAnswer(int256 answer);

    constructor(address feedAddress, uint256 maxStalenessSeconds) {
        feed = AggregatorV3Interface(feedAddress);
        maxStaleness = maxStalenessSeconds;
    }

    function latestPrice18() external view returns (uint256) {
        (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = feed.latestRoundData();
        if (answer <= 0) revert NonPositiveAnswer(answer);
        if (updatedAt == 0 || block.timestamp - updatedAt > maxStaleness) revert StaleAnswer(updatedAt);
        if (answeredInRound < roundId) revert StaleAnswer(updatedAt);

        uint8 d = feed.decimals();
        return uint256(answer) * (10 ** (18 - d));
    }
}

使用排序器正常运行时间源保护 L2 部署

在 Layer 2 网络上,数据源可能看起来是新鲜的,而排序器才刚刚恢复在线,因为在中断期间排队的交易可能会突发执行。Chainlink 为此发布了 L2 排序器正常运行时间源。模式是读取正常运行时间源,确认排序器已经启动了一段宽限期,然后才信任价格源。

宽限期的存在是因为排序器恢复后价格可能没有立即刷新。选择一个反映你的目标数据源在中断后更新速度的宽限期,并记录该选择。相同的防护应该包裹受影响代码路径中的每次价格读取,而不仅仅是入口点。

interface AggregatorV3Interface {
    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80);
}

contract SequencerGuard {
    AggregatorV3Interface public immutable uptimeFeed;
    uint256 public constant GRACE_PERIOD = 30 minutes;

    constructor(address uptimeFeedAddress) {
        uptimeFeed = AggregatorV3Interface(uptimeFeedAddress);
    }

    function sequencerHealthy() public view returns (bool) {
        (, int256 answer, uint256 startedAt, , ) = uptimeFeed.latestRoundData();
        if (answer != 0) return false; // 0 = up, 1 = down
        return block.timestamp - startedAt > GRACE_PERIOD;
    }
}

使用 ethers 进行链下读取

链下读取对于仪表板、清算机器人和交易前检查很有用。下面的脚本读取最新轮次并打印归一化价格、轮次 ID 和答案的年龄。将 RPC_URL 设置为你的提供商端点,将 FEED_ADDRESS 设置为你所在网络的代理。

由于读取是普通的 eth_call,它适用于任何标准 RPC 端点。如果你正在比较提供商的延迟或归档深度,以太坊 RPC API 概述和定价页面描述了需要评估的内容。

import { ethers } from "ethers";

const ABI = [
  "function decimals() view returns (uint8)",
  "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)"
];

async function main() {
  const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
  const feed = new ethers.Contract(process.env.FEED_ADDRESS, ABI, provider);

  const [decimals, round] = await Promise.all([
    feed.decimals(),
    feed.latestRoundData()
  ]);

  const price = Number(round.answer) / 10 ** Number(decimals);
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(round.updatedAt);

  console.log({ price, roundId: round.roundId.toString(), ageSeconds });
}

main().catch((e) => { console.error(e); process.exit(1); });

可复现的验证流程

在信任一个集成之前,请根据数据源自身的元数据进行验证,而不是根据你在其他地方看到的价格。下面的流程生成一个小型结果表,你可以在任何网络上重新运行并随时间进行比较。它不需要任何特定的数值结果;关键是各列被填充且内部一致。

每隔几分钟运行链下脚本三次,并在 Markdown 表格中记录以下列:网络、数据源地址、描述、小数位数、roundId、答案、updatedAt、年龄(秒)以及年龄是否低于你的过期阈值。健康的数据源显示 roundId 单调非递减、答案为正,并且年龄低于心跳间隔加上一个小余量。如果 roundId 在多次运行之间没有推进,这在平静的市场中是预期的;这本身并不是故障。

然后在分叉或测试网上调用你的 Solidity 消费者,并确认 latestPrice18 返回与链下脚本相同的归一化值。不匹配通常表明小数位数假设错误或数据源地址与预期不同。

  • 确认描述与你预期的资产对匹配。
  • 确认小数位数与你用于归一化的值匹配。
  • 确认 updatedAt 非零且年龄在你的阈值内。
  • 确认链上消费者和链下脚本一致。

排查常见的集成故障

大多数数据源集成错误属于少数几类。下表将症状映射到可能的原因以及确认每个原因的检查。

如果读取回退且没有返回数据,该地址可能不是该链上的合约,这通常意味着数据源地址是从不同网络复制的。如果价格相差几个数量级,则小数位数被硬编码或应用了两次。如果你的协议仅在 L2 上表现异常,请检查是否存在排序器防护以及宽限期是否对你的数据源足够长。

  • 调用回退:使用 eth_getCode 验证地址是目标链上的合约。
  • 价格缩放错误:读取 decimals() 并归一化一次。
  • 接受了过期数据:添加与文档记录的心跳间隔绑定的 updatedAt 检查。
  • 中断后的 L2 突发:添加带有宽限期的排序器正常运行时间防护。
  • 轮次不匹配:比较 answeredInRound 与 roundId。

局限性与权衡

基于推送的数据源优化的是链上可组合性和可预测的读取成本,而不是最低延迟。如果你的应用需要亚秒级更新或每区块粒度,基于拉取的数据流是合适的产品,但它们需要不同的验证流程,并且不能直接替代 latestRoundData 调用。

心跳间隔和偏差阈值因数据源而异,并且可能变化;今天正确的过期常量在数据源配置更新后可能是错误的。将阈值视为配置,监控它,并在观察到的年龄接近它时发出警报。还要记住,数据源的答案是聚合值,而不是对任何单一场所的保证;对于抵押品估值,将其与你自己的风险参数结合,而不是将其视为绝对真理。

最后,RPC 层对链下读取很重要。滞后的节点可能返回较旧的区块,因此返回较旧的轮次。如果新鲜度至关重要,请在特定区块号读取并跨提供商比较,并考虑使用专用节点以获得一致的头部延迟。

生产集成的后续步骤

首先列出每个消费价格的代码路径,并确认每个路径都有过期检查,并且在 L2 上有排序器防护。然后添加按计划记录 roundId 和年龄的监控,以便在事件发生前了解数据源行为。如果你运营自己的基础设施,专用节点可以减少链下读取延迟的方差;专用节点概述解释了权衡。

对于跨多个链构建的团队,请在每个网络的配置文件中标准化数据源地址和小数位数,并在部署时加载,而不是硬编码。网络目录列出了支持的链,RPC 定价页面帮助你为监控产生的读取量确定端点容量。当你准备为特定链选择具体端点时,请向 RPC Assistant 寻求推荐,而不是猜测。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始