如何通过 Chainlink Price Feeds获得加密资产的历史价格

对于 Web3 应用来说,获取加密资产的价格数据是一个很常见的要求,许多协议都需要依赖于高质量且及时更新的数据来运营DeFi 应用并且保证其安全性。除此之外,智能合约开发者有的时候也需要获取加密资产的历史数据。

在这篇文章中,我们将演示如何从 Chainlink Price Feeds 中获得历史价格数据,并且在链上验证获得的结果,你可以在这里查看代码。

获得历史价格数据的需求

在过去的几年中,我们见证了 DeFi 爆炸式增长,这些 DeFi 协议的一个共同的需求是它们需要非常安全,准确和值得信任的数据。Chainlink Price Feeds 已经成为了在 DeFi 生态中最常被使用的价格预言机,并且集成进来数十个百亿美元级别的协议,比如 AaveSynthetixTrader Joe。.

Price Feeds 最常见的使用场景是从既定的资产对中获取最新的价格数据。然而,有的时候 DeFi 协议或者 dApp 也会有查看某个资产在某个时间点的的历史价格。相关案例很多,比如一个金融产品会比较不同时间段的资产价格,比如加密资产保险就会使用历史数据来计算出来市场波动率,然后动态调整保证金。

历史价格数据可以通过很多市场数据的 API 被获得,然后通过 Chainlink Any API 的功能在链上获取。然而这个解决方案有安全性的顾虑:就是数据源和传递数据的预言机可能是中心化的,并且没有办法验证这个数据是否是准确的。就像实时价格数据一样,历史价格数据也需要去中心化的方式获得,同时也需要足够多的数据源,这就要求不能使用单一的数据源,交易所或者 API。

Chainlink Price Feeds 通过多个数据提供商,提供去中心化的,高质量的价格数据解决了问题。当需要历史价格数据的时候,Price Feeds 可以保证价格数据是准确的,同时某个时间点的价格数据来源是整个市场,而不是单一交易所。

当然,还有一些其他解决方案来获取 Chainlink Price Feed 的数据,比如 Pier Two’s API 或者是 Graph 的一个 subGraph。尽管这些都是有效的解决方案,但是它们还是依赖于单一 API 或者是链下数据是正确的这个前提条件。

在这个解决方案中,我们将展示通过使用 Chainlink 语言来进行必要的链下计算,然后从 Chainlink Price Feeds 中获得历史数据。通过使用 Chainlink Price Feeds 这个入口,用户合约可以通过信任最小化的方式在链上获得历史价格数据。

Price Feed 合约简介

从用户合约的视角来看,Chainlink Price Feeds 智能合约大体上可以分为两个类型:代理合约聚合合约

代理合约代表一个价格对(像是 ETH/USD),同时也是用户要交互的合约。这个合约有多个函数,可以根据特定的参数获取 round 数据,比如说获取某个交易对最新的 round 数据或者在某个 round 中获取价格数据。

代理合约中有所有的聚合合约的信息,同时也知道现在正在使用哪一个聚合合约。可以通过 `aggregator` 或者给 `phaseAggregator` 传入正确 phase ID 来获得这些信息。在下面的例子中,我们可以看到 Kovan ETH/USD 这个代理合约中,第二个聚合合约正在被使用(phase ID = 2)。

Screenshot of the Kovan ETH/USD Proxy contract
获得 Kovan ETH/USD 中第二个聚合合约
Screenshot of obtaining the current aggregator in the Kovan ETH/USD Proxy contract.
获得 ETH/USD 代理合约中当前使用的聚合合约

这些聚合合约在实现的时候稍有不同,这取决于它们是在哪个版本上开发的(FluxAggregator,legacy Aggregator 等等),但是它们都存储了聚合以后的 round 数据,同时也都有一个函数让预言机可以提交价格数据。

Diagram of Chainlink Price Feeds' contracts overview
Chainlink Price Feeds 合约架构

解决方案概述

Chainlink Price Feeds 的价格数据是存在链上的,同时开发者可以执行 `getLatestRoundData` 函数来获取某一个交易对最新的价格数据。然而,从 Price Feeds 中获取历史数据却并不简单,过程比较复杂。

Chainlink Price Feeds 存储聚合以后的价格数据,每一个数据都会有一个独特的 round ID。当价格对的波动超过特性的波动阈值,或者超过心跳时间上限,一个新的 round 就会产生。如果开发者知道历史价格数据的 round ID,那么可以很容易通过 getHistoricaPrice 这个函数找到这个历史价格。然而,roundID 和时间戳,区块,或者任何其他可以被用来决定时间的变量都没有直接的联系。

另外要注意的是,Chainlink Price Feed 有很多版本的聚合合约,比如 FluxAggregator 和使用 Off-Chain Reporting 的 OffchainAggregator 合约。一个交易对的喂价有可能在以前的一段时间里使用的是 FluxAggregator,然后换成兼容 OCR 的 OffchainAggregator 来更新价格。所以在找到对应时间的 round ID 的时候,需要分辨不同的聚合合约版本。

