区块链预言机数据采集最佳实践【Solidity/中级】

大多数软件工程师在需要获取外部数据时,都会利用API调用或HTTP请求来实现。 类似的,在有些智能合约中开发者也希望利用外部API实现对链外数据的采集。 在这个教程中,我们将学习如何利用预言机/Oracle实现智能合约访问链外数据, 如何消除预言机/Oracle引入的单点故障,以及如何实现可复用的预言机数据采集 方案。

实际上我们无法像在普通的软件应用中那样直接通过一个API调用就 获取到外部数据。然而,在某些情况下,我们总是希望区块链智能合约 可以访问外部世界的数据,那么,应该怎么办?

以太坊区块链是完全确定性的,这意味着如果你用整个网络的历史交易 在自己的计算机上重放,总是可以得到正确的状态。 由于互联网是非确定性的并且随时间变化,那么每次我重放网络上的交易, 就会得到不同的答案。– Tjaden Hess

如果我们在没有预言机的情况下重放区块链并使用常规API调用,那么API调用 可能已经发生了变化,我们将得到不同的结果。

为了避免让智能合约直接调用API,我们需要让合约调用一个中间应用, 通过这个应用和外部世界交互,并将交互结果记录在链上。这些中间应用 被称为预言机(Oracle),任何与区块链外部世界交互并将交互结果 记录在链上的应用,都是预言机。

现在让我们看看具体该怎么做,在下面的示例中,我们将尝试获取ETH的美元价格。

用自己熟悉的语言学习以太坊DApp开发: Java | Php | Python | .Net / C# | Golang | Node.JS | Flutter / Dart

1、朴素的预言机数据采集方案:简单,但存在故障点

大多数区块链开发人员想到的第一个方案就是找一个现成的预言机实现,然后 把我们要调用的API的URL传给这个预言机,接下来就让预言机与API交互并将 交互结果数据记录到区块链上供我们使用。有很多可用的预言机,我们下面使用 Chainlink,至于为什么选择chainlink,很快你就指导原因了。

为了使用Chainlink,我们首先要选一个chainlink预言机/节点,它们是在 区块链上独立运营的智能合约。我们可以使用像Linkpool的Market.Link 这样的节点列表服务,任选一个节点就可以了。接下来我们需要确保节点有一个 http Get > uint256 任务。并非所有的节点都可以执行URL调用并返回Uint256 类型的结果,但绝大部分节点都是支持的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
pragma solidity ^0.6.0;

import "github.com/smartcontractkit/chainlink/evm-contracts/src/v0.6/ChainlinkClient.sol";


contract GetData is ChainlinkClient {
uint256 public currentPrice;
address public owner;

// The address of an oracle
address ORACLE = 0x83F00b902cbf06E316C95F51cbEeD9D2572a349a;

// The address of the http get job
string constant JOB = "c179a8180e034cf5a341488406c32827";

// When you call a job, you have to make a payment in LINK token
// So be sure to send this contract's address some LINK
uint256 constant private ORACLE_PAYMENT = 1 * LINK;

// The constructor
constructor() public {
setPublicChainlinkToken();
owner = msg.sender;
}

// This is where the magic happens
// And it will be the big orange button on the side of remix
function requestEthereumPrice()
public
onlyOwner
{
// We build the API call to send to the node
// noting that the result will come back through the `fulfill` method
Chainlink.Request memory req = buildChainlinkRequest(stringToBytes32(JOB), address(this), this.fulfill.selector);
// Here is where we enter the URL
req.add("get", "https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD");
// The response is in JSON, and the value is in the USD keyword, so we add this path
req.add("path", "USD");
// Solidity can't handle decimals, so we have to multiply by 100 to get a whole number
req.addInt("times", 100);
// Then we send the request!
sendChainlinkRequestTo(ORACLE, req, ORACLE_PAYMENT);
}

// When the URL finishes, the response is routed to this function
function fulfill(bytes32 _requestId, uint256 _price)
public
recordChainlinkFulfillment(_requestId)
{
currentPrice = _price;
}

// Don't worry about this for now
modifier onlyOwner() {
require(msg.sender == owner);
_;
}

// A helper funciton to make the string a bytes32
function stringToBytes32(string memory source) private pure returns (bytes32 result) {
bytes memory tempEmptyStringTest = bytes(source);
if (tempEmptyStringTest.length == 0) {
return 0x0;
}
assembly { // solhint-disable-line no-inline-assembly
result := mload(add(source, 32))
}
}
// Allows the owner to withdraw their LINK on this contract
function withdrawLink() external onlyOwner() {
LinkTokenInterface _link = LinkTokenInterface(chainlinkTokenAddress());
require(_link.transfer(msg.sender, _link.balanceOf(address(this))), "Unable to transfer");
}
}

