どうも、イーサリアムnavi運営のでりおてんちょーです。
今回は、開発記事として「Hardhatを用いたSBT(譲渡不可なNFT)の作成方法」について解説していきます。
🧭イーサリアムnaviで「サポータープラン」を開始しました
— でりおてんちょー|derio (@yutakandori) January 26, 2023
構想から4ヶ月を経て、ついに始動🔥
クリプトネイティブな題材を中心に扱うメディアとして、コアなプロジェクトの認知を広めながら、コミュニティ作りにも力を入れていきます💪
ぜひご一読いただけると幸いです!https://t.co/nZdjHp0k4J
その開発の過程で、色々と分からないことが出てきてその都度調べたり、人に聞いて教えてもらったりする機会が多かったので、備忘録ならびにSBT開発に挑む方の役に立つことを願い、本記事の執筆に至った次第です。
サポータープランやSBTコレクション「Ethereumnavi Supporter Plan 2023」の詳細については、以下の記事をご一読いただけますと幸いです。

ちなみに、「そもそもSBTとは何か?」については議論の余地があるテーマだと思いますが一旦端に置いておき、本記事では簡略化して『ゼロアドレス(つまりミント時)以外に譲渡する権限を持たないNFT』と定義します。
さて、今回は以下の手順でSBT発行コントラクトの開発を進めていきます。
- 事前準備
- コントラクトを作成してローカル環境にデプロイ
- GoerliテストネットにデプロイしてSBTをミント
- SBTをテストネット版OpenSeaで確認
- Ethereumメインネット上へコントラクトをデプロイ
ちなみに、Goerliテストネットのガス代に関しては、以下の記事でご紹介した「Goerli Gas Heatmap」で簡単に確認できますので、必要な方は併せてご活用ください。

本記事が、SBT発行コントラクトの実装方法やデプロイまでの一連の流れなどについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。
※本記事は一般的な情報提供を目的としたものであり、法的または投資上のアドバイスとして解釈されることを意図したものではなく、また解釈されるべきではありません。ゆえに、特定のFT/NFTの購入を推奨するものではございませんので、あくまで勉強の一環としてご活用ください。

事前準備
本記事では、HardhatというSolidity開発ツールを使用していきます。Hardhatのインストールがまだお済みでない方は以下の記事を参考にご準備ください。
【脱Truffle】Solidity開発ツール「Hardhat」の概要やメリット・使い方について徹底解説
また今回、筆者の作業実行環境は以下です。
- macOS Big Sur v12.5.1
- Visual Studio Code – コード エディター
- Node.js v18.13.0
※なお、今回は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に格納

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

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

Pinataを使えば、100個までの画像データ(かつ総容量1GB以下)であれば無料でピン留めまでおこなってくれるので、今回のケースのように同一画像を用いる場合は画像データを1つ用意するだけで良く、適しているかと思います。
補足2:発行上限数のないNFTにおけるjsonファイルの扱い
一般的にNFTの情報部分はメタデータと呼ばれ、jsonファイル形式であらかじめ作成しておく必要があります。
よくあるパターンだと、「1.json」「2.json」のように{tokenID}.jsonといったファイル名で用意するのですが、NFTの発行上限数が存在しない場合この方法は不向きです。(上限数が存在しないものを用意できないため)
そこで今回は、画像データのみIPFSにアップして生成されたCIDを含むURLを設定し、その他の情報(名前、説明、属性など)はSBTがミントされる度にtokenIDごとにsvg形式で自動生成するという、いわゆる「フルオンチェーンライクな形式」を採用しました。

ローカル環境にデプロイ
コントラクトにコードの記載が完了したら、以下コマンドを実行してコンパイルをおこない、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

「Deployed to:(コントラクトアドレス)」と返されたら、ローカル環境でのデプロイは成功です。お疲れ様でした。
GoerliテストネットにデプロイしてSBTをミント
ローカル環境でのテストが完了したら、次はいよいよコントラクトをGoerliテストネットへデプロイし、さらにSBTを直コンミントしてみましょう。

