🌊

Dynamic NFTを作る(決定版)

2022/04/03に公開1

はじめに

ブロックチェーン上で動くスマートコントラクトは,そのままでは外部から情報を取得することができません.
情報を得るためには,オラクルと呼ばれるレイヤーを利用して,オフチェーンとオンチェーンの橋渡しをする必要があります.
本記事では,分散型オラクルネットワークであるChainlinkを利用して,OpenSeaからユーザー情報を取得し, NFTの画像として表示する会員証NFTの作り方を紹介します.
ちなみに,このように外部から情報を受け取って動的に変化するNFTは,DynamicNFTと呼ばれます.

本記事は,先日筆者が参加したハッカソンで発表した"Magic Plank"の技術解説も兼ねています.
この記事でどういったものが作れるのか知りたい方は,ぜひMagicPlankのホワイトペーパーをご覧ください.
また,本記事で紹介するコードは全て,githubに公開しています.
https://twitter.com/allegory_write/status/1508818083955507201?s=20&t=2J8EdNr53wk7RtLUTnobEA
https://github.com/BlueGamma

概要

DynamicNFTの構築は,スマートコントラクトの作成から,Chainlinkの構築まで多岐にわたるため,記事はステップごとにそれぞれ分けようと思います.それでは始めていきましょう.

  1. Chainlinkを理解する
  2. Chainlinkを利用する
  3. ChainlinkNodeを構築する ~ExternalAdapter編~
  4. ChainlinkNodeを構築する ~Node(JOB)編~
  5. ChainlinkNodeを構築する ~Oracle編~
  6. OpenSeaのapiを利用する方法 ~番外編~
  7. Chainlinkからデータを受け取るコントラクトを作る
  8. まとめ

1. Chainlinkを理解する

Chainlinkは,ブロックチェーンの外側に存在する情報を,チェーン上のスマートコントラクトで利用できるようにするためのミドルウェアです.また,ChainlinkNodeを構築すれば誰でも情報の提供者となれるため,絶対的な管理者が存在しないという意味で"分散型オラクルネットワーク"と呼ばれています.

Chainlinkのほかにも,オラクルは多く存在し,分散型でないオラクルも存在します.オラクルの種類についてはこちらの記事がわかりやすいので興味があれば見てみてください.
https://academy.binance.com/ja/articles/blockchain-oracles-explained

Chainlinkは,提供するデータに応じた様々なノードから構成されており,そのノードをChainlinkNodeと呼びます.また,ChainlinkNodeに情報を渡すアダプターをExternalAdapter, ChainlinkNodeから情報を受け取るコントラクトをOracleと呼びます.ChainlinkNodeを独自に構築する際には,この3つを作る必要があります.



本記事では,OpenSeaのapiから取得したユーザーデータをもとに会員証を画像生成し,コントラクトに格納するので,情報の流れは以下のようになります.

  1. コントラクトからNFTの所有者のアドレスをChainlinkNodeに送る
  2. Etheriumアドレスを使って,OpenSeaからプロフィール画像のurlとユーザーネームを取得
  3. プロフィール画像とユーザーネームを使って,ExternalAdapterで会員証の画像を生成する
  4. Jobが会員証の画像urlをExternalAdapterから受け取り,Oracleコントラクトを通じて自身のコントラクトにデータを格納する.

それぞれの役割は次のようになります.

名前 役割 使用言語
JOB ExternalAdapterやapiの呼び出しを含めたデータ処理のパイプラインとなる TOML
ExternalAdapter apiから取得したデータを用いて,画像生成とアップロードを行う Javascript, YAML(Docker用)
Oracle ChainlinkNodeから画像urlを受け取り,自作コントラクトに送る Solidity
MyContract Oracleから画像urlをうけとって格納する Solidity

ExternalAdapterやJOB,さらにこれらをまとめたNodeは,ノードオペレーターと呼ばれる開発者によってすでに作られたものを利用することができます.乱数の生成やETHその他の資産価格などは,利用される機会が多く,ノードオペレーターも多数存在するので,独自にChainlinkNodeを構築する必要はありません.現在どのようなノードやアダプターが利用できるかは,Chainlink Marketで知ることができます.
https://market.link/

2. Chainlinkを利用する

Chainlinkでは,既に構築されたChainlinkNodeを利用することで,簡単にデータを取得することが可能です.
ノードオペレーターが存在しない情報については自分でノードを構築する必要がありますが,まずは既存のChainlinkNodeを利用する方法について解説します.

参考

一つ目の記事でも触れた通り,Chainlinkの利用目的のほとんど(?)は,乱数や資産価格データの取得であるため,この2つについてはそれに特化した仕様が存在します.本記事のChainlinkの利用目的は乱数や資産価格データではないため,これらについては深入りしませんが,それぞれの簡単な利用方法をまとめました.

