🎨

Ethereum,Solidityを使用して基本的なNFTを作成する手順を理解する

2023/01/19に公開

こんにちは、巷では NFT の話題が盛り上がっては落ち着いて、また盛り上がっては...ということを繰り返す毎日ですね。

いろんなところが NFT を作成して、配布したりしているのをよく見ます。この記事ではそんな NFT を、できるだけシンプルな形で作成する手順について書きたいと思います。

そもそも NFT とは

詳しい説明は割愛しますが、NFT とは Non-Fungible Token の略称であり、日本語では非代替性トークンと訳されています。

NFT は

  • 同じトークンが存在しない
  • 分割ができない

という性質を持ち、その性質からアート、会員権などの 1 点ものをデジタルで表現したいときに利用されるケースがほとんどです。

Ethereum では ERC721 という規格が定められており、NFT 発行者はこの規格が定めたインターフェースをスマートコントラクト[1]として実装する必要があります。

今回はそんな NFT を使って、デジタルアートを NFT の売買プラットフォームである Opensea にリストする、という想定でスマートコントラクト作ってみたいと思います。

作成してみる

前述の通り、Ethereum で NFT を作成する場合は ERC721 が定めるインターフェースを実装することが必要です。それに加えて今回は慣例的に実装されているものも含めて解説していきます。

開発環境

NFT のみならず、スマートコントラクトを使った開発をする場合、まずは自身のローカル環境に開発できる環境を整える必要があります。

今回は Ethereum でのスマートコントラクト開発のツールセットである hardhatを利用したいと思います。

まず Node.js がインストールされているか確認してください。バージョンは現状 LTS である 18 がインストールされていれば問題ありません。

次に任意のフォルダで npm プロジェクトを初期化します(yarn を使う方は適当に読み替えてください)

npm init --yes
Wrote to /Users/shell/contract/package.json:

{
  "name": "contract",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

project local で利用する hardhat をインストールします

npm install --save-dev hardhat

hardhat プロジェクトを初期化します。今回は TypeScript プロジェクトを選択するとします。プロンプトの質問には全て Y で答えます。

npx hardhat

888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.12.5 👷‍

? What do you want to do? …
  Create a JavaScript project
❯ Create a TypeScript project
  Create an empty hardhat.config.js
  Quit


✔ What do you want to do? · Create a TypeScript project
✔ Hardhat project root: · /Users/shell/Desktop/contract
✔ Do you want to add a .gitignore? (Y/n) · Y
✔ Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)? (Y/n) · Y

依存パッケージのインストール後、npx hardhat test を実行しサンプルのコードのテストが Pass したら準備は完了です。

npx hardhat test
Compiled 1 Solidity file successfully


  Lock
    Deployment
      ✔ Should set the right unlockTime (1403ms)
      ✔ Should set the right owner
      ✔ Should receive and store the funds to lock
      ✔ Should fail if the unlockTime is not in the future
    Withdrawals
      Validations
        ✔ Should revert with the right error if called too soon
        ✔ Should revert with the right error if called from another account
        ✔ Shouldn't fail if the unlockTime has arrived and the owner calls it
      Events
        ✔ Should emit an event on withdrawals
      Transfers
        ✔ Should transfer the funds to the owner


  9 passing (1s)

フォルダ構成は以下のようになると思います。

tree -L 2 -I node_modules/
.
├── README.md
├── artifacts
│   ├── build-info
│   └── contracts
├── cache
│   └── solidity-files-cache.json
├── contracts
│   └── Lock.sol
├── hardhat.config.ts
├── package-lock.json
├── package.json
├── scripts
│   └── deploy.ts
├── test
│   └── Lock.ts
├── tsconfig.json
└── typechain-types
    ├── Lock.ts
    ├── common.ts
    ├── factories
    ├── hardhat.d.ts
    └── index.ts

とりあえず、コントラクトを書くために必要なところは

  • contracts
    • solidity のファイルを配置する
  • test
    • solidity のテストを配置する
  • scripts
    • デプロイやコントラクトを呼び出す処理を書くスクリプトを配置する

です。今回はプロジェクトを作成した時点ですでにサンプル(Lock.sol, Lock.ts, deploy.ts)がありますが、これらは削除してしまって構いません。

