【Solidity】SBTの作り方|Hardhatを用いた譲渡不可なNFTの開発手順について解説

どうも、イーサリアムnavi運営のでりおてんちょーです。

今回は、開発記事として「Hardhatを用いたSBT(譲渡不可なNFT)の作成方法」について解説していきます。

先日筆者は「イーサリアムnaviのサポータープラン」というものを発表し、その中でSBTを用いる旨を説明したのですが、そこで実際にコントラクトを作成し、デプロイまで辿り着くことができました。

その開発の過程で、色々と分からないことが出てきてその都度調べたり、人に聞いて教えてもらったりする機会が多かったので、備忘録ならびにSBT開発に挑む方の役に立つことを願い、本記事の執筆に至った次第です。

サポータープランやSBTコレクション「Ethereumnavi Supporter Plan 2023」の詳細については、以下の記事をご一読いただけますと幸いです。

ちなみに、「そもそもSBTとは何か?」については議論の余地があるテーマだと思いますが一旦端に置いておき、本記事では簡略化して『ゼロアドレス(つまりミント時)以外に譲渡する権限を持たないNFT』と定義します。

▼SBTについて深掘りして知りたい方は、以下をご参考いただけるとよろしいかと思います。
vitalik.ca/general/2022/01/26/soulbound.html
Privacy to Earn:SBT(ソウルバウンドトークン)が実現するプログラム可能なプライバシー

さて、今回は以下の手順でSBT発行コントラクトの開発を進めていきます。

  1. 事前準備
  2. コントラクトを作成してローカル環境にデプロイ
  3. GoerliテストネットにデプロイしてSBTをミント
  4. SBTをテストネット版OpenSeaで確認
  5. Ethereumメインネット上へコントラクトをデプロイ

ちなみに、Goerliテストネットのガス代に関しては、以下の記事でご紹介した「Goerli Gas Heatmap」で簡単に確認できますので、必要な方は併せてご活用ください。

本記事が、SBT発行コントラクトの実装方法やデプロイまでの一連の流れなどについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。

※本記事は一般的な情報提供を目的としたものであり、法的または投資上のアドバイスとして解釈されることを意図したものではなく、また解釈されるべきではありません。ゆえに、特定のFT/NFTの購入を推奨するものではございませんので、あくまで勉強の一環としてご活用ください。

イーサリアムnaviの「今まで」と「これから」
目次

事前準備

本記事では、HardhatというSolidity開発ツールを使用していきます。Hardhatのインストールがまだお済みでない方は以下の記事を参考にご準備ください。
【脱Truffle】Solidity開発ツール「Hardhat」の概要やメリット・使い方について徹底解説

また今回、筆者の作業実行環境は以下です。

  • macOS Big Sur v12.5.1
  • Visual Studio Code – コード エディター
  • Node.js v18.13.0

先ほど掲載した記事にある『実践編①〜インストールしてみよう〜』の作業を済ませておいてください。(※記事中では省略していたhardhat-etherscanもインストールもおこなう)

※なお、今回はPolygonscanは使わずEtherscanのみを使用し、またRinkebyではなくGoerliテストネット/Ethereumメインネットを利用するので、hardhat.config.jsの内容を以下に変更してください。

const { privateKey, etherscanApiKey, goerliAlchemyApiKey, ethereumAlchemyApiKey } = require("./secrets.json");

require("@nomiclabs/hardhat-waffle");
require('@nomiclabs/hardhat-ethers');
require("@nomiclabs/hardhat-etherscan");

module.exports = {
  solidity: {
    version: "0.8.9",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    hardhat: {},
    ethereum: {
      url: "https://eth-mainnet.g.alchemy.com/v2/" + ethereumAlchemyApiKey,
      chainId: 1,
      accounts: [ privateKey ]
    },
    goerli: {
      url: "https://eth-goerli.g.alchemy.com/v2/" + goerliAlchemyApiKey,
      chainId: 5,
      accounts: [ privateKey ]
    }
  },
  etherscan: {
    apiKey: etherscanApiKey
  }
};

※同じく、secrets.jsonの内容を以下に変更し、××の箇所は各自書き換えてください。