乱数の取得

乱数は,次のようにChainlinkのライブラリからVRFConsumerBase.solを継承することで取得できます.

VRFD20.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;

import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract VRFD20 is VRFConsumerBase {

}

詳しくは公式ドキュメントをご覧ください

資産価格の取得

資産価格のデータフィードは次のようなフォーマットを用いて取得できます.

PriceConsumerV3.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {

    AggregatorV3Interface internal priceFeed;

    constructor() {
        priceFeed = AggregatorV3Interface(CONTRACT_ADDRESS);
    }
    
    function getLatestPrice() public view returns (int) {
        int price = priceFeed.latestRoundData();
        return price;
    }
}

価格データごとのアドレスは以下のサイトから取得できます.
https://data.chain.link/ethereum/mainnet

詳しくは公式ドキュメントをご覧ください


筆者の作業実行環境は以下の通りです

  • MacOS Catalina v10.15
  • Rimix-Etherium IDE

https://remix.ethereum.org/#url=https://docs.chain.link/samples/APIRequests/APIConsumer.sol

RemixでAPIConsumer.solを開き,以下の画像にしたがって,コードのコンパイル,デプロイを行います.
デフォルトのサンプルコードでは,使っているoracleアドレスとjobIdが,kovanテストネット用になっているので,rinkebyでテストしたい方は,33,34行目の入力値をそれぞれ以下のものに変更してください.(入手ソース)

  • oracle
    0x142b60da0bfA583Dc2877e2aC12B7f511b8bD2db
  • jobId
    3381d621bc574c4590163a12a990c377

デプロイの際

  1. EnvironmentにInjected Web3を指定していること
  2. 自身のウォレットでrinkebyを利用ネットワークに選択していること
  3. デプロイするコントラクトにAPIConsumerを選択していること

に注意してください.


コンパイル


デプロイ

APIConsumerがデプロイできたら,デプロイされたコントラクトのアドレスに対して,テストネットのLINKトークンを送信する必要があります.

以下のサイトでウォレットを接続し,NetworkからRinkebyを選んでLINKトークンを取得してください.
https://faucets.chain.link/

LINKトークンを取得できたら,以下の画像にしたがってアドレスをコピーし,自身のウォレットからLINKを送信します(0.1LINK以上).
LINKトークンが送信されたのを確認したら,requestVolumeDataを実行してください.

一定時間が経つと,ChainlinkからETH/USDの価格データが送られて,volume変数に格納されます.

3. ChainlinkNodeを構築する ~ExternalAdapter編~

本章では,Chainlinkの構築に必要なExternalAdapterを実装します.ExternalAdapterは,JSON形式でデータの入出力を行うことができればどんな言語でも実装することができます.今回は,Node.jsで実装を行いました.
DynamicNFTを作るために,ExternalAdapterで行う処理は,次の4つです.

  1. 画像urlとユーザーネームをJSON形式のデータとして受け取る
  2. 受け取ったデータから会員証の画像を生成する
  3. 会員証の画像をIPFSにアップロードし,urlを取得する
  4. urlの変数部分をbyteコードに変換して,JSON形式のデータとして返す

1.の処理を実装したコードがこちらです

ExternalAdapter/main.js
const http = require("http");
const { exFunction } = require("./service");

const server = http.createServer((req, res) => {
    if (req.method == "POST") {
        let job;
        req.on("data", async (json) => {
            job = await JSON.parse(json);
        }).on("end", async () => {
            const ret = await exFunction(job.data.image, job.data.address, job.data.name)
                                        
            const json = {
                "data": ret
            }
            res.end(JSON.stringify(json));
        });
    } else {
        res.end(JSON.stringify({}));
    }
});

server.listen(5000);

2.の処理を実装したコードがこちらです

const canvasInit = async (x, y) => {
    canvas = createCanvas(x, y);
    ctx = canvas.getContext("2d");
}

// ./input/usrIcon.png -> ./output/icon.png
const iconCreating = async (url) => {
    const icon = await loadImage(url);
    const iw = icon.width;
    const ih = icon.height;
    await canvasInit(iw, ih);

    if (iw >= ih) {
        canvas.height = 160;
        canvas.width = 160 * iw / ih;
        ctx.arc( 80 * iw /ih, 80, 75, 0 * Math.PI / 180, 360 * Math.PI / 180 );
        ctx.clip();
        ctx.drawImage(icon, 0, 0, canvas.width, canvas.height);
    } else {
        canvas.height = 160 * ih / iw;
        canvas.width = 160;
        ctx.arc( 80, 80 * iw /ih, 75, 0 * Math.PI / 180, 360 * Math.PI / 180 );
        ctx.clip();
        ctx.drawImage(icon, 0, 0, canvas.width, canvas.height);
    }
    fs.writeFileSync("./output/icon.png", canvas.toBuffer("image/png"));
}

