📀

Omnichain NFTを作る (決定版)

2022/05/20に公開

はじめに

Ethereum,Polygon,Solanaなど,ブロックチェーンには様々な種類がありますが. 異なるチェーン上に同一のトークンを持ち込むことはできません.そのため資産を異なるチェーンに移動する際は,主にブリッジという仕組みが用いられます.ブリッジについてはわかりやすい記事がすでに存在するのでこちらをご覧ください.
このブリッジは一般的にERC20という通貨用トークン規格のためのものですが,NFT用のブリッジシステムも存在します.そのうち現在最も有名なものがLayerZeroが提供するOmniChainNFTです.しかし,一般的なブリッジとLayerZeroのシステムは少々違うので,本記事ではブリッジではなくオムニセンド(OmniSend)または単にSendとよびます.
流動性はブロックチェーンにとって最も重要なテーマの一つなので,今後もこの仕組みを使って様々なアプリケーションが生まれると思います.
本記事は筆者が開発させていただいたhibikillaさんの音楽NFT"Risin' to the top"の技術解説も兼ねています.
https://www.youtube.com/watch?v=nUpwipQDK3k
(筆者が知る限り)世界初となるオムニチェーンのMusicNFTについて興味がある方は,ぜひアーティストであるhibikillaさんのLightPaperをご覧ください.
https://twitter.com/hibikilla30/status/1522505204658491392?s=12&t=IergfczJciNqNx_aBEqsOA

目次

本記事ではOmnichainNFTの解説から実装時のガス代など現実的な問題まで扱います.目次は以下の通りです.

  1. Omnichainを理解する
  2. Omnichianのサンプルを使ってみる
  3. OmnichianNFTのコード解説
  4. OmnichianNFTの実装(ERC1155)
  5. 番外編:ガス代を制御する方法
  6. 番外編:Openseaに最適化したNFT
  7. まとめ

1. Omnichainを理解する

はじめにでも紹介した通り,LayerZeroは異なるブロックチェーン間の通信を可能にする相互運用性プロトコル(Omnichain Interoperability Protocol)であり,異なるチェーン上にそれぞれ存在するスマートコントラクトのリレイヤー(データの受け渡しを行うノード)として機能します.
Cross-Chain-Transactionと言われるチェーン間の通信(ブリッジも含まれる)には,様々な種類がありますが,このLayerZeroは

  • リレイヤー:トランザクションのデータを受け渡す
  • オラクル:トランザクションを監視し,ブロックヘッダー(実行時に生成されるブロック自身の情報)を受け渡す

という独立した二つのノードの情報を示し合わせることで,システムの分散性を高めています.
また,LayerZeroは,情報を保持するクライアントをチェーン上にコントラクトとして実装するライトクライアント型であることも特徴の一つです.このクライアントの名前はLayerZeroEndpointといって,コードを理解する上で重要な知識になります.

Sendを実行する際には,上の画像で示したような流れでトークンが転送されます.
一方のチェーンでNFTをburnし,もう一方のチェーンでNFTをmintする仕組みは,lock&mint方式を採用する一般的なブリッジシステムとは異なるところです.

一般向けのLayerZeroとOmnichainNFTの解説記事は,すでにとてもわかりやすい日本語記事が存在するのでこちらも合わせてご覧ください.
https://ethereumnavi.com/2022/04/15/omnichain-layerzero/
また,少々難解ですがLayerZeroのホワイトペーパーも示しておきます.

2. Omnichianのサンプルを使ってみる

実装は公式のサンプルコードを利用するので,まずはサンプルを動かして見るところから始めます.
https://github.com/LayerZero-Labs/solidity-examples

オムニチェーンNFTの実装には,次の手順が必要です

  1. 2つの異なるチェーンに同様のコントラクトをデプロイ
  2. それぞれのコントラクトに,もう一方のコントラクトを認証させる
  3. トークンをmintする
  4. トークンをsend(転送)する

