预言机详解系列之Chainlink(上)

在区块链领域中,预言机是一种能够为链上智能合约提供外部信息的系统。作为连接智能合约和区块链以外世界的中间件,预言机扮演着极其关键的基础设施角色,它的主要功能是为区块链中的智能合约提供数据。

例如,如果我们在以太坊网络上创建一个智能合约,而这个合约需要访问原油某天的交易量数据。然而智能合约本身无法获取这种链下的现实世界数据,因此需要通过预言机来实现。在这种情况下,智能合约会将所需日期的原油交易量写入事件日志,然后,链下会启动一个进程来监控并订阅这个事件日志,当监听到交易中的请求时,该进程会通过提交链上交易,调用合约的相关方法,把指定日期的原油交易量信息上传到智能合约中。

1 Chainlink

在区块链中,市场占有率最大的莫过于Chainlink预言机。Chainlink 是一个去中心化的预言机项目,它的作用就是以最安全的方式向区块链提供现实世界中产生的数据。Chainlink 在基本的预言机原理的实现方式之上,围绕 LINK token 通过经济激励建立了一个良性循环的生态系统。Chainlink 预言机需要通过 LINK token 的转账来实现触发。而 LINK 则是以太坊网络上的 ERC677 合约。而基于 LINK ERC677 token完成的预言机功能,属于其中的请求/响应模式。

1.1 ERC677代币中的transferAndCall

import { ERC20 as linkERC20 } from "./ERC20.sol";

contract ERC677 is linkERC20 {
  function transferAndCall(address to, uint value, bytes data) returns (bool success);

  event Transfer(address indexed from, address indexed to, uint value, bytes data);
}

预言机实质上是提供服务的一方,ChainLink在设计预言机框架的时候首先想到的是预言机的用户如何向提供服务的预言机支付服务费用。但由于标准的同质化Token合约ERC20无法满足支付后提供服务这样的一个需求,因此ChainLink自己提出了一个适用于预言机服务场景的标准——ERC677。

从上面的代码中可以看到,ERC677其实只是在标准ERC20的基础上增加了一个transferAndCall方法。该方法将支付和服务请求合二为一,满足了预言机业务场景的需求。

contract ERC677Token is ERC677 {
  function transferAndCall(address _to, uint _value, bytes _data) public returns (bool success) {
    super.transfer(_to, _value);
    Transfer(msg.sender, _to, _value, _data);
    if (isContract(_to)) {
      contractFallback(_to, _value, _data);
    }
    return true;
  }

  ......
}

当用户进行transferAndCall进行转账时,除了ERC20的转账以外,还会判断to地址是否为一个合约地址,如果是,则调用该to地址的onTokenTransfer方法。(这里ERC677Receiver里面只有一个方法:onTokenTransfer)

我们也可以去到Etherscan上查看LINK代币的合约源码:https://etherscan.io/address/0x514910771af9ca656af840dff83e8264ecf986ca#code

