天天看點

Chainlink預言機基本原理預言機Chainlink總結歡迎加入 Chainlink 開發者社群

Chainlink 基本原理

本文我們來從技術上簡述一下 Chainlink 的基本原理。如果用最短的一句話解釋什麼是 Chainlink,可以說 Chainlink 一個去中心化的預言機項目,是以為了了解 Chainlink 的工作原理,我們首先要明白什麼是預言機。

預言機

預言機的英文為 Oracle,和著名的資料庫服務提供商 Oracle(甲骨文)重名,但是兩者除了名字相同以為并沒有任何關系。Oracle 這個單詞是什麼意思,下面是我在 vocabulary.com 上查到的 Oracle 的含義:

Back in ancient times, an Oracle was someone who offered advice or a prophecy thought to have come directly from a divine source. In modern usage, any good source of information can be called an oracle.

中文的大概意思是:在古代,Oracle 是一個提出建議或預言的人,他的建議或預言被認為是直接來自于神。在現代用法中,任何好的資訊來源都可以稱為 Oracle。

這樣就不難了解了,Oracle 傳達了萬能全知的神的旨意,而甲骨文最初就是用來占蔔吉兇時的記錄,也在當時也被認為是神谕,傳達了神的意思。說以不管是“預言機”還是“甲骨文”都表達了“資訊源”的意思。

計算機領域内的預言機一詞,最早是圖靈提出的。圖靈在圖靈機(Turing Machine)的基礎上,加入了一個稱為預言者(Oracle)的黑盒,組成了預言機(Oracle Machine)。所謂預言者,是一個可以回答特定問題集合的實體。即它可以向圖靈機系統内部輸入資訊,幫助圖靈機完成運算。以太坊的智能合約是“圖靈完備(Turing Complete)”的,某種意義上可以看做一個圖靈機,是以以太坊的設計者借鑒這個概念,把向“圖靈完備的智能合約”這個圖靈機輸入資訊的也被稱為預言機 Oracle。是以說“預言機”這個名字并不是區塊鍊技術領域内的獨創概念,它來源于非常早期的計算機抽象設計,在密碼學等領域内也都有類似的概念。

而在區塊鍊領域,預言機被認為是可以為智能合約提供外部資料源的系統。從傳統技術架構方面來看,預言機是連接配接智能合約與區塊鍊外部世界的中間件(middleware),是區塊鍊重要的基礎設施,它的作用是為區塊鍊上的智能合約(Smart Contract)提供資料資訊的。

正如以太坊的定義,區塊鍊是一個交易驅動的狀态機(a transaction-based state machine),它能做的事情非常簡單,就是通過向區塊鍊送出事務/交易(transaction),來将區塊鍊從一個狀态轉變成另一個狀态。為了保持共識,EVM 的執行過程必須完全确定,并且僅基于以太坊狀态和簽名交易的共享上下文。這産生了兩個特别重要的後果:一個是 EVM 和智能合約沒有内在的随機性來源;另一個是外部資料隻能作為交易的資料載荷引入。用通俗的話講,區塊鍊沒有主動擷取資料的能力,它能用的隻有區塊鍊自己本身的資料。資料的缺失導緻智能合約的應用範圍非常少,目前大部分的應用都是圍繞着 token 來展開的。

區塊鍊的确定性的意思是,在任何節點上,隻要連入到區塊鍊的分布式網絡中,它就可以同步所有的曆史區塊,回放出一套完全相同的賬本。換句話說:在沒有網際網路連接配接的情況下,給定完整的塊,節點必須能夠從頭開始重新建立區塊鍊的最終狀态。如果賬本在形成過程中,依賴于某個外部的 API 調用結果,那在不同時間不同環境下回放的結果就會不一樣。這種情況是區塊鍊所不允許的,是以區塊鍊在設計之初就沒有網絡調用。

那麼要實作向區塊鍊提供資料,應該怎麼做呢?區塊鍊能留下的隻有賬本,而區塊鍊所能輸入的隻有交易。我們就從這兩個方面入手。

幾乎每一個合約系統,都會有事件記錄的功能,比如以太坊中的 EventLog 功能。

