NFTアートのDappを作る

最近,NFT アートが高値をつけて少し盛り上がりを見せていましたが,実際に内部ではどのようなことが行われているのかパッと見ではわかりにくいように感じたので,作ってみることにしました.

NFT

NFT (Non-Fungible Token) は一般的に非代替性トークンと呼ばれ,世界で唯一のものをシステム的に記述したいときに利用されます.

同じ額面であれば同じ価値を持つ紙幣などが代替性を持つのに対し,不動産やアートなどはそれぞれ異なる価値を持つため,代替できません.

このような資産をブロックチェーン上で表現したいときに用いられるのが NFT です.

NFT に資産自体や所有者等の情報を紐づけておくことで,ブロックチェーンによって情報の正しさを保証することができます.

ブロックチェーンによって実装方法は異なってくると思いますが,今回はおそらく最もメジャーな Ethereum ブロックチェーン上で扱うことのできる ERC721 形式のトークンで実装してみます.

JSON

ERC721 には tokenURI を設定でき,JSON 形式で持たせたデータを結びつけられるようになっています.

デジタルアート作品であれば,紐付けたいデータには以下のようなものが考えられます.

  • デジタルアートの作品名
  • 説明
  • 作成者アドレス
  • タイムスタンプ

Constructing an ERC721 Token Contract | OpenZeppelin Docs

IPFS

IPFS (Inter Planetary File System) は P2P ネットワーク上に構築された分散型ファイルシステムで,耐障害性や耐改竄性がブロックチェーンとの相性が良いということでしばしば利用されています.

従来より利用されてきたロケーション指向型プロトコル,すなわち URL によるアクセスはサーバ故障による接続不良やコンテンツの書き換えが発生する可能性を含んでいました.

これに対し,IPFS はコンテンツ指向型といわれ,コンテンツのハッシュであるコンテンツ識別子 (CID: Contents IDentifier) による直接のアクセスを可能にするプロトコルです,

開発環境構築

Truffle

コントラクトのソースコードのコンパイルやテストには Truffle を用います.

terminal

$ npm i -g truffle

ローカルブロックチェーン

テストネットにアップロードしても良いですが,手元で実行してみるのがベストなので Ganache でローカルチェーンを立ち上げます.

Ganache | Truffle Suite

Contract

terminal

$ truffle init
// scaffold
$ truffle create contract Tokenxart

$ npm i @openzeppelin/contracts

実装

pragma solidity >=0.4.22 <0.9.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract Tokenxart is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    constructor() ERC721("Tokenxart", "TXR") {}

    function registerWork(address creator, string memory tokenURI)
        public
        returns (uint256)
    {
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        _safeMint(creator, newTokenId);
        _setTokenURI(newTokenId, tokenURI);

        return newTokenId;
    }
}

Migration

コントラクトを反映するにはマイグレーションが必要です.

2_deploy_tokenxart.js

const Tokenxart = artifacts.require('Tokenxart')

module.exports = function (deployer) {
  deployer.deploy(Tokenxart)
}

Configuration

Ganache ではポート 7545 がデフォルトのため,設定を変更しておきます.

truffle-config.js

module.exports = {
  networks: {
    development: {
      host: '127.0.0.1',
      port: 7545,
      network_id: '*',
    },
  },
  compilers: {
    solc: {
      version: '^0.8.0',
    },
  },
}

Test

script

テスト用スクリプトは JS や Solidity で書けます.対象外の拡張子ファイルは無視されます.

test/tokenxart.js

const Tokenxart = artifacts.require('Tokenxart')
const testURI = 'https://example.com/test.json'

contract('Tokenxart', (accounts) => {
  it('should get the same URI in contract', async () => {
    const instance = await Tokenxart.deployed()
    console.log(`creator: ${accounts[0]}`)
    await instance.registerWork(accounts[0], testURI)
    const tokenURI = await instance.tokenURI.call(1)
    assert.equal(tokenURI, testURI, `tokenURI should be ${testURI}`)
  })
  it('should be 1 token if minted', async () => {
    const instance = await Tokenxart.deployed()
    const balance = (await instance.balanceOf.call(accounts[0])).toNumber()
    assert.equal(balance.valueOf(), 1, 'Balance of token should be 1')
  })
  it('should be 3 tokens', async () => {
    const instance = await Tokenxart.deployed()
    await instance.registerWork(accounts[0], testURI)
    await instance.registerWork(accounts[0], testURI)
    const count = (await instance.tokenId.call()).toNumber()
    assert.equal(count.valueOf(), 3, 'Balance of token should be 1')
  })
})

こんな感じで動作をテストできます.

Ganache 起動

$ truffle migrate
$ truffle test

マイグレーションを忘れると,以下のエラーが出力されます.