{
    "privateKey": "××××××××××××××××××××××××",
    "etherscanApiKey": "××××××××××××××××××××××××",
    "goerliAlchemyApiKey": "××××××××××××××××××××××××",
    "ethereumAlchemyApiKey": "××××××××××××××××××××××××"
}

コントラクトを作成してローカル環境にデプロイ

コントラクトの作成

では、ルートディレクトリに「contracts」「scripts」ディレクトリを、以下コマンドでそれぞれ作成します。

$ mkdir contracts scripts

次に、contractsディレクトリに簡易なコントラクトを作成するので、「EthereumnaviSupporterPlan2023.sol」というファイルを作成します。

$ touch contracts/EthereumnaviSupporterPlan2023.sol

今回は、OpenZeppelin Wizardでベースのコードを作成したり、「譲渡不可」にするために_beforeTokenTransfer関数を追加したりなどして、最終的に以下のようなコードとなりました。

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";

contract EthereumnaviSupporterPlan2023 is ERC721, Pausable, Ownable {
    using Counters for Counters.Counter;
    using Strings for uint256;

    string public _imageUrl = "https://ipfs.io/ipfs/Qmao9Q11ZqLez1BrGk33BXR9TiPLVLNJMQpeG7B3EAi1uv";
    uint256 public constant MINT_PRICE = 0.25 ether;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("EthereumnaviSupporterPlan2023", "ESP2023") {}

    function setImageUrl(string memory _url) public onlyOwner {
        _imageUrl = _url;
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function mint() public payable whenNotPaused{
        require(msg.value == MINT_PRICE, "Error: Invalid value");

        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(msg.sender, tokenId);
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        require(_exists(tokenId), "URI query for nonexistent token");

        bytes memory attributes = abi.encodePacked(
            '{"trait_type": "ID", "value": "',
                tokenId.toString(),
            '"},',
            '{"trait_type": "name", "value": "',
                "Ethereumnavi Supporter Plan 2023",
            '"}'
        );

        bytes memory description = 'This SBT is a collection for Ethereumnavi supporter plans; it will serve as an access path to the discord during 2023.';
        bytes memory metadata =
            abi.encodePacked(
                '{"name": "Ethereumnavi Supporter Plan 2023 #',
                tokenId.toString(),
                '", "description": "',
                description,
                '", "image": "',
                _imageUrl,
                '", "attributes": [',
                attributes,
                ']}'
            );

        return
            string(
                abi.encodePacked(
                    "data:application/json;base64,",
                    Base64.encode(metadata)
                )
            );
    }

    function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
        internal
        whenNotPaused
        override
    {
        require(from == address(0), "Err: token is SOUL BOUND");
        super._beforeTokenTransfer(from, to, tokenId, batchSize);
    }

    function withdraw() external onlyOwner {
        Address.sendValue(payable(owner()), address(this).balance);
    }

    function renounceOwnership() public override onlyOwner {}   
}

補足1:imageデータをIPFSに格納

出典:Etherscan

画像データ(_imageUrl)は、AWSのようなサーバーではなく「IPFS」に格納しています。

IPFSに簡単に画像データをアップロードしてピン留め(ネットワークから削除されないために必要な作業)するために、今回は「Pinata」というツールを使用しました。

出典:app.pinata.cloud

Pinataを使えば、100個までの画像データ(かつ総容量1GB以下)であれば無料でピン留めまでおこなってくれるので、今回のケースのように同一画像を用いる場合は画像データを1つ用意するだけで良く、適しているかと思います。

補足2:発行上限数のないNFTにおけるjsonファイルの扱い

一般的にNFTの情報部分はメタデータと呼ばれ、jsonファイル形式であらかじめ作成しておく必要があります。

よくあるパターンだと、「1.json」「2.json」のように{tokenID}.jsonといったファイル名で用意するのですが、NFTの発行上限数が存在しない場合この方法は不向きです。(上限数が存在しないものを用意できないため)

そこで今回は、画像データのみIPFSにアップして生成されたCIDを含むURLを設定し、その他の情報(名前、説明、属性など)はSBTがミントされる度にtokenIDごとにsvg形式で自動生成するという、いわゆる「フルオンチェーンライクな形式」を採用しました。

出典:Etherscan

これにより、各SBTのメタデータはimage部分をのぞき自動生成されるようになり、あらかじめjsonファイルを用意する必要がなくなりました。

ローカル環境にデプロイ

コントラクトにコードの記載が完了したら、以下コマンドを実行してコンパイルをおこない、Solidityのコードをデプロイできる形にします。

$ npx hardhat compile
成功するとこのように表示されます。

これでコントラクトをデプロイする準備が完了したので、次はデプロイ手順に移ります。

まずは以下のコマンドを実行して、scriptsディレクトリにdeploy.jsファイルを作成します。

$ touch scripts/deploy.js

続いて、作成したdeploy.jsファイルに以下コードを記載して保存します。

async function main() {
    const factory = await ethers.getContractFactory("EthereumnaviSupporterPlan2023");
    const contract = await factory.deploy();
    await contract.deployed();
    console.log("Deployed to:", contract.address)
  }
  main()
    .then(() => process.exit(0))
    .catch((error) => {
      console.error(error);
      process.exit(1);
    });

上のコードについて簡潔に説明すると、先ほど作成したEthereumnaviSupporterPlan2023というコントラクトをデプロイし、成功したら「Deployed to:(コントラクトアドレス)」を返すが、失敗したらエラーメッセージを返すというものです。

ではローカル環境でデプロイしてみるために、まずは以下コマンドでローカルネットワークを起動します。

$ npx hardhat node

するとこのように、ローカル環境用に10,000ETHが入ったアカウントが20個作成されます。

このローカルネットワークを立ち上げたまま、別のターミナルシェルを開いて以下コマンドを実行します。

$ npx hardhat run scripts/deploy.js --network localhost

ローカル環境の場合は、--network localhost は省略しても問題なく動作します。

「Deployed to:(コントラクトアドレス)」と返されたら、ローカル環境でのデプロイは成功です。お疲れ様でした。

GoerliテストネットにデプロイしてSBTをミント

ローカル環境でのテストが完了したら、次はいよいよコントラクトをGoerliテストネットへデプロイし、さらにSBTを直コンミントしてみましょう。

Goerliテストネットにデプロイするためには、テストネット用のETHが必要になります。まだお持ちでない方は、goerlifaucet.comなどを通して入手しておいてください。

テストネット用のETHの準備ができたら、以下コマンドを実行します。

$ npx hardhat run scripts/deploy.js --network goerli

デプロイが成功したらコントラクトアドレスが表示されるので、コピーしてgoerli.etherscan.ioに打ち込みます。

無事にコントラクトがGoerliテストネットにデプロイされていますが、まだverifyしていないので「Contract」にチェックマークが入っていない状態です。

このままではEtherscanから直コンミントができないので、一度コマンドラインに戻って以下を実行し、Etherscanでコントラクトをverifyします。(×××××のコントラクトアドレス部分は各自で書き換えてください)

$ npx hardhat verify --network goerli ×××××××××××××××××××××××××
「Successfully verified contract ○○ on Etherscan.」と返されたら成功です。

エラー解決メモ①:
Hardhat verifyでError: ENOENT: no such file or directoryのエラーが返された際は、npx hardhat cleanを実行した後、再度上のverifyコマンドを入力したら解決しました。

エラー解決メモ②:
Error in plugin @nomiclabs/hardhat-etherscan: The Etherscan API responded with a failure status.
The verification may still succeed but should be checked manually.
Reason: Fail - Unable to verify. Compiled contract runtime bytecode does NOT match the on-chain runtime bytecode.
というエラーが返された場合は、しばらく(筆者の場合は半日ほど)時間を置いて再度試してみると解決しました。おそらくEtherscan側のAPIの問題のようです。

成功したら上画像のように緑色のチェックマークが付くので、直コンミントするために以下の順でクリックしていきます。

  1. 「Contract」タブをクリック
  2. 「Write Contract」」タブをクリック
  3. 「Connect to Web3」で、SBTをミントしたい対象アカウントと接続
  4. 「2. mint」をクリックしてタブを展開
  5. payableAmount (ether)欄に「0.25」と入力してから、「Write」ボタンをクリックして署名

なかなかトランザクションがブロックに取り込まれない場合は、Goerliテストネットが渋滞している可能性があるので、以下のGoerliガスヒートマップなどを活用しながら空いている時間帯を狙ってみてください。

SBTをテストネット版OpenSeaで確認

出典:testnets.opensea.io

トランザクションが承認されたら、テストネット版OpenSeaで保有NFT(SBT)を確認してみます。

画像は適当なものを使っています。

該当のSBTが見つかり、無事にミントできていることが確認できました。

他のアドレスにtransferできないことを確認するために、以下などを試します。

  • 「Sell」ボタンでリストしても他のアカウントから購入できないこと
  • 「Transfer」ボタンで他のアドレスに転送できないこと
「Execution reverted. Please reach out to the collection owner to troubleshoot.」などエラーが表示されればOK

一通りのテストが完了し、想定通りの動作であることを確認したら、次はいよいよ作成したコントラクトをEthereumメインネットへデプロイしてみましょう。

Ethereumメインネット上へコントラクトをデプロイ

必要な方は、secrets.jsonファイルのprivateKeyをメインネットデプロイ用の別アカウントの秘密鍵に書き換え、保存します。

準備ができたら、以下コマンドを実行し、Ethereumメインネットにデプロイします。

※本番環境のETHが使用されるため、デプロイの際はミスのないよう十分に確認した上でおこなってください。

$ npx hardhat run scripts/deploy.js --network ethereum
成功するとこのように表示されます。

デプロイが成功したらコントラクトアドレスが表示されるので、コピーしてetherscan.ioに打ち込みます。

最後に、テストネットの時と同様に以下コマンドを実行し、Etherscanでコントラクトをverifyします。(×××××のコントラクトアドレス部分は各自で書き換えてください)

$ npx hardhat verify --network ethereum ×××××××××××××××××××××××××
出典:etherscan.io/address/0xe1B36259a0a4fC6ea2F3d28a4687317Bb9040689

「Contract」タブの右上に緑色のチェックマークが表示されたら、SBT発行コントラクトのメインネットデプロイまでの一通りの開発事項は終了です。お疲れ様でした。

余談:ハードウェアウォレット(HWW)でコントラクトをデプロイすることは可能?

結論から言えば、ニーモニックフレーズや秘密鍵をsecrets.jsonファイルに書き込めば可能ですが、それではHWWの意味がありません。

よくある手法としては以下の手順で、コントラクトをデプロイした後オーナー権限をHWWで管理するアドレスに移譲する方法です。

  1. MetaMaskなどのソフトウェアウォレットからコントラクトをデプロイ
  2. Ownable.solにあるtransferOwnership関数でWWで管理するアドレスにオーナー権限を移譲

これにより、移譲後はwithdrawなどを含む「onlyOwnerで管理された関数」の呼び出しが新オーナーに移されるため、HWWで対象スマートコントラクトのニーモニックフレーズや秘密鍵を管理することが可能になります。

まとめ

【AD】Nouns DAO JAPAN

Nouns DAO JAPANは世界で一番Nounsを広げるコミュニティを目指します。Discord参加はこちら


今回は、開発記事として「Hardhatを用いたSBT(譲渡不可なNFT)の作成方法」について解説しました。

本記事が、SBT発行コントラクトの実装方法やデプロイまでの一連の流れなどについて理解したいと思われている方にとって、少しでもお役に立ったのであれば幸いです。

また励みになりますので、参考になったという方はぜひTwitterでのシェア・コメントなどしていただけると嬉しいです。

derio

サポータープランに関する記事も、ご一読いただけると嬉しいです。

イーサリアムnaviを運営するSTILL合同会社では、以下などに関するお問い合わせを受け付けております。

  • 広告掲載
  • リサーチ代行業務
  • アドバイザー業務
  • その他(ご依頼・ご提案・ご相談など)

まずはお気軽に、ご連絡ください。

励みになるので、よかったらSNSなどでシェアしてください!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

最先端のCryptoネイティブなプロジェクトや、Solidityなどのweb3開発情報、その他Ethereum周りの情報などを中心に発信しています。

リサーチ業務やアドバイザー業務などのお仕事のご依頼や、イーサリアムnaviに対する広告掲載などは、お問合せページよりお気軽にご連絡ください。

目次