const CardCreating = async (text) => {
    let filepath = "./input/roundrobin.png";
    let iconpath = "./output/icon.png";
    await canvasInit(1000, 1000);
    const background = await loadImage(filepath);
    const icon = await loadImage(iconpath);
    canvas.height = background.height;
    canvas.width = background.width;
    ctx.drawImage(background, 0, 0);
    ctx.font = '32px fantasy';
    ctx.fillStyle = '#FFFFFF';
    ctx.textBaseline = 'center';
    ctx.textAlign = 'center';
    var x = (canvas.width / 2);
    var y = (canvas.height / 2);
    var y = (canvas.height / 2);
    var ix = (icon.width / 2);
    var iy = (icon.height / 2);
    ctx.translate(x, y);
    ctx.rotate(-22 * Math.PI / 180);
    ctx.translate(-x, -y);
    ctx.drawImage(icon, x - ix, y - iy - 40);
    ctx.fillText(text, x, y + 85);
    fs.writeFileSync("./output/magicplank.png", canvas.toBuffer("image/png"));
}

生成されるMagicPlankの画像はこんな感じになります.

3.の処理は,MoralisというWeb3用のミドルウェアを使用します.Moralisは,Dappのバックエンドを実装するための様々な機能をサポートしており,界隈では有名なミドルウェアです.

Moralisを使ったことがない方は以下の説明タブをご覧ください.

Moralisの利用方法

以下のサイトから,Moralisにサインアップします.
https://moralis.io/
ログインできたら,下の画像にしたがってサーバー情報が取得できます.

Moralisをノードプロバイダーとして利用する際は,Web3APIメニューからAPIKeyを取得することができます.

const uploadImage = async(data) => {
    // data from ./output/icon.png
    const base64 = await btoa(fs.readFileSync("./output/magicplank.png"));
    const file = new Moralis.File("magicplank.png", { base64: `data:image/png;base64,${base64}` });
    await file.saveIPFS({ useMasterKey: true });
    console.log("IPFS address of Image: ", file.ipfs());
    return file.ipfs();
}

const getURIonIPFS = async() => {
    const imageURL = await uploadImage();

    const metadata = {
        "name": "DynamicNFT",
        "description": "This is DynamicNFT.",
        "image": imageURL
    }
    
    const file = new Moralis.File("file.json", { base64: btoa(JSON.stringify(metadata)) });
    await file.saveIPFS({ useMasterKey: true });
    console.log("IPFS address of metadata", file.ipfs());
    return file.ipfs();
}

4.の処理を実装したコードがこちらです

// upload the image and metadata on IPFS and encoding the URL, then return it
    const ipfs = await getURIonIPFS();
    const ipfsVar = ipfs.slice(34); // cut off "https://ipfs.moralis.io:2053/ipfs/"
    const len = ipfsVar.length / 2;
    const byte0 = ethers.utils.formatBytes32String(ipfsVar.slice(0, len));
    const byte1 = ethers.utils.formatBytes32String(ipfsVar.slice(len));
    return { "furi": byte0, "luri": byte1, "name": ethers.utils.formatBytes32String(name) }

これらの処理をまとめたコードをgithubに公開しています.
https://github.com/BlueGamma/ExternalAdapter
作成したディレクトリ直下で以下コマンドを実行し,Localhostを立ち上げます.

$ git clone https://github.com/BlueGamma/ExternalAdapter.git
$ npm install

#画像データを出力するための空ファイルを作成します
$ mkdir ./ExternalAdapter/output

$ npm run start

PostmanでPOSTして次のようなデータが得られれば成功です.

4. ChainlinkNodeを構築する ~Node(JOB)編~

本章では,Chainlinkの構築に必要なChainlinkNodeの立ち上げとJobの実装を行います.
解説するコードはgithubに公開しています.
https://github.com/BlueGamma/ChainlinkNode
ChainlinkNodeを立ち上げるためにはPostgreSQLの実行も必要です.
したがって今回は,ChainlinkNode,PostgrSQL,ExternalAdapterの3つをDocker Composeで立ち上げます.
DockerとPostgreSQLの環境構築については以下を参照してください

Docker
PostgrSQL

次のようなフォルダ構成のもとで,run-node内にdocker-compose.ymlを作成します.

