分散型オラクルであるChainlinkを使い、WEBサイトからスクレイピングしたデータをスマートコントラクトに記録しみようと思います。
取得するデータは、最速最強のWebサイトとして有名な阿部寛の最新映画出演情報です。
Chainlinkを使用するにあたってはスマートコントラクトを書くだけでなく、NodeやExternal Adapter、JOB(v2)も実際に動かしてみます。
Chainlinkとは
Ethereumでは、スマートコントラクト内から外部データにアクセスできません。
Chainlinkは、外部にあるデータをスマートコントラクト内部に保存するためのミドルウェアで、スマートコントラクト内から外部データにアクセスする仕組みを提供しており、分散型オラクルと呼ばれています。
最近話題のDeFiでは、スマートコントラクトから外部のトークンやETHのレート情報にアクセスする際に使われています。
本件概要
阿部寛の最新映画出演情報を取得する流れは、以下の通りです。
- WEBスクレイピングコントラクト(WebScrapingData.sol)にデータをリクエストする。
- Oracleコントラクトを経由して、Chainlink Nodeに登録されたJobが実行される。
- JobがWebスクレイピングを行うExternal Adapterを呼び出す。
- External Adapterが阿部寛のWebサイトをスクレイピングし、最新の映画出演情報を取得する。
- External Adapterよりデータを取得したJobがOracleコントラクトを経由してWEBスクレイピングコントラクトにデータを書き込む。
No | 名称 | 役割 | 処理概要 |
---|---|---|---|
1 | WebScrapingData.sol | 外部データ取得のリクエストの受付と、保存を行うスマートコントラクト | 阿部寛の最新映画出演情報の取得をリクエスト、管理する |
2 | Oracle.sol | Chainlink NodeとWebScrapingData.sol間のリクエストを橋渡しするコントラクト | |
3 | JOB | コントラクトからのリクエストの受付や、外部データとのやり取りなど一連の処理を実行する仕組み | External Adapterを呼び出し、受け取った値をbytes32でコントラクトに書き込む |
4 | External Adapter | 外部データを取得し、Chanlinkで扱える形にしてデータをChainlink Nodeに渡すサービス | 阿部寛のWebsサイトをスクレイピングして最新映画出演情報を取得する。 |
実装してみる
1. External Adapterを動かす
Extarnal Adapterは指定のフォーマットのインターフェースを持っていればどんな言語で開発しても問題ありません。
具体的には下記の2点の要件を満たす必要があります。
- POST APIが、処理成功時に
{jobRunID: string, data: any}
の形でデータを返却すること。 - POST APIが、処理失敗時に
{jobRunID: string, status: string, error: any}
の形でデータを返却すること。
External Adapterの開発方法は、下記公式ドキュメントにも記載されています。
https://docs.chain.link/docs/developers
今回はWebスクレイピングをし、指定したhtml要素の値を取得する処理を実装します。
汎用的に使用できるAdapterにするため、引数にid, url, path, filterを指定できるようにします。
Input Params
Key | Description | Value Example | |
---|---|---|---|
id |
Required | chainlink nodeに渡すためのjobRunID
|
1 |
url |
Required | スクレイピングする対象のURL | http://abehiroshi.la.coocan.jp/movie/eiga.htm |
path |
Required | データを取得する対象のDOMツリー | html.body.center.1.table.tr |
filter |
Optional | 取得したDOMツリーをフィルタするパラメーター | td.strong |
Output Format
{
"jobRunID": "1",
"data": [
"2022年夏公開予定",
"「",
{
"type": "a",
"content": [
"異動辞令は音楽隊!"
],
"attributes": {
"href": "https://gaga.ne.jp/ongakutai/",
"target": "_blank"
}
},
"」"
]
}
実際にNest.js(Node.js)で実装したものがこちら。
chainlink-scraping-adapter
下記コマンドで実行できます。
$ git clone git@github.com:biga816/chainlink-scraping-adapter.git
$ npm install
$ npm run start
2. Chainlink Nodeを動かす
Chainlink Node、PostgreSQL、External Adapterの3つを同時に動かす必要があるため、Docker Composeを使用します。
フォルダ構成は以下の通りにした上で、docker-compose.yml
を作成します。
今回はRinkeby用の設定になります。
.
├── chainlink-node
└── chainlink-scraping-adapter
version: '3.4'
services:
postgresql:
image: postgres:14.2
container_name: postgresql
ports:
- 5432:5432
volumes:
- ./postgres/init:/docker-entrypoint-initdb.d
- database:/var/lib/postgresql/data
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: root
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
POSTGRES_HOST_AUTH_METHOD: password
hostname: postgres
restart: always
user: root
chainlink:
image: smartcontract/chainlink:1.3.0
container_name: chainlink
tty: true
ports:
- 6688:6688
env_file:
- ./chainlink/.env
volumes:
- ./chainlink/data:/chainlink
depends_on:
- postgresql
entrypoint: "/bin/bash -c 'chainlink local n -p /chainlink/.password -a /chainlink/.api'"
restart: on-failure
adapter:
container_name: scraping-adapter
ports:
- "5000:5000"
build:
context: ../chainlink-scraping-adapter
dockerfile: ./Dockerfile
image: scraping-adapter
restart: on-failure
volumes:
database:
driver: local
次に、それぞれのサービスに必要な設定ファイルを追加していきます。
IDやパスワードなどは適宜書き換えてください。
CREATE DATABASE chainlink;
ROOT=/chainlink
LOG_LEVEL=debug
ETH_CHAIN_ID=4
MIN_OUTGOING_CONFIRMATIONS=2
LINK_CONTRACT_ADDRESS=0x01BE23585060835E02B77ef475b0Cc51aA1e0709
CHAINLINK_TLS_PORT=0
SECURE_COOKIES=false
ALLOW_ORIGINS=*
ETH_URL=wss://rinkeby.infura.io/ws/v3/<YOUR INFURA ID>
DATABASE_URL=postgresql://root:root@postgresql:5432/chainlink?sslmode=disable
DATABASE_TIMEOUT=0
<YOUR WALLET PASSWORD>
<YOUR LOGIN EMAIL>
<YOUR LOGIN PASSWORD>
ここまでできたら準備完了です。
下記コマンドで、chainlink nodeを立ち上げます。
$ cd chainlink-node
$ docker-compose up -d
下記URLにアクセスしてsignin画面が表示されれば無事起動完了です。
http://localhost:6688
3. Webスクレイピング用のコントラクトを作成する
ChsinlinkのコントラクトとOpenZeppelinのコントラクトを利用するため、まずnpmモジュールをインストールします。
$ cd chainlink-node
$ npm install --save @chainlink/contracts openzeppelin-solidity
次にWebスクレイピングの実行をリクエストし、実行結果を保存するためのコントラクトWebScrapingData.sol
を作成します。
// SPDX-License-Identifier: MIT
pragma solidity 0.7.0;
import "@chainlink/contracts/src/v0.7/ChainlinkClient.sol";
import "openzeppelin-solidity/contracts/access/Ownable.sol";
contract WebScrapingData is ChainlinkClient, Ownable {
using Chainlink for Chainlink.Request;
struct Response {
string date;
string title;
}
string public url;
Response public response;
constructor(address _link) public Ownable() {
if (_link == address(0)) {
setPublicChainlinkToken();
} else {
setChainlinkToken(_link);
}
}
function setUrl(string memory _url) public onlyOwner {
url = _url;
}
function getChainlinkToken() public view returns (address) {
return chainlinkTokenAddress();
}
function createRequestTo(
address _oracle,
bytes32 _jobId,
uint256 _payment,
string memory _path,
string memory _filter
) public onlyOwner returns (bytes32 requestId) {
require(bytes(url).length > 0, "url is required");
Chainlink.Request memory req = buildChainlinkRequest(
_jobId,
address(this),
this.fulfill.selector
);
req.add("url", url);
req.add("path", _path);
req.add("filter", _filter);
requestId = sendChainlinkRequestTo(_oracle, req, _payment);
}
function fulfill(
bytes32 _requestId,
string calldata _date,
string calldata _title
) public recordChainlinkFulfillment(_requestId) {
response = Response({date: _date, title: _title});
}
/**
* @notice Allows the owner to withdraw any LINK balance on the contract
*/
function withdrawLink() public onlyOwner {
LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress());
require(
link.transfer(msg.sender, link.balanceOf(address(this))),
"Unable to transfer"
);
}
function cancelRequest(
bytes32 _requestId,
uint256 _payment,
bytes4 _callbackFunctionId,
uint256 _expiration
) public onlyOwner {
cancelChainlinkRequest(
_requestId,
_payment,
_callbackFunctionId,
_expiration
);
}
}
createRequestTo()
がデータの取得をリクエストするファンクションで、fulfill()
がデータを受け取るためのファンクションになります。
JOBのv2からはfulfillメソッドにて複数のデータを受け取ることができるようになったので、ここでは最新映画出演情報のうち、公開日とタイトルを受け取っています。
4. Oracleコントラクトを作成する。
fulfillメソッドにて複数のデータを受け取るには、ChainlinkのコントラクトにあるOracle.soldではなくOperator.solを使って下記のコントラクトを作成する必要があります。
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
import "@chainlink/contracts/src/v0.7/Operator.sol";
contract Oracle is Operator {
constructor(address _link, address owner) public Operator(_link, owner) {}
}
5. コントラクトをデプロイする。
Truffleを使用する場合は以下のようなmigrationファイルを用意することでWebScrapingDataとOracleの2つのコントラクトをデプロイします。
require("dotenv/config");
const WebScrapingData = artifacts.require("WebScrapingData");
const Oracle = artifacts.require("Oracle");
module.exports = (deployer, network, [defaultAccount]) => {
Oracle.setProvider(deployer.provider);
deployer
.deploy(WebScrapingData, process.env.LINKTOKEN_ADDRESS)
.then(async (instance) => {
await instance.setUrl("http://abehiroshi.la.coocan.jp/movie/eiga.htm");
Oracle.setProvider(deployer.provider);
return deployer.deploy(
Oracle,
process.env.LINKTOKEN_ADDRESS,
defaultAccount,
{
from: defaultAccount,
}
);
})
.then(async (instance) => {
await instance.setAuthorizedSenders(
[process.env.CHAINLINK_NODE_ACCOUNT],
{ from: defaultAccount }
);
});
};
※1 WebScrapingDataとOracleそれぞれのデプロイ時にLINKトークンのアドレスを設定する必要があるため、環境変数LINKTOKEN_ADDRESS
にはRinkebyのLINKトークンのアドレスである0x01BE23585060835E02B77ef475b0Cc51aA1e0709
をセットする必要があります。
※2 OracleコントラクトはChainlink Nodeに対して権限を与える必要があるため、デプロイ後にsetAuthorizedSenders()
を用いてChainlink Nodeのアカウントアドレスをセットします。Chainlink Nodeのアカウントアドレスは下記のトップページで確認できます。
ここまでに作成した構成は以下のリポジトに格納してあるので、うまく動かない場合はこちらをご利用ください。
https://github.com/biga816/chainlink-node
6. Bridgeを登録する
作成したExternal Adapterを呼び出すために、下記画面よりBridgeを登録します。
各項目に入力する値は以下の通りです。
項目名 | 値 |
---|---|
Bridge Name | scraping |
Bridge URL | http://adapter:5000 |
Minimum Contract Payment | 0 |
Confirmations | 0 |
7. JOBを登録する
JOBの内容は以下の通りです。
contractAddress
には先ほどデプロイしたOracleコントラクトのアドレスをセットします。
type = "directrequest"
schemaVersion = 1
name = "Get Scraping Data"
contractAddress = "<YOUR_ORACLE_ADDRESS>"
observationSource = """
decode_log [
type="ethabidecodelog"
abi="OracleRequest(bytes32 indexed specId, address requester, bytes32 requestId, uint256 payment, address callbackAddr, bytes4 callbackFunctionId, uint256 cancelExpiration, uint256 dataVersion, bytes data)"
data="$(jobRun.logData)"
topics="$(jobRun.logTopics)"
]
decode_cbor [type=cborparse data="$(decode_log.data)"]
fetch [
type="bridge"
name="scraping"
requestData="{\\"id\\": $(jobSpec.externalJobID), \\"url\\": $(decode_cbor.url), \\"path\\": $(decode_cbor.path), \\"filter\\": $(decode_cbor.filter)}"
]
decode_log -> decode_cbor -> fetch
data_date [type="jsonparse" path="data,0" data="$(fetch)"]
date_title [type="jsonparse" path="data,2,content,0" data="$(fetch)"]
fetch -> data_date
fetch -> date_title
encode_data [
type="ethabiencode"
abi="(bytes32 requestId, string date, string title)"
data="{\\"requestId\\": $(decode_log.requestId), \\"date\\": $(data_date), \\"title\\": $(date_title)}"
]
data_date -> encode_data
date_title -> encode_data
encode_tx [
type="ethabiencode"
abi="fulfillOracleRequest2(bytes32 requestId, uint256 payment, address callbackAddress, bytes4 callbackFunctionId, uint256 expiration, bytes calldata data)"
data="{\\"requestId\\": $(decode_log.requestId), \\"payment\\": $(decode_log.payment), \\"callbackAddress\\": $(decode_log.callbackAddr), \\"callbackFunctionId\\": $(decode_log.callbackFunctionId), \\"expiration\\": $(decode_log.cancelExpiration), \\"data\\": $(encode_data)}"
]
submit_tx [
type="ethtx" to="<YOUR_ORACLE_ADDRESS>"
data="$(encode_tx)"
]
encode_data -> encode_tx -> submit_tx
"""
各taskの役割は以下の通りです。
type | Description |
---|---|
ethabidecodelog | コントラクトから受け取ったデータをデコードする |
bridge | 指定したbridgeを呼び出す |
jsonparse | jsonデータをパースする |
ethabiencode | コントラクトを呼べる形にデータをエンコードする |
ethtx | コントラクトを実行する |
ethabiencodeのabiに指定しているメソッド名がfulfillOracleRequest2
になっているのがポイントです。
複数のデータをコントラクトに返したい場合には、fulfillOracleRequest
ではなくfulfillOracleRequest2
にする必要があります。
上記以外のtaskを使用したい場合は、こちらのドキュメントから探せます。
https://docs.chain.link/docs/tasks/
8.LINKトークン、ETHを準備する
コントラクト実行にあたり、WebScrapingDataコントラクトにはLINKトークン、Chainlink NodeのアカウントアドレスにはETHが必要なので、コントラクト実行前にそれぞれ送ります。
RinkebyのLinkトークンは、ここから取得できます。
https://faucets.chain.link/
JOBの登録が完了すると、下記のようなフローが確認できます。
9. コントラクトを実行する。
コントラクトの実行には、Remixを使用します。
Remixを開いたら、下記手順でコントラクトを読み込みます。
- FILE EXPLORERSで先ほどデプロイしたWebScrapingDataコントラクトのABIをファイルとして保存する。
-
DEPLOY & RUN TRANSACTIONSでENVIRONMENT
でInjected Web3
を選択する。(コントラクトのでデプロイ時のアドレスと同じであること。) -
CONTRACT
のAt Address
に先ほどデプロイしたWebScrapingDataのコントラクトアドレスを入力し、At Address
を押下する。
コントラクトが読み込めたら、url
ファンクションを実行します。
デプロイ時にセットしたhttp://abehiroshi.la.coocan.jp/movie/eiga.htm
が返ってくれば準備OKです。
最後に、createRequestTo
ファンクションに下記値をセットして呼び出します。
項目名 | 値 |
---|---|
_oracle | Oracleコントラクトのアドレス |
_jobId | 作成したJOBのIDをbyte32に変換したもの※1 |
_payment | 支払うLINKトークンの数量 (Decimalsは18) |
_path | External Adapterに渡すpath |
_filter | External Adapterに渡すpath |
※1
こちらで変換できます。
https://web3-type-converter.brn.sh/
変換の際には、実際のJOBIDからハイフンを削除したものをbyte32に変換する必要があります。
例)
010e6657-4114-4248-8737-3272de4d9c2a
-> 010e66574114424887373272de4d9c2a
-> 0x3031306536363537343131343432343838373337333237326465346439633261
JOBがCompletedになったら、Remixに戻り、response
ファンクションを実行します。
値が格納されていたら、データの取得完了です。
ちなみにこちらが阿部寛の最新映画出演情報です。
一番上の最新映画出演情報がコントラクトに格納されたものと同じであることが分かります。
まとめ
これでChainlinkの機能を全体的に使用した上で、阿部寛のアクティブな最新映画出演情報をスマートコントラクトに記録することができました。
今回は阿部寛のWebサイトのデータを対象としましたが、作成したExternal Adapterは汎用的にWEBスクレイピングできるように作成したため、他のサイトからもデータをとってこれますし、そもそもExternal Adapterを使わなくてもJOBを正しく定義することで様々な外部データをコントラクトに記録することができます。
今後さらに面白い用途に応用していけたらと思います。