【真のNFT!?】メタデータを作って返す【フルオンチェーン】

## はじめに
NFTの「メタデータ」を意識したことはありますか？
メタデータとは「データのデータ」といわれるもので、NFTにおいては名前や説明、そして多くの場合、その NFTを保有する人に与えられるコンテンツ(画像や動画)への参照先が含まれます。

いわば、NFTの「納品証」のようなものであり、NFTを買うということはメタデータを買うといっても過言ではありません。
そして、メタデータがどのように取り扱われているかによって、そのNFTへの印象が大きく変わってしまうことすらあります。

この記事では、独自のNFT(スマートコントラクト)を作成する際に、どのようなメタデータの取り扱い方があるかを書いてみます。
また、メタデータの提供を外部(ファイルサーバーやIPFS)に頼るのではなく、NFTの内部で済ますやり方についても説明します。
想定するチェーンは Ethereum/Polygonとし、NFTを閲覧する環境としてOpenSeaを使うものとします。

## メタデータの扱い方
独自のNFT(スマートコントラクト)を作成する場合、メタデータの取り扱い方には大きく分けて下記の３パターンがあります。
・ファイルサーバー上に保存する
・IPFS上に保存する
・NFT上に保存する

### パターン①：メタデータをファイルサーバー上に保存する
メタデータが S3等のファイルサーバーにおかれているパターンです。
そして、Web3原理主義者からの評価が低くなりがちなパターンでもあります（個人的な感想ですが）。

なぜかと言いますと、ファイルサーバー上に置かれたメタデータの場合、その内容が変更される可能性を否定できないからです。たとえば、メタデータの内容が書き換えられることで NFTの内容が変わってしまったり、ファイルサーバーが停止することでメタデータそのものが読み込めなくなるリスクがあります。

とくに最近は、[こんなもの](https://chrome.google.com/webstore/detail/nifty-scanner/eoepooeledpjaoehhokhalpmlmjbedhe)まで登場してきました(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**によって返されます。
この処理からメタデータの内容そのものを返す処理のサンプルが下記となります。

``` solidity
// 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として利用（０はじめの連番となる）
        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は０はじめの連番となり、そのまま各データ配列の添字としてアクセスができます（コメント②の部分）。

そして、tokenURI では、tokenIdで指定される NFTのデータを利用して、メタデータを作成して返却するといった感じです(コメント③の部分)。
この時、メタデータとして返したい形式は、下記のようなjsonファイルとなります。

``` json
{
  "name":"ミント時にわたされた名前",
  "description":"ミント時に渡された説明",
  "image":"data:image/svg+xml;base64,ミント時に渡された言葉を含む画像"
}
```

まず、名前(name)と説明(description)の要素の文字列を作成します（コメント④と⑤の部分）。
solidityの文字列処理は少し面倒ですが、**abi.encodePacked**により、文字列をえっちらおっちら結合していくことになります。

ポイントとなるのが、画像(image)の取り扱いです。
このサンプルコードではミント時に渡された文字(word)を SVGで表示しようと思います。
そのため、文字を表示する SVGとして下記のような XMLもコード側で作成することになります。

``` 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オブジェクトとして３つの要素を合体させたあと（コメント⑧の部分)、再度、全体をbase64エンコードしています(コメント⑨の部分)。

さて、２回目の 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](https://rinkeby.etherscan.io/address/0x6bEE30f938F8EB392D1632445A31e22c2ba61eB9#code)

また、NFTの表示は OpenSeaにて確認できます。
[https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/0](https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/0)
[https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/1](https://testnets.opensea.io/assets/0x6bee30f938f8eb392d1632445a31e22c2ba61eb9/1)

これにて、ファイルサーバーも IPFSも不要、フルオンチェーン NFTの完成です！


## 実践編
サンプルだと味気ないので、もう少し実践的なNFTも作成してみました。
16x16サイズのドット絵のデータを、作成者のアドレスとともにNFTが保持する内容となります。

テストサイト：[Pixel Work 4-bit](https://pw4b.hakumai-dev.com/)
OpenSeaのコレクション：[OpenSea(Polygon)](https://opensea.io/collection/pixel-work-4-bit)

画像データをスマートコントラクトへ直接送るため、IPFS等にアップロードする必要がなく実装がとてもシンプルです。
同様に、画像のデータをスマートコントラクトから直接受け取れるため、NFTの閲覧も可能となります。

メタデータをNFT側で管理することで、フロント＋スマコンという最小構成で、色々できそうな気がしてきませんか？
githubにてソースを公開しておりますので興味のある方はご覧ください。
[https://github.com/hakumai-iida/PixelWork4bit](https://github.com/hakumai-iida/PixelWork4bit)

## おわりに
最近は、メタデータのあり方にまで踏み込んで、NFTの価値を語る人が増えてきた印象を受けます。
それと同時に、ファイルサーバーへアップした画像の NFTなどは、見向きもされなくなってきた印象もあります。
（実際に、ファイルサーバーへアップした NFTが[こちら](https://qiita.com/hakumai-iida/items/c5d1c47499bbfe5d2b30)ですが、最近パタリと売れなくなりました...）

独自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デコードサイト](https://www.en-pc.jp/tech/base64.php)等でデコードして内容を確認します。
例えば、上記の値の場合は下記のようにデコードされます。

```
{"name":"Test NFT #001","description":"A token for metadata creation sample","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+IC5mIHsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6MzAwcHg7IGZpbGw6MDAwMDAwO30gPC9zdHlsZT48cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMzUwIiBoZWlnaHQ9IjM1MCIgZmlsbD0iIzAwMDAwMCIgLz48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIzMzAiIGhlaWdodD0iMzMwIiBmaWxsPSIjZmZmZmZmIiAvPjx0ZXh0IHg9IjE3NSIgeT0iMjkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBjbGFzcz0iZiI+UTwvdGV4dD48L3N2Zz4="}
```

### デコードされたメタデータのパース
デコードされたメタデータが jsonのフォーマットとして正常かどうかを、[jsonのパースサイト](http://json.parser.online.fr/)等で確認します。
例えば、上記の値は下記のようにパースされます。

``` 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デコードサイト](https://www.en-pc.jp/tech/base64.php)等でデコードします。

上記の例だと下記のような出力になります。

``` 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">Q</text></svg>
```

このデータを、ビューワー等で確認し、正常に表示されるか確認することになります。
SVGであれば、単純にテキストとしてファイルに貼り付け、Chrome等で表示させてみれば、エラーの有無がわかります。

ほんの些細なミスだとしても、不整合があるとOpenSeaでは画像等が表示されません。
「あれ、おかしいな」と思ったら、慌てず騒がず、**tokenURI**の内容を調べましょう。

だいたいどこかに、ダブルクォートの重複やら、カンマの指定し忘れが潜んでいいるものです。