下面我們通過一個例子,來介紹一下預言機的基本原理。我們在以太坊鍊上建立一個使用者合約,它需要擷取到某個城市的氣溫資料。當然,智能合約自己是無法擷取到這個發生于鍊下真實世界中的資料資訊的,需要借助預言機來實作。智能合約将需要擷取天氣溫度的的城市寫入到 EventLog 中,鍊下我們會啟動一個程序,監聽并訂閱這個事件日志,擷取到智能合約的請求之後,将指定城市的溫度,通過送出 transaction 的方式,調用合約中的回填方法,送出到智能合約中。

聲明:以下代碼僅供示範預言機原理,沒有做參數檢測和錯誤處理,請不要在生産環境中使用。

消費者合約:

contract WeatherOracle {
  // 使用者存儲預言機送出的天氣數值
  uint256 public temperature;

  // 定義事件
  event RequestTemperature (bytes city);

  // 發出擷取請求,即發出一個事件日志
  function requestTemperature (string memory _city) public {
    emit RequestTemperature(bytes(_city));
  }

  // 預言機回調方法,預言機擷取到資料後通過這個方法将資料送出到鍊上
  function updateWeather (uint256 _temperature) public {
    temperature = _temperature;
  }
}
           

上面的代碼非常簡單,定義了一個變量用來存儲結果,一個方法用于送出請求,一個方法用于接收結果。

鍊下,我們啟動一個程序,以訂閱 topic 的方式擷取日志資訊,之後通過建構一個 transaction,送出一個結果到合約中。

func SubscribeEventLog() {
  topic := crypto.Keccak256([]byte("RequestTemperature(bytes)"))
  query := ethereum.FilterQuery{
    Topics: [][]common.Hash{
      {
        common.BytesToHash(topic),
      },
    },
  }

  // 訂閱相關主題的日志事件
  events := make(chan types.Log)
  sub, err := EthClient.SubscribeFilterLogs(ctx, query, events)

  // 加載合約的ABI檔案
  ta, err := abi.JSON(strings.NewReader(AbiJsonStr))

  // 監聽事件訂閱
  for {
    select {
    case err := <-sub.Err():
      log.Error(err)
      break
    case ev := <-events:
      // 擷取到訂閱的消息
      ej, _ := ev.MarshalJSON()
      log.Info(string(ej))

      // 解析資料
      var sampleEvent struct {
        City []byte
      }
      err = ta.Unpack(&sampleEvent, "RequestTemperature", ev.Data)
      log.Info(string(sampleEvent.City))

      // 建構交易送出結果,需要提供私鑰用于簽署交易
      CallContract("b7b502b...164b42c")
    }
  }
}
           
func CallContract(keyStr string) {
  addr := PrivateKeyToAddress(keyStr)
  nonce, err := EthClient.PendingNonceAt(ctx, addr)

  gasPrice, err := EthClient.SuggestGasPrice(ctx)

  privateKey, err := crypto.HexToECDSA(keyStr)

  auth := bind.NewKeyedTransactor(privateKey)
  auth.Nonce = big.NewInt(int64(nonce))
  auth.Value = big.NewInt(0)
  auth.GasLimit = uint64(300000)
  auth.GasPrice = gasPrice

  instance, err := event.NewEvent(common.HexToAddress("0x8A421906e9562AA1c71e5a32De1cf75161C5A463"), EthClient)

  // 調用合約中的updateWeather方法,回填資料"29"
  tx, err := instance.UpdateWeather(auth, big.NewInt(29))

  log.Info(tx.Hash().Hex())
}
           

用一個圖來展示這個過程:

Chainlink

Chainlink 是一個去中心化的預言機項目,它的作用就是以最安全的方式向區塊鍊提供現實世界中産生的資料。Chainlink 在基本的預言機原理的實作方式之上,圍繞 LINK token 通過經濟激勵建立了一個良性循環的生态系統。Chainlink 預言機需要通過 LINK token 的轉賬來實作觸發。

LINK 是以太坊網絡上的 ERC677 合約,關于各類 ERC token 的差別,請參考這篇文章。

在《精通以太坊(Matering Ethereum)》一書中,提出了三種預言機的設計模式,分别是

  • 立即讀取(immediate-read)
  • 釋出/訂閱(publish–subscribe)
  • 請求/響應(request–response)

