第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アドレスでない場合を探しています。
つまり、このような状況です。
ということは
ということがわかりました。
ここで、「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
まずはどこからミントを行うのかであるstartTokenIdにcurrentIndexを入れます。
初期値は0ですね。
4ー1ー2 requireの処理について
ここでは簡単に。
最初にミント先が0アドレスでないことの確認を行っています。
次にstartTokenIdのトークンが存在していない(ミントされていない)ことを確認し、
最後にミントする量が上限以下かを確認しています。
maxBatchSizeが一度にミントができる最大の数です。
4ー2 _addressDataの更新
ミントによって、ミント先のアドレスの_addressDataの情報が変更します。
例えば、元々
であり、今回新しく3個ミントするのであれば、次のように加える必要がありますね。
下の部分で実現しています。
4ー3 _ownershipsの登録
ここがポイントだと思います。
仮にtokenId:30~32の3個をミントした場合、_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[][]にtrueかfalseを設定しているようですね。
図にするとこんな感じでしょうか。
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 処理前の動作
まずは処理に入る前の動作を見てみましょう。
isApprovedOrOwnerにboolを入れています。
実行するアドレスが、次のどれかである必要があります。
その次の2つのrequireは次のことを確認しているようです。
9ー2 _tokenApprovals[tokenId]の初期化
ここはとても大事な部分ですね。
現所有者が送付するtokenIdにBアドレスに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を入れれば良いですね。
あとはこれをコードで実現しているだけです。
を確認し、条件を満たす場合には、送付元の情報を入れています。
9ー6 結局_transfer関数は何をやっているの?
結局、下のようなことをやっていましたね。
ややこしいと思いますので、整理しながら見てみてください。
10 tokenByIndex関数について
その全体の中で特定のindexのtokenIdを取得します。
tokenIdと全体のindexは一致するため、indexを返すだけです。
11 tokenOfOwnerByIndex関数について
11ー1 概要
では、tokenOfOwnerByIndex関数について見てみましょう。
特定のownerのindex番目のtokenIdを取得します。
次のような例で考えていきましょう。
例えば、Cが所有している1番目のtokenIdは6が返ります。
11ー2 具体的に見てみよう
tokenId:0から順にtokenを見ていきます。
大きな流れとしては独立して次の2つを行います。
具体的に見てみましょう。
なお、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つです。
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を利用した場合
テストとして、次のコードを実行してみます。
// 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個のミント時などに大きな差が出ることがわかりました。
本日は以上です。
サポートをしていただけたらすごく嬉しいです😄 いただけたサポートを励みに、これからもコツコツ頑張っていきます😊