价格预言机的使用总结(一):Chainlink篇

Chainlink 价格预言机接入方便,且安全性还是比较高的,但因为其价格更新机制存在偏差阈值,导致价格更新比较慢,短则几分钟或几十分钟更新一次,长则可能达 24 小时才更新一次,因此,一般只适用于对价格更新不太敏感的应用。这也是 Chainlink 价格预言机的局限性,并无法适用所有场景的应用。

前言

价格预言机已经成为了 DeFi 中不可获取的基础设施,很多 DeFi 应用都需要从价格预言机来获取稳定可信的价格数据,包括借贷协议 Compound、AAVE、Liquity ,也包括衍生品交易所 dYdX、PERP 等等。

目前最主流的价格预言机主要有 Chainlink、UniswapV2、UniswapV3 ,这几种价格预言机的接入方式和适用场景都不太一样,可以单独使用,也可以结合使用。鉴于不少同学还不知道这些预言机具体有哪些接入方式,也不了解背后的机制,更不清楚如何才能做到保证安全性的同时又能以最小的成本接入。下面,我将分享下我的经验总结,以供参考。

Chainlink

先从 Chainlink 的价格预言机开始聊起,这应该是使用最广泛的价格预言机了。

其实,Chainlink 提供的产品不只是价格预言机,还有其他产品,包括 Verifiable Random Numbers (VRF)、Call External APIs、Chainlink Keepers 。当然,使用最广泛的还是价格预言机,叫 Data Feeds

Chainlink Data Feeds 目前已经支持了多条链,主要还是 EVM 链,包括 Ethereum、BSC、Heco、Avalanche 等,也包括 Arbitrum、Optimism、Polygon 等 L2 的链。另外,也支持了非 EVM 链,目前支持了 SolanaTerra 。不过,我对非 EVM 链并不熟悉,所以只讲 EVM 链的使用。

DeFi 应用接入使用 Chainlink Data Feeds 其实很简单,而且还有不同的使用方式,下面就来看看最常用的使用方式。

Price Feed

第一种使用方式,官方给的示例代码是这样的:

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

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

contract PriceConsumerV3 {

    AggregatorV3Interface internal priceFeed;

    /**
     * Network: Kovan
     * Aggregator: ETH/USD
     * Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
     */
    constructor() {
        priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
    }

    /**
     * Returns the latest price
     */
    function getLatestPrice() public view returns (int) {
        (
            uint80 roundID, 
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();
        return price;
    }
}

首先,每个交易对都有一个单独的 Price Feed ,也叫 Aggregator ,其实就是一个个 AggregatorProxy ,像下面这样:

1.png

可以看到,每个 Pair 都有一个对应的 Proxy,读取价格其实就是从 Proxy 提供的方法读取的。Proxy 的具体实现稍微有点复杂,但 DeFi 应用要接入的话,只要知道 Interface 就够了,这个 Interface 则很简单,就是示例代码中所引入的 AggregatorV3Interface,其代码如下:

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

interface AggregatorV3Interface {
  function decimals() external view returns (uint8);

  function description() external view returns (string memory);

  function version() external view returns (uint256);

  // getRoundData and latestRoundData should both raise "No data present"
  // if they do not have data to report, instead of returning unset values
  // which could be misinterpreted as actual reported values.
  function getRoundData(uint80 _roundId)
    external
    view
    returns (
      uint80 roundId,
      int256 answer,
      uint256 startedAt,
      uint256 updatedAt,
      uint80 answeredInRound
    );

  function latestRoundData()
    external
    view
    returns (
      uint80 roundId,
      int256 answer,
      uint256 startedAt,
      uint256 updatedAt,
      uint80 answeredInRound
    );
}

就 5 个查询方法而已,简单介绍下这几个方法:

  • decimals() :返回的价格数据的精度位数,一般为 8 或 18
  • description() :一般为交易对名称,比如 ETH / USD
  • version() :主要用来标识 Proxy 所指向的 Aggregator 类型
  • getRoundData(_roundId) :根据 round ID 获取当时的价格数据
  • latestRoundData() :获取最新的价格数据

大部分应用场景可能只需要读取最新价格,即调用最后一个方法,其返回参数中,answer 就是最新价格。

另外,大部分应用读取 token 的价格都是统一以 USD 为计价单位的,若如此,你会发现,以 USD 为计价单位的 Pair,精度位数都是统一为 8 位的,所以一般情况下也无需根据不同 token 处理不同精度的问题。

当然,在实际应用中,肯定不会只读取一个固定 Token 的价格,更多场景是根据 Token 读取该 Token 的 USD 价格,因此可以将前面的示例合约升级为如下:

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

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

contract PriceConsumerV3 is Ownable {
    mapping(address => AggregatorV3Interface) internal priceFeedMap;

    function setPriceFeed(address token, address priceFeed) external onlyOwner {
        priceFeedMap[token] = AggregatorV3Interface(priceFeed);
    }

    /**
     * Returns the latest price
     */
    function getLatestPrice(address token) public view returns (int) {
        (
            uint80 roundID, 
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeedMap[token].latestRoundData();
        return price;
    }
}

原先的示例只能读取 ETH/USD 一个 Pair 的价格,而现在则可以设置和读取多个不同 token 的价格。比如,现在想要读取 UNI 的 USD 价格,就可以先查出 UNI/USD 的 priceFeed,查出其 Proxy 为 0x553303d460EE0afB37EdFf9bE42922D8FF63220e ,而 UNI token 地址为 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984,那就可以调用:

setPriceFeed("0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", "0x553303d460EE0afB37EdFf9bE42922D8FF63220e");

如此,UNI 所使用的 priceFeed 就设置好了,想读取 UNI 的最新价格时,调用 getLatestPrice() 就可读取到结果了,如下:

int price = getLatestPrice("0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984");

另外,mapping 所使用的 key 也可以不用 token address,改用 token symbol 或其它具有唯一标识性的属性也是可以的。

虽然该示例比较简单,很多实际应用中,可能比这复杂,但基本核心功能是差不多的了。

Feed Registry

第一种接入方式虽然已经很简单,但每个 token 都需要 owner 执行 setPriceFeed ,治理成本其实有点高,对某些场景来说就不太灵活。这时候,就可以考虑使用第二种方式来接入 Chainlink Data Feeds 了,通过使用 Feed Registry 的方式来接入。

Feed Registry 可以简单理解为 PriceFeeds 的聚合器,已经聚合了多个 priceFeed,有了它,使用者就无需自己去设置 priceFeed 了,可直接通过 Feed Registry 读取价格数据,如下图:

2.png

官方给的使用示例代码则如下:

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

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

contract PriceConsumer {
    FeedRegistryInterface internal registry;

    /**
     * Network: Ethereum Kovan
     * Feed Registry: 0xAa7F6f7f507457a1EE157fE97F6c7DB2BEec5cD0
     */
    constructor(address _registry) {
        registry = FeedRegistryInterface(_registry);
    }

    /**
     * Returns the ETH / USD price
     */
    function getEthUsdPrice() public view returns (int) {
        (
            uint80 roundID,
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = registry.latestRoundData(Denominations.ETH, Denominations.USD);
        return price;
    }

    /**
     * Returns the latest price
     */
    function getPrice(address base, address quote) public view returns (int) {
        (
            uint80 roundID, 
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = registry.latestRoundData(base, quote);
        return price;
    }
}

可看到,开头引入了两个 sol 文件,FeedRegistryInterface 和 Denominations。Denominations 是一个很简单的 library,主要定义了各种货币的地址,如下:

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

library Denominations {
  address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
  address public constant BTC = 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB;

  // Fiat currencies follow https://en.wikipedia.org/wiki/ISO_4217
  address public constant USD = address(840);
  address public constant GBP = address(826);
  address public constant EUR = address(978);

  // ... other fiat currencies
}

FeedRegistryInterface 则定义了不少函数,包括和 AggregatorV3Interface 一样的几个函数,只是每个函数相比 AggregatorV3Interface 多了两个参数:basequote如下:

interface FeedRegistryInterface {
 // V3 AggregatorV3Interface
 function decimals(address base, address quote) external view returns (uint8);
 function description(address base, address quote) external view returns (string memory);
 function version(address base, address quote) external view returns (uint256);
 function latestRoundData(address base, address quote)
   external
   view
   returns (
     uint80 roundId,
     int256 answer,
     uint256 startedAt,
     uint256 updatedAt,
     uint80 answeredInRound
   );

 function getRoundData(address base, address quote, uint80 _roundId)
   external
   view
   returns (
     uint80 roundId,
     int256 answer,
     uint256 startedAt,
     uint256 updatedAt,
     uint80 answeredInRound
   );

 // ... other functions
}

假设交易对为 UNI/USD,那 base 为 UNI 的 token 地址,quote 则为 USD 的地址,即为 Denominations.USD;假设交易对为 ETH/BTC,那 base 则为 Denominations.ETH,quote 为 Denominations.BTC。

另外,也可以通过 getFeed(address base, address quote) 直接读取到 priceFeed。

可以发现,使用 Feed Registry 的方式,主要都是用 base/quote 的方式进行查询。

FeedRegistry 里的每个 priceFeed 则是通过先后调用 proposeFeed()confirmFeed() 两个函数设置的,不过这两个函数只有 FeedRegistry 的 owner 才可以调用。

喂价机制

至此,我们已经知道如何接入 Chainlink Data Feeds 来获取价格信息了,但还不够,我们还要了解背后的喂价机制,也要了解价格数据多久更新一次的,如此才能更好地判定 Chainlink 的价格预言机是否能满足具体的场景需求。

首先,Price Feed 的价格是通过多个层级的数据聚合得到的。实际上有三个数据聚合层:数据源聚合、节点运营商聚合、预言机网络聚合

3.png

最原始的价格数据主要来源于币安、火币、Coinbase 等中心化交易平台,以及 Uniswap、Sushi 等去中心化交易平台。存在一些专门做数据聚合的服务商(比如 amberdata、CoinGecko ),会从这些交易平台收集原始的价格数据,并对这些数据源进行加工整合,比如根据交易量、流动性和时差等进行加权计算。

这就是第一个层面的聚合,对数据源的聚合 。拥有可靠的价格数据源的关键是要有全面的市场覆盖,才能保证一个价格点能代表所有交易环境的精确聚合,而不是单个交易所或少数交易所的价格,以防止数据被人为操纵和出现价格偏差。也因此,为了确保数据具有高度的防篡改和可靠性,Chainlink Data Feeds 只会从优质的数据聚合服务商获取数据,这意味着每个数据源都代表一个从所有中心化和去中心化交易所聚合的经过交易量调整的精细价格点,也因此可以有效抵抗闪电贷或价格异常偏差等攻击。

第二层则是 Chainlink Node Operators 所做的聚合。每个 Chainlink Node Operator 主要负责运行用于在区块链上获取和广播外部市场数据的 Chainlink 核心软件。Node Operators 会从多个独立的数据聚合服务商获取价格数据,并获取它们之间的中值,剔除掉异常值和 API 停机时间。比如,从 A 数据聚合服务商获取到价格点为 7.0,从 B 服务商获取到价格点为 7.2,那取中值后的价格点为 7.1。这意味着不仅每个单独的数据源反映了来自所有交易环境的聚合价格点,而且每个单独的节点的响应代表了来自多个数据源的聚合,进一步防止任何单一来源成为故障点,即避免了单点故障。

最后一层则是整个预言机网络的聚合,其聚合的方式有多种,但最常见的聚合方式是当响应节点数量达到预设值时对数据取中值。比如总共有 31 个节点,预设值为 21,即收到了 21 个节点的响应后,就取这些节点的价格数据的中值作为最终的价格。不过,并非每一轮的价格结果都会更新到链上,只有满足两个触发参数之一的时候才会更新:偏差阈值(Deviation Threshold)和心跳阈值(Heartbeat Threshold) 。而且,不同 PriceFeed 的这两个参数的值可能会不一样。

4.png

比如,ETH/USD 的偏差阈值为 0.5%,即表示新一轮的价格点跟上一次更新的价格偏差超过 0.5% 的时候才会更新链上价格;而心跳阈值为 3600 秒,即表示上一次价格更新后过了 1 小时后才会更新链上价格。另外,因为每一轮的数据聚合都不是实时的,也需要时间,再加上偏差阈值的限制,所以,有时候,要隔几十分钟才会有价格更新,这点比较关键,需要清楚。有些 Price Feed 的偏差阈值比较大,会高达十几个小时才会有价格更新,比如下面这个:

5.png

可看到,其偏差阈值高达 5%,且已经长达 11 个小时没有价格更新了,而它的心跳阈值其实也比较高,长达 24 小时。

高达 5% 的偏差阈值,且这么长时间都没有更新价格,这如果是应用到一些高杠杆的交易产品,可能就不太合适了。

总结

总而言之,Chainlink 价格预言机接入方便,且安全性还是比较高的,但因为其价格更新机制存在偏差阈值,导致价格更新比较慢,短则几分钟或几十分钟更新一次,长则可能达 24 小时才更新一次,因此,一般只适用于对价格更新不太敏感的应用。这也是 Chainlink 价格预言机的局限性,并无法适用所有场景的应用。


文章首发于「Keegan小钢」公众号:# 价格预言机的使用总结(一):Chainlink篇

点赞 8
收藏 9
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
Keegan小钢
Keegan小钢
公众号自媒体「Keegan小钢」,Web3从业者、培训讲师