我们使用ORACLE变量来表示所选择的LinkPool节点地址,上面代码的 关键在requestEthereumPrice函数中。如果你直接接触过Chainlink, 就会指导这是用Chainlink获取数据的最简单的办法。

很好!我们已经能够获取到数据了。如果你的目的只是测试,或者就像 快速实现代码,那到这里就够了,但是不要在生产级别的智能合约里使用 上面的代码。为什么?因为上面代码最大的问题是:

它,它,它,它,它,它不是去中心化的!

你在从一个预言机和一个数据提供商拉取数据。虽然现在Linkpook是最可信的Chainlink 节点服务之一,但是你的应用不应当依赖于这些信任,而且你也不应该从 单一数据源获取数据,现在你的应用中存在两个故障点。CryptoCompare和 LinkPool都是很可靠的。现在让我们继续深入。

你的智能合约不应该存在单点故障,因为你所依赖的单点有可能由于被贿赂、 被黑客攻击、临时宕机等非常多的原因而导致你的智能合约不能正常工作。

那么,我们该如何解决这一问题?

2、聚合式预言机数据采集方案:复杂,但消除了单点故障

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
pragma solidity ^0.6.0;

import "github.com/smartcontractkit/chainlink/evm-contracts/src/v0.6/ChainlinkClient.sol";

// MyContract inherits the ChainlinkClient contract to gain the
// functionality of creating Chainlink requests
contract ChainlinkExample is ChainlinkClient {
// Stores the answer from the Chainlink oracle
uint256 public currentPrice;

// This is where you'll save each response from the
// nodes you've chosen in a list, and then you can
// take the median of those nodes
uint256 public index;
uint256[3] public currentPriceList;

address public owner;
// An "Oracle" you'll learn about soon :)
// You'll have to add a little more LINK in due to this one
address ORACLE1 = 0x11db7845a757041F5Dad3fAf81d70ebAd394e9A2;
bytes32 constant JOBID1 = 0xcb08d1dbcc1b0621b4e8d4aea6bafc79f456e12332c782b4258c7e83bcedb74c;

// LinkPool yessir!
address ORACLE2 = 0x83F00b902cbf06E316C95F51cbEeD9D2572a349a;
bytes32 JOBID2 = stringToBytes32("c179a8180e034cf5a341488406c32827");

// Alpha Vantage WOOO!
address ORACLE3 = 0xB36d3709e22F7c708348E225b20b13eA546E6D9c;
bytes32 JOBID3 = stringToBytes32("b1440eefbbff416c8b99062a128ca075");

uint256 constant private ORACLE_PAYMENT = 1 * LINK;

constructor() public {
// Set the address for the LINK token for the network
setPublicChainlinkToken();
owner = msg.sender;
currentPriceList[0] = 0;
currentPriceList[1] = 0;
currentPriceList[2] = 0;
}

// Creates a Chainlink request with the uint256 multiplier job
function requestEthereumPrice(address _address, bytes32 _jobID)
public
onlyOwner
{
// newRequest takes a JobID, a callback address, and callback function as input
Chainlink.Request memory req = buildChainlinkRequest(_jobID, address(this), this.fulfill.selector);
// Adds a URL with the key "get" to the request parameters
req.add("get", "https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD");
// Uses input param (dot-delimited string) as the "path" in the request parameters
req.add("path", "USD");
// Adds an integer with the key "times" to the request parameters
req.addInt("times", 100);
// Sends the request with the amount of payment specified to the oracle
sendChainlinkRequestTo(_address, req, ORACLE_PAYMENT);
}

// fulfill receives a uint256 data type
function fulfill(bytes32 _requestId, uint256 _price)
public
// Use recordChainlinkFulfillment to ensure only the requesting oracle can fulfill
recordChainlinkFulfillment(_requestId)
{
// This is where the magic happens
// Once we have all 3 responses, we can calculate the median value!
currentPriceList[index] = _price;
// This ensures the array never goes past 3, we just keep rotating responses
index = (index + 1) % 3;
currentPrice = median();
}


function multipleData() public{
requestEthereumPrice(ORACLE1, JOBID1);
requestEthereumPrice(ORACLE2, JOBID2);
requestEthereumPrice(ORACLE3, JOBID3);
}

// cancelRequest allows the owner to cancel an unfulfilled request
function cancelRequest(
bytes32 _requestId,
uint256 _payment,
bytes4 _callbackFunctionId,
uint256 _expiration
)
public
onlyOwner
{
cancelChainlinkRequest(_requestId, _payment, _callbackFunctionId, _expiration);
}

// withdrawLink allows the owner to withdraw any extra LINK on the contract
function withdrawLink()
public
onlyOwner
{
LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress());
require(link.transfer(msg.sender, link.balanceOf(address(this))), "Unable to transfer");
}