コントラクトは全て自分で書く必要はなく @openzeppelin/contracts からひな形を持ってきて、継承してきて必要に応じてカスタマイズするのが良いかなと思います。
@openzeppelin/contracts は前述した ERC721 などの Ethereum の規格に沿った形でコントラクトを実装し、パッケージとして提供してくれています。

npm install @openzeppelin/contracts

実際に書いてみる

さて、ここまできたら NFT のためのコントラクトを書いていきましょう。contracts/ ディレクトリに Token.sol を作成し、このように書いてみます

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyToken is ERC721 {
    constructor() ERC721("My NFT", "MYNFT") {}
}

OpenZeppelin から ERC721 を継承した contract を作成しています。しかしこれだけでは足りない要素があるので、ここに追加していきましょう。

ここから NFT を実際に OpenSea にリストするまでに必要なことはなんでしょうか?まず大きく分けて箇条書きにしてみます。

  • NFT の発行ができること
  • 画像、タイトルなどの NFT を構成するデータが読み書きできること
  • 誰でも NFT を発行できないようにするアクセス制限ができること
  • NFT 作成者などに支払うロイヤリティを設定できること

このあたりが一般的な NFT のコントラクトには必要そうです。1 つずつ見ていきましょう。

NFT の発行ができること

当然ですが必要です。発行時には最低限必要なものとして token の ID を指定する必要があります。
これは発行された NFT ごとにユニークなものにしましょう。簡単な方法としては連番でつくるのがシンプルだと思います。

画像、タイトルなどの NFT を構成するデータが読み書きできること

Opensea などでリストされている NFT は画像やタイトル、その他の情報が記載されてます。これらの情報は tokenURI(uint256 _tokenId) (string) メソッドから返し、JSON 形式である必要があります。
JSON のどのようなプロパティが Opensea 上でどのように表示されるかは https://docs.opensea.io/docs/metadata-standards を参照してみてください。

誰でも NFT を発行できないようにするアクセス制限ができること

コントラクトはなにも制限をしなければ誰からでもメソッドを呼び出すことができてしまいます。勝手に誰でもコレクションを増やせてしまうと困るので、特定の、例えばコントラクトのオーナーに指定されたアドレスからのみ実行できる関数などがあったほうが便利そうです。

NFT 作成者などに支払うロイヤリティを設定できること

ERC2981 という、クリエーターの NFT の二次流通時のロイヤリティの還元方法に関する仕様があり ERC721 と組み合わせて使うことができます。
実装できるインターフェースとしてはロイヤリティの額、率などを伝えるだけであり、この仕様を踏襲したからと言って必ずロイヤリティが回収できるわけではないのですが、Opensea など主要な NFT マーケットプレイスはこのインターフェースをサポートしているため、ロイヤリティを回収することができます。

これらを踏まえて実際のコントラクトを書いていきましょう。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyToken is ERC721 {
    uint256 private constant _START_TOKEN_ID = 1;

    uint256 public totalSupply = 0;

    constructor() ERC721("My NFT", "MYNFT") {}

    function mint(
        uint256 tokenID_,
        address address_
    ) external {
        require(tokenID_ >= _START_TOKEN_ID, "invalid token id");

        unchecked {
            ++totalSupply;
        }

        _safeMint(address_, tokenID_);
    }
}

まずは NFT を発行できる関数を追加しました。ID は 1 が最小で連番になる想定、totalSupply で総発行枚数を記録しておきましょう。

unchecked をつけるとアンダーフロー,オーバーフローをする可能性がありますが、単純な加算だけの場合 gas が節約できるため、つけておきます。

そして _safeMint が実際に NFT を発行するメソッドですね。実装は OpenZeppelin 側にあり、今回は呼び出すだけにしています。

次に画像、タイトルなどの NFT を構成するデータが読み書きできることを実現しましょう。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyToken is ERC721 {
    using Strings for uint256;

    uint256 private constant _START_TOKEN_ID = 1;

    uint256 public totalSupply = 0;

    string private _baseTokenURI = "https://example.com/token/";

    mapping(uint256 => string) private _tokenURIs;

    constructor() ERC721("My NFT", "MYNFT") {}

    function mint(
        uint256 tokenID_,
        address address_
    ) external {
        require(tokenID_ >= _START_TOKEN_ID, "invalid token id");

        unchecked {
            ++totalSupply;
        }

        _safeMint(address_, tokenID_);
    }

    function tokenURI(
        uint256 tokenID_
    ) public view override returns (string memory) {
        _requireMinted(tokenID_);

        string memory uri = _tokenURIs[tokenID_];

        if (bytes(uri).length > 0) {
            return uri;
        }

        return
            string(
                abi.encodePacked(_baseTokenURI, tokenID_.toString(), ".json")
            );
    }

    function setBaseTokenURI(
        string calldata newBaseTokenURI_
    ) external {
        _baseTokenURI = newBaseTokenURI_;
    }

    function setTokenURI(
        uint256 tokenID_,
        string calldata uri_
    ) external {
        _requireMinted(tokenID_);

        _tokenURIs[tokenID_] = uri_;
    }
}

