Solidity + Chainlink + ExternalAdapter
作ったもの
ChainlinkとExternalAdapter
Chainlinkとは分散型Oracleプラットフォームです。
ExternalAdapterという形で自作したAPIをNode運用することでそのAPIと自身のコントラクトを組み合わせて動かすことができます。
ただ、実際に組み合わせるまでの手順がそこそこあり、公式ドキュメントは部分的に書かれているため、覚えることが困難です。そのため備忘録として手順を残します。
ExternalAdapterの作成
ExternalAdapter自体はどんな言語で実装してもよく、特定のインターフェースを満たしていればよいです。
今回はさくっと検証したかったので、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
ここで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を利用している場合の設定方法は以下です。
利用するコントラクトには Oracle.sol
と Operator.sol
の2つ候補がありますが、前者は1回のリクエストにつきByte32にフォーマットされた1つのみのレスポンスしかサポートしていません。後者は複数のデータを一度に受け取れるため、こちらを使う方がよいと思います。
このコントラクトはLinkTokenコントラクトへの参照が必要で、そのアドレスをセットします。
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コントラクトのアドレスを指定してください。
今回の例だと、
- Oracleからのリクエストをデコード
- デコードしたパラメータでExternalAdapterへリクエストを行う
- そのレスポンスをコントラクトが理解できる形にエンコード
- 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コントラクトへの参照が必要で、そのアドレスをセットします。
メインのコントラクトにLinkトークンを送る
上でデプロイしたメインのコントラクトに対して自身のウォレットからLinkを送信しておいてください。コントラクトはこのLinkを利用してOracle、ChainlinkNodeとやりとりを行います。
動作確認
準備は全て完了したので、あとはRemix等を用いて createGetTimeRequestTo
を叩いてみてください。しばらくしたのち、fulfillのメソッドが呼ばれて結果が格納されています。
補足
今回は一番複雑なExternalAdapterの実装をやりましたが、世の中にはすでにたくさんのAdapterが運用されていて、それを利用することもできます。公式が出しているOracleもあります。
参考
Discussion