また実際には,実行時に通常のガス代に加えて転送先のチェーンにおけるガス代,LayerZeroの利用料金なども支払う必要があるため,gas代の算出も必要になります.

なので,これらの手順を順番に実行していくことになります.
まずは,サンプルコードをgithubから取得します.

$ git clone https://github.com/LayerZero-Labs/solidity-examples.git

次に,ディレクトリに移動し必要なパッケージをインストールします

$ cd solidity-examples
$ npm install

サンプルコードでは, Mnemonic(ウォレットのシードフレーズ)を読み込んでprivateKeyに変換していますが,記法がわかりにくいので,そのままPrivateKeyを書き込む形にhardhat.config.jsを書き換えておきます.
ついでに,後の章で解説する幾つかの機能追加も行っておきました.

hardhat.config.js
const { privateKey, alchemyApiKey, polygonscanApiKey, etherscanApiKey } = require("./secrets.json");
require("@nomiclabs/hardhat-etherscan");
require("@nomiclabs/hardhat-waffle");
require('@nomiclabs/hardhat-ethers');
require("hardhat-gas-reporter");
require('hardhat-contract-sizer');
require("solidity-coverage");
require('hardhat-deploy');
require('hardhat-deploy-ethers');

module.exports = {
  solidity: {
    version: "0.8.7",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  gasReporter: {
    currency: 'JPY',
    coinmarketcap: 'e9a2f0b2-8e69-4298-8d70-89b9a0064144',
    token: 'ETH',
    gasPrice: 30
  },
  contractSizer: {
    alphaSort: false,
    runOnCompile: true,
    disambiguatePaths: false,
  },
  networks: {
    hardhat: {},
    ethereum: {
      url: "https://eth-mainnet.alchemyapi.io/v2/" + alchemyApiKey,
      chainId: 1,
      accounts: [ privateKey ]
    },
    rinkeby: {
      url: "https://eth-rinkeby.alchemyapi.io/v2/" + alchemyApiKey,
      chainId: 4,
      accounts: [ privateKey ]
    },
    goerli: {
      url: "https://eth-goerli.alchemyapi.io/v2/" + alchemyApiKey,
      chainId: 5,
      accounts: [ privateKey ]
    },
    polygon: {
      url: "https://matic-mainnet.chainstacklabs.com",
      chainId: 137,
      accounts: [ privateKey ]
    },
    mumbai: {
      url: "https://matic-mumbai.chainstacklabs.com",
      chainId: 80001,
      accounts: [ privateKey ]
    }
  },
  etherscan: {
    // apiKey: etherscanApiKey // EtherscanでVerifyする際にコメントアウトを外して使用
    apiKey: polygonscanApiKey // PolygonscanでVerifyする際にコメントアウトを外して使用
  }
};

次は必要な環境変数を設定します.コントラクトの構築自体に必要なのは下2行のみですが,コントラクトに格納するURIの生成に必要になるので取得しておくと良いです.
solidity-exampleディレクトリ直下に.envファイルとsecrets.jsonファイルを作成します.

.env
NODE_ENV=dev
// メタデータをアップロードする用の環境変数(Moralis)
APP_ID="YOUR_APP_ID"
API_URL="https://deep-index.moralis.io/api/v2/ipfs/uploadFolder"
API_KEY="YOUR_API_KEY"
SERVER_URL="YOUR_SERVER_URL"
MASTER_KEY="YOUR_MASTER_KEY"
// デプロイしたコントラクトアドレス
ETH_CONTRACT_ADDRESS="YOUR_ETH_CONTRACT_ADDRESS"
MATIC_CONTRACT_ADDRESS="YOUR_MATIC_CONTRACT_ADDRESS"

より重要な情報はsecrets.jsonに書き込みます.

secrets.json
{
  "privateKey": "YOUR_PRIVATE_KEY",
  "etherscanApiKey": "YOUR_API_KEY",
  "polygonscanApiKey": "YOUR_API_KEY",
  "alchemyApiKey": "YOUR_API_KEY"
}

それぞれの情報は下記の通りに取得できます.

Moralis

以下のサイトから,Moralisにサインアップします.
https://moralis.io/
ログインできたら,下の画像にしたがってサーバー情報が取得できます.

Moralisをノードプロバイダーとして利用する際は,Web3APIメニューからAPIKeyを取得することができます.

etherscanApiKey,polygonscanApiKey

それぞれのサインアップは以下のサイトから行います

ログインできたら,下の画像にしたがって,APIKeyが取得できます.polygonApiKeyも同様にして取得できます.

alchemyApiKey

以下のサイトからalchemyにサインアップします.
https://dashboard.alchemyapi.io/signup/chain
ログインできたら,下の画像にしたがって,ApiKeyが取得できます.createapp時にrinkebyネットワークを選択することに注意してください.

hardhatを利用してコントラクトの動作確認を行います.

$ npx hardhat test 

テストが成功したら,ONFT721というERC721規格のオムニチェーンコントラクトをデプロイしてみましょう.
今回は説明時の例にならって,polygonとethereumのテストネットであるrinkebyとmumbaiを利用します.
そのために,constantsディレクトリ内のonftArgs.jsonを以下のように書き換えてください.

constants/onftArgs.json
{
  "rinkeby": {
    "startMintId": 1,
    "endMintId": 10
  },
  "mumbai": {
    "startMintId": 11,
    "endMintId": 20
  }
}

コントラクトをデプロイします.

$ npx hardhat --network rinkeby deploy --tags ExampleUniversalONFT721
$ npx hardhat --network mumbai deploy --tags ExampleUniversalONFT721

それぞれのコントラクトでトークンをmintします.

$ npx hardhat --network rinkeby onftMint
$ npx hardhat --network mumbai onftMint

この時点で,各チェーン上にあるトークンの数(rinkeby:1, mumbai:1)を確認しておきます.

$ npx hardhat --network rinkeby onftOwnerOf --token-id 1
$ npx hardhat --network mumbai onftOwnerOf --token-id 11

rinkebyからmumbaiにトークンをsendします

$ npx hardhat --network rinkeby onftSend --target-network mumbai --token-id 1

トランザクションが通った後にもう一度トークンの数を確認すると,id:1のトークンがmumbaiに転送されていることがわかるはずです.(rinkeby:0, mumbai:1)

$ npx hardhat --network rinkeby onftOwnerOf --token-id 1
$ npx hardhat --network mumbai onftOwnerOf --token-id 1

3. OmnichianNFTのコード解説

本章では,ERC1155規格のオムニチェーンNFTである"MusicNFT"のコード解説をします.
MusicNFTはLayerZeroの提供するONFT1155を継承することでオムニチェーン化していますが,コントラクトの継承関係は下のようになっています.

構成するコントラクトの数が多いので省略したものもありますが,主要なコントラクトの役割は次のようになっています.

名前 役割
MusicNFT 独自機能の実装(購入制限,その他の基本機能)
ONFT1155 (Omni)Send時のmintとburnの実行
ONFT1155Core (Omni)Sendの実行,ガス代の推定など
NonblockingLzApp (Omni)Receivとmintの中継とエラーの保存
LzApp _lzSendとlzReceiveの実行など

オムニチェーンNFTを構成するコントラクトの中で,最も重要な実装は,1章の図に示されている処理の部分です.
これらの処理をかいつまんで見ていきます.

ONFT1155Core.solには,send時に外部から呼び出すsendFrom()が定義されています.sendFrom()は,直下の_sendBatch()を呼び出し,引数にとったデータをエンコードし,payloadとしてLzApp()に引き渡します.

ONFT1155Core.sol
    function sendFrom(address _from, uint16 _dstChainId, bytes memory _toAddress, uint _tokenId, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams) public payable virtual override {
        _sendBatch(_from, _dstChainId, _toAddress, _toSingletonArray(_tokenId), _toSingletonArray(_amount), _refundAddress, _zroPaymentAddress, _adapterParams);
    }
    function _sendBatch(address _from, uint16 _dstChainId, bytes memory _toAddress, uint[] memory _tokenIds, uint[] memory _amounts, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams) internal virtual {
        _debitFrom(_from, _dstChainId, _toAddress, _tokenIds, _amounts);

        bytes memory payload = abi.encode(_toAddress, _tokenIds, _amounts);
        _lzSend(_dstChainId, payload, _refundAddress, _zroPaymentAddress, _adapterParams);

        uint64 nonce = lzEndpoint.getOutboundNonce(_dstChainId, address(this));
        if (_tokenIds.length == 1) {
            emit SendToChain(_from, _dstChainId, _toAddress, _tokenIds[0], _amounts[0], nonce);
        } else if (_tokenIds.length > 1) {
            emit SendBatchToChain(_from, _dstChainId, _toAddress, _tokenIds, _amounts, nonce);
        }
    }

LzApp.solは最も重要なコントラクトで,receive時にEndpointから呼び出されるlzReceive()と,send時ONFT1155Coreから内部的に呼び出される_lzSend()を定義しています.

LzApp.sol
...
    function lzReceive(uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload) public virtual override {
        // lzReceive must be called by the endpoint for security
        require(msg.sender == address(lzEndpoint));

        bytes memory trustedRemote = trustedRemoteLookup[_srcChainId];
        // if will still block the message pathway from (srcChainId, srcAddress). should not receive message from untrusted remote.
        require(_srcAddress.length == trustedRemote.length && keccak256(_srcAddress) == keccak256(trustedRemote), "LzApp: invalid source sending contract");

        _blockingLzReceive(_srcChainId, _srcAddress, _nonce, _payload);
    }
...
    function _lzSend(uint16 _dstChainId, bytes memory _payload, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams) internal virtual {
        bytes memory trustedRemote = trustedRemoteLookup[_dstChainId];
        require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source");
        lzEndpoint.send{value: msg.value}(_dstChainId, trustedRemote, _payload, _refundAddress, _zroPaymentAddress, _adapterParams);
    }
...

ONFT1155.solではsend前にトークンをburnする_debitFrom(),receive後にトークンをmintする_creditTo()が定義されています.

ONFT1155.sol
    function _debitFrom(address _from, uint16, bytes memory, uint[] memory _tokenIds, uint[] memory _amounts) internal virtual override {
        address spender = msg.sender;
        require(spender == _from || isApprovedForAll(_from, spender), "ONFT1155: send caller is not owner nor approved");
        _burnBatch(_from, _tokenIds, _amounts);
    }

    function _creditTo(uint16, address _toAddress, uint[] memory _tokenIds, uint[] memory _amounts) internal virtual override {
        _mintBatch(_toAddress, _tokenIds, _amounts, "");
    }

※Ownableについて

2022/5/17現在,ERC1155においてOwnableを継承すると,OpenseaにコントラクトのCreatorが認識されず,ChangeCollectionなどの機能が制限されるバグが発生しているようです.したがって,メインネットでの実装を検討する場合は,Ownableの継承を行わず必要な機能をlzAppに書きこむことで解決する場合があります.今後バグ修正が行われる可能性もあるため,実装の際は実際にテストした上でご自身の判断でデバッグしてください.
また,Ownableの非継承は,TransferOwnerの実行(Creatorの移動)とトレードオフの関係にあるため,慎重に行う必要があります.

4. OmnichianNFTの実装(ERC1155)

メタデータを生成する

NFTを開発するには画像やdescriptionなどのメタデータを含んだURIが必要です.
そのため,

  1. アセット(画像データなど)を読み込んでIPFSにアップロード
  2. jsonファイルに取得した画像URLとその他のメタデータを格納
  3. 生成したjsonファイルをIPFSにアップロード
  4. アップロードしたjsonファイルのURIを表示

の機能が必要です.
これらを実装したコードがこちらです.IPFSへのアップロードにはMoralisのAPIを用いています.

generate_metadata.js
// import dependencies
const dotenv = require("dotenv");
dotenv.config(); // setup dotenv

const Moralis = require("moralis/node");
const request = require("request");
const fs = require("fs");
const { default: axios } = require("axios");
const { editionSize, assetElement } = require("../asset/config.js");

const serverUrl = process.env.SERVER_URL;
const appId = process.env.APP_ID;
const masterKey = process.env.MASTER_KEY;
const apiUrl = process.env.API_URL;
const apiKey = process.env.API_KEY;

Moralis.start({ serverUrl, appId, masterKey});

const btoa = (text) => {
  return Buffer.from(text, "binary").toString("base64");
}

// ローカルにmetadataを書き込み
const writeMetaData = (metadata) => {
  fs.writeFileSync("./output/_metadata.json", JSON.stringify(metadata));
};

// モラリスにアップロード
const saveToDb = async (metaHash) => {
  for(let i = 1; i < editionSize + 1; i++){
    let id = i.toString();
    let paddedHex = (
      "0000000000000000000000000000000000000000000000000000000000000000" + id
    ).slice(-64);
    let url = `https://ipfs.moralis.io:2053/ipfs/${metaHash}/metadata/${paddedHex}.json`;
    let options = { json: true };
  
    request(url, options, (error, res, body) => {
      if (error) {
        return console.log(error);
      }
  
      if (!error && res.statusCode == 200) {
        // moralisのダッシュボードにセーブ
        const FileDatabase = new Moralis.Object("Metadata");
        FileDatabase.set("BookName", body.name);
        FileDatabase.set("Description", body.description);
        FileDatabase.set("image", body.image);
        FileDatabase.set("attributes", body.animation_url);
        FileDatabase.set("meta_hash", metaHash);
        FileDatabase.save();
      }
    });
  }
};

const uploadImage = async () => {
  const UrlArray = [];

  for (let i = 1; i < editionSize + 1; i++) {
    let id = i.toString();
    let image_base64, music_base64, ifiletype, mfiletype;
    
    // データをIPFSにアップロード
    if(fs.existsSync(`./asset/${id}/image.jpg`)){
      image_base64 = await btoa(fs.readFileSync(`./asset/${id}/image.jpg`));
      ifiletype = "jpg";
    } else if(fs.existsSync(`./asset/${id}/animation.gif`)) {
      image_base64 = await btoa(fs.readFileSync(`./asset/${id}/animation.gif`, (err,data) => {
        console.log(err)
      }));
      ifiletype = "gif";
    }
    if(fs.existsSync(`./asset/${id}/music.wav`)){
      music_base64 = await btoa(fs.readFileSync(`./asset/${id}/music.wav`));
      mfiletype = "wav";
    } else if(fs.existsSync(`./asset/${id}/music.mp3`)) {
      music_base64 = await btoa(fs.readFileSync(`./asset/${id}/music.mp3`));
      mfiletype = "mp3";
    } else if(fs.existsSync(`./asset/${id}/music.mp4`)) {
      music_base64 = await btoa(fs.readFileSync(`./asset/${id}/music.mp4`, (err,data) => {
        console.log(err)
      }));
      mfiletype = "mp3";
    }

    let image_file = new Moralis.File("image", { base64: `data:image/${ifiletype};base64,${image_base64}` });
    let music_file = new Moralis.File("music", { base64: `data:audio/${mfiletype};base64,${music_base64}` });
    await image_file.saveIPFS({ useMasterKey: true });
    await music_file.saveIPFS({ useMasterKey: true });
    console.log(`Processing ${i}/${editionSize}...`)
    console.log("IPFS address of Image: ", image_file.ipfs());
    console.log("IPFS address of Music: ", music_file.ipfs());

    UrlArray.push({
      imageURL:image_file.ipfs(), 
      musicURL:music_file.ipfs()
    })
  }

  console.log(UrlArray)
  return UrlArray
}

const createMetadata = async () => {

  const metaDataArray = [];
  const DataArray  = await uploadImage();

  for (let i = 0; i < editionSize; i++){
    let id = (i+1).toString()
    let imageURL = DataArray[i].imageURL
    let musicURL = DataArray[i].musicURL
  
    // メタデータを記述
    let metadata = {
      "name": assetElement[i].name,
      "description": assetElement[i].description,
      "image": imageURL,
      "animation_url": musicURL,
      "attributes": assetElement[i].attributes
    }
    metaDataArray.push(metadata);
  
    fs.writeFileSync(
      `./output/${id}.json`,
      JSON.stringify(metadata)
    );
  }
  writeMetaData(metaDataArray);
}

const uploadMetadata = async () => {
  const promiseArray = []; 
  const ipfsArray = []; 

  for(let i = 1; i < editionSize + 1; i++){
    let id = i.toString();
    let paddedHex = (
      "0000000000000000000000000000000000000000000000000000000000000000" + id
    ).slice(-64);
  
    // jsonファイルをipfsArrayにpush
    promiseArray.push(
      new Promise((res, rej) => {
        fs.readFile(`./output/${id}.json`, (err, data) => {
          if (err) rej();
          ipfsArray.push({
            path: `metadata/${paddedHex}.json`,
            content: data.toString("base64")
          });
          res();
        });
      })
    );
  }

  //プロミスが返ってきたらipfsArrayをapiにpost
  Promise.all(promiseArray).then(() => {
  axios
    .post(apiUrl, ipfsArray, {
      headers: {
        "X-API-Key": apiKey,
        "content-type": "application/json",
        accept: "application/json"
      }
    })
    .then(res => {
      let metaCID = res.data[0].path.split("/")[4];
      console.log("META FILE PATHS:", res.data);
      //モラリスにアップロード
      saveToDb(metaCID);
    })
    .catch(err => {
      console.log(err);
    });
  });
};

const startCreating = async () => {
  await createMetadata();
  await uploadMetadata();
};

startCreating();

ONFTを継承する

コードが衆目に晒されるリスクを考えて,完全なコントラクトはお見せできませんが,基本的にはLayerZeroのコントラクトであるONFT1155を継承するだけで問題ありません.しかし,ONFT1155には外部か呼び出せるmint関数が実装されていないので,最低限mint関数は実装する必要があります.

サンプルコードはこんな感じです.

sumple.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../token/ONFT1155.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";

contract exminMusicNFT is ONFT1155 {
    using SafeMath for uint256;
    
    // コントラクト名
    string private _name;
    // NFT単位
    string private _symbol;
    // 画像データ
    string private _uri = "ipfs://${META_HASH}/metadata/{id}.json";

    constructor(
        string memory name_,
        string memory symbol_,
        address _lzEndpoint
    ) ONFT1155(_uri, _lzEndpoint){
        _name = name_;
        _symbol = symbol_;
    }
    
    /*
    * @title mint
    * @notice ミント
    * @param _tokenId ミントするトークンのID
    * @param _amount ミントする数
    */
    function mint(uint256 _tokenId, uint256 _amount) public (_tokenId, _amount){
        _mint(msg.sender, _tokenId, _amount, "");
    }

    /*
    * @title _beforeTokenTransfer
    * @notice transferに連動する購入制限
    * @param operator 実行者アドレス
    * @param from 移転元アドレス
    * @param to 移転先アドレス
    * @param ids 移転するレアリティのID
    * @param amounts 移転する数
    * @param data オプションパラメータ
    */
    function _beforeTokenTransfer(
        address operator,
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) internal override {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
	// ToDo
    }

    /*
    * @title name
    * @notice コントラクト名の呼び出し
    * @return _name コントラクト名
    * @dev OpenSeaに表示するための技術要件
    */
    function name() public view virtual returns (string memory) {
        return _name;
    }

    /*
    * @title symbol
    * @notice NFT単位の呼び出し
    * @return _symbol NFT単位
    * @dev OpenSeaに表示するための技術要件
    */
    function symbol() public view virtual returns (string memory) {
        return _symbol;
    }
}

5. 番外編:ガス代を制御する方法

metamaskによる決済では,トランザクションの実行前に必要なガス代が表示されますが,hardhatを用いてコマンドラインからトランザクションを実行する場合にはガス代が予測しにくいという問題があります.
ブロックチェーンの性質上ガス代を正確に予想することは困難ですが,NFT開発においてガス代の制御は重要な問題であるため,hardhat上でガス代の制御を行う方法をいくつか紹介します.

ガス代を推定する

hardhatのプラグインであるhardhat-gas-reporterを利用すれば,コントラクトのテスト時にガス代を推定することができます.
https://www.npmjs.com/package/hardhat-gas-reporter
hadhat.config.js内に以下のように記載することで利用できます.

hardhat.config.js
require("hardhat-gas-reporter");
...
module.exports = {
...
  gasReporter: {
    currency: 'JPY',
    coinmarketcap: 'YOUR_API_KEY',
    token: 'ETH',
    gasPrice: 30
  }
...
};

gasRepoterタグの中で色々な設定を行えます.currencyをJPYにするとガス代を現在の日本円価格で表示できます.この機能を使う際はCoinMarketCapから相場情報を取得する必要があるため,ApiKeyの取得も同時に必要です.詳しくはhardhatのドキュメントをご覧ください.
CoinMarketCapのApiキー取得は公式サイトから行ってください.
これは筆者が実際に開発を行った際の出力です.

デプロイにかかる費用以外に,それぞれの関数の実行にかかる費用も算出されます.筆者の実行時には,40gweiというgasPriceも相まって,デプロイに8万円以上かかる計算になってしまっています(笑).これは懐事情的にまずいので,最終的には3~4万ほどにダウンサイズしました.

コントラクトのサイズを表示する

hardhatのプラグインであるhardhat-contract-sizerを利用すれば,コードのコンパイル時にコントラクトのサイズを表示することができます.直接ガス代を計算することはできませんが,コードの圧縮によるガス代節約の確認などに有用です.
http://correccionesweb.com.ar/plugins/hardhat-contract-sizer.html
hadhat.config.js内に以下のように記載することで利用できます.

hardhat.config.js
require('hardhat-contract-sizer');
...
module.exports = {
...
  contractSizer: {
    alphaSort: false,
    runOnCompile: true,
    disambiguatePaths: false,
  }
...
};

詳しくはhardhatのドキュメントをご覧ください.

ガス代をハードコードする

上記二つのプラグインでガス代を推定できるとはいえ,ガス代を指定できているわけではないので不安が残ります.そういった場合は,コードからGas代を直接指定することが可能です.
そのためにはまず,Gas代について理解する必要がありますが,ここに関しては筆者の理解も不十分なのでご自身でも調査されることをお勧めします.
デプロイや関数実行時にかかるGasの総量は,

\textrm{GasUnit(処理するデータ量)} \times \textrm{GasPrice(1Unitあたりのガス価格)}

によって決定されます.また,処理するデータが予想したよりも膨大な量になり,大量のガス代を支払わなくてはならない状況に陥ることのないように,GasUnitの上限としてGasLimitが設定されています.

\textrm{GasUnit} \leqq \textrm{GasLimit}

GasPriceの高いものからトランザクションが通るので,GasPriceの価格は需要と供給によって変動します.
この値はetherscanのgasTrackerページなどから確認することができます.

hardhatでは,関数の実行時にgasPriceとgasLimitを指定することができます.gasLimitは安全装置的に働くものなので,gasLimitを指定する機会はあまりないと思って良いと思います.(故意に大量のデータを処理させたい場合など?)
gasPriceの指定の方法は,特定のチェーンに対する指定と特定の関数に対する指定の二つがあります.

  • 特定のチェーンに対する指定

特定のチェーンに対するgasPriceはhardhat.config.jsに書き込むことで指定できます.

hardhat.config.js
ethereum: {
      url: "https://eth-mainnet.alchemyapi.io/v2/" + alchemyApiKey,
      gasPrice: 25 * 10**9,
      chainId: 1,
      accounts: [ privateKey ]
    },

gasPriceはEthereumの最小単位であるwei(ウェイ)で指定されるので,末尾に10**9をつけるようにしてください.

  • 特定の関数に対する指定

特定のチェーンに対するgasPriceは実行する関数の引数にoptionオブジェクトを追加することで指定できます.

deploy.js
async function main() {
  const factory = await hre.ethers.getContractFactory("MusicNFT");
  const option = {
    gasPrice: 25 * 10**9
  }
  const contract = await factory.deploy(option);
  await contract.deployed();
  console.log("NFT deployed to:", contract.address);
  const gasPrice = contract.deployTransaction.gasPrice;
  const gasLimit = contract.deployTransaction.gasLimit;

  console.log("GasPrice(gwei):", gasPrice / 10**9);
  console.log("GasLimit:", gasLimit);
  console.log("GasFee:", ethers.utils.formatEther(gasPrice) * gasLimit)
}

6. 番外編:Openseaに最適化したNFT

Openseaでの取引を目的としたNFTを実装する場合,ガス代節約やOpenseaの仕様に対応するためにいくつかの機能を実装する必要があります.これらの機能は必要不可欠ではないものの,予想外のバグや不具合を起こさないために有効な手段となります.特にPolygonチェーンを利用する場合には非常に重要な機能です.

主な機能はisApprovalForAll関数のオーバーライドです.

function isApprovedForAll(
        address _owner,
        address _operator
    ) public override view returns (bool isOperator) {
       if (_operator == address(OPENSEA_PROXY_ADDRESS)) {
            return true;
        }
        return ERC1155.isApprovedForAll(_owner, _operator);
    }

Openseaの販売は,関数の実行権限をOperatorであるOpenseaのプロキシアドレスに移譲することによって行われるのでisApprovedForAll()の実行が必須ですが,これにもガス代が必要になります.
そのためOpenseaのプロキシアドレスをOperatorとして事前に設定しておけば,ガス代を抑えることができます.

Openseaのプロキシアドレスを以下にまとめました,使用するアドレスはチェーンごと,ERCの規格ごとに異なるので注意してください.
Ethereum

Polygon

7. まとめ

これで,OmnichainNFTの実装は以上となります.お疲れ様でした.ONFTに限らず,Polkadotなどブロックチェーンに流動性をもたらす試みは,今後も注視する必要があると思います.
LayerZeroやOmnichainNFTの実装にあたり,このブログが参考になったと感じて頂けた方は,ぜひ"いいね"とTwitterのフォローをお願いします.日々,OmnichainNFTやブロックチェーンに関する発信を行っています.
https://twitter.com/allegory_write/status/1527601388037226496?s=20&t=aFsJXpO89QAHcM8211IJgg
また,今回紹介したOmnichainNFTは,日本のレゲエアーティストhibikillaさんの音楽NFTに実装されています.興味を持った方は我々のDiscodeコミュニティに参加していただけるとこれほど嬉しいことはございません.
https://discord.gg/6V9tR6wS

Discussion