.
├── chainlink-node ── run-node ── docker-compose.yml
└── external-adapter 
docker-compose.yml
version: '3'
services:
  pg_chainlink:
    image: "postgres"
    ports:
      - "5434:5434"
    env_file:
      - database.env
    volumes:
      - [PATH_TO_POSTGRES_DATA]:/var/lib/postgressql/data/
  
  chainlink:
    image: "smartcontract/chainlink:1.2.0"
    env_file: .env
    depends_on:
      - pg_chainlink
    ports:
      - "6688:6688"
    volumes:
      - [PATH_TO_REPO_CHAINLINK_VOLUME]:/chainlink/
    command: node start --password /chainlink/password.txt --api /chainlink/apicredentials.txt 
  
  adapter:
    container_name: adapter
    ports:
      - "5000:5000"
    build:
      context: ../ExternalAdapter
      dockerfile: ./Dockerfile
    restart: on-failure
    command: npm start

つぎに,環境変数を設定するためのファイルを作成します.

chainlink-node/run-node/chainlink-volume/apicredentials.txt
YOUR_MAIL_ADDRESS
YOUR_LOGIN_PASSWORD
chainlink-node/run-node/chainlink-volume/password.txt
secret
chainlink-node/run-node/.env
#ETH_CHAIN_ID=4 Rinkeby Testnet
#ETH_CHAIN_ID=42 Kovan Testnet
#ETH_CHAIN_ID=1 Mainnet
#ETH_CHAIN_ID=3 Ropsten Testnet

#LINK_CONTRACT_ADDRESS=Contract where $LINK is located https://ropsten.etherscan.io/address/0x20fE562d797A42Dcb3399062AE9546cd06f63280
#DATABASE_URL=postgresql://$USERNAME:$PASSWORD@$DOCKER_INSTANCE_NAME:5432/$DATABASE?sslmode=disable

ROOT=/chainlink
LOG_LEVEL=debug
MIN_OUTGOING_CONFIRMATIONS=2
ETH_CHAIN_ID=4
LINK_CONTRACT_ADDRESS=0x01BE23585060835E02B77ef475b0Cc51aA1e0709
CHAINLINK_TLS_PORT=0
SECURE_COOKIES=false
GAS_UPDATER_ENABLED=true
FEATURE_FLUX_MONITOR=true
CHAINLINK_DEV=true
ALLOW_ORIGINS=*
DATABASE_URL=postgresql://postgres:secret@pg_chainlink:5432/chainlink?sslmode=disable
ETH_URL=wss://rinkeby.infura.io/ws/v3/734287574c4b493da95a5788ea9cb70d
DATABASE_TIMEOUT=0
chainlink-node/run-node/database.env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=secret
POSTGRES=_DB=chainlink
infuraApiKeyの取得方法

以下のサイトからinfuraにサインアップします.
https://infura.io/
ログインできたら,下の画像にしたがって,ApiKeyが取得できます.

今回はエンドポイントurlを利用するので,Rinkebyネットワークを選択してurlをコピーします.

Mac上のローカルディレクトリを,Dockerで利用できるようにするためには,Preferenceでファイル共有を行う必要があります.
以下の画像にしたがって,postgreSQLと先ほど作成したchainlink-volumeのディレクトリのパスを追加してください.

chainlink postgres databaseを作る必要があるので,Docker Desktopから,下のようにpostgresのCLIに入りデータベースを作成します.

postgrsのCLI内で以下のコマンドを実行してください.

# psql -U postgres -h localhost
postgres=# CREATE DATABASE chainlink;

最後にターミナルでdockerを立ち上げます

$ docker-compose up

下記のローカルホストからChainlink Operatorにアクセスできれば正常に動作しています.
http://localhost:6688/

Chainlink Operatorにログインした後は, まず下の画像の手順でExternalAdapterをChainlinkNodeに登録(ブリッジ)します.


ブリッジ時のパラメータは次のとおりです

Name BridgeURL Minimum Contract Payment Confirmations
generate http://adapter:5000 1 1

つぎに,JOBを設定します.

上の画像の通りに進み,下記のコードを貼り付けてください.