而基于 LINK ERC677 token完成的預言機功能,就屬于其中的請求/響應模式。這是一種較為複雜的模式,上圖中展示的是一個不含有聚合過程的簡單請求/相應流程。

我們以 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;
}
           

其中

link.transferAndCall

方法即是 ERC677 定義的 token 轉賬方法,與 ERC20 的

transfer

方法相比,它多了一個

data

字段,可以在轉賬的同時攜帶資料。這裡就将之前打包好的請求資料放在了 data 字段,跟随轉賬一起發送到了 Oracle 合約。

transferAndCall

方法定義如下:

/**
  * @dev 将token和額外資料一起轉移給一個合約位址
  * @param _to 轉移到的目的位址
  * @param _value 轉移數量
  * @param _data 傳遞給接收合約的額外資料
  */
  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;
  }
           

其中的

Transfer(msg.sender, _to, _value, _data);

是發出一個事件日志:

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

将這次轉賬的詳細資訊(發送方、接收方、金額、資料)記錄到日志中。

Oracle 合約在收到轉賬之後,會觸發

onTokenTransfer

方法,該方法會檢查轉賬的有效性,并通過發出

OracleRequest

事件記錄更為詳細的資料資訊:

event OracleRequest(
  bytes32 indexed specId,
  address requester,
  bytes32 requestId,
  uint256 payment,
  address callbackAddr,
  bytes4 callbackFunctionId,
  uint256 cancelExpiration,
  uint256 dataVersion,
  bytes data
);
           

這個日志會在 Oracle 合約的日志中找到,如圖中下方所示。鍊下的節點會訂閱該主題的日志,在擷取到記錄的日志資訊之後,節點會解析出請求的具體資訊,通過網絡的 API 調用,擷取到請求的結果。之後通過送出事務的方式,調用 Oracle 合約中的

fulfillOracleRequest

方法,将資料送出到鍊上。

fulfillOracleRequest

定義如下:

/**
  * @notice 由Chainlink節點調用來完成請求
  * @dev 送出的參數必須是`oracleRequest`方法所記錄的哈希參數
  * 将會調用回調位址的回調函數,`require`檢查時不會報錯,以便節點可以獲得報酬
  * @param _requestId 請求ID必須與請求者所比對
  * @param _payment 為Oracle發放付款金額 (以wei為機關)
  * @param _callbackAddress 完成方法的回調位址
  * @param _callbackFunctionId 完成方法的回調函數
  * @param _expiration 請求者可以取消之前節點應響應的到期時間
  * @param _data 傳回給消費者合約的資料
  * @return 外部調用成功的狀态值
  */
function fulfillOracleRequest(
  bytes32 _requestId,
  uint256 _payment,
  address _callbackAddress,
  bytes4 _callbackFunctionId,
  uint256 _expiration,
  bytes32 _data
)
  external
  onlyAuthorizedNode
  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");
  // All updates to the oracle's fulfillment should come before calling the
  // callback(addr+functionId) as it is untrusted.
  // See: https://solidity.readthedocs.io/en/develop/security-considerations.html#use-the-checks-effects-interactions-pattern
  return _callbackAddress.call(_callbackFunctionId, _requestId, _data); // solhint-disable-line avoid-low-level-calls
}
           

這個方法會在進行一系列的檢驗之後,會将結果通過之前記錄的回調位址與回調函數,傳回給消費者合約:

_callbackAddress.call(_callbackFunctionId, _requestId, _data);           

這樣一次請求就全部完成了。

總結

本文從預言機的概念開始,通過一個簡單的擷取 ETH 價格的例子,講解了請求/響應模式的 Chainlink 預言機的基本過程,希望對你了解預言機與 Chainlink 的運作原理有所幫助。

參考:

https://medium.com/@liyunlong518/%E5%88%9B%E5%BB%BA%E4%BD%A0%E7%9A%84%E7%AC%AC%E4%B8%80%E4%B8%AA%E4%BB%A5%E5%A4%AA%E5%9D%8A%E9%A2%84%E8%A8%80%E6%9C%BA-df53e50cc2d5

歡迎加入 Chainlink 開發者社群