はじめに
近年、Web3.0が注目を集めていますが、なかでもNFT(Non-Fungible Token:非代替性トークン)の活用が多くみられています。
※ここでは、ブロックチェーンを基盤とする分散型Web1をWeb3.0と整理します。
NFTはブロックチェーンを用いて取引を記録するため、流通状況を可視化、追跡することができます。
NFTにはさまざまなデータを紐づけることができ、物理資産やデジタル資産を表現したり、その価値を取引したりといった用途に適しています。また、個々にIDが割り振られるため、唯一性のあるデータとして扱うことができ、一点モノのデジタルアートやコンテンツの表現などにも活用されています。
実装内容
今回は、NFTのコントラクトやウェブサイトでの実装方法、ブロックチェーン上での挙動を簡易的に検証するため、発行・流通機能をもつ最小構成のNFTを作成し、運営側からユーザーへ、あるいはユーザーの間でNFTを転送できるウェブサイトを実装します。
利用イメージ
簡易的なECサイトを作成し、商品購入時に付属して得られるNFTを送り合うようなケースを考えます。
- 商品を選択し、購入する
- 購入した際にNFTを受け取り、誰かに送る、あるいは貯める
- 宛先のアドレスを選択し、NFTを送付する
NFTは事前に発行しており、ウェブサイト運営者が初期保有者となっています。
現在よくみられているユースケースでは、MetaMaskのような自己管理型ウォレットを利用してアドレスや秘密鍵を管理し、トランザクションに署名しています。
一方、自己管理型ウォレットやブロックチェーンを基盤としたサービスの利用には秘密鍵の理解やウォレットの扱い方に関する知識などが求められるのが現状です。
そのため、今後サービスが拡大する場合には、暗号資産交換業者が提供するCeFi(Centralized Finance:中央集権型金融)のような形式と同様に、暗号資産カストディ事業者のようなサービス提供者がユーザーの秘密鍵管理を代行するケースも残ると想定されます。
今回のウェブサイトでは、運営者が秘密鍵を管理し、ユーザーは従来のECサイトと変わりなく直感的な操作によって利用できるようなイメージで作成しました。
また、本検証では、自己管理型ウォレットを利用する場合と運営者が秘密鍵を管理する場合について、実装方法の違いにも触れています。
技術スタック
ウェブサイトのフロントエンドにはNext.js(React.jsを利用したウェブアプリフレームワーク)、Ethereumとの接続にはethers.jsというライブラリを利用します。
ウェブサイトはMicrosoft Azureのサービスの一つであるApp Serviceを利用し、Node.js環境でホストします。
また、EthereumのスマートコントラクトにはSolidityという言語を利用します。NFTなどのトークンは規格化されており、OpenZeppelinというライブラリを利用することで共通化された機能を実装できます。
スマートコントラクトのPoCにはEthereumのテストネットワークが利用できます。ローカルでテストする場合にはDApp開発用フレームワークであるTruffleやGanacheが提供するプライベートテストネット、外部のウェブサーバなどから接続する場合にはパブリックテストネット(Goerli)を利用します。
開発環境
開発に用いたPCやパッケージのバージョン情報は以下の通りです。
- OS: macOS Monterey (Version 12.1)
- Processor: 2 GHz Quad-Core Intel Core i5
$ node -v
v16.13.2
$ truffle version
Truffle v5.6.3 (core: 5.6.3)
Ganache v7.4.4
Solidity v0.5.16 (solc-js)
Node v16.13.2
Web3.js v1.7.4
$ yarn list --pattern ethers
yarn list v1.22.15
├─ @ethersproject/abi@5.7.0
├─ (中略)
└─ ethers@5.7.2
スマートコントラクト
Ethereum上にデプロイし、NFTを発行・流通するコントラクトを作成します。
準備
TruffleはEVM(Ethereum Virtual Machine)を使った開発環境、パイプラインなどを提供します。コントラクトを含むプロジェクトの管理やコンパイル、EthereumへのデプロイはTruffleを用いて行います。
// if not installed
$ npm i -g truffle
$ mkdir nft_sample && cd nft_sample
$ truffle init
Starting init...
================
> Copying project files to /path/to/nft_sample
Init successful, sweet!
Try our scaffold commands to get started:
$ truffle create contract YourContractName # scaffold a contract
$ truffle create test YourTestName # scaffold a test
http://trufflesuite.com/docs
truffle init
では最低限のディレクトリとファイルしか生成されないため、実際に動くコントラクトを作成するには以下のファイルが必要になります。
- contracts/<contract>.sol:コントラクト本体
- migrations/<migration>.js:ブロックチェーンにコントラクトを載せる処理
- test/:テストコード JS/TS/SOL/etc.(今回は省略)
コントラクトの作成
NFTを扱うためのコントラクトを作成します。ここでは、SampleTokenという名前でコントラクトの型枠を用意しています。
$ truffle create contract SampleToken
ERC721として規格化されているため、OpenZeppelinのパッケージを導入することで基本的な機能が実装できます。
$ npm i @openzeppelin/contracts
本体のコントラクトは以下の通りです。SampleTokenコントラクトは、OpenZeppelinが提供するパッケージに含まれるERC721URIStorageコントラクトを継承して作成します。
OpenZeppelinのERC721に関するパッケージはCoreとExtensionに分割して実装されています。
NFTの所有数や所有者などのクエリ、発行(mint)、転送(transfer)などの基本的な機能はCoreとして提供されるERC721コントラクトに実装されています。TokenURIの設定や焼却(burn)の有効化に関しては、Extensionとして提供される各種コントラクトを利用します。
Extensionに含まれるコントラクトはERC721コントラクトを継承するため、単体でも利用できます。ここで用いるERC721URIStorageはTokenURIを設定するための機能をもつコントラクトです。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract SampleToken is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
constructor() ERC721("SampleToken", "SMPL") {}
function createItem(address creator, string memory tokenURI)
public
returns (uint256)
{
uint256 newItemId = _tokenIds.current();
_mint(creator, newItemId);
_setTokenURI(newItemId, tokenURI);
_tokenIds.increment();
return newItemId;
}
}
creator
のアドレス宛にNFTを発行(mint)します。それぞれのNFTには固有のIDを割り当てており、tokenURI
をセットすることでNFTにデータを紐付けています。
上記は最も原理的なNFTの仕組みですが、tokenURIはブロックチェーン上に記録される一方、tokenURI
が指し示すデータ本体は外部ストレージなどで管理されているケースが多くみられています。
もともとブロックチェーンは大規模ストレージの用途を想定したものではなく、データサイズが大きくなればノード間の転送や複製に時間がかかったり、トランザクション記録のためのガス代が高くなったりといった問題が生じます。
しかし、データを外部に出すことによって、その中身が外部ストレージの管理者や利用者によって変更(改ざん)される可能性も指摘され、オンプレミスやクラウドではなく、IPFS(InterPlanetary File System)などの分散ストレージが用いるケースもあります。
コントラクトのマイグレーション
動作検証はローカルテストネットやパブリックテストネットを用いて行います。本検証では、ローカルテストネットは Ganache というソフトウェアで管理します。パブリックテストネットにはInfuraが提供するノードを経由して接続できます。
テストネット構築にはGethも利用できますが、常にブロックを生成し続けるため、PCに負荷がかかります。一方、Ganacheはトランザクション生成時のみブロックを作成するため、低負荷で利用できます。
コントラクトのデプロイ先はtruffle-config.jsで管理されています。必要に応じ、パッケージ(dotenv, @truffle/hdwallet-provider)や環境変数(.env ファイル)の用意が必要です。
require('dotenv').config();
const { MNEMONIC, PROJECT_ID } = process.env;
const HDWalletProvider = require('@truffle/hdwallet-provider');
module.exports = {
networks: {
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 7545, // Standard Ethereum port (default: none)
network_id: "*", // Any network (default: none)
},
goerli: {
provider: () => new HDWalletProvider(MNEMONIC, `https://goerli.infura.io/v3/${PROJECT_ID}`),
network_id: 5, // Goerli's id
confirmations: 2, // # of confirmations to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
}
},
(中略)
};
マイグレーション(ブロックチェーンへのアップロード)を実行するためのコードは以下の通りです。
var contract = artifacts.require("SampleToken");
module.exports = function(deployer) {
deployer.deploy(contract);
};
事前にコンパイルしていない場合、マイグレーション時に自動でコンパイルされます。
$ truffle migrate
Compiling your contracts...
===========================
> Compiling ./contracts/SampleToken.sol. Attempt #1
> Compiling @openzeppelin/contracts/token/ERC721/ERC721.sol
> Compiling @openzeppelin/contracts/token/ERC721/IERC721.sol
> Compiling @openzeppelin/contracts/token/ERC721/IERC721Receiver.sol
> Compiling @openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol
> Compiling @openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol
> Compiling @openzeppelin/contracts/utils/Address.sol
> Compiling @openzeppelin/contracts/utils/Context.sol
> Compiling @openzeppelin/contracts/utils/Counters.sol
> Compiling @openzeppelin/contracts/utils/Strings.sol
> Compiling @openzeppelin/contracts/utils/introspection/ERC165.sol
> Compiling @openzeppelin/contracts/utils/introspection/IERC165.sol
> Artifacts written to /Users/aita/Dev/Solidity/SampleToken/build/contracts
> Compiled successfully using:
- solc: 0.8.17+commit.8df45f5f.Emscripten.clang
⠸ Fetching solc version list from solc-bin. Attempt #1
⠼ Fetching solc version list from solc-bin. Attempt #1
Starting migrations.... Attempt #1.
======================
> Network name: 'development'
> Network id: 5777
> Block gas limit: 6721975 (0x6691b7)
1_deploy_contract.js
====================
⠙ Fetching solc version list from solc-bin. Attempt #1
Deploying 'SampleToken'pt #1.
--------------------------
> transaction hash: 0xd3ddb530bbb5b2d4ebd60171e7106e34c9f837ddd5935123690115e5e4fc5c39
> Blocks: 0 Seconds: 0
> contract address: 0x04b8DbfDE83b2eD0bd0AE22475bd83b55Cf57c85
> block number: 1
> block timestamp: 1667264177
> account: 0x7CCf94d929c12CbF51d5eb4f5f063474833e984a
> balance: 99.9513466
> gas used: 2432670 (0x251e9e)
> gas price: 20 gwei
> value sent: 0 ETH
> total cost: 0.0486534 ETH
> Saving artifacts
-------------------------------------
> Total cost: 0.0486534 ETH
Summary
=======
> Total deployments: 1
> Final cost: 0.0486534 ETH
マイグレーション先のネットワークはデフォルトで development
が選択されます。それ以外のネットワークにデプロイする場合は --network
オプションで指定します。
ウェブサイトの作成
ウェブサイト(DApp)開発において、ブロックチェーンとの連携には以下に示す二つのオープンソースパッケージが主に用いられています。
- web3.js(Ethereum財団が開発)
- ethers.js(Ethers社が開発)
web3.jsがよく利用されてきましたが、直近のダウンロード数2はweb3.jsがおよそ50万、ethers.jsがおよそ90万となっており、ethers.jsの利用が拡大しています。
コントラクトとの接続
コントラクトに対して適したアクセスを行うため、ABI(Application Binary Interface)を読み込む必要があります。
※ABIとは、ソフトウェアの動作の互換性をバイナリレベルで保証するためのインターフェースを指し、スマートコントラクトに実装されている関数の名前や必要な引数などが記述されている。
ABIはコントラクトのコンパイル時にJSON形式で自動生成されます。
import artifact from 'path/to/build/contracts/SampleToken.json'
外部ウォレットを利用する場合
Metamaskなどの外部ウォレットを利用する場合は、ethers.providers.Web3Provider()
を利用します。
引数には window.ethereum
を指定しますが、Next.jsをSSR3で動かす場合はサーバに window
オブジェクトが準備されないため、useEffect
によってクライアントで処理し、ステートとして渡しています。4
また、eth_requestAccounts
によって、外部ウォレットに対してウェブサイトと接続するためのリクエストが送信されます。
一度リクエストを承認すると、ウェブサイトが外部ウォレットに登録され、アドレスを取得したりトランザクションの署名リクエストが送信できたりといった操作が可能になります。
import { ethers } from 'ethers';
import type { NextPage } from 'next';
import { useEffect, useState } from 'react';
const Home: NextPage = () => {
const [windowEthereum, setWindowEthereum] = useState();
useEffect(() => {
const { ethereum } = window as any;
setWindowEthereum(ethereum);
}, []);
if (windowEthereum) {
const provider = new ethers.providers.Web3Provider(windowEthereum);
// MetaMask requires requesting permission to connect users accounts
provider.send('eth_requestAccounts', []).then(console.log);
const signer = provider.getSigner();
const contract = new ethers.Contract(
contractAddress,
artifact.abi,
provider
);
const contractWithSigner = contract.connect(signer);
}
}
web3.jsでは、コントラクトのインスタンスを作成するだけでトランザクションの準備ができましたが、ethers.jsでは connect()
を用いてコントラクトとウォレットを接続する必要があります。
ethers.Wallet を利用する場合
外部ウォレットではなく、ウェブサイトにウォレット機能をもたせることも可能です。その場合、秘密鍵を引数に渡してウォレットのインスタンスを初期化します。
RPCのURLは利用するテストネットに応じて書き分けます。Goerliテストネットに接続する場合は、Infuraのノードを指定します。
// Network endpoint of Ganache (local testnet)
const rpcURL = 'http://localhost:7545'
// Network endpoint of Infura (Goerli testnet)
const rpcURL = 'https://goerli.infura.io/v3/<API_KEY>'
const provider = new ethers.providers.JsonRpcProvider(rpcURL);
const wallet = new ethers.Wallet('<private key>', provider);
const contract = new ethers.Contract(
contractAddress,
artifact.abi,
wallet.provider
);
const contractWithSigner = contract.connect(wallet);
コントラクトとの通信
条件に応じ、利用するコントラクトのインスタンスは異なります。
-
contractWithSigner
:データの書込が発生(アカウントによるトランザクションの署名、ガス代の支払いが必要) -
contract
:データの参照のみ(クエリの利用はガス代不要)
ABIを読み込んでいるため、コントラクトインスタンスの functions
で呼び出す関数を指定できます。
また、複数の引数を取る関数に関しては、次項の方法で実装する必要があります。
const addressA = '0xaaa'
const addressB = '0xbbb'
// Query:
// Get balance of NFT
const balance = await contract.functions.balanceof(addressA)
// Execute:
// Mint NFT (owner: addressA)
await contract.functions.createItem(addressA, '<Token URI>')
// Transfer NFT
await contract['safeTransferFrom(address,address,uint256)'](
addressA,
addressB,
tokenId
);
※非同期処理(Promise)が必要です。
オーバーライドへの対応
NFTの保有者を変更する関数である safeTransferFrom
は、引数の取り方が複数あります。
safeTransferFrom(from, to, tokenId)
safeTransferFrom(from, to, tokenId, _data)
このように、同じ関数名に対して複数の引数の取り方がある場合、下記のようなエラーメッセージが表示されます。
const addressA = '0xaaa';
const addressB = '0xbbb';
const tokenId = 0;
await contract.functions.safeTransferFrom(addressA, addressB, tokenId);
TypeError: contract.functions.safeTransferFrom is not a function
この場合は、関数を連想配列の名前として指定することで、正しく呼び出すことができます。5
await contract['safeTransferFrom(address,address,uint256)'](
addressA,
addressB,
tokenId
);
ウェブサイトのデプロイ
App Serviceの実行環境
- インスタンス詳細
- 名前:<custom>.azurewebsites.net
- 公開:コード
- ランタイムスタック:Node 16 LTS
- App Service プラン
- SKU とサイズ:Basic B1
デプロイ
App Serviceでは、GitHub Actionsを用いた継続的デプロイが利用できます。
継続的デプロイを有効化し、GitHubアカウントへの接続を許可すると組織、リポジトリ、ブランチを選択できます。
この設定を行うことで、指定したブランチにプッシュした際にApp Serviceに自動デプロイされるようになります。
その他の項目はデフォルト通りです。
GitHub Actions経由でデプロイした場合、ディレクトリのサイズによって非常に時間がかかることがあります(数十分〜)。
パス起因のエラー
GitHub ActionsではデプロイOKとなっていても、ページに接続するとApplication Errorとなる場合があります。
App Serviceの「問題の診断と解決」にてアプリケーションログにて、エラーメッセージを確認できます。
2022-10-31T03:37:42.964193824Z yarn run start
2022-10-31T03:37:45.662339916Z yarn run v1.17.3
2022-10-31T03:37:46.033872906Z $ next start
2022-10-31T03:37:46.693486409Z node:internal/modules/cjs/loader:936
2022-10-31T03:37:46.693530009Z throw err;
2022-10-31T03:37:46.693536809Z ^
2022-10-31T03:37:46.693541509Z
2022-10-31T03:37:46.693545809Z Error: Cannot find module '../build/output/log'
2022-10-31T03:37:46.693550509Z Require stack:
2022-10-31T03:37:46.693554909Z - /home/site/wwwroot/node_modules/.bin/next
2022-10-31T03:37:46.693559509Z at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
2022-10-31T03:37:46.693576909Z at Function.Module._load (node:internal/modules/cjs/loader:778:27)
2022-10-31T03:37:46.693581709Z at Module.require (node:internal/modules/cjs/loader:1005:19)
2022-10-31T03:37:46.693585709Z at require (node:internal/modules/cjs/helpers:102:18)
2022-10-31T03:37:46.693589609Z at Object.<anonymous> (/home/site/wwwroot/node_modules/.bin/next:3:35)
2022-10-31T03:37:46.693594209Z at Module._compile (node:internal/modules/cjs/loader:1103:14)
2022-10-31T03:37:46.693598209Z at Object.Module._extensions..js (node:internal/modules/cjs/loader:1157:10)
2022-10-31T03:37:46.693602209Z at Module.load (node:internal/modules/cjs/loader:981:32)
2022-10-31T03:37:46.693606110Z at Function.Module._load (node:internal/modules/cjs/loader:822:12)
2022-10-31T03:37:46.693610110Z at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12) {
2022-10-31T03:37:46.693614210Z code: 'MODULE_NOT_FOUND',
2022-10-31T03:37:46.693618210Z requireStack: [ '/home/site/wwwroot/node_modules/.bin/next' ]
2022-10-31T03:37:46.693622210Z }
2022-10-31T03:37:46.766468463Z error Command failed with exit code 1.
2022-10-31T03:37:46.767672773Z info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Cannot find module '../build/output/log'
により、起動できないようです。
この場合、node_modules
から直接呼び出す形式にすると解決できます。6 7
{
..,
"scripts": {
"dev": "next dev",
"build": "next build",
- "start": "next start"
+ "start": "node_modules/next/dist/bin/next start"
},
}
実装内容の詳細は GitHub のリポジトリをご確認ください。
参考
検証用のWebアプリは以下のフレームワークやソースコードを参考に作成しました。
- Ethers.js
- Truffle | Overview - Truffle Suite
- ERC721 - OpenZeppelin Docs
- React - A JavaScript library for building user interfaces
- Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.
- 【Solidity / React】シンプルな dapps を作ってみる
-
【先端技術リサーチ】Web3.0トレンドを俯瞰する ~ブロックチェーン技術が実現する次世代のインターネット~(https://www.jri.co.jp/page.jsp?id=103308) ↩
-
2023年1月4日〜10日のパッケージダウンロード数(https://www.npmjs.com) ↩
-
従来のJavaScriptはクライアント(ブラウザ)側で処理した内容を描画する形式であるのに対し、サーバ側で描画まで行う形式のこと。Server Side Renderingの略。 ↩
-
https://forum.openzeppelin.com/t/safetransferfrom-is-not-a-function/27137 ↩