sedTokenURI で tokenURI を設定できるようにしてみました。今回は mint 時ではなく、mint したあとに tokenURI をセットする想定です。

さて、ここまでで NFT の発行、 tokenURI の設定はできましたが、どちらのメソッドも特に制限はしておらず、デプロイ後はだれでも呼び出せる関数になってしまっています。
ですので、ここでアクセス制限をつけましょう

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyToken is ERC721, Ownable {
    using Strings for uint256;

    uint256 private constant _START_TOKEN_ID = 1;

    uint256 public totalSupply = 0;

    string private _baseTokenURI = "https://example.com/token/";

    mapping(uint256 => string) private _tokenURIs;

    constructor() ERC721("My NFT", "MYNFT") {}

    function mint(
        uint256 tokenID_,
        address address_
    ) external onlyOwner {
        require(tokenID_ >= _START_TOKEN_ID, "invalid token id");

        unchecked {
            ++totalSupply;
        }

        _safeMint(address_, tokenID_);
    }

    function tokenURI(
        uint256 tokenID_
    ) public view override returns (string memory) {
        _requireMinted(tokenID_);

        string memory uri = _tokenURIs[tokenID_];

        if (bytes(uri).length > 0) {
            return uri;
        }

        return
            string(
                abi.encodePacked(_baseTokenURI, tokenID_.toString(), ".json")
            );
    }

    function setBaseTokenURI(
        string calldata newBaseTokenURI_
    ) external onlyOwner {
        _baseTokenURI = newBaseTokenURI_;
    }

    function setTokenURI(
        uint256 tokenID_,
        string calldata uri_
    ) external onlyOwner {
        _requireMinted(tokenID_);

        _tokenURIs[tokenID_] = uri_;
    }
}

といってもやることは単純で、 OpenZeppelin にある便利な Ownable モジュールを持ってきて、owner のみが実行したいものに onlyOwner modifier をつけましょう。
誰が owner になるのか?というとデフォルトではデプロイしたアドレスが owner となっています。

さて、最後にロイヤリティを設定します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";


contract MyToken is ERC721, Ownable {
    using Strings for uint256;

    uint256 private constant _START_TOKEN_ID = 1;

    uint256 public totalSupply = 0;

    string private _baseTokenURI = "https://example.com/token/";

    mapping(uint256 => string) private _tokenURIs;

    constructor() ERC721("My NFT", "MYNFT") {
        _setDefaultRoyalty(owner(), 0);
    }

    function mint(
        uint256 tokenID_,
        address address_
    ) external onlyOwner {
        require(tokenID_ >= _START_TOKEN_ID, "invalid token id");

        unchecked {
            ++totalSupply;
        }

        _safeMint(address_, tokenID_);
    }

    function tokenURI(
        uint256 tokenID_
    ) public view override returns (string memory) {
        _requireMinted(tokenID_);

        string memory uri = _tokenURIs[tokenID_];

        if (bytes(uri).length > 0) {
            return uri;
        }

        return
            string(
                abi.encodePacked(_baseTokenURI, tokenID_.toString(), ".json")
            );
    }

    function setBaseTokenURI(
        string calldata newBaseTokenURI_
    ) external onlyOwner {
        _baseTokenURI = newBaseTokenURI_;
    }

    function setTokenURI(
        uint256 tokenID_,
        string calldata uri_
    ) external onlyOwner {
        _requireMinted(tokenID_);

        _tokenURIs[tokenID_] = uri_;
    }

    function supportsInterface(
        bytes4 interfaceID_
    ) public view override(ERC721, ERC2981) returns (bool) {
        return super.supportsInterface(interfaceID_);
    }

    function royaltyInfo(
        uint256 tokenID_,
        uint256 salePrice_
    ) public view override returns (address, uint256) {
        _requireMinted(tokenID_);

        return super.royaltyInfo(tokenID_, salePrice_);
    }

    function setDefaultRoyalty(
        address receiver_,
        uint96 feeNumerator_
    ) external onlyOwner {
        _setDefaultRoyalty(receiver_, feeNumerator_);
    }
}

