在区块链领域中,预言机是一种能够为链上智能合约提供外部信息的系统。作为连接智能合约和区块链以外世界的中间件,预言机扮演着极其关键的基础设施角色,它的主要功能是为区块链中的智能合约提供数据。
作者:ZAN Team
封面:Chainlink
在区块链领域中,预言机是一种能够为链上智能合约提供外部信息的系统。作为连接智能合约和区块链以外世界的中间件,预言机扮演着极其关键的基础设施角色,它的主要功能是为区块链中的智能合约提供数据。
例如,如果我们在以太坊网络上创建一个智能合约,而这个合约需要访问原油某天的交易量数据。然而智能合约本身无法获取这种链下的现实世界数据,因此需要通过预言机来实现。在这种情况下,智能合约会将所需日期的原油交易量写入事件日志,然后,链下会启动一个进程来监控并订阅这个事件日志,当监听到交易中的请求时,该进程会通过提交链上交易,调用合约的相关方法,把指定日期的原油交易量信息上传到智能合约中。
Chainlink
在区块链中,市场占有率最大的莫过于 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 事件:
该事件可以看出,是 0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F 这个 ETH/USD 价格聚合器向 oracle:0x7e94a8a23687d8c7058ba5625db2ce358bcbd244 发出的价格数据请求。如果 oracle 返回请求数据的话,可以从这里面知道返回的合约地址:0xF79D6aFBb6dA890132F9D7c355e3015f15F3406F,需要调用的方法 ID:6A9705B4,以及过期时间:1618185924。
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: 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 处理不同精度的问题。
免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。