👻

よく使うERC721の各種コントラクトのガス比較を行ってみた

2023/10/31に公開

OpenZeppelin V5.0 を読み解くついでに、ERC721 関係のガス代の検証を行ってみました。

ストレージの構造などすごい感銘を受けたため、個人的には、ERC721A 推しですがかなり厳しい戦いになりそう。

https://github.com/masahirodev/compare-erc721

結果

検証方法については後述します。

totalSupply の仕様が異なるため、tokenId position における差異が出ました。
発行総数が 10,000 になるように調整した結果を下記に示しています。

単体 mint を想定した場合(mint1premint9999)

  • mint:ERC721A と ERC721Solmate が安い
  • transfer:ERC721Psi が安い
  • burn:ERC721Solmate が安い
contract scenario mintAverage transferFromFirst transferFromLast burnFirst burnLast
ERC721A mint1 25934 28594 0 24325 0
ERC721Oz mint1 115932 53028 0 7401 0
ERC721Psi mint1 48507 15047 0 25957 0
ERC721Solmate mint1 26105 28435 0 4203 0

複数 mint を想定した場合(mint10premint9990)

  • mint:ERC721A と ERC721Psi が安い
  • transfer:ERC721Solmate が安い
  • burn:ERC721Solmate が安い
contract scenario mintAverage transferFromFirst transferFromLast burnFirst burnLast
ERC721A mint10 4362 50991 46628 24325 4425
ERC721Oz mint10 114963 53622 51622 7995 7401
ERC721Psi mint10 6751 37634 35314 25957 6057
ERC721Solmate mint10 25156 28435 6535 4203 4203

結論

どのような機能を実装するかによって変動するため一概には言えませんが、

  • 機能性を持たせた NFT を作る場合は、ERC721Solmate が最適かもしれない。
  • PFP 作るなら ERC721A が最適かもしれない。

検証について

  • 対象とするコントラクト:ERC721A,ERC721Oz(OpenZeppelin),ERC721Psi,ERC721Solmate
  • ケース:単数 mint,複数 mint(1 回のセールで制限を設けることも多く 10 枚を最大値にしました)
  • 各種 function について
    • mint:複数枚 mint を想定した作りになっています。最初から単数 mint を想定した作りだと多少結果が変わるかと。
    • transfer:特に差異はありませんでした。
    • burn:token owner のチェックがあるものと無いものがありました。

tokenId position は関係ないはずなんですが、気になるコメントを見たので念の為検証してみました。

※なお、OpenZeppelin V5.0 に伴い、ERC721Solmate を少し書き換えました。

ソースコードについて

癖で fuzz test で書いてますが、あまり意味がない fuzz なので固定値で検証すると速度が上がると思います。

ちゃんと分けて検証しても良かったのですが、手間暇かけるのもアレだったので、import するコントラクトを切り替えて検証しました。

Erc721.t.sol
import {Erc721} from "src/Erc721Oz.sol";
// import {Erc721} from "src/Erc721A.sol";
// import {Erc721} from "src/Erc721Solmate.sol";
// import {Erc721} from "src/Erc721Psi.sol";

各コントラクト

mint,tansferFrom,burnを実行するために必要なものを継承しています。
次の機能チェックを行って、イコールコンディションに持って行ってます。

例えば、Erc721Solmateは、burn の際、owner チェックがないため、owner チェックを追加しています。

src/Erc721Solmate.sol
    function burn(uint256 tokenId) public {
        address owner = ownerOf(tokenId);
        require(msg.sender == owner, "Not the owner");
        _burn(tokenId);
    }

機能チェック

BaseCheck.t.sol にて transfer とか burn のチェックを行っています。
ここではイコールコンディションのチェックだけしたかったので、owner 以外が呼び出せないかどうかしかチェックしていません。

test/BaseCheck.t.sol
    function testFailTransferFrom(address owner, address notOwner) public User(owner) {
        vm.assume(owner != zeroAddress && notOwner != zeroAddress && owner != notOwner);

        uint256 quantity = 1;
        uint256 tokenId = quantity - 1;

        erc721Contract.mint(quantity);

        // msg.sender = from --> to
        vm.startPrank(notOwner);

        erc721Contract.transferFrom(owner, notOwner, tokenId);
    }

実際の検証

Erc721.t.sol にて実際の検証を行っています。毎回コントラクトをリセットして、forEachFunction を実行しています。
forEachFunction(address from, address to, uint256 quantity, uint256 premintQuantity)の 引数はこんな感じです。

Erc721.t.sol
erc721Contract = new Erc721();
keys[0] = forEachFunction(from, to, 1, 1);

この forEachFunction では、premint → mint → transfer → burn しています。

各 function 毎のガスの計測は下記の通り行っています。

Erc721.t.sol
startGas = gasleft();
erc721Contract.mint(quantity);
gasUsages[key].mint = startGas - gasleft();

各種計測結果は、test/helper/GasReport.t.solにて書き出しています。

test/helper/GasReport.t.sol
    function createGasReport(string memory targetContract, string[] memory keys) public {
        string memory path = string(abi.encodePacked("./dist/", targetContract, ".json"));

        string memory writeJson;
        string memory object;

        uint256 len = keys.length;

        for (uint256 i = 0; i < len;) {
            string memory key = keys[i];

            // create writeJson
            object = vm.serializeString("index", key, "");
            unchecked {
                i++;
            }
        }
        writeJson = vm.serializeString("result", "result", object);
        vm.writeJson(writeJson, path);

        for (uint256 i = 0; i < len;) {
            string memory key = keys[i];
            GasUsage memory gasUsage = gasUsages[key];

            // create writeJson
            object = vm.serializeUint(key, "mint", gasUsage.mint);
            object = vm.serializeUint(key, "mintAverage", gasUsage.mintAverage);
            object = vm.serializeUint(key, "transferFromFirst", gasUsage.transferFromFirst);
            object = vm.serializeUint(key, "transferFromLast", gasUsage.transferFromLast);
            object = vm.serializeUint(key, "burnFirst", gasUsage.burnFirst);
            object = vm.serializeUint(key, "burnLast", gasUsage.burnLast);

            writeJson = vm.serializeString(i.toString(), key, object);

            vm.writeJson(object, path, string(abi.encodePacked(".result.", key)));

            unchecked {
                i++;
            }
        }
    }

出力結果は、distフォルダに出力されます。import するコントラクト名での書き出しになっています。

totalSupply

totalSupplyについて、ERC721Psi は仕様をチェックした方が良いというご指摘をいただきました。
調べてみると、ERC721Psi では、totalSupply 用のストレージを持たず、都度算出するようになっています。

function totalSupply() public view virtual override returns (uint256) {
    return _totalMinted() - _burned();
}

つまり下記のような実装をしてしまうと、がっつりガス代がかかることに・・・。

src/Erc721Psi.sol
function supplyCheckMint(uint256 quantity) external payable {
    if (totalSupply() + quantity > 10_000) {
        revert ExceedSupply();
    }
    _mint(msg.sender, quantity);
}

mint10premint9990の条件で検証した結果だと、mintAverageで 16,268 でした。

mint5premint9995の条件で検証した結果だと、mintAverageで 30,424 に跳ね上がっていました。その辺りも深く掘り下げていかないといけないなぁって思っています。

ERC721Psi の内部構造を見直すのが最適ではありますが、下記のような実装でも相当ガス代が抑えられます。
totalSupply 用のストレージとして、counterを追加しています。

function supplyCheckMint(uint256 quantity) external payable {
    if (counter + quantity > 10_000) {
            revert ExceedSupply();
    }
    counter = counter + quantity;
    _mint(msg.sender, quantity);
}

mint10premint9990の条件で検証した結果だと、mintAverageで 6,751 になります。

コントラクトの内部構造をきっちり理解していないと気づけないポイントでした。ガス代の検証は、奥が深い。


いかがだったでしょうか?

コントラクトによって採用しているストレージ構造が異なるので、使用用途に合わせてコントラクトを切り替えられるようになりたいですね。

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion