😊

Solidity + Chainlink + ExternalAdapter

2022/11/15に公開

作ったもの

https://github.com/akihokurino/rust-ethereum

ChainlinkとExternalAdapter

https://docs.chain.link/docs/conceptual-overview/

Chainlinkとは分散型Oracleプラットフォームです。
ExternalAdapterという形で自作したAPIをNode運用することでそのAPIと自身のコントラクトを組み合わせて動かすことができます。
ただ、実際に組み合わせるまでの手順がそこそこあり、公式ドキュメントは部分的に書かれているため、覚えることが困難です。そのため備忘録として手順を残します。

ExternalAdapterの作成

ExternalAdapter自体はどんな言語で実装してもよく、特定のインターフェースを満たしていればよいです。
https://docs.chain.link/docs/developers/

今回はさくっと検証したかったので、NodeJSで実装しています。
シンプルに、現在時刻を2種類のフォーマットで返すだけのAPIです。

import express from "express";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post("/", (req: express.Request, res: express.Response) => {
  const now = new Date();
  res.send(
    JSON.stringify({
      data: {
        jobRunID: req.body.id ?? 0,
        now: now.toISOString(),
        timestamp: now.getTime(),
      },
    })
  );
});

const port = 8080;
app.listen(port, () => {
  console.log("Node.js is listening to PORT:" + port);
});

ChainlinkNodeの作成

上のExternalAdapterと一緒にPostgreSQLとsmartcontract/chainlinkというイメージを一緒に動かすので、docker-composeを利用して作成します。

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.9.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

  time_adapter:
    container_name: time-adapter
    ports:
      - "8080:8080"
    build:
      context: ../time-adapter
      dockerfile: ./Dockerfile
    image: time-adapter
    restart: on-failure

volumes:
  database:
    driver: local

https://hub.docker.com/r/smartcontract/chainlink

ここでentrypointで参照している .password に内部で生成されるウォレットのパスワードを、 .api に内部の管理画面で利用するメアドとパスワードを指定します。
context等は自分の環境に合わせて変更してください。
PostgreSQLの起動時に chainlink というデータベースが必要になるので、上の例では初期化スクリプトで作成するようにしています。

ここまででdocker-composeを起動し、 chainlink のコンテナにアクセスすると、添付のような管理画面が表示されます。これでChainlinkNodeの作成が完了です。

ChainlinkNodeにEtherを送る

管理画面内で確認できるChainlinkNodeのアカウントアドレスに対して自身のウォレットからEtherを送信しておいてください。ChainlinkNodeはこのEtherをガス代として消費する代わりにノードの利用者からLINKを受け取ります。

ChainlinkNodeのアカウントアドレスは、ChainlinkNodeの管理画面上で確認できます。

Oracleコントラクトの作成とデプロイ

自身の作るコントラクトとChainlinkNodeが直接やりとりするわけではなく、間にOracleコントラクトが介在します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
import "@chainlink/contracts/src/v0.7/Operator.sol";

contract Oracle is Operator {
    constructor()
        Operator(0x326C977E6efc84E512bB9C30f76E30c160eD06FB, msg.sender)
    {}
}

@chainlink/contracts というnpmパッケージが必要になります。
まだ v0.8 にはOracleの実装が入っていないため、ここだけ v0.7 で実装を行います。
hardhatを利用している場合の設定方法は以下です。
https://hardhat.org/hardhat-runner/docs/advanced/multiple-solidity-versions

利用するコントラクトには Oracle.solOperator.sol の2つ候補がありますが、前者は1回のリクエストにつきByte32にフォーマットされた1つのみのレスポンスしかサポートしていません。後者は複数のデータを一度に受け取れるため、こちらを使う方がよいと思います。

このコントラクトはLinkTokenコントラクトへの参照が必要で、そのアドレスをセットします。
https://docs.chain.link/docs/link-token-contracts/

OracleコントラクトにChainlinkNodeへの権限を追加

Oracleコントラクトのデプロイ後にそのコントラクトに対して setAuthorizedSenders を叩いてChainlinkNodeのアカウントアドレスを指定します。

ChainlinkNodeのアカウントアドレスは、ChainlinkNodeの管理画面上で確認できます。

Jobの作成

ChainlinkNode内でJobを作成します。
Jobは全体のパイプラインを定義するところです。

contractAddress = "<Oracle Contract Address>"
name = "Get Time"
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="time-adapter"
    requestData="{\\"id\\": $(jobSpec.externalJobID), \\"params\\": $(decode_cbor.params)}"
  ]

  decode_log -> decode_cbor -> fetch

  data_now [type="jsonparse" path="data,now" data="$(fetch)"]
  data_timestamp [type="jsonparse" path="data,timestamp" data="$(fetch)"]

  fetch -> data_now
  fetch -> data_timestamp

  encode_data [
    type="ethabiencode"
    abi="(bytes32 requestId, string now, int256 timestamp)"
    data="{\\"requestId\\": $(decode_log.requestId), \\"now\\": $(data_now), \\"timestamp\\": $(data_timestamp)}"
  ]
  
  data_now -> encode_data
  data_timestamp -> 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="<Oracle Contract Address>"
    data="$(encode_tx)"
  ]

  encode_data -> encode_tx -> submit_tx