Error: Tokenxart has not been deployed to detected network (network/artifact mismatch)

Web3

環境構築

ブラウザで Ethereum を扱うには Metamask 等のウォレットがインストールされている環境を用意する必要があります.

ウォレットがインストールされている場合は,Web3.js を用いてウォレットを操作し,いわゆる DApps を動かせます.

また,画像を直接ブロックチェーンに載せるのは大変コストがかかるため,JS IPFS に格納することにし,CID で紐づけることにします.

terminal

$ yarn add web3
$ yarn add ipfs-core

実装

ここではサンプルとして Web3.js と JS IPFS を利用し,NFT を発行するようなコードを記述しています.

import TokenxartJSON from 'path/to/tokenxart/build/contracts/Tokenxart.json';

const IPFS = require('ipfs-core');
const Web3 = require('web3');

// Connect to Metamask
const web3 = new Web3(Web3.givenProvider || 'ws://localhost:7545');
const accounts = await web3.eth.getAccounts();

// Upload an image to IPFS
const ipfs = await IPFS.create();
const { cid } = await ipfs.add(imageBlob);

// Connect to contract
const ABI = TokenxartJSON.abi;
const CONTRACT_ADDRESS = process.env.contractAddress;
const contract = new web3.eth.Contract(ABI,
  CONTRACT_ADDRESS,
  {
    from: accounts[0],
  }
);

// Mint NFT
try {
  const result = await contract.methods
    .registerWork(accounts[0], 'https://example.com/tokenXXX.json')
    .send();
  const tokenId = result.events.Transfer.returnValues.tokenId;
  console.log(tokenId);
} catch (error) {
  switch (error.code) {
    case 4001:
      // Reject signing the tx in Metamask
      this.errorMessages.push(error.message)
      this.isActiveLink = true
      break;
    default:
      console.error(error);
  }
}

JSON ファイルはコントラクトをコンパイルしたときに生成されたものを使用しています.コントラクトと接続するにあたり,対応する ABI (Application Binary Interface) を json interface として入力する必要があります.

また,画像は事前に Base64 形式に変換しており,Blob として IPFS に格納しています(Blob にすると容量は大きくなってしまう…).

Deploy to public test network

Rinkeby テストネットワークにデプロイする場合は以下のような設定を用意し,マイグレーションコマンドを実行します.

(Infura を使用する場合は,上記ソースコードのままでは使用できません)

デプロイしたコントラクト:etherscan.io

truffle-config.js

require('dotenv').config()

const HDWalletProvider = require("@truffle/hdwallet-provider");
const infuraKey = process.env.INFURA;
const mnemonic = process.env.MNEMONIC;

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 7545,
      network_id: "*",
    },
    rinkeby: {
      provider: () =>
        new HDWalletProvider(
          mnemonic,
          `wss://rinkeby.infura.io/ws/v3/${infuraKey}`
        ),
      network_id: 4,
      gas: 3000000,
      gasPrice: 10000000000, //10Gwei
      confirmations: 2,
      timeoutBlocks: 200,
      skipDryRun: false,
    },
  },
  compilers: {
    solc: {
      version: "^0.8.0",
    },
  },
  db: {
    enabled: false,
  },
};

terminal

$ truffle migrate --network rinkeby

Infura利用上の注意

自分でノードを建てない場合は Infura の提供している JSON RPC API を使用すると思いますが,Infura では eth_sendTransaction メッセージが提供されておらず,eth_sendRawTransaction を使用しなければなりません.

Web3.js でトランザクションを生成すると前者が使用されるため,このままで Infura に投げるとエラーが返ります.

例えば,ethereumjs-tx パッケージで秘密鍵を用いた署名済みトランザクションを生成し,eth_sendRawTransaction すれば利用できます.

作ったもの

最後に

NFT はデータをトークンに載せているものと思っていましたが,URI が登録されているだけでデータ本体はホスティングや分散サーバに保存するというのが最初に想定された使い方でした.

データをトークンに載せるとデータ容量の問題とか手数料の問題とかあったのかもしれませんが,分散サーバはまだしも独自運営のサーバに置いているだけであればそのデータが書き換えられてしまうと意味がないのでは?とも思ったり(データのハッシュをトークンに書き込むとか?).

NFT アートに始まり,思ったより NFT の活用は増えているようです.人間が作ったデジタルアートだけでなく,SVG 形式で自動生成する Generative NFT というのもあるそうです.

データ領域を節約するという意味では自動生成したパラメータを渡して画像にするようなものも考えられるかもしれません.

Infura を使えばパブリックのテストネットを簡単に使用できるかと思いましたが,残念ながらそうでもありませんでした.

参考

実行環境

NFT

IPFS

Web3.js

Infura