type = "directrequest"
schemaVersion = 1
name = "Get > Byte32"
externalJobID = "1da91556-c78d-4fe8-9184-498f2387f58f"
maxTaskDuration = "0s"
contractAddress = "0x3217a8A47de2EdE4170B6B2045f191386F6Ff4b7"
minIncomingConfirmations = 0
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_opensea [type="http" method=GET url="$(decode_cbor.get)"]

    decode_log -> decode_cbor -> fetch_opensea

    fetch_opensea -> parse_image
    fetch_opensea -> parse_address
    fetch_opensea -> parse_name

    parse_image   [type="jsonparse" path="$(decode_cbor.path_image)" data="$(fetch_opensea)"]
    parse_address [type="jsonparse" path="$(decode_cbor.path_address)" data="$(fetch_opensea)"]
    parse_name    [type="jsonparse" path="$(decode_cbor.path_name)" data="$(fetch_opensea)"]

    parse_image -> generate
    parse_address -> generate
    parse_name -> generate

    generate [type=bridge name="generate"
              requestData="{\\"data\\":{\\"image\\": $(parse_image),\\"address\\":$(parse_address),\\"name\\": $(parse_name)}}"
             ]

    generate -> parse_furi
    generate -> parse_luri
    generate -> parse_bname

    parse_furi  [type="jsonparse" path="data,furi" data="$(generate)"]
    parse_luri  [type="jsonparse" path="data,luri" data="$(generate)"]
    parse_bname [type="jsonparse" path="data,name" data="$(generate)"]

    parse_furi -> encode_data
    parse_luri -> encode_data
    parse_bname -> encode_data

    encode_data [type="ethabiencode"
                 abi="(bytes32 requestId, bytes32 furi, bytes32 luri, bytes32 name)"
                 data="{ \\"requestId\\": $(decode_log.requestId), \\"furi\\": $(parse_furi), \\"luri\\": $(parse_luri), \\"name\\": $(parse_bname) }"
                ]
    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="0x3217a8A47de2EdE4170B6B2045f191386F6Ff4b7" data="$(encode_tx)"]

    encode_data -> encode_tx -> submit_tx
