はじめに
NFTの「メタデータ」を意識したことはありますか?
メタデータとは「データのデータ」といわれるもので、NFTにおいては名前や説明、そして多くの場合、その NFTを保有する人に与えられるコンテンツ(画像や動画)への参照先が含まれます。
いわば、NFTの「納品証」のようなものであり、NFTを買うということはメタデータを買うといっても過言ではありません。
そして、メタデータがどのように取り扱われているかによって、そのNFTへの印象が大きく変わってしまうことすらあります。
この記事では、独自のNFT(スマートコントラクト)を作成する際に、どのようなメタデータの取り扱い方があるかを書いてみます。
また、メタデータの提供を外部(ファイルサーバーやIPFS)に頼るのではなく、NFTの内部で済ますやり方についても説明します。
想定するチェーンは Ethereum/Polygonとし、NFTを閲覧する環境としてOpenSeaを使うものとします。
メタデータの扱い方
独自のNFT(スマートコントラクト)を作成する場合、メタデータの取り扱い方には大きく分けて下記の3パターンがあります。
・ファイルサーバー上に保存する
・IPFS上に保存する
・NFT上に保存する
パターン①:メタデータをファイルサーバー上に保存する
メタデータが S3等のファイルサーバーにおかれているパターンです。
そして、Web3原理主義者からの評価が低くなりがちなパターンでもあります(個人的な感想ですが)。
なぜかと言いますと、ファイルサーバー上に置かれたメタデータの場合、その内容が変更される可能性を否定できないからです。たとえば、メタデータの内容が書き換えられることで NFTの内容が変わってしまったり、ファイルサーバーが停止することでメタデータそのものが読み込めなくなるリスクがあります。
とくに最近は、こんなものまで登場してきました(IPFSにデータがおかれないと減点となるNFTのスカウター)。一点モノのアート作品等、高額なNFTの売買において、ファイルサーバーにメタデータを置く形式に対する視線は厳しくなると思われます。
とは言え、修正が効くという特性をいかして、メタデータの内容が変化することを想定したNFT(たとえばDappゲームにおけるキャラクタ等)での採用は一考の余地があると思います。
パターン②:メタデータをIPFS上に保存する
メタデータが、IPFSにおかれているパターンです。
パターン①とは違い、Web3原理主義者もニッコリなパターンです(あくまで個人の感想です)。
なぜニッコリかと言いますと、IPFSへアップロードされたファイルに割り当てられる「ハッシュ値」に理由があります。IPFS上のファイルのハッシュ値は同じ内容であれば必ず同じ値になります。一方で、ほんの少しでも内容が変わるとハッシュ値も変化してしまいます。
この特性のおかげで、IPFSのハッシュ値で参照されるメタデータはその内容が変わらないことが保証されます。NFTの価値を証明するという点で、メタデータがIPFSに置かれている方が好まれるのはこのためです。
パターン③:メタデータをNFT上に保存する
ここまでに説明したパターン①、②のどちらも「外部」ファイルを参照する形式でした。
この仕組みに対応するのが、NFTのプログラム(スマートコントラクト)の tokenURIという処理になります。
例えば、パターン①のNFTの場合、tokenURI は下記のような値を返します。
https://hakumai-iida.s3-ap-northeast-1.amazonaws.com/fcic/json/meta_100.json
パターン②の場合も同様に下記のような値を返します。
ipfs://QmbU7U7HZHDttXsH7MGEX8nWRjFDy7yKUznVz1JxSCHBvw
NFTが「私の詳細はココで確認してくれ」と、住所の書かれた紙を差し出すイメージです。
tokenURI という名前からすると当然のように感じられますね。
ところが、tokenURIはなにも「参照先」だけを返す処理ではありません。
「私の価値はこれだ!」とメタデータそのものを返してしまうこともできるのです。
tokenURIにおけるメタデータの返し方
では実際に、ERC721トークンの solidityコードを見ながら説明します。
先述の通り、NFTのメタデータの情報は tokenURIによって返されます。
この処理からメタデータの内容そのものを返す処理のサンプルが下記となります。
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.7 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// https://github.com/zlayine/epic-game-buildspace/blob/master/contracts/libraries/Base64.sol
import "./Base64.sol";
// サンプルトークン
contract Token is ERC721 {
// NFTが保存するデータ
string[] private _names;
string[] private _descriptions;
string[] private _words;
constructor() ERC721( "Meta Data Create Token 1", "MDCT1" ) {}
// トークンの発行
function mintToken( string calldata name, string calldata description, string calldata word ) external {
//② // トークンの発行:[_names.length]をIDとして利用(0はじめの連番となる)
uint256 tokenId = _names.length;
_safeMint( msg.sender, tokenId );
//① // データの保存(これがメタデータの作成に利用される)
_names.push( name );
_descriptions.push( description );
_words.push( word );
}
//③// メタデータを作成して返す
function tokenURI( uint256 tokenId ) public view override returns (string memory) {
require( _exists( tokenId ), "nonexsitent token" );
//④ // name要素の作成
bytes memory bytesName = abi.encodePacked(
'"name":"', _names[tokenId], '"'
);
//⑤ // description要素の作成
bytes memory bytesDesc = abi.encodePacked(
'"description":"', _descriptions[tokenId], '"'
);
//⑦ // image要素の作成:SVG要素をByte64エンコードしてコンテンツタイプの指定
bytes memory bytesImage = abi.encodePacked(
'"image":"data:image/svg+xml;base64,',
Base64.encode( _createSVG( tokenId ) ),
'"'
);
//⑧ /// jsonオブジェクトの作成
bytes memory bytesObject = abi.encodePacked(
'{',
bytesName, ',',
bytesDesc, ',',
bytesImage,
'}'
);
//⑨ // jsonオブジェクトをBase64エンコードしてコンテンツタイプの指定
bytes memory bytesMetadata = abi.encodePacked(
'data:application/json;base64,',
Base64.encode( bytesObject )
);
// 文字列として返す
return( string( bytesMetadata ) );
}
//⑥// SVGデータの作成
function _createSVG( uint256 tokenId ) internal view returns (bytes memory) {
// wordの部分を作っておく
bytes memory bytesWord = abi.encodePacked(
'<text x="175" y="290" text-anchor="middle" class="f">',
_words[tokenId],
'</text>'
);
// SVGとしてまとめる
bytes memory bytesSVG = abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350">',
'<style> .f { font-family: serif; font-size:300px; fill:000000;} </style>',
'<rect x="0" y="0" width="350" height="350" fill="#000000" />',
'<rect x="10" y="10" width="330" height="330" fill="#ffffff" />',
bytesWord,
'</svg>'
);
return( bytesSVG );
}
}
NFTとしてはとてもシンプルです。
NFTをミントする際、引数で渡された、名前(name)、説明(description)、文字(word)を保存します(コメント①の部分)。
補足として、トークンIDは0はじめの連番となり、そのまま各データ配列の添字としてアクセスができます(コメント②の部分)。
そして、tokenURI では、tokenIdで指定される NFTのデータを利用して、メタデータを作成して返却するといった感じです(コメント③の部分)。
この時、メタデータとして返したい形式は、下記のようなjsonファイルとなります。
{
"name":"ミント時にわたされた名前",
"description":"ミント時に渡された説明",
"image":"data:image/svg+xml;base64,ミント時に渡された言葉を含む画像"
}
まず、名前(name)と説明(description)の要素の文字列を作成します(コメント④と⑤の部分)。
solidityの文字列処理は少し面倒ですが、abi.encodePackedにより、文字列をえっちらおっちら結合していくことになります。
ポイントとなるのが、画像(image)の取り扱いです。
このサンプルコードではミント時に渡された文字(word)を SVGで表示しようと思います。
そのため、文字を表示する SVGとして下記のような XMLもコード側で作成することになります。
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350">
<style>
.f { font-family: serif; font-size:300px; fill:000000;}
</style>
<rect x="0" y="0" width="350" height="350" fill="#000000" />
<rect x="10" y="10" width="330" height="330" fill="#ffffff" />
<text x="175" y="290" text-anchor="middle" class="f">ミント時に渡された文字</text>
</svg>
この SVGの生成は createSVGにより行います(コメント⑥の部分)。
そして、ポイントとなるのが、SVGの内部にあるダブルクォート等が json要素に対して干渉しないように base64エンコードして image要素の文字列へ結合している点です(コメント⑦の部分)。
で、最後に、jsonオブジェクトとして3つの要素を合体させたあと(コメント⑧の部分)、再度、全体をbase64エンコードしています(コメント⑨の部分)。
さて、2回目の base64エンコードですが、一見不要そうに見えます。
実際、base64エンコードをせずに、**"data:application/json;charset=UTF-8"**のコンテンツタイプでメタデータを返却しただけで、Rinkeby環境では問題なく画像が表示されました。が、MumbaiとPolygon環境では、base64エンコードをしないと正常に画像が表示されませんでした。
おそらくですが、Ethereumのメインネットであれば、Rinkebyと同様、最後の base64エンコードをせずとも正常に表示されると思われるのですが、Ethereum環境でのテストはガス代が高くて試せていないため、ここは用心に base64をしています。
さて、これでめでたく、スマートコントラクトの完成です。
Rinkeby環境にデプロイしてコードをベリファイ済みですので、誰でも mintTokenからトークンの発行のテストができます。
https://rinkeby.etherscan.io/address/0x6bEE30f938F8EB392D1632445A31e22c2ba61eB9#code
また、NFTの表示は OpenSeaにて確認できます。
https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/0
https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/1
これにて、ファイルサーバーも IPFSも不要、フルオンチェーン NFTの完成です!
実践編
サンプルだと味気ないので、もう少し実践的なNFTも作成してみました。
16x16サイズのドット絵のデータを、作成者のアドレスとともにNFTが保持する内容となります。
テストサイト:Pixel Work 4-bit
OpenSeaのコレクション:OpenSea(Polygon)
画像データをスマートコントラクトへ直接送るため、IPFS等にアップロードする必要がなく実装がとてもシンプルです。
同様に、画像のデータをスマートコントラクトから直接受け取れるため、NFTの閲覧も可能となります。
メタデータをNFT側で管理することで、フロント+スマコンという最小構成で、色々できそうな気がしてきませんか?
githubにてソースを公開しておりますので興味のある方はご覧ください。
https://github.com/hakumai-iida/PixelWork4bit
おわりに
最近は、メタデータのあり方にまで踏み込んで、NFTの価値を語る人が増えてきた印象を受けます。
それと同時に、ファイルサーバーへアップした画像の NFTなどは、見向きもされなくなってきた印象もあります。
(実際に、ファイルサーバーへアップした NFTがこちらですが、最近パタリと売れなくなりました...)
独自NFT(スマートコントラクト)を作ろうと思っている方、特に画像等のコンテンツの見た目で勝負しようと思っている方。
ファイルサーバーではなく IPFSへメタデータをアップロードするのが NFTの価値づけ的におすすめだと思います。
そして、もし、コンテンツを表現するデータが少なくてすみそうな場合は、メタデータを作成するという選択肢も検討してみてはいかがでしょうか?
NFTが認知されるなかで、メタデータのあり方にこだわりを感じる人たちも出てくるはずです。
全ての情報を NFTに含めることで、そういった人たちの需要を狙うのも面白いと思います。
補足:base64エンコードされたデータの確認の仕方
実際にスマートコントラクトを開発していると、OpenSeaで画像がうまく表示されないなんてことは日常茶飯事です。
(そして情けないことに、原因のほとんどはダブルクォートかカンマの指定ミス...)
そんな時は、メタデータの内容を見直すことになるのですが、base64エンコードされていると、なかなか確認がしづらいものです。
ここでは補足として、base64エンコードされたメタデータの確認手順を記載しておきます。
tokenURIの出力結果の確認
truffleで直接 tokenURIをたたいたり、etherscanの Read Contract等からtokenURIの値を確認します。
例えば、下記のような値が帰ってきます。
data:application/json;base64,eyJuYW1lIjoiVGVzdCBORlQgIzAwMSIsImRlc2NyaXB0aW9uIjoiQSB0b2tlbiBmb3IgbWV0YWRhdGEgY3JlYXRpb24gc2FtcGxlIiwiaW1hZ2UiOiJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSEJ5WlhObGNuWmxRWE53WldOMFVtRjBhVzg5SW5oTmFXNVpUV2x1SUcxbFpYUWlJSFpwWlhkQ2IzZzlJakFnTUNBek5UQWdNelV3SWo0OGMzUjViR1UrSUM1bUlIc2dabTl1ZEMxbVlXMXBiSGs2SUhObGNtbG1PeUJtYjI1MExYTnBlbVU2TXpBd2NIZzdJR1pwYkd3Nk1EQXdNREF3TzMwZ1BDOXpkSGxzWlQ0OGNtVmpkQ0I0UFNJd0lpQjVQU0l3SWlCM2FXUjBhRDBpTXpVd0lpQm9aV2xuYUhROUlqTTFNQ0lnWm1sc2JEMGlJekF3TURBd01DSWdMejQ4Y21WamRDQjRQU0l4TUNJZ2VUMGlNVEFpSUhkcFpIUm9QU0l6TXpBaUlHaGxhV2RvZEQwaU16TXdJaUJtYVd4c1BTSWpabVptWm1abUlpQXZQangwWlhoMElIZzlJakUzTlNJZ2VUMGlNamt3SWlCMFpYaDBMV0Z1WTJodmNqMGliV2xrWkd4bElpQmpiR0Z6Y3owaVppSStVVHd2ZEdWNGRENDhMM04yWno0PSJ9
エンコードされたメタデータのデコード
取得した URIの先頭の data:application/json;base64, から後ろの文字列を全て選択してコピーして、base64デコードサイト等でデコードして内容を確認します。
例えば、上記の値の場合は下記のようにデコードされます。
{"name":"Test NFT #001","description":"A token for metadata creation sample","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+IC5mIHsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6MzAwcHg7IGZpbGw6MDAwMDAwO30gPC9zdHlsZT48cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMzUwIiBoZWlnaHQ9IjM1MCIgZmlsbD0iIzAwMDAwMCIgLz48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIzMzAiIGhlaWdodD0iMzMwIiBmaWxsPSIjZmZmZmZmIiAvPjx0ZXh0IHg9IjE3NSIgeT0iMjkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBjbGFzcz0iZiI+UTwvdGV4dD48L3N2Zz4="}
デコードされたメタデータのパース
デコードされたメタデータが jsonのフォーマットとして正常かどうかを、jsonのパースサイト等で確認します。
例えば、上記の値は下記のようにパースされます。
{
"name":"Test NFT #001",
"description":"A token for metadata creation sample",
"image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+IC5mIHsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6MzAwcHg7IGZpbGw6MDAwMDAwO30gPC9zdHlsZT48cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMzUwIiBoZWlnaHQ9IjM1MCIgZmlsbD0iIzAwMDAwMCIgLz48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIzMzAiIGhlaWdodD0iMzMwIiBmaWxsPSIjZmZmZmZmIiAvPjx0ZXh0IHg9IjE3NSIgeT0iMjkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBjbGFzcz0iZiI+UTwvdGV4dD48L3N2Zz4="
}
仮に、パースに失敗した場合は、どこが悪いかを確認して修正しましょう。
コンテンツ内容のデコード
メタデータの json形式に問題がない場合、コンテンツの内容(この例では SVG)の中身を確認しましょう。
メタデータの image要素の、**data:image/svg+xml;base64,**から後ろの内容を選択してコピーし、再度、base64デコードサイト等でデコードします。
上記の例だと下記のような出力になります。
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350"><style> .f { font-family: serif; font-size:300px; fill:000000;} </style><rect x="0" y="0" width="350" height="350" fill="#000000" /><rect x="10" y="10" width="330" height="330" fill="#ffffff" /><text x="175" y="290" text-anchor="middle" class="f">Q</text></svg>
このデータを、ビューワー等で確認し、正常に表示されるか確認することになります。
SVGであれば、単純にテキストとしてファイルに貼り付け、Chrome等で表示させてみれば、エラーの有無がわかります。
ほんの些細なミスだとしても、不整合があるとOpenSeaでは画像等が表示されません。
「あれ、おかしいな」と思ったら、慌てず騒がず、tokenURIの内容を調べましょう。
だいたいどこかに、ダブルクォートの重複やら、カンマの指定し忘れが潜んでいいるものです。