另外因为 round ID 在面向用户的代理合约和底层的聚合合约中,是被有目的地分成不同的 phase,所以代理合约中的 round ID 比聚合合约中的要大很多。代理合约中的 round ID 总是需要自增 1,而底层的聚合合约在每次部署的时候 round ID 都是重新从 1 开始计数。通过下面的方法,可以由聚合合约的 round ID 计算出代理合约的 round ID,首先 phase 是基于代理合约部署的聚合合约的顺序(第一次,第二次,第三次等等),originalRoundId 就是聚合合约部署的 round。你可以通过调用代理合约的 `phaseAggregators` 这个 getter 方法来获得 phase 参数。

external proxy round ID = uint80(uint256(phase) << 64 | originalRoundId);

最后,并不是每一个 round 都会有价格数据。有一些 round(主要是在测试网)可能没有价格或者时间戳数据,主要是因为当时连接超时或者一些环境问题。

因为这些复杂性,在链上获得一个可验证且准确的历史数据是很复杂的,比如你需要做一个循环,就要去遍历大量的 round 数据,或者在链上存储一个 round 和时间戳的 mapping,但是这些操作都非常贵。

除了能够给智能合约提供链上数据和时间,Chainlink 去中心化预言机网络还提供了一个通用的框架来做链下运算,这样用户就不用存储大量的链上数据,也不用对未知数量的 round 做循环了。链上合约还可以通过一个运行着外部适配器(external adapter)的预言机来获得某个时间点的特定交易对价格。预言机把 round ID 返回到链上,然后用户合约可以马上使用这个数据,并通过历史数据 API 验证链上数据来验证数据的准确性,验证方式是通过 round ID 获得价格数据,然后与返回的价格数据比较。

这个解决方案所基于的概念是:预言机会处理任何区块链自身所不能处理的数据,或者区块链因为容量限制和效率不能或不应该处理的问题。除此以外,通过这种方式获得历史价格数据,还有很多优势:

  • 不需要在链上存储大量的数据,或者对链上数据进行大量的循环检查。
  • 使用链上的函数获取历史数据,和从链下取得的数据比较,杜绝恶意预言机提供的错误数据的风险。
  • 外部适配器是无状态的,不会像 Chainlink 节点运营商一样存储数据并且提供一个通用的方法来获取历史数据。
  • 外部适配器提供的解决方案不依赖于外部 API 或者其他系统,它直接与链上数据交互。
Diagram of using External Adapters with on-chain smart contracts
在智能合约中使用外部适配器

怎样使用 Chainlink 预言机获得加密资产历史价格数据

创建一个历史价格数据请求

在初始化一个历史价格数据的请求之前,用户合约需要给一个预言机提交一个 API 请求,这个预言机在自己的任务中需要运营一个自定义的历史价格数据的外部适配器。在发送请求的参数中,用户合约需要传入有效的代理合约地址和时间戳(Unix 时间)以返回价格数据。

 function getHistoricalPrice(address _proxyAddress, uint _unixTime) public returns (bytes32
requestId)
    {

        Chainlink.Request memory request = buildChainlinkRequest(jobId, address(this), 
this.singleResponseFulfill.selector);

        // Set the URL to perform the GET request on
        request.add("proxyAddress", addressToString(_proxyAddress));
        request.add("unixDateTime", uint2str(_unixTime));

        //set the timestamp being searched, we will use it for verification after
        searchTimestamp = _unixTime;

        //reset any previous values
        answerRound = 0;
        previousRound = 0;
        nextRound = 0;
        nextPrice = 0;
        nextPriceTimestamp = 0;
        previousPrice = 0;
        previousPriceTimestamp = 0;
        priceAnswer = 0;
        priceTimestamp = 0;

        // Sends the request
        return sendChainlinkRequestTo(oracle, request, fee);
    }

历史价格数据外部适配器

一旦 Chainlink 节点收到这个请求,它会将输入信息发送给历史价格外部适配器,这个外部适配器将会找时间戳所对应的 round ID,以及这个 round ID 之前和之后的 round ID。这两个额外的 round ID 在验证环节中会被用到,我们需要通过这些信息来验证价格数据的时间是最接近所查找的时间戳的,在这个 round 中 updatedAt 这个时间戳一定要比要搜索的时间戳参数要小。