modifier onlyOwner() {
require(msg.sender == owner);
_;
}

// helper function
function stringToBytes32(string memory source) private pure returns (bytes32 result) {
bytes memory tempEmptyStringTest = bytes(source);
if (tempEmptyStringTest.length == 0) {
return 0x0;
}
assembly { // solhint-disable-line no-inline-assembly
result := mload(add(source, 32))
}
}

// Our sort of lame approach to getting the median
function median() public view returns(uint256){
if (currentPriceList[0] > currentPriceList[1]){
if(currentPriceList[0] > currentPriceList[2]){
if(currentPriceList[1] > currentPriceList[2]){
return currentPriceList[1];
} else {return currentPriceList[2];}
} else {return currentPriceList[0];}
} else if (currentPriceList[1] > currentPriceList[2]){
return currentPriceList[2];
}
return currentPriceList[1];
}
// Allows the owner to withdraw their LINK on this contract
function withdrawLink() external onlyOwner() {
LinkTokenInterface _link = LinkTokenInterface(chainlinkTokenAddress());
require(_link.transfer(msg.sender, _link.balanceOf(address(this))), "Unable to transfer");
}
}

我们现在提高一步,使用3个节点来处理我们的API调用,这样可以在 很大程度上解决上面朴素方案存在的问题。现在,为了获取以太币的价格, 我们添加了一个median函数,它负责计算所有结果的中位值。这样即使 其中的一个节点返回离谱的答案,我们也可能得到正确的以太币价格。 容易理解,通过添加更多的节点,就可以减小我们对单个节点的信任依赖。

理想的方案是我们同时选择不同的数据提供商,不过出于简单化考虑, 在这个示例中我们只使用一个数据提供商。当然如果我们使用3个以上的 节点也会更好,不过3个节点已经消除了单点故障。目前来讲,7~21个 节点就可以视为相当不错了。

现在的方案要比一开始的朴素方案好多了,虽然增加了不少代码。那么, 有没有简单的办法来将API调用路由给多个节点?

我们继续。

3、动态路由预言机数据采集方案:可复用,支持生产环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
pragma solidity ^0.6.0;

import "github.com/smartcontractkit/chainlink/evm-contracts/src/v0.6/ChainlinkClient.sol";