コンストラクタで最初のロイヤリティは 0 に設定しつつ、あとで設定できるようにしておきました。これを実装しておけば Opensea 上でもロイヤリティが確認できます。receiver がロイヤリティを受け取るアドレスですが、こちらは簡単のために owner になっていますが、実際では NFT のクリエイターなどを設定するケースもあるかなと思います。

デプロイしてみる

さて、これで基本的なところは書くことができました。なのでここで一旦デプロイをしてみましょう。

Hardhat を利用している場合のコントラクトのデプロイ方法としては scripts/ 以下にスクリプトを配置するか、hardhat.config.ts などに task として定義するか、があります。今回は前者でやってみましょう。scripts/ 以下に deploy.ts という名前でファイルを作り、以下のように書きます

import { ethers } from "hardhat";

async function main() {
  const [owner] = await ethers.getSigners();
  const Token = await ethers.getContractFactory("MyToken");
  const token = await Token.deploy();

  await token.deployed();

  console.log(`My NFT is deployed to ${token.address}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

デプロイだけでしたら、これだけで大丈夫です。ここまでかけたら npx hardhat run scripts/deploy.ts を実行してみましょう。必要に応じてネットワークを goerli などに指定すれば指定したネットワーク上にデプロイすることができます。

あると良さそうな実装

さて、これで一通りの NFT としての実装を完了することができました。最後に少しだけ NFT にあると良いと思う実装について紹介します。

Token URI を固定(Freeze)させる

Token URI は前述したとおり、発行した NFT ごとに設定します。しかしこれは NFT を発行した後も owner は自由に Token URI を設定できてしまうため、所有者からすると自身の持っているはずの NFT のメタデータが突然変わってしまうリスクがあります。これを防ぐために、Token URI は開発後、固定し、二度と変更できないようにすると良さそうです。実装としては以下のようなものを追加します

mapping(uint256 => bool) private _isTokenURIFrozens;

event PermanentURI(string uri, uint256 indexed tokenID);

function freezeTokenURI(
    uint256 tokenID_,
    string calldata uri_
) external onlyOwner {
    _requireMinted(tokenID_);
    _requireTokenURINotFrozen(tokenID_);

    _tokenURIs[tokenID_] = uri_;
    _isTokenURIFrozens[tokenID_] = true;

    emit PermanentURI(uri_, tokenID_);
}

function _requireTokenURINotFrozen(uint256 tokenID_) private view {
    require(
        !_isTokenURIFrozens[tokenID_],
        "My Token: token URI frozen"
    );
}

Token ID ごとに Token URI が固定されているかを保持しておき、固定されるタイミングで PermanentURI イベントを発行します。このイベントは Opensea 側に Token URI が固定されたことを知らせることができ、それによって Opensea 上でも Token URI が固定されていることが表示されます。https://docs.opensea.io/docs/metadata-standards#freezing-metadata

Token URI 以外にも Royalty なども固定できる機構があると購入者側からしても更に安心かなと思います。

間違って送信された資産を返却できるようにする

コントラクトも独自のアドレスをもつため、やろうと思えばそのアドレスに対して Ether を送金することができます。しかし当たり前ですがコントラクトには送金機能などはつけていないため、その送金された Ether をどうすることもできません。何らかのミスで誰かがコントラクトに送金してしまった場合、返してあげられるようにしておくと親切かもしれません。

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

function forwardERC20s(IERC20 token_, uint256 amount_) public onlyOwner {
    require(address(msg.sender) != address(0));
    token_.transfer(msg.sender, amount_);
}

さて、これで一通りの実装ができたので、晴れて Opensea へリストすることができます。 NFT は意外と考慮する点がいくつかありますが、ポイントさえ抑えておけば理解しやすいのかなと思います。誰かのお役に立てれば幸いです。

脚注
  1. スマートコントラクトについては https://gaiax-blockchain.com/smart-contract などがよくまとまっており、こちらをご参照ください。 ↩︎

Discussion