"""

最後に,ChainlinkNodeのアドレスにETHとLINKを送信します.画像手順通りにEVM Chain AccountのRegularアカウントアドレスをコピーし,このアドレスに向けて自身のウォレットからテストネットETHを送信してください.
ChainlinkNodeは,このETHをガス代として消費する代わりに,ノードの利用者からLINKを受け取ります.

5. ChainlinkNodeを構築する ~Oracle編~

本章では,ChainlinkNodeに対するデータの要求・受け取りをオンチェーンで行うOracleコントラクトをデプロイします.
Rimixを開いて,Oracle.solを作成します.
OracleコントラクトはChainlinkのライブラリにフォーマットが存在するので,インポートしてデプロイするだけでOracleを作成できます.以下のように実装します.

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

デプロイする際にコンストラクタに入力する値が必要になりますが,ここには以下の値を入力してください.

  • LINK
    0x01BE23585060835E02B77ef475b0Cc51aA1e0709(rinkeby用のLINKトークンアドレス)
  • owner
    ウォレットのアドレス(公開鍵)

さらに,構築したChainlinkNodeをOracleコントラクトに登録する必要があります.

デプロイしたOperator.solのsetAuthorizedSenders関数に,ChainlinkNodeのアドレスを入力して実行しておいてください.ChainlinkNodeのアドレスは前章の最後と同じ手順で取得できます.

※OracleとOperatorについて

2022/4/1現在,Chainlinkに用意されているOracleコントラクトは,Oracle.solOperator.solの2つが存在します.単純なByteデータの取得を行う場合はどちらを使っても大丈夫なのですが,Oracle.solは一回のリクエストにつきByte32にフォーマットされた1つのみのレスポンスしかサポートしていないので,複数のデータを一度に受け取りたい場合や,Bytes32に収まりきらないデータを取得したい場合には,Operator.solを使う必要があります.

6. OpenSeaのapiを利用する方法 ~番外編~

本章では,OpenSeaのapiを利用して,ユーザーのプロフィールやユーザーが所有するNFTの情報を取得する方法を紹介します.
https://docs.opensea.io/reference/api-overview

上のサイトから,OpenSeaTestnetApiリストのRetrieving assets-Testnetsを選びます.
すると,下のようなページが表示されます. 
下記のフォームにウォレットアドレスを入力して,右上のボタンをクリックすると,所有しているNFTの情報が表示されます.

WebAPIのテストクライアントサービスであるPostmanを使用すると,apiの汎用的なテストをすることができます.

7. Chainlinkからデータを受け取るコントラクトを作る

本章では,Chainlinkからデータを受け取るDynamicNFTのコントラクトを実装します.

筆者の作業実行環境は以下の通りです

  • MacOS Catalina v10.15
  • Visual Studio Code
  • Node.js v16.14.0
  • Node Package Manager(npm) v8.3.1
  • Hardhat

作業するディレクトリ直下で次のコマンドを実行します

# Hardhatと必要なライブラリのインストール
$ npm init -y
$ npm install --save-dev hardhat
$ npm install --save-dev @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-waffle ethereum-waffle chai @openzeppelin/contracts @chainlink/contracts

# Hardhatの起動(起動画面でCreate an empty hardhat.config.jsを選択してください)
$ npx hardhat

# ディレクトリの作成
$ mkdir contracts scripts

Rinkebyに接続するために,hardhat.config.jsを以下のように書き換えます.

hardhat.config.js
const { privateKey, alchemyApiKey } = require("./secrets.json");

require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-ethers");

module.exports = {
  solidity: {
    version: "0.8.4",
    settings:{
      optimizer:{
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    hardhat: {},
    rinkeby: {
      url: "https://eth-rinkeby.alchemyapi.io/v2/" + alchemyApiKey,
      chainId: 4,
      accounts: [ privateKey ]
    }
  }
};

同時にsecrets.jsonも作成してください.

secrets.json
{
  "privateKey": "~",
  "alchemyApiKey": "~"
}
alchemyApiKeyの取得方法

以下のサイトからalchemyにサインアップします.
https://dashboard.alchemyapi.io/signup/chain
ログインできたら,下の画像にしたがって,ApiKeyが取得できます.createapp時にrinkebyネットワークを選択することに注意してください.

セットアップが完了したら,先ほど作ったcontractsディレクトリの下に,MagicPlank.solを作成します.
まずは説明は抜きにして,実装するコードを示します.

MagicPlank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

/**
 * @title MagicPlank
 * @author tomoking
 * @notice ERC721
 */
contract MagicPlank is ERC721URIStorage, ChainlinkClient, Ownable {
    using Chainlink for Chainlink.Request;
    using SafeMath for uint256;
    using Counters for Counters.Counter;
    using Strings for uint256;

    Counters.Counter private _robinCounter;

    //@notice 列挙型グレード
    enum Rarity {STANDARD, EPIC, LEGEND}

    //@notice 会員証ごとの継承者数
    mapping(uint256 => uint256) RobinsToSuccessors;
    //@notice 会員証ごとの継承者名マッピング
    mapping(uint256 => string) Successors; 
    //@notice 会員証ごとのグレード
    mapping(uint256 => Rarity) public tokenIdToRarity;
    mapping(uint256 => address) toAddr;
    mapping(uint256 => string) strToAddr;

    //ユーザーデータ
    string public strData;
    string private _transitionUri;
    string private robinUri;
    string private baseUrl = "https://testnets-api.opensea.io/api/v1/assets?format=json&limit=1&offset=0&order_direction=desc&owner=";

    //Chainlinknode情報
    address private oracle;
    bytes32 private jobId;
    uint256 private fee;

    uint256 private currentRobinId;

    //@title コンストラクタ
    constructor(string memory initialUri_)ERC721("MagicPlank","MP"){
      _transitionUri = initialUri_;
      setPublicChainlinkToken();
      // Oracle address here
      oracle = ORACLE_ADDRESS;
      // Job Id here
      jobId = "JOB_ID";
      fee = 1 * 10 ** 18; 
    }

    /*
    * @title createPlainPlank
    * @notice 会員証のMintとinitialURIの設定
    * @dev RobinIdはCounterを使用
    */
    function createPlainPlank() public {
      //準備
      uint256 _RobinId = _robinCounter.current();
      Rarity initialEigenVal = Rarity(0);
      tokenIdToRarity[_RobinId] = initialEigenVal;
      RobinsToSuccessors[_RobinId] = 0;
      _robinCounter.increment();

      _safeMint(_msgSender(), _RobinId);
      _setTokenURI(_RobinId, _transitionUri);
    }

    /*
    * @title createRobin
    * @notice 会員証のMintと継承
    * @dev RobinIdはCounterを使用
    */
    function createPlank() public {
      //準備
      uint256 _RobinId = _robinCounter.current();
      Rarity initialEigenVal = Rarity(0);
      tokenIdToRarity[_RobinId] = initialEigenVal;
      RobinsToSuccessors[_RobinId] = 1;
      uint256 successorId = RobinsToSuccessors[_RobinId];
      RobinsToSuccessors[_RobinId] = RobinsToSuccessors[_RobinId].add(1);
      _robinCounter.increment();

      _safeMint(_msgSender(), _RobinId);
      _setTokenURI(_RobinId, _transitionUri);
      _change(successorId, _RobinId, _msgSender());
    }

    /*
    * @title Inherit
    * @notice 会員証の継承
    * @param to 継承先のアドレス
    * @param robinId 継承する会員証のId
    * @dev ConsumerApiからprofileを取得して格納
    */
    function Inherit(
      address to,
      uint256 robinId
    ) public {
      uint256 successorId = RobinsToSuccessors[robinId];
      RobinsToSuccessors[robinId] = RobinsToSuccessors[robinId].add(1);

      _setTokenURI(robinId, _transitionUri);
      _change(successorId, robinId, to);
      transferFrom(_msgSender(), to, robinId);
    }

    /*
    * @title _change
    * @notice profileの更新
    * @param successorId 継承者数
    * @param robinId 会員証ID
    * @dev url => _setTokenURI
    *      successor => Successors
    */
    function _change(uint256 successorId, uint256 robinId, address to) private {
      toAddr[robinId] = to;
      currentRobinId = robinId;
      requestData(robinId);
      _grading(robinId, successorId);
    }

    /*
    * @title _grading
    * @notice グレードの判定
    * @dev Successor = 0~2=>STANDARD, 3~9=>EPIC, 10~=>LEGEND
    */
    function _grading(uint256 robinId, uint256 successorId) private {
      if(successorId <= 2){
        tokenIdToRarity[robinId] = Rarity.STANDARD;
      }
      else if(successorId <= 9){
        tokenIdToRarity[robinId] = Rarity.EPIC;
      }
      else {
        tokenIdToRarity[robinId] = Rarity.LEGEND;
      }
    }

    /*
    * @title requestData
    * @notice apiにデータ取得をリクエストする
    * @return requestId 
    * @dev requestの経路 DynamicRountRobin => ChainlinkClient => Oracle
    * => Job(Chainlinknode) => api
    */
    function requestData(uint256 id) public returns (bytes32 requestId) 
    {
        Chainlink.Request memory request = buildChainlinkRequest(jobId, address(this), this.multiFulfill.selector);

        strToAddr[id] = addressToString(toAddr[id]);
        request.add("get", string(abi.encodePacked(baseUrl, strToAddr[id])));
        request.add("path_image", "assets,0,image_url");
        request.add("path_address", "assets,0,owner,address");
        request.add("path_name", "assets,0,owner,user,username");

        return sendChainlinkRequestTo(oracle, request, fee);
    }

    /*
    * @title fulfill
    * @notice apiからデータを受け取ってコントラクトに格納する関数
    * @param _requestId 
    * @param _data bytes32のデータ群
    * @dev ChainlinkClientがこの関数を呼び出すことをbuildChainlinkRequest()で設定
    */
    function fulfill(bytes32 _requestId, bytes32 _data) public recordChainlinkFulfillment(_requestId)
    {
        strData = toString(_data);
    }

    /*
    * @title multiFulfill
    * @notice apiから複数データを受け取ってコントラクトに格納する関数
    * @param _requestId 
    * @param _data bytes32のデータ群
    * @dev ChainlinkClientがこの関数を呼び出すことをbuildChainlinkRequest()で設定
    */
    function multiFulfill(
      bytes32 _requestId, bytes32 _uri1, bytes32 _uri2, bytes32 _username
      ) public recordChainlinkFulfillment(_requestId){
        string memory StrUri1 = toString(_uri1);
        string memory StrUri2 = toString(_uri2);
        Successors[currentRobinId] = toString(_username);
        robinUri = string(abi.encodePacked("https://ipfs.moralis.io:2053/ipfs/", StrUri1, StrUri2));
        _setTokenURI(currentRobinId, robinUri);
    }

    /*
    * @title toString
    * @notice bytes32 => string
    * @param _bytes32 oracleから渡されるbytes32のデータ
    * @return string
    * @dev 引数はマージンが右側のByteコードのみ
    */
    function toString(bytes32 _bytes32) public pure returns (string memory) {
        uint8 i = 0;
        while(i < 32 && _bytes32[i] != 0) {
            i++;
        }
        bytes memory bytesArray = new bytes(i);
        for (i = 0; i < 32 && _bytes32[i] != 0; i++) {
            bytesArray[i] = _bytes32[i];
        }
        return string(bytesArray);
    }

    /*
    * @title addressToString
    * @notice address => string
    * @param _addr 継承先のアドレス
    * @return string apiurlに利用するアドレス文字列
    */
    function addressToString(address _addr) public pure returns(string memory) 
    {
      string memory result = Strings.toHexString(uint256(uint160(_addr)), 20);
      return result;
    }    

    function getSuccessors(uint256 robinId) public view returns(uint256){
      return RobinsToSuccessors[robinId].sub(1);
    }

    function getGrade(uint256 robinId) public view returns(Rarity){
      return tokenIdToRarity[robinId];
    }

    function getSuccessorName(uint256 robinId) public view returns(string memory){
      return Successors[robinId];
    }
}

コンストラクタで設定するOracleアドレスとjobIdにはそれぞれ,5章でデプロイしたオラクルのコントラクトアドレスと,6章でjobを登録した際に表示されるjobIdを入力してください.

MagicPlankがインポートしているライブラリは次の6つになります.

  • ChainlinkClient
    Oracleコントラクトの呼び出しを行うクライアント
  • ERC721URIStorage
    URIを設定できるNFT規格
  • SafeMath
    オーバーフローやアンダーフローをチェックしてくれるライブラリ
  • Strings
    Byteコード⇆文字列の変換を行えるライブラリ
  • Ownable
    所有者の管理を行うためのライブラリ
  • Counters
    回数を数えるためのカウンタライブラリ

つぎに,このMagicPlankのdeployとmintを行うコードをscriptsディレクトリ内に作成します.
deploy.jsとmint.jsを分けている理由は,ChainlinkNodeの利用をするためにはデプロイしたコントラクトにLINKトークンを送る必要があるからです.MagicPlankコントラクトは,ChianlinkNodeにLINKトークンを手数料として支払い,データを取得することができます.

deploy.js
const hre = require("hardhat");

const initialUri = "https://ipfs.moralis.io:2053/ipfs/Qme6DxdoYJLifLgv5bTNBWc23SPRY39HdxuTRVZ2to6ubF/metadata/0000000000000000000000000000000000000000000000000000000000000001.json";

async function main() {
  const factory = await hre.ethers.getContractFactory("MagicPlank");
  const MagicPlank = await factory.deploy(initialUri);
  await MagicPlank.deployed();
  console.log("NFT deployed to:", MagicPlank.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
mint.js
//main.jsの実行前にDynamicRoundRobinにLINKトークン(継承あたり1link)を送ること!!

const contractAddr = "YOUR_CONTRACT_ADDRESS";

async function main() {
  const factory = await ethers.getContractFactory("MagicPlank");
  const MagicPlank = await factory.attach(contractAddr);
  console.log("NFT Deployed to:", MagicPlank.address);
  const MintTx = await MagicPlank.createPlainPlank();
  await MintTx.wait();
  await MintTx1.wait();
  const uri = await MagicPlank.tokenURI(0);
  console.log(uri);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

これらのファイルはgithubからダウンロードすることも可能です.
https://github.com/BlueGamma/MagicPlank

一連のコードを作成したら次のコマンドでdeploy.jsを実行します.

$ npx hardhat run scripts/deploy.js --network rinkeby

成功すると,デプロイされたコントラクトのアドレスが表示されるので,

  • そのアドレスに向けて自身のウォレットからLINKトークンを送信
  • mint.jsのcontractAddr変数にアドレスを入力

を行ってください

テストネットLINKの入手方法

以下のサイトでウォレットを接続し,NetworkからRinkebyを選んでLINKトークンを取得できます.
https://faucets.chain.link/

LINKトークンが送信されたのを確認して,mint.jsを実行します

$ npx hardhat run scripts/mint.js --network rinkeby

しばらく経つと,テストネットのOpenSeaにMagicPlankが表示されていることを確認できます.

8. まとめ

これで,DynamicNFTの実装は以上となります.お疲れ様でした.
ChainlinkやDynamicNFTの実装にあたり,このブログが参考になったと感じて頂けた方は,ぜひ"いいね"とTwitterのフォローをお願いします.日々,DynamicNFTやブロックチェーンに関する発信を行っています.

https://twitter.com/allegory_write/status/1510443253254193154?s=20&t=cUrAUZJGCu8wiBWfiqztxg

ちなみに,今回実装したMagicPlankのコントラクトでは,継承する関数とOpenSeaの取引時に呼び出される関数が異なるので,OpenSeaでの売買に連動して画像が変わることはありませんが,以下のようにERC721の_beforeTokenTransferをOverrideして機能追加すれば,Tranferに連動した画像変更を実装することができます.余力がある方は,実装してみるとUXが圧倒的に変わると思います.

/*
* @title _beforeTokenTransfer
* @notice OpenseaのSell連動用
* @param from 継承元のアドレス
* @param to 継承先のアドレス
* @param robinId 継承する会員証のId
* @dev Mint時には実行されない
*/
function _beforeTokenTransfer (
  address from,
  address to,
  uint256 robinId
) internal virtual override {
  if(from != address(0)){
    super._beforeTokenTransfer(from, to, robinId);
    uint256 successorId = RobinsToSuccessors[robinId];
    RobinsToSuccessors[robinId] = RobinsToSuccessors[robinId].add(1);

    _setTokenURI(robinId, _transitionUri);
    _change(successorId, robinId, to);
  }
}

Discussion

koheikohei

こちらの記事、とても参考になりました。ありがとうございます。
chainlinkのrinkeby testnetが稼働してないので、もしお時間あれば、更新していただけますと、読者層の幅が広がるかもしれません。