[Ethereum] Chainlinkで阿部寛の最新の映画出演情報をスマートコントラクトに記録する

分散型オラクルであるChainlinkを使い、WEBサイトからスクレイピングしたデータをスマートコントラクトに記録しみようと思います。
取得するデータは、最速最強のWebサイトとして有名な阿部寛の最新映画出演情報です。
Chainlinkを使用するにあたってはスマートコントラクトを書くだけでなく、NodeやExternal Adapter、JOB(v2)も実際に動かしてみます。

![阿部寛のホームページ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/db533cfb-438f-f3b6-8656-6e6c9e223f68.png)


# Chainlinkとは
Ethereumでは、スマートコントラクト内から外部データにアクセスできません。
Chainlinkは、外部にあるデータをスマートコントラクト内部に保存するためのミドルウェアで、スマートコントラクト内から外部データにアクセスする仕組みを提供しており、分散型オラクルと呼ばれています。
最近話題のDeFiでは、スマートコントラクトから外部のトークンやETHのレート情報にアクセスする際に使われています。

# 本件概要
阿部寛の最新映画出演情報を取得する流れは、以下の通りです。

1. WEBスクレイピングコントラクト(WebScrapingData.sol)にデータをリクエストする。
2. Oracleコントラクトを経由して、Chainlink Nodeに登録されたJobが実行される。
3. JobがWebスクレイピングを行うExternal Adapterを呼び出す。
4. External Adapterが阿部寛のWebサイトをスクレイピングし、最新の映画出演情報を取得する。
5. External Adapterよりデータを取得したJobがOracleコントラクトを経由してWEBスクレイピングコントラクトにデータを書き込む。

![全画面_2022_05_22_23_21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/a2323e33-5d4c-a2c8-d15b-5a93cf66ef62.png)



| 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点の要件を満たす必要があります。

1. POST APIが、処理成功時に`{jobRunID: string, data: any}`の形でデータを返却すること。
2. 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

```json
{
    "jobRunID": "1",
    "data": [
        "2022年夏公開予定",
        "「",
        {
            "type": "a",
            "content": [
                "異動辞令は音楽隊！"
            ],
            "attributes": {
                "href": "https://gaga.ne.jp/ongakutai/",
                "target": "_blank"
            }
        },
        "」"
    ]
}
```