// MyContract inherits the ChainlinkClient contract to gain the
// functionality of creating Chainlink requests
contract ChainlinkExample is ChainlinkClient {
// Stores the answer from the Chainlink oracle
uint256 public currentPrice;
address public owner;

address preCoordinatorOracleAddress = 0x11db7845a757041F5Dad3fAf81d70ebAd394e9A2;
bytes32 constant private Service_Agreement_ID = 0xcb08d1dbcc1b0621b4e8d4aea6bafc79f456e12332c782b4258c7e83bcedb74c;
uint256 constant private ORACLE_PAYMENT = 5 * LINK;

constructor() public {
// Set the address for the LINK token for the network
setPublicChainlinkToken();
owner = msg.sender;
}

// Creates a Chainlink request with the uint256 multiplier job
function requestEthereumPrice(address _address, bytes32 _jobID)
public
onlyOwner
{
// newRequest takes a JobID, a callback address, and callback function as input
Chainlink.Request memory req = buildChainlinkRequest(_jobID, address(this), this.fulfill.selector);
// Adds a URL with the key "get" to the request parameters
req.add("get", "https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD");
// Uses input param (dot-delimited string) as the "path" in the request parameters
req.add("path", "USD");
// Adds an integer with the key "times" to the request parameters
req.addInt("times", 100);
// Sends the request with the amount of payment specified to the oracle
sendChainlinkRequestTo(_address, req, ORACLE_PAYMENT);
}

// fulfill receives a uint256 data type
function fulfill(bytes32 _requestId, uint256 _price)
public
// Use recordChainlinkFulfillment to ensure only the requesting oracle can fulfill
recordChainlinkFulfillment(_requestId)
{
currentPrice = _price;
}

//0x11db7845a757041F5Dad3fAf81d70ebAd394e9A2, 0xcb08d1dbcc1b0621b4e8d4aea6bafc79f456e12332c782b4258c7e83bcedb74c

function getData() public{
requestEthereumPrice(0x11db7845a757041F5Dad3fAf81d70ebAd394e9A2, 0xcb08d1dbcc1b0621b4e8d4aea6bafc79f456e12332c782b4258c7e83bcedb74c);
}

// cancelRequest allows the owner to cancel an unfulfilled request
function cancelRequest(
bytes32 _requestId,
uint256 _payment,
bytes4 _callbackFunctionId,
uint256 _expiration
)
public
onlyOwner
{
cancelChainlinkRequest(_requestId, _payment, _callbackFunctionId, _expiration);
}


// withdrawLink allows the owner to withdraw any extra LINK on the contract
function withdrawLink()
public
onlyOwner
{
LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress());
require(link.transfer(msg.sender, link.balanceOf(address(this))), "Unable to transfer");
}

modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function stringToBytes32(string memory source) private pure returns (bytes32 result) {
bytes memory tempEmptyStringTest = bytes(source);
if (tempEmptyStringTest.length == 0) {
return 0x0;
}
assembly { // solhint-disable-line no-inline-assembly
result := mload(add(source, 32))
}
}
}

这需要一个额外的步骤。你可能注意到remix链接,这是一个只有 两行代码的文件:

1
2
3
pragma solidity ^0.5.0;

import "github.com/smartcontractkit/chainlink/evm-contracts/src/v0.5/PreCoordinator.sol";

上面代码创建了一个服务协议,它读取一组你输入的预言机地址和工作ID,然后 自动将你的API调用分发给这些预言机并计算结果中位值,逻辑和我们之前的朴素方案一样样的!

在GUI中部署theprecoordinator.sol合约,加上你的预言机地址、 jobIDpayments (100000000000000000 = 0.1 LINK)以及_minResponses, 它指的是所需响应结果的最少数量。

假设我们有5个预言机,但只收到2个响应记录。如果你设置的最小响应数量为3, 那就不会使用这个记录。

一旦你载入服务协议,就可以使用服务协议的地址作为预言机地址,服务协议的ID作为 作业ID。你会注意到这一步的语法实际上基本和朴素方案一样。因此如果你使用单一 预言机做完测试,就可以快速进入生产环节:只需要启动服务协议并修改预言机和作业ID, 代码的其他地方都不需要调整。

如果当前使用的预言机性能不行,那么你使用新的预言机创建新的服务协议很简单。 不需要重写你的代码就可以实现,只要替换到原有的作业ID就可以了。

4、使用Chainlinkg的参考合约

很多预言机厂商提供开箱即用的解决方案,Chainlink也一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
pragma solidity ^0.4.24;
import "github.com/smartcontractkit/chainlink/evm-contracts/src/v0.4/interfaces/AggregatorInterface.sol";
contract Demo {

AggregatorInterface internal ref;

constructor(address _aggregator) public {
ref = AggregatorInterface(_aggregator);
}
function getLatestPrice() public view returns (int256) {
return ref.latestAnswer();
}
}

你可以在chainlink的feeds页面找到参考合约的清单,这里面唯一有些 中心化的环节,就是Chainlink负责节点列表的整理,这意味着你不得不 对chainlink团队的节点选择能力有一点信任。然而,如果你需要一个 现成的外包方案,那么Chainlink还是有巨大的优势的。

这一服务目前还是免费的,由赞助商负责买单,不过这不是可持续的 商业模式,相信不久之后就会有变化。


原文链接:API calls on Blockchain; best practices for data collection

汇智网翻译整理,转载请标明出处