テストネット用のETHの準備ができたら、以下コマンドを実行します。
$ npx hardhat run scripts/deploy.js --network goerli

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

無事にコントラクトがGoerliテストネットにデプロイされていますが、まだverifyしていないので「Contract」にチェックマークが入っていない状態です。
このままではEtherscanから直コンミントができないので、一度コマンドラインに戻って以下を実行し、Etherscanでコントラクトをverifyします。(×××××のコントラクトアドレス部分は各自で書き換えてください)
$ npx hardhat verify --network goerli ×××××××××××××××××××××××××

エラー解決メモ①:
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.
というエラーが返された場合は、しばらく(筆者の場合は半日ほど)時間を置いて再度試してみると解決しました。おそらくEtherscan側のAPIの問題のようです。
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.

成功したら上画像のように緑色のチェックマークが付くので、直コンミントするために以下の順でクリックしていきます。
- 「Contract」タブをクリック
- 「Write Contract」」タブをクリック
- 「Connect to Web3」で、SBTをミントしたい対象アカウントと接続
- 「2. mint」をクリックしてタブを展開
- payableAmount (ether)欄に「0.25」と入力してから、「Write」ボタンをクリックして署名

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

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

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

他のアドレスにtransferできないことを確認するために、以下などを試します。
- 「Sell」ボタンでリストしても他のアカウントから購入できないこと
- 「Transfer」ボタンで他のアドレスに転送できないこと

一通りのテストが完了し、想定通りの動作であることを確認したら、次はいよいよ作成したコントラクトをEthereumメインネットへデプロイしてみましょう。
Ethereumメインネット上へコントラクトをデプロイ
準備ができたら、以下コマンドを実行し、Ethereumメインネットにデプロイします。
※本番環境のETHが使用されるため、デプロイの際はミスのないよう十分に確認した上でおこなってください。
$ npx hardhat run scripts/deploy.js --network ethereum

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

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

「Contract」タブの右上に緑色のチェックマークが表示されたら、SBT発行コントラクトのメインネットデプロイまでの一通りの開発事項は終了です。お疲れ様でした。
余談:ハードウェアウォレット(HWW)でコントラクトをデプロイすることは可能?
結論から言えば、ニーモニックフレーズや秘密鍵をsecrets.jsonファイルに書き込めば可能ですが、それではHWWの意味がありません。
よくある手法としては以下の手順で、コントラクトをデプロイした後オーナー権限をHWWで管理するアドレスに移譲する方法です。
- MetaMaskなどのソフトウェアウォレットからコントラクトをデプロイ
- Ownable.solにあるtransferOwnership関数でWWで管理するアドレスにオーナー権限を移譲
まとめ

Nouns DAO JAPANは世界で一番Nounsを広げるコミュニティを目指します。Discord参加はこちら
今回は、開発記事として「Hardhatを用いたSBT(譲渡不可なNFT)の作成方法」について解説しました。
本記事が、SBT発行コントラクトの実装方法やデプロイまでの一連の流れなどについて理解したいと思われている方にとって、少しでもお役に立ったのであれば幸いです。
また励みになりますので、参考になったという方はぜひTwitterでのシェア・コメントなどしていただけると嬉しいです。
🆕記事をアップしました🆕
— イーサリアムnavi🧭 (@ethereumnavi) January 28, 2023
今回は、Hardhatを使って簡単なSBTを作成する方法について📝
先日はじめて『譲渡不可能なNFT』の開発に挑んた筆者が、作成する過程でつまづいたことや得た知見などを添えて、開発手順を解説💻
これから開発に挑戦する方のお役に立てば幸いです🙏https://t.co/EHBqNT1nl4

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




イーサリアムnaviを運営するSTILL合同会社では、以下などに関するお問い合わせを受け付けております。
- 広告掲載
- リサーチ代行業務
- アドバイザー業務
- その他(ご依頼・ご提案・ご相談など)
まずはお気軽に、ご連絡ください。
- Webサイト:still-llc.co.jp
- Twitter:@STILL_Corp
- メールアドレス:info@still-llc.com