一旦外部适配器接收到代理合约的地址和要查找的时间戳参数之后,它会进行以下的计算:

  • 对传入的地址和时间戳进行验证
  • 确定哪一个聚合合约包含要寻找的时间戳的 round
  • 在上一步找到的聚合合约中,获取存储在聚合合约中的 round ID 列表(使用 eth_getlogs
  • 在返回的 round ID 列表中,进行二分查找,找到包含时间戳的round ID。这个搜索的时间复杂度是 O(logN),相比之下,线性搜索的的时间复杂度更高是 O(N)
  • 如果找到的 round 是空或者无效(因为超时的原因等等),这个算法会分辨出在二分查找中的空值,然后把这些 round 排除掉,再在新的列表中进行新的二分查找。
  • 当被查找的 round ID 被找到之后,外部适配器会使用这个聚合合约的 round ID 以及它前面和后面的 round ID,算出这三个 round ID 在代理合约中对应的 round ID,然后在适配器中返回 `roundAnswer`,`earlierRoundAnswer` 和 `laterRoundAnswer` 这三个值。
  • 处理历史价格数据的请求的预言机会获取上一步的三个值,然后在通过 multi-variable response 的功能链上返回给链上的用户合约。
{
    "jobRunID": "534ea675a9524e8e834585b00368b178",
    "data": {
        "roundAnswer": "36893488147419111519",
        "earlierRoundAnswer": "36893488147419111518",
        "laterRoundAnswer": "36893488147419111520"
    },
    "result": null,
    "statusCode": 200
}

在链上验证结果

使用中心化数据源或者预言机来获得历史价格数据,会给智能合约带来潜在的安全风险。然而,在这个例子中,对于预言机返回的 round ID,我们可以利用现存的历史价格数据函数在链上验证 round ID,这样就可以用信任最小化的方式验证获取到的历史价格数据。在这种方式中,数据还是从链上获取的,只不过 round ID 是由外部的预言机计算出来的。

我们可以通过以下的方式来验证 round 数据,然后使用这个数据来获取到最终的历史数据:

首先,我们需要验证三个 round(answerRound, previousRound, nextRound) 中包含的有效的返回 round 数据

//verify the responses
        //first get back the responses for each round
        (
            uint80 id,
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeed.getRoundData(_answerRound);
        require(timeStamp > 0, "Round not complete");
        priceAnswer = price;
        priceTimestamp = timeStamp;

        (
            id,
            price,
            startedAt,
            timeStamp,
            answeredInRound
        ) = priceFeed.getRoundData(_previousRound);
        require(timeStamp > 0, "Round not complete");
        previousPrice = price;
        previousPriceTimestamp = timeStamp;

        (
            id,
            price,
            startedAt,
            timeStamp,
            answeredInRound
        ) = priceFeed.getRoundData(_previousRound);
        require(timeStamp > 0, "Round not complete");
        nextPrice = price;
        nextPriceTimestamp = timeStamp;

下一步,我们验证这些 round 数据以及它们包含的时间戳。

  • 保证 round 的顺序是正确的
  • 保证三个 round 中包含的时间戳时间顺序是对的
  • 如果这些 round 中有任何的间隔(比如 previousRound = 1625097600 而 answerRound = 1625097605),要确保在这两个 ID 之间没有任何有效的 round ID。所以在这个例子中,previousRound = 1625097600 并且 answerRound = 1625097605,这个合约需要保证 1625097601, 1625097602, 1625097603 和 1625097604 这些 round 不会返回有效数据
​​//first, make sure order of rounds is correct
        require(previousPriceTimestamp < timeStamp, "Previous price timetamp must be < answer
timestamp");
        require(timeStamp < nextPriceTimestamp, "Answer timetamp must be < next round
timestamp");

        //next, make sure prev round is before timestamp that was searched, and next round is
after
        require(previousPriceTimestamp < searchTimestamp, "Previous price timetamp must be < 
search timestamp");
        require(searchTimestamp < nextPriceTimestamp, "Search timetamp must be < next round 
timestamp");
require(priceTimestamp <= searchTimestamp, "Answer timetamp must be less than or equal to 
searchTimestamp timestamp");

        //check if gaps in round numbers, and if so, ensure there's no valid data in between
        if (answerRound - previousRound > 1) {
            for (uint80 i= previousRound; i<answerRound; i++) {
                (uint80 id,
                int price,
                uint startedAt,
                uint timeStamp,
                uint80 answeredInRound
                ) = priceFeed.getRoundData(i);
                require(timeStamp == 0, "Missing Round Data");
            }
        }

        if (nextRound - answerRound > 1) {
            for (uint80 i= answerRound; i<nextRound; i++) {
                (uint80 id,
                int price,
                uint startedAt,
                uint timeStamp,
                uint80 answeredInRound
                ) = priceFeed.getRoundData(i);
                require(timeStamp == 0, "Missing Round Data");
            }
        }

如果上述的检查都可以通过,那么返回的 round (answerRound) 的价格数据,就是某个交易对在某个时间点上经过验证的历史价格数据。

总结

Chainlink Price Feeds 提供一种方法让 Solidity 智能合约获得高质量的价格数据。除此之外,Chainlink 的预言机框架可以实现链下计算,允许开发者可以通过信任最小化的模式,获得安全可验证的历史价格数据。

您可以关注 Chainlink 预言机并且私信加入开发者社区,有大量关于智能合约的学习资料以及关于区块链的话题!

Need Integration Support?
Talk to an expert
Faucets
Get testnet tokens
Read the Docs
Technical documentation