実際にNest.js(Node.js)で実装したものがこちら。
[chainlink-scraping-adapter](https://github.com/biga816/chainlink-scraping-adapter)

下記コマンドで実行できます。

```bash
$ git clone git@github.com:biga816/chainlink-scraping-adapter.git
$ npm install
$ npm run start
```

下記のような実行結果になれば正常に動作しています。
![Postman.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/23e33e77-cb9f-261e-e2f1-07e748318398.png)


## 2. Chainlink Nodeを動かす
Chainlink Node、PostgreSQL、External Adapterの3つを同時に動かす必要があるため、Docker Composeを使用します。

フォルダ構成は以下の通りにした上で、`docker-compose.yml`を作成します。
今回はRinkeby用の設定になります。

```
.
├── chainlink-node
└── chainlink-scraping-adapter
```

```yaml:./chainlink-node/docker-compose.yml
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やパスワードなどは適宜書き換えてください。

```sql:./chainlink-nodepostgres/init/1_create.sql
CREATE DATABASE chainlink;
```

```:./chainlink-node/postgres/chainlink/.env
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
```

```:./chainlink-node/chainlink/data/.password
<YOUR WALLET PASSWORD>
```

```:./chainlink-node/chainlink/data/.api
<YOUR LOGIN EMAIL>
<YOUR LOGIN PASSWORD>
```

ここまでできたら準備完了です。
下記コマンドで、chainlink nodeを立ち上げます。

```bash
$ cd chainlink-node
$ docker-compose up -d
```

下記URLにアクセスしてsignin画面が表示されれば無事起動完了です。
http://localhost:6688
![Sign_In.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/1b636900-7207-9945-3f17-f55c69a0e94e.png)

## 3. Webスクレイピング用のコントラクトを作成する

ChsinlinkのコントラクトとOpenZeppelinのコントラクトを利用するため、まずnpmモジュールをインストールします。

```bash
$ cd chainlink-node
$ npm install --save @chainlink/contracts openzeppelin-solidity
```

次にWebスクレイピングの実行をリクエストし、実行結果を保存するためのコントラクト`WebScrapingData.sol`を作成します。


```solidity:./chainlink-node/contracts/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を使って下記のコントラクトを作成する必要があります。

```solidity./chainlink-node/contracts/Oracle.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つのコントラクトをデプロイします。

```javascript:./chainlink-node/migrations/2_webscrapingdata_migration.js
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のアカウントアドレスは下記のトップページで確認できます。

![Bridges.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/972b0b6c-9655-7410-e2d0-f6a8d203932b.png)


ここまでに作成した構成は以下のリポジトに格納してあるので、うまく動かない場合はこちらをご利用ください。
https://github.com/biga816/chainlink-node

## 6. Bridgeを登録する
作成したExternal Adapterを呼び出すために、下記画面よりBridgeを登録します。
![Bridges.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/96434673-8827-a3b2-e0ec-c2e01a131279.png)


各項目に入力する値は以下の通りです。

|項目名|値|
|:-----------|:------------|
| Bridge Name | scraping |
| Bridge URL | http://adapter:5000 |
| Minimum Contract Payment | 0 |
| Confirmations | 0 |


## 7. JOBを登録する
下記画面よりJOBを登録します。
![Jobs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/6b462cdd-392e-ef52-de2a-2a1e2dac5d5c.png)


JOBの内容は以下の通りです。
`contractAddress`には先ほどデプロイしたOracleコントラクトのアドレスをセットします。

```toml
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の登録が完了すると、下記のようなフローが確認できます。
![Sign_In.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/c0fe71e6-a776-b8ae-22a1-8bf1277bbbc1.png)


## 9. コントラクトを実行する。
コントラクトの実行には、[Remix](https://remix.ethereum.org/)を使用します。
Remixを開いたら、下記手順でコントラクトを読み込みます。

1. FILE EXPLORERSで先ほどデプロイしたWebScrapingDataコントラクトのABIをファイルとして保存する。
2. `DEPLOY & RUN TRANSACTIONSでENVIRONMENT`で`Injected Web3`を選択する。（コントラクトのでデプロイ時のアドレスと同じであること。）
3. `CONTRACT`の`At Address`に先ほどデプロイしたWebScrapingDataのコントラクトアドレスを入力し、`At Address`を押下する。

![Remix_-_Ethereum_IDE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/b8fb0a1c-d57a-9a24-2e89-3378deb2ca21.png)

コントラクトが読み込めたら、`url`ファンクションを実行します。
デプロイ時にセットした`http://abehiroshi.la.coocan.jp/movie/eiga.htm`が返ってくれば準備OKです。
![Remix_-_Ethereum_IDE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/7f39337d-6e52-7df0-4a0c-7052d0c6963a.png)


最後に、`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`

![Remix_-_Ethereum_IDE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/07ddb752-a891-bd35-60a4-75786c2a89b7.png)

トランザクションが確定すると、JOBが動き出します。
![Jobs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/84200502-f6d0-a681-c9ad-181b5e04d2a5.png)

JOBがCompletedになったら、Remixに戻り、`response`ファンクションを実行します。
値が格納されていたら、データの取得完了です。
![Remix_-_Ethereum_IDE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/901e6056-d7dd-f1d4-a7e7-10b749120b08.png)


ちなみにこちらが阿部寛の最新映画出演情報です。
一番上の最新映画出演情報がコントラクトに格納されたものと同じであることが分かります。
![阿部_寛の映画出演.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92492/c31acdd8-8379-93c3-3455-d2809d144e7a.png)

# まとめ
これでChainlinkの機能を全体的に使用した上で、阿部寛のアクティブな最新映画出演情報をスマートコントラクトに記録することができました。
今回は阿部寛のWebサイトのデータを対象としましたが、作成したExternal Adapterは汎用的にWEBスクレイピングできるように作成したため、他のサイトからもデータをとってこれますし、そもそもExternal Adapterを使わなくてもJOBを正しく定義することで様々な外部データをコントラクトに記録することができます。
今後さらに面白い用途に応用していけたらと思います。