"""
schemaVersion = 1
type = "directrequest"

<Oracle Contract Address> には先ほどデプロイしたOracleコントラクトのアドレスを指定してください。

今回の例だと、

  1. Oracleからのリクエストをデコード
  2. デコードしたパラメータでExternalAdapterへリクエストを行う
  3. そのレスポンスをコントラクトが理解できる形にエンコード
  4. Oracleに返す (このときにOracle.solかOperator.solかで差が生じる)

という流れになります。

これをChainlinkNodeに登録したのち、そこで生成されるExternalJobIDを控えておきます。

メインのコントラクトの作成とデプロイ

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SampleOracle is ChainlinkClient, Ownable {
    using Chainlink for Chainlink.Request;

    struct GetTimeResponse {
        string now;
        int256 timestamp;
    }
    GetTimeResponse public getTimeResponse;

    uint256 private fee;
    address private oracleAddress;
    bytes32 private getTimeJobId;

    /**
     * Network: Goerli
     *
     * Link Token: 0x326C977E6efc84E512bB9C30f76E30c160eD06FB
     * Oracle Address: 0x00
     * GetTime JobId: 
     */
    constructor() {
        setChainlinkToken(0x326C977E6efc84E512bB9C30f76E30c160eD06FB);

        fee = 1 * 10**18;
        oracleAddress = 0x00; // デプロイしたOracleコントラクトのアドレス
        getTimeJobId = "JOB_ID"; // 作成したJobのExternalJobID(ハイフン抜き)
    }

    function getChainlinkToken() public view returns (address) {
        return chainlinkTokenAddress();
    }

    function setGetTimeJobId(bytes32 id) public onlyOwner {
        getTimeJobId = id;
    }

    function createGetTimeRequestTo()
        public
        onlyOwner
        returns (bytes32 requestId)
    {
        Chainlink.Request memory req = buildChainlinkRequest(
            getTimeJobId,
            address(this),
            this.fulfillGetTimeRequest.selector
        );
        req.add("params", "sample time adapter");
        requestId = sendChainlinkRequestTo(oracleAddress, req, fee);
    }

    function fulfillGetTimeRequest(
        bytes32 requestId,
        string memory _now,
        int256 _timestamp
    ) public recordChainlinkFulfillment(requestId) {
        getTimeResponse = GetTimeResponse({now: _now, timestamp: _timestamp});
    }

    function cancelRequest(
        bytes32 requestId,
        bytes4 callbackFunctionId,
        uint256 expiration
    ) public onlyOwner {
        cancelChainlinkRequest(requestId, fee, callbackFunctionId, expiration);
    }

    function withdrawLink() public onlyOwner {
        LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress());
        require(
            link.transfer(msg.sender, link.balanceOf(address(this))),
            "Unable to transfer"
        );
    }
}

今回の例だと、 createGetTimeRequestTo を叩くことでOracleコントラクト経由でChainlinkNodeへリクエストを行い、その結果のレスポンスが fulfillGetTimeRequest に非同期で入ってきます。
リクエストをどう処理するか、レスポンスをどう返すかはJobの定義に書いています。

このコントラクトにもLinkTokenコントラクトへの参照が必要で、そのアドレスをセットします。
https://docs.chain.link/docs/link-token-contracts/

メインのコントラクトにLinkトークンを送る

上でデプロイしたメインのコントラクトに対して自身のウォレットからLinkを送信しておいてください。コントラクトはこのLinkを利用してOracle、ChainlinkNodeとやりとりを行います。

動作確認

準備は全て完了したので、あとはRemix等を用いて createGetTimeRequestTo を叩いてみてください。しばらくしたのち、fulfillのメソッドが呼ばれて結果が格納されています。

補足

今回は一番複雑なExternalAdapterの実装をやりましたが、世の中にはすでにたくさんのAdapterが運用されていて、それを利用することもできます。公式が出しているOracleもあります。

https://docs.chain.link/docs/consuming-data-feeds/
https://docs.chain.link/docs/intermediates-tutorial/
https://docs.chain.link/docs/advanced-tutorial/

参考

https://docs.chain.link/docs/conceptual-overview/
https://qiita.com/biga816/items/ff5b9ac8c7233acc4deb#4-oracleコントラクトを作成する
https://zenn.dev/allegorywrite/articles/a8be18daa57980#5.-chainlinknodeを構築する-~oracle編~

Discussion