在區塊鏈領域中,預言機是一種能夠為鏈上智慧合約提供外部資訊的系統。作為連接智慧合約和區塊鏈以外世界的中間件,預言機扮演著極其關鍵的基礎設施角色,它的主要功能是為區塊鏈中的智慧合約提供數據。

作者:ZAN Team

封面: Chainlink

在區塊鏈領域中,預言機是一種能夠為鏈上智慧合約提供外部資訊的系統。作為連接智慧合約和區塊鏈以外世界的中間件,預言機扮演著極其關鍵的基礎設施角色,它的主要功能是為區塊鏈中的智慧合約提供數據。

例如,如果我們在以太坊網路上建立一個智慧合約,而這個合約需要存取原油某天的交易量資料。然而智能合約本身無法取得這種鏈下的現實世界數據,因此需要透過預言機來實現。在這種情況下,智能合約會將所需日期的原油交易量寫入事件日誌,然後,鏈下會啟動一個進程來監控並訂閱這個事件日誌,當監聽到交易中的請求時,該進程會透過提交鏈上交易,調用合約的相關方法,把指定日期的原油交易量資訊上傳到智能合約中。

數據源自 https://defillama.com/oracles

鏈條連結

在區塊鏈中,市佔率最大的莫過於 Chainlink 預言機。 Chainlink 是一個去中心化的預言機項目,它的作用就是以最安全的方式向區塊鏈提供現實世界中產生的數據。 Chainlink 在基本的預言機原則的實現方式之上,圍繞著 LINK token 透過經濟誘因建立了一個良性循環的生態系統。 Chainlink 預言機需要透過 LINK token 的轉帳來實現觸發。而 LINK 則是以太坊網路上的 ERC677 合約。而基於 LINK ERC677 token 完成的預言機功能,屬於其中的請求/回應模式。

目錄

1 ERC677 Token 中的 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 方法。注意:在請求預言機服務之前,要先確定該預言機是否可信,因為預言機為消費者提供服務之前需要先付款。(人人都能提供預言機服務)

預言機可信度劃分

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 事件:

OracleRequest 事件範例

事件可以看出,是 0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F 這個 ETH/USD  價格  聚合器向 oracle:0x7e94a8a23687d8c7058256252323568b23687d8c7058 如果 oracle 回傳請求資料的話,可以從這裡面知道回傳的合約位址:0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F,需要呼叫的方法 ID:6A9705B4,以及過期時間:1618185994。

3 鏈下節點回應

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 連結以提高鏈下發送交易的速度。

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: MITpragma 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 處理不同精確度的問題。

免責聲明:作為區塊鏈資訊平台,本站所發布文章僅代表作者及來賓個人觀點,與 Web3Caff 立場無關。文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。