見出し画像

第7回 solidity学習会 講義ノート(4/16 AM8:00~) ERC 721Aを学ぼう!

こんにちは、CryptoGamesの高橋です。

クリスペの会社です。

また、CryptoMaidsのアンバサダーも務めさせていただいております。

今回は、4/16 AM8:00から次の場所で勉強会を行いますので、その講義ノートの公開です。

場所 meet.google.com/cas-bnjw-rpg

予習や復習などに役立てていただければ幸いです。

では、やっていきましょう。

0 はじめに

本日は、Azukiで採用されたERC721Aについて見ていきたいと思います。

なお、今回は、Azukiで用いられたバージョンのERC721Aを見ていきたいと思います。

Azukiコントラクト

https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#code

では、早速見ていきましょう。

1 ownershipOf関数とは

1ー1 概要

tokenIdを渡すとTokenOwnershipという情報が返ってきます。

所有者の情報が返ってくるのですね。

では、TokenOwnershipとはなんでしょう?

1ー2 TokenOwnershipとは(構造体)

こちらは構造体になります。

アドレスタイムスタンプが入っているようです。

つまり、tokenIdを渡すと、①アドレス②タイムスタンプの情報が入った、「TokenOwnership」を返してくれます。

ちなみに、タイムスタンプ

①ミント
②トランスファー
③バーン

の時のタイムスタンプのようです。

1ー3 _ownershipsとは(mapping)

では、下の部分を見てみましょう。

上で扱った、TokenOwnershipの構造体に_ownerships[curr]を代入しているようです。

_ownershipsとはmappingのようです。

tokenIdを入れるとそれに応じたTokenOwnershipが返ってくるようです。

つまり、こんな感じですね。

そのため、ここでは、例えばtokenId:1の場合、新しく作った「ownership」にtokenid:1のアドレスとタイムスタンプを入れているのですね。

1ー4 lowestTokenToCheckとは

次は、下を見てみましょう。

maxBatchSizeとは最大のミント数です。

上のようにtokenId:96, maxBatchSizeを5とした時、lowestTokenToCheckは92になります。

これはなんでしょう?

下を見てみると、TokenId:96から92までのうち、0アドレスでない場合を探しています。

つまり、このような状況です。

ということは

① _ownerships[トークンID]にアドレスが入っていない場合がある
② 別の_ownerships[トークンID]のアドレスを返しうる

ということがわかりました。

ここで、「lowestTokenToCheck」である92は96までを一括ミントした可能性があるTokenIdです。

1ー5 ミント時に何が行われた?(現時点では推定)

これを踏まえると、次のことが推測できます。

92から96までを一括ミントした場合、_ownerships[92]にだけ情報を入れ、93から96までは何も入れないということです。

これにより、96が0アドレスであっても92まで確認する必要があり、92にアドレスが入っていればそれを返すということがわかります。

しかし、例えば、ミント後にTokenId:94をトランスファーしたらどうでしょう?

このようなケースも踏まえながら、先を見ていきましょう。


2 balanceOf関数とは

2ー1 概要

では、次にbalanceOf関数を見ていきましょう。

オーナーのアドレスを渡すことで、NFTをいくつ持っているかを返してくれます。

最初の部分でオーナーが0アドレスでないことを確認しています。

では、次の_addressDataとはなんでしょう。

2ー2 AddressDataとは(構造体)

その前に、AddressDataという構造体を見てみましょう。

つまり、こんな感じですね、

2ー3 _addressDataとは(mapping)

では、_addressDataを見てみましょう。

 アドレスを渡すことで、AddressDataを取得することができます。

構造は_ownershipsと同じですね。

2ー4 結局、balanceOf関数とは?

では、balanceOf関数を見てみましょう。

_addressData[owner]のバランスをとっているようですね。

つまり、こんな挙動をしているようですね。

3 _numberMinted関数とは

こちらの関数はついでです。

ミントした数を取得するようですね。

構造はbalanceOf関数とほぼ同じです。

_addressData[owner]ミント数をとっているようですね。


4 _safeMintとは

では、ミントの部分を見てみましょう。

引数として、quantity(いくつミントするか)を渡しています。

4ー1 処理前の動作

まずは簡単に処理前を見ていきましょう。

4ー1ー1 startTokenId

まずはどこからミントを行うのかであるstartTokenIdcurrentIndexを入れます。

初期値は0ですね。

4ー1ー2 requireの処理について

ここでは簡単に。

最初にミント先が0アドレスでないことの確認を行っています。

次にstartTokenIdのトークンが存在していない(ミントされていない)ことを確認し、

最後にミントする量が上限以下かを確認しています。

maxBatchSizeが一度にミントができる最大の数です。

4ー2 _addressDataの更新

ミントによって、ミント先のアドレスの_addressData情報が変更します。

例えば、元々

①バランス:20
②ミント数:15

であり、今回新しく3個ミントするのであれば、次のように加える必要がありますね。

下の部分で実現しています。

4ー3 _ownershipsの登録

ここがポイントだと思います。

仮にtokenId:30~323個をミントした場合、_ownershipsを登録するのは先頭の30番だけです!

これが1ー5の箇所で触れた内容になります。

これを実現しているのが下の箇所です。

なんと一行なのですね。この一行がとても大事なポイントでした。

4ー4 currentIndexの更新

最後にcurrentIndexの更新です。

これを更新することで、次回どこからミントするのかを保持します。

今回は30~32をミントしたので、次回は33からですね。

イベントの発火やERC721Receivedについては今回は省略いたします。

4ー5 結局 _safeMintは何をやっているの?

今までのことを踏まえると、_safeMintは主に下の3つをやっていることがわかりました。

ぜひ、整理してみてください。

5 approve系を見てみよう

5ー1 isApprovedForAllについて

では、isApprovedForAllについて見てみましょう。

どうやら、①owner、②operatorの2つのアドレスを渡して、bool型(○か×か)を受け取っているようです。

この_operatorApprovalsってなんでしょう。[]が2つもあってちょっと難しそうですね。

5ー2 _operatorApprovalsとは(mapping)

では、見ていきましょう。

下のように、mappingの中にmappingがあるようです。

なんでこんなに複雑なんでしょう?

理由は明確で要素が3つあるからです。

あとは、書き方さえ押さえれば、良さそうですね。

5ー3 結局、isApprovedForAllって?

以上から、isApprovedForAllはAアドレスがBアドレスをapproveしているか否かをmappingを使って返していることがわかりました。

6 setApprovalForAllとは

こちらも一緒に見ていきましょう。

_operatorApprovals[][]truefalseを設定しているようですね。

図にするとこんな感じでしょうか。

7 mappingの初期状態は?

7ー1 問題提起

ここは曖昧にしてしまうと、想定とは異なる挙動になりやすい部分なので見ていきたいと思います。

例えば、今回、0xXX..123アドレス0xXX..012に対して、全く設定を行っていません。

その場合、_operatorApprovals[0xXX..123][0xXX..012]を取得しようとすると、次のどちらが起こるでしょうか?

① デフォルト値を取得する
② エラーが起こる(設定していないため)

7ー2 実際に試してみよう

実際にどうなるかを試してみましょう。

AzukiのEtherscanから適当なアドレスを入れてみましょう。

https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#readContract

すると、上のように、エラーではなくfalseという結果が返ってきました。

以上から、設定を行っていない場合、エラーではなく、デフォルト値が返ってくることがわかりました。

○ ① デフォルト値を取得する
× ② エラーが起こる(設定していないため)

実際にsolidityのドキュメントも見てみましょう。

下のように、全ての可能なキーが存在し、デフォルト値が割り当てられるようです。

そして、デフォルト値はbool型ならfalse、uintやint型なら0が割り当てられます。

7ー3 どんな想定外が起こりうるの?

これによって、どんな想定外の自体が起こり得るでしょうか?

仮に、下のようなそれぞれの人の好きな色を格納するmappingがあったとします。

現在、0xXX..012は設定を行っていません。

しかし、エラーではなく、デフォルト値の0が入ってしまうので、0xXX..012の値を取り出すと0(red)となってしまいます。

このような挙動を知ることで、少しでも想定外やエラーの防止につながっていくと思います。

8 getApprovedとは

ここはサラッと見ていきましょう。

_tokenApprovals[tokenId]を返していますね。

こんな感じのmappingになっていますね。

では、先ほどの復習もかねてEtherscanでtokenId:1を見てみましょう。

https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#readContract

下のように、「1」でQueryすると0アドレスが返ってきます。

これは0アドレスを設定しているのではなく何も設定を行っていないため、デフォルト値である0アドレスが返ってきていると考えるのが妥当です。

(ただ、_transfer時に0アドレスを設定された可能性も大いにあります。)

9 _transfer関数とは?

では、_transfer関数を見てみましょう。

9ー1 処理前の動作

まずは処理に入る前の動作を見てみましょう。

isApprovedOrOwnerboolを入れています。

実行するアドレスが、次のどれかである必要があります。

①現在の所有者
②getApprovedでtrue
③isApprovedForAllでtrue

その次の2つのrequireは次のことを確認しているようです。

①「from」が現在の所有者と一致している
②「to」は0アドレスではない

9ー2 _tokenApprovals[tokenId]の初期化

ここはとても大事な部分ですね。

現所有者が送付するtokenIdBアドレスにapproveをしていたとします。

もしこれがそのままの状態でtransferされてしまったら、transfer後もそのtokenIdをBアドレスで操作できてしまいます。

それを防ぐために、_approve関数で0アドレスを設定しています。

_tokenApprovals[tokenId]は1対1のmappingなので、0アドレスを設定することで、上書くことができます。

9ー3 _accessDataの更新

_accessDataはそのアドレスが持っている①バランス②ミントした数の情報でした。

そのため、送付先はバランスの量を1増やし、送付元は1減らせば良いですね。

9ー4 _ownershipsの更新①(トランスファーしたtokenId)

_ownershipsはtokenIdにどの①アドレス②タイムスタンプが紐づいているかのmappingでした。

トランスファーによってそのtokenId所有者が変わるので、更新を行います。

9ー5 _ownershipsの更新②(トランスファーした次のtokenId)

9ー5ー1 tokenIdの所有者情報の取得について(復習)

ownershipOf関数について、簡単に復習してみましょう(詳しくは第1章)

tokenId:96の所有者を探すとき、96番が0アドレスであれば、一つずつ遡り、最初に見つけた0アドレスでないアドレスを返していましたね。

では、ここで94番がトランスファーされたらどうでしょう?

96番は0xXXX..123が所有しているにもかかわらず、このままでは、0xXXX..465のものとして判定されてしまいます。

ということは、トランスファーした94番目の次が0アドレスであれば、0xXXX..123を入れれば良いですね。

あとはこれをコードで実現しているだけです。

①0アドレスか否か
②存在するか否か

を確認し、条件を満たす場合には、送付元の情報を入れています。

9ー6 結局_transfer関数は何をやっているの?

結局、下のようなことをやっていましたね。

ややこしいと思いますので、整理しながら見てみてください。

1_tokenApprovals[tokenId]の初期化
 ⇨前の所有者が操作できないように
2_accessDataの更新
 1)送付先 ⇨ バランスを1増やす
 2)送付元 ⇨ バランスを1減らす
3_ownershipsの更新①
 ⇨送付したtokenIdにアドレス情報を追加する
4_ownershipsの更新②
 ⇨送付したtokenIdの次が下の条件を満たす時、
  1)0アドレス
  2)存在する
  送付元のアドレス情報を追加する

10 tokenByIndex関数について

その全体の中で特定のindextokenIdを取得します。

tokenId全体のindexは一致するため、indexを返すだけです。

11 tokenOfOwnerByIndex関数について

11ー1 概要

では、tokenOfOwnerByIndex関数について見てみましょう。

特定のownerindex番目のtokenIdを取得します。

次のような例で考えていきましょう。

例えば、Cが所有している1番目のtokenIdは6が返ります。

11ー2 具体的に見てみよう

tokenId:0から順にtokenを見ていきます。

大きな流れとしては独立して次の2つを行います。

① 0アドレスか否か
 ⇨0アドレスでなければ、currOwnershipAddr変更する
currOwnershipAddrがCアドレスか否か
 ⇨
同じならtokenIdsIdxに+1,異れば次に行く

具体的に見てみましょう。

なお、CアドレスのIndex:1を探すものとします。

1) i = 0(tokenId:0)の時

2) i = 1(tokenId:1)の時

3) i = 4(tokenId:4)の時

4) i = 5(tokenId:5)の時

5) i = 6(tokenId:6)の時

コード自体は難しい構文などなさそうですので、上の動きになっているかを見てみてください。

12 コンストラクタについて

コンストラクタについても見てみましょう。

引数として渡すのは次の4つです。

①name_
②symbol_
③maxBatchSize_(一度にミントできる量)
④collectionSize_(コレクションのMaxサイズ)

13 ガス代の比較をしてみよう

では、コンストラクタとして渡すものもわかりましたので、ERC721とERC721Aでガス代を比較してみましょう。

13ー1 ERC721Aを利用した場合

テストとして、次のコードを実行してみます。

注意
こちらのコードはガス代の比較のみを目的としている、公式を参考にしたコードです。他の動作などは保証していません。

https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "./ERC721A.sol";
contract AzukiTest is ERC721A { constructor() ERC721A("AzukiTest", "AZUKI TEST",100,100) {}
    function mint(uint256 quantity) external payable { 
    // _safeMint's second argument now takes in a quantity, not a tokenId. 
    _safeMint(msg.sender, quantity); 
    }
}

13ー1ー1 デプロイ

デプロイしたところ、3,016,243gasでした。(後にRinkebyで試したところ、3,016,135gasでした。)

13ー1ー2 100個ミント

下のように、340,681gasでした。(後にRinkebyで試したところ、340,681gasでした。(同じ))

13ー1ー3 送付①(tokenId:50)

下のように、245,866gasでした。(後にRinkebyで試したところ、245,854gasでした)

13ー1ー4 送付②(tokenId:49)

次のtokenId:50が既にミント済みの場合の検証です。

この場合、tokenId:50の更新が不要のため、若干ガス代が下がります。

下のように、205,178gasでした。(後にRinkebyで試したところ、205,166gasでした)

13ー2 ERC721Enumerableを利用した場合

テストとして、次のコードを実行してみます。

注意
こちらのコードはガス代の比較のみを目的としているコードです。他の動作などは保証していません。
比較を行うため、ownableなどの通常必要なものもつけていません。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract AzukiTest2 is ERC721Enumerable {

    constructor() ERC721("AzukiTest", "AZUKI TEST") {} 

    function mint(uint256 quantity) external payable { 
        for(uint256 i = 0; i < quantity; i++) { 
            _safeMint(msg.sender, i); 
        } 
    }
}

13ー2ー1 デプロイ

デプロイしたところ、2,738,229gasでした。

13ー2ー2 100個ミント

100個のミントをしようとすると、下のようなエラーが出てしまいました。

そのため、これ以降は、Rinkebyで行います。

ただ、それにより、gas自体は変わらないことを確認するため、デプロイ時のgasを確認します。

下のように、11,523,464gasでした。


13ー2ー3 送付(tokenId:49)

下のように、88,824gasでした。

13ー3 まとめてみよう

まとめてみると、下のようになりました。

小さい方を赤色太字にしています。

デプロイやトランスファーではERC721Enumerableの方が小さいものの、100個のミント時などに大きな差が出ることがわかりました。


本日は以上です。

サポートをしていただけたらすごく嬉しいです😄 いただけたサポートを励みに、これからもコツコツ頑張っていきます😊