Now and Nawoo

NFTの制作記録、技術メモ → C#, Solidity, Blockchain, Bitcoin, Ethereum, NFT

失敗談: NFTを作ったらmintできなくなってしまった

ERC721EnumerableとERC721Burnableを継承してNFTを作って、テストネットにデプロイしたのですが、 mintできなくなってしまいました。

バッドケースとして共有しておきます。

作成したコントラクト

すごくシンプルに書くとこうなります。(他の関数は省略)

contract MyNFT is ERC721Enumerable, ERC721Burnable {
    function mint() public {
        uint256 tokenId = totalSupply();
        _safeMint(_msgSender(), tokenId);
    }
   //...略...
}

totalSupply()で総発行数を取得して、それをtokenIdにしています。 mintが呼ばれる度にtokenIdが0,1,2...と増えていく、という単純な仕組みです。

ところが

これをデプロイして、mintを2回して、burnして、もう一度mintしようとすると、

Error: execution reverted: ERC721: token already minted

というエラーが出て、mintできなくなってしまいました。

なんで?

totalSupply()は、burnされた分は除外して総発行数を返すようです。つまり減ることもある。(知らんかった。。。)

  • 最初の状態では、 totalSupply = 0
  • mint 1回目 (tokenID=0) → totalSupply = 1 に増える
  • mint 2回目 (tokenID=1) → totalSupply = 2 に増える
  • tokenID=0をburn → totalSupply = 1 に減る
  • mint 3回目 (tokenID=1) → エラー

となってしまいます。3回目のmintのときに tokenId=1で発行しようとしますが、1番は発行済のためにエラーになってしまう、というわけです。

(追記:ここの説明が間違ってたので修正しました。)

まとめ

tokenIdにtotalSupply()を使うのは危険。tokenId用の変数を用意しよう。

デプロイ後に気づいたので削除も修正もできず、失敗作が(テストネットとはいえ)ブロックチェーン上に残ることに。。。orz

burnによってtotalSupplyが減るまでの処理を追ってみる

burnというのは単にアドレス(0x0)に送信するもの、というふうに思っていましたが、 実際にどういう処理をしているのか見てみました。

まず、ERC721Burnableのburnからスタートします。多重継承なので、あっちこっち飛んでわかりにくいです。

// ERC721Burnable.sol
function burn(uint256 tokenId) public virtual {
    require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721Burnable: caller is not owner nor approved");
    _burn(tokenId);  // ←
}

ERC721の_burnを呼び出し。

// ERC721.sol
function _burn(uint256 tokenId) internal virtual {
    address owner = ERC721.ownerOf(tokenId);
    _beforeTokenTransfer(owner, address(0), tokenId);  // ←
    _approve(address(0), tokenId);
    _balances[owner] -= 1;
    delete _owners[tokenId];
    emit Transfer(owner, address(0), tokenId);
}

ERC721Enumerableの_beforeTokenTransferを呼び出し。

// ERC721Enumerable.sol
function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal virtual override {
    super._beforeTokenTransfer(from, to, tokenId);
    if (from == address(0)) {
        _addTokenToAllTokensEnumeration(tokenId);
    } else if (from != to) {
        _removeTokenFromOwnerEnumeration(from, tokenId);
    }
    if (to == address(0)) {
        _removeTokenFromAllTokensEnumeration(tokenId);  // ←
    } else if (to != from) {
        _addTokenToOwnerEnumeration(to, tokenId);
    }
}

to==address(0) なので、_removeTokenFromAllTokensEnumeration を呼び出し。

// ERC721Enumerable.sol
function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {
    uint256 lastTokenIndex = _allTokens.length - 1;
    uint256 tokenIndex = _allTokensIndex[tokenId];
    uint256 lastTokenId = _allTokens[lastTokenIndex];
    _allTokens[tokenIndex] = lastTokenId;
    _allTokensIndex[lastTokenId] = tokenIndex;
    delete _allTokensIndex[tokenId];
    _allTokens.pop();  // ←
}

最後の_allTokens.pop()で最後の要素が削除され、_allTokens.lengthが一つ小さくなります。

// ERC721Enumerable.sol
function totalSupply() public view virtual override returns (uint256) {
    return _allTokens.length;
}

totalSupply関数は _allTokens.length を返しているので、burnによってtotalSupplyが1つ減ることがわかります。

ただし、burnしたtokenIdは再びmintすることは可能です。

おまけ:今日の運勢

f:id:nawoo5:20211004143146p:plain