よく使うERC721の各種コントラクトのガス比較を行ってみた
OpenZeppelin V5.0 を読み解くついでに、ERC721 関係のガス代の検証を行ってみました。
ストレージの構造などすごい感銘を受けたため、個人的には、ERC721A 推しですがかなり厳しい戦いになりそう。
結果
検証方法については後述します。
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 するコントラクトを切り替えて検証しました。
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 チェックを追加しています。
function burn(uint256 tokenId) public {
address owner = ownerOf(tokenId);
require(msg.sender == owner, "Not the owner");
_burn(tokenId);
}
機能チェック
BaseCheck.t.sol
にて transfer とか burn のチェックを行っています。
ここではイコールコンディションのチェックだけしたかったので、owner 以外が呼び出せないかどうかしかチェックしていません。
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)
の 引数はこんな感じです。
erc721Contract = new Erc721();
keys[0] = forEachFunction(from, to, 1, 1);
この forEachFunction では、premint → mint → transfer → burn しています。
各 function 毎のガスの計測は下記の通り行っています。
startGas = gasleft();
erc721Contract.mint(quantity);
gasUsages[key].mint = startGas - gasleft();
各種計測結果は、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();
}
つまり下記のような実装をしてしまうと、がっつりガス代がかかることに・・・。
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