contract LinkToken is StandardToken, ERC677Token {
  uint public constant totalSupply = 10**27;
  string public constant name = 'ChainLink Token';
  uint8 public constant decimals = 18;
  string public constant symbol = 'LINK';

  function LinkToken() public {
    balances[msg.sender] = totalSupply;
  }

  function transferAndCall(address _to, uint _value, bytes _data)
    public
    validRecipient(_to)
    returns (bool success)
  {
    return super.transferAndCall(_to, _value, _data);
  }
......
modifier validRecipient(address _recipient) {
    require(_recipient != address(0) && _recipient != address(this));
    _;
  }

可以看到LINK Token在实现的时候除了多对_to地址进行了校验以外,都是实实在在继承了ERC677的transferAndCall方法。注意:在请求预言机服务之前,要先确定该预言机是否可信,因为预言机为消费者提供服务之前需要先付款。(人人都能提供预言机服务)

1.2 链上oracle请求

下面来看看oracle合约的onTokenTransfer方法是如何实现的:

function onTokenTransfer(address _sender,uint256 _amount,bytes _data)
    public
    onlyLINK
    validRequestLength(_data)
    permittedFunctionsForLINK(_data)
  {
    assembly { // solhint-disable-line no-inline-assembly
      mstore(add(_data, 36), _sender) // ensure correct sender is passed
      mstore(add(_data, 68), _amount) // ensure correct amount is passed
    }
    require(address(this).delegatecall(_data), "Unable to create request"); // calls oracleRequest
  }

当预言机的消费者使用transferAndCall方法支付费用并请求预言机的服务,这里这个to地址就是被请求的预言机的地址了。预言机中的onTokenTransfer方法首先会校验转账是否为LINK代币(onlyLINK),其实就是判断msg.sender是否为Link代币合约的地址。然后会判断_data的长度有没有超过最大限度。最后会判断_data中是不是以“oracleRequest”开头的function selector。当然这里的function selector可以根据预言机所提供的服务进行定制,不一定非要是“oracleRequest”,具体看这个预言机对外暴露什么样的接口了。

当这些modifier都判断通过后,再检查当前的函数调用者和转账金额是否跟_data中的相同。这一些列的安全检查都通过后,才通过一个delegatecall来call当前这个oracle合约。当然,因为已经检查过_data中的function selector了,所以其实是call的oracleRequest方法。

function oracleRequest(
    address _sender,
    uint256 _payment,
    bytes32 _specId,
    address _callbackAddress,
    bytes4 _callbackFunctionId,
    uint256 _nonce,
    uint256 _dataVersion,
    bytes _data
  ) external onlyLINK
    checkCallbackAddress(_callbackAddress)	//检查_callbackAddress不能是LINK Token的地址
  {
    bytes32 requestId = keccak256(abi.encodePacked(_sender, _nonce));
    require(commitments[requestId] == 0, "Must use a unique ID");
    // solhint-disable-next-line not-rely-on-time
    uint256 expiration = now.add(EXPIRY_TIME);

    commitments[requestId] = keccak256(
      abi.encodePacked(
        _payment,
        _callbackAddress,
        _callbackFunctionId,
        expiration
      )
    );

    emit OracleRequest(
      _specId,
      _sender,
      requestId,
      _payment,
      _callbackAddress,
      _callbackFunctionId,
      expiration,
      _dataVersion,
      _data);
  }

首先,将oracle请求者和他发送过来的nonce拼接然后进行哈希,作为本次请求的requestId,并通过查检查commitments映射看是否是唯一的id。检查没问题的话,就设置一个过期时间,并将requestId添加到commitments中去,并将_payment、_callbackAddress、_callbackFunctionId和expiration进行拼接作为value。最重要的是,发出一个OracleRequest事件,该事件中包含了请求数据_data,是一种Concise Binary Object Representation(CBOR)数据。该编码格式轻量简洁,可以简单理解为二进制形式JSON格式。这个数据可以是各种各样的形式,看链下节点是如何设计的了。

例如:一个Chainlink: ETH/USD Aggregator,有一笔交易包含了OracleRequest事件:

该事件可以看出,是0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F这个ETH/USD价格聚合器向oracle:0x7e94a8a23687d8c7058ba5625db2ce358bcbd244发出的价格数据请求。如果oracle返回请求数据的话,可以从这里面知道返回的合约地址:0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F,需要调用的方法ID:6A9705B4,以及过期时间:1618185924。

1.3 链下节点回应

1.3.1 链下调用fulfillOracleRequest

function fulfillOracleRequest(
    bytes32 _requestId,
    uint256 _payment,
    address _callbackAddress,
    bytes4 _callbackFunctionId,
    uint256 _expiration,
    bytes32 _data
  ) external onlyAuthorizedNode override isValidRequest(_requestId)
    returns (bool)
  {
    bytes32 paramsHash = keccak256(
      abi.encodePacked(
        _payment,
        _callbackAddress,
        _callbackFunctionId,
        _expiration
      )
    );
    require(commitments[_requestId] == paramsHash, "Params do not match request ID");
    withdrawableTokens = withdrawableTokens.add(_payment);
    delete commitments[_requestId];
    require(gasleft() >= MINIMUM_CONSUMER_GAS_LIMIT, "Must provide consumer enough gas");
    (bool success, ) = _callbackAddress.call(abi.encodeWithSelector(_callbackFunctionId, _requestId, _data)); // solhint-disable-line avoid-low-level-calls
    return success;
  }

首先进行检查:

  • onlyAuthorizedNode:函数调用者(msg.sender)必须是合约的owner或在授权的列表内;
  • isValidRequest:依旧去commitments映射中检查是否存在该requestId;
  • 将payment、callbackAddress、_callbackFunctionId和expiration进行拼接,检查是否是该requestId在commitments映射中对应的值。

如果这些检查都通过了的话,那么将这次的请求的花费累加到withdrawableTokens中,记录可以取款的数额。之后将该_requestId从commitments映射中删除。最后计算一下剩余的gas量,看是否大于MINIMUM_CONSUMER_GAS_LIMIT,即回调发出请求的合约的回调函数执行最小需要的gas量。

如果上述检查都通过了,那么可以用call的形式正式调用请求者合约的回调函数。

回应request应该尽量迅速,因此这里推荐使用ZAN的节点服务(https://zan.top/home/node-service?chInfo=ch_WZ)来提高响应速度。可以在节点服务控制台找到获取对应的 RPC 链接以提高链下发送交易的速度。

1.3.2 回调函数

之前我们从oracleRequest中知道了回调函数的id是 6A9705B4,查询得到该方法为chainlinkCallback(bytes32,int256)

function chainlinkCallback(bytes32 _clRequestId, int256 _response)
    external
  {
    validateChainlinkCallback(_clRequestId);

    uint256 answerId = requestAnswers[_clRequestId];
    delete requestAnswers[_clRequestId];

    answers[answerId].responses.push(_response);
    emit ResponseReceived(_response, answerId, msg.sender);
    updateLatestAnswer(answerId);
    deleteAnswer(answerId);
  }

validateChainlinkCallback是一个可以自定义的函数,这里有一个modifier:

modifier recordChainlinkFulfillment(bytes32 _requestId) {
    require(msg.sender == pendingRequests[_requestId], "Source must be the oracle of the request");
    delete pendingRequests[_requestId];
    emit ChainlinkFulfilled(_requestId);
    _;
  }

在pendingRequests里面检查该_requestId对应请求的oracle是否匹配。并发出事件ChainlinkFulfilled:

如果校验都通过了的话,那么就可以对responds做进一步的处理了,这里是对answers映射进行更新。那么如果是价格预言机的话,则是将回应的价格数据赋给currentPrice做相应的价格更新:

function fulfillEthereumPrice(bytes32 _requestId, uint256 _price)
    public
    recordChainlinkFulfillment(_requestId)
  {
    emit RequestEthereumPriceFulfilled(_requestId, _price);
    currentPrice = _price;
  }

以上是通用预言机服务的完整流程

我们以Chainlink提供的TestnetConsumer合约中的一个requestEthereumPrice 方法为例来简单讲一下价格预言机请求响应的流程。这个函数定义如下:

function requestEthereumPrice(address _oracle, string _jobId)
  public
  onlyOwner
{
  Chainlink.Request memory req = buildChainlinkRequest(stringToBytes32(_jobId), this, this.fulfillEthereumPrice.selector);
  req.add("get", "https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD");
  req.add("path", "USD");
  req.addInt("times", 100);
  sendChainlinkRequestTo(_oracle, req, ORACLE_PAYMENT);
}

它所实现的功能就是从指定的API(cryptocompare)获取ETH/USD的交易价格。函数传入的参数是指定的oracle地址和jobId。将一些列的请求参数组好后,调用sendChainlinkRequestTo 方法将请求发出。sendChainlinkRequestTo是定义在Chainlink提供的中的一个接口方法,定义如下:

/**
  * @notice 向指定的oracle地址创建一个请求
  * @dev 创建并存储一个请求ID, 增加本地的nonce值, 并使用`transferAndCall` 方法发送LINK,
  * 创建到目标oracle合约地址的请求
  * 发出 ChainlinkRequested 事件.
  * @param _oracle 发送请求至的oracle地址
  * @param _req 完成初始化的Chainlink请求
  * @param _payment 请求发送的LINK数量
  * @return 请求 ID
  */
function sendChainlinkRequestTo(address _oracle, Chainlink.Request memory _req, uint256 _payment)
  internal
  returns (bytes32 requestId)
{
  requestId = keccak256(abi.encodePacked(this, requests));
  _req.nonce = requests;
  pendingRequests[requestId] = _oracle;
  emit ChainlinkRequested(requestId);
  require(link.transferAndCall(_oracle, _payment, encodeRequest(_req)), "unable to transferAndCall to oracle");
  requests += 1;

  return requestId;
}

Oracle合约在收到转账之后,会触发onTokenTransfer方法,该方法会检查转账的有效性,并通过发出OracleRequest事件记录更为详细的数据信息

这个日志会在oracle合约的日志中找到。链下的节点会订阅该主题的日志,在获取到记录的日志信息之后,节点会解析出请求的具体信息,通过网络的API调用,获取到请求的结果。之后通过提交事务的方式,调用Oracle合约中的fulfillOracleRequest方法,将数据提交到链上。

这个方法会在进行一系列的检验之后,会将结果通过之前记录的回调地址与回调函数,返回给消费者合约。

那我作为开发者我只想要用已有的币对价格,而不需要自己指定这些url可不可以呢?

答案是可以。第一种使用方式,官方给的示例代码是这样的:

// 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,像下面这样:

具体这个interface实现比较简单,可以参考AAVE/ETH这个pair:https://etherscan.io/address/0x6Df09E975c830ECae5bd4eD9d90f3A95a4f88012#code

总共有5个查询方法:

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

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

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

关于 ZAN

ZAN 是蚂蚁数科旗下 Web3 科技品牌,致力于 Web3 应用优化--降低成本、增强安全和提升性能,围绕 Web3 应用全生命周期,提供可靠、稳定安全、定制化的产品和服务。依托 AntChain OpenLabs 的 TrustBase 开源开放技术体系,ZAN 拥有 Web3 领域独特的优势和创新能力,为 Web3 社区的区块链应用开发、企业和开发者的 Web3 应用提供了全面的技术产品和服务,其中包括节点服务(ZAN Node Service)zk 加速(ZAN PowerZebra)身份验证eKYC(ZAN Identity)以及智能合约审计(ZAN Smart Contract Review)等。

联系我们

WebsiteXDiscordTelegram