この記事では、NFTのアローリスト(ホワイトリスト)をスマートコントラクトで実装しサイトからNFTを購入する方法を紹介します。
スマートコントラクトのコードは誰でも見れるので、間違ったセールのコードを書いてしまうとハッキングのリスクがあります。
なので、安全なアローリスト登録のコードを書きたい方はぜひ見てください!
対象読者
この記事は自分でコードを書いてNFTを発行したい方向けです。
特にギミックがない簡単なNFTならthirdwebなどを使った方が安全だしいいと思います。
また、Solidityの基礎ができていることを想定しています。
フロントに関しては購入サイト(ミントサイト)をゼロから作りたい方向けです。
NFTのアローリスト登録をする方法は3つある【マークルツリーがおすすめ】
NFTのアローリストの登録をスマートコントラクトで実装する方法は3つあります。
難しそうなものが3つ並んでいますが、タイトルにある通り特別な理由がない限りマークルツリーを採用してください。
なぜかというと、mapping
は簡単だけどガス代かなりかかりますし、ECDSA
は実装の難易度が高くてハッキングの落とし穴も結構あるからです。
それに比べてマークルツリーは多くのNFTプロジェクトで採用されており、ガス代あまりかかりませんし実装難易度も ECDSA
に比べたら難しくありません。
なので今回はマークルツリーの実装方法を詳しくみていきたいと思います!
CNPRのECDSAについて
当時はセールを複数回に分けて実地している事例がなくて、ガス代などを含めて何回もセールをするなら ECDSA
の方が良いと思い採用しました。
今はマークルツリーで複数回セールをしている事例はたくさんあるので分割セールでもマークルツリーでいいと思います。
僕もZUTTO MAMORUでは ECDSA
ではなくマークルツリーを採用しました。
マークルツリーの基礎
先ほどから単語が出ているマークルツリーについて解説します。
マークルツリーはブロックチェーンにも使われており、主にたくさんのデータを使って何かチェックしたいときに使います。
例えば、以前もらった色付きの箱が複数あって、前と比べて凹んでいないか確認するときは普通に見て確認しますよね。
ただ、これが1000万以上ある場合だと一個一個確認するのきついですよね。
そこで使われるのがマークルツリーです。
確認したい箱をマークルツリーの機械に入れると必ず一定の大きさの文字列を返してくれます。
また、最後に出てくる文字列は適当に出てくるのではなくて以下の法則を持っています。
- 同じ箱を入れたら出てくる文字列は常に一緒
- 出てきた文字列から入れる前の箱を調べることができない
- 少しでも入れた箱が違うと全く違う文字列が出てくる
順番に見ていきましょう。
同じ箱を入れたら出てくる文字列は常に一緒
これは同じものを入れたら常に同じデータが返ってくるということです。
change
を押した後に reset
を押してみてください。
0x
の後の値が最初の値と変わっていないのがわかると思います。
出てきた文字列から入れる前の箱を調べることができない
こちらはタイトル通り、文字列をどんなに調べてもそこから使われているデータを当てることはできないということです。
先ほどの例は下の値が公開されていたので文字列を作るのに何が使われていたのか分かりましたが、以下の情報だけだとわからないですよね。
少しでも入れた箱が違うと全く違う文字列が出てくる
下記のデモで実際に角を変えてみてください。
ちょっとだけしか変えていないのに上のラベルの文字列がかなり変わっているのが確認できると思います。
上記を踏まえて、1000万の箱を以前と比べて凹んでいないか確認したいときは以下の手順でチェックできます。
-
箱をもらった時にペアを作り最後の一個になるまでマークルツリーの機械に入れて出てきた文字列をメモしておく
-
凹んでいないかチェックする時も同じ処理をして最後に出てきた文字列があっているかチェックする
これだけで1000万の箱が凹んでいないかチェックできます。
一連の流れを画像で説明するとこんな感じです。
画像についての注意
1000万のデータを画像で表すのは無理なので、簡略化しています。
なぜ最後の文字列だけ見ればいいかというと先ほどのデモで見た少しでも入れたデータが違うと全く違う文字列が出てくると言う性質があるので、最後に出てくる値が違ってきてしまうからですね。
この最後の値だけ確認すればいいと言うのがアローリスト登録で重要です。
なぜならイーサリアムではデータを設定する度にガス代がかかるのですが、マークルツリーならどんなにデータが多くても最後の文字列を設定するだけで検証できます。
なのでアローリストの登録をするのにガス代をすごく安くできるので、特定の人しかNFTを購入できないようにしたいときに使えるんですね!
マークルツリーの基礎について分かったと思うので、次の章では実際にアローリストを登録する方法を解説します。
マークルツリーの用語
Leaves(Leaf)
データをハッシュ化したものNodes(Node)
LeafまたはNodeを組み合わせてしてハッシュ化したものRoot
最後に出てくる文字列先ほどの図で表すとこんな感じです。
マークルツリーでNFTのアローリスト登録を実装する
さてマークルツリーについて大体わかったところで、実際にマークルツリーでNFTのアローリストを登録していきましょう!
さっきは登録するデータが箱でしたがそれをアドレスと購入できる最大数に変更する感じですね。
アローリストを登録するときはアドレスのみじゃなくて、アドレスと購入できる最大数を登録するのが一般的なのでこの二つを設定する方法を見ていきます!
手順としては以下の通りです。
- スマートコントラクトを作る
- テストコードを書く
- マークルツリーの認証をフロントで作る
では見ていきましょう!
環境構築
スマートコントラクトを作る
完成系のコードはこちらです。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTFree is ERC721A("MyToken", "MTK"), Ownable {
enum SalePhase {
Locked,
Presale
}
SalePhase public phase = SalePhase.Locked;
bytes32 public merkleRoot;
mapping(address user => uint256 mintAmount) public presaleMintCount;
// =============================================================
// ONLY OWNER
// =============================================================
function setPhase(SalePhase _phase) external onlyOwner {
phase = _phase;
}
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
// =============================================================
// MINT FUNCTION
// =============================================================
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
require(phase == SalePhase.Presale, "presale event is not active");
require(isWhitelisted(msg.sender, _maxMintAmount, _proof), "you don't have a whitelist");
require(presaleMintCount[msg.sender] + _mintAmount <= _maxMintAmount, "exceeds number of earned tokens");
presaleMintCount[msg.sender] += _mintAmount;
_mint(msg.sender, _mintAmount);
}
// =============================================================
// MERKLE TREE
// =============================================================
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
いきなりコードを全部出されても分かりずらいと思うので順番に解説していきます。
まずは以下のライブラリをインストールします。
openzeppelinは安全なコードを提供してくれるところでたくさんの人が利用しています。
今回は、MerkleProofとOwnableを使います。
また、NFTプロジェクトを作る場合はERC721Aが使われることが多いので今回はこちらをベースとして使います。
インストールが完了したら、.sol
ファイルを作りライブラリをインポートしてコードを以下のようにします。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
-
SPDX-License-Identifier: MIT
コードのライセンス -
pragma solidity
バージョン指定
次に contract
を作り ERC721A
と Ownable
を継承します。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFT is ERC721A("MyToken", "MTK"), Ownable {
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
MyToken
がトークン名で MTK
がシンボル名です。
ERC721AはトークンIDが 0
から始まるので 1
から始まるように変更しています。
これで準備ができたのでセールのコードを追加します。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTFree is ERC721A("MyToken", "MTK"), Ownable {
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
_mint(msg.sender, _mintAmount);
}
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
presaleMint
は3つの引数を受け取ります。
-
_mintAmount
購入する枚数 -
_maxMintAmount
最大で購入できる枚数 -
_proof
マークルツリー内にデータがあるかどうか調べる
その下にERC721Aの _mint
関数を設置しています。
これを実行すると購入しようとしている人に〇〇量のNFTが送られます。
ただこれだと誰でも実行できてしまうので、マークルツリーで特定の人しか購入できないようにしていきましょう!
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTFree is ERC721A("MyToken", "MTK"), Ownable {
bytes32 public merkleRoot;
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
require(isWhitelisted(msg.sender, _maxMintAmount, _proof), "you don't have a whitelist");
_mint(msg.sender, _mintAmount);
}
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
merkleRoot
を誰でも変更できてしまうと悪用されてしまうので setMerkleRoot
に onlyOwner
をつけています。
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
onlyOwner
をつけると関数を実行できるのをコントラクトのオーナーのみに制限してくれます。
また、 presaleMint
に require
を追加して isWhitelisted
が false
だったらエラーが出るようにしています。
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
require(isWhitelisted(msg.sender, _maxMintAmount, _proof), "you don't have a whitelist");
_mint(msg.sender, _mintAmount);
}
isWhitelisted
では、ウォレットアドレスと購入できる最大数で leaf
を作ってマークルツリーの認証をしています。
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
結構難しそうなことをしているように見えますが、一個一個見ていきます。
まず、abi.encodePacked
はデータをくっつけて バイト型
に変換します。
なぜ、これをやるかというとデータを バイト型
に変えないとEVMが読み取れないからですね。
もう少し詳しくいうとEVMがバイトデータを扱うようにできていてそこで使われる keccak256
の受け付けるデータが バイト型
だから変換しています。
試しに以下のコードを作って呼び出すとコメントアウトのデータが返ってきます。
//return: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000000a
function testAbiEncodePacked() public pure returns (bytes memory) {
address a = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
uint256 b = 10;
return abi.encodePacked(a, b);
}
さらに詳しく見たい方はこちらのabi.encodePacked()の働きを見てみようがわかりやすいと思います。
ただ、これだと何のデータを入れたかわかってしまうので keccak256
で一定の長さのハッシュ値に変換します。
まぁ要するに入れたデータをわからないようにして悪いことできないようにしている感じです。
前の章のマークルツリーの基礎で説明した常に一定の大きさの文字列を返して逆から計算することができないというのはこの機能を使っているからです。
試しに先ほどのデータに対して keccak256
を使ってみるとコメントアウトのデータが返ってきます。
//return: 0x569ab48c70cc15322c9253243aab005d1c64df7c33031cf8dfb5a8ac071d368d
function testKeccak256AbiEncode() public pure returns (bytes32) {
address a = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
uint256 b = 10;
return keccak256(abi.encodePacked(a, b));
}
さっきの文字列に比べてだいぶ短くなったのがわかると思います。
また、Chatgptに値を戻すように指示しても無理ですね。
ちなみに前の 0x
は文字列が16進数であることを表しています。
さらに詳しくみたい方はこちらのHashing Functions In Solidity Using Keccak256が参考になると思います。
まとめると leaf
を生成するためにやっていることは以下の通りです。
_address
と_maxMintAmount
をくっつけてバイト型
にデータを変換- それをハッシュ化して一定の長さの文字列に変換
これだけです。
次に、MerkleProof.verifyCalldata
を見ていきます。
ここでは、3つの引数を受け取って root
の中にさっき作った leaf
があるかどうか確認しています。
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
leaf
が root
の中にあるかどうか確認するためには、leaf
と proof
から root
をもう一回計算して設定されている root
と同じかどうか比較します。
function verifyCalldata(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
return processProofCalldata(proof, leaf) == root;
}
例えば、青の箱が root
の中に含まれているかどうか確認したいときは、水色の枠線のハッシュ値が必要です。
bytes32[] calldata proof
とあるように渡すときはそれらを配列に入れて渡します。
これで特定の人しか購入できないNFTのセールのコントラクトを作ることができました!
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTFree is ERC721A("MyToken", "MTK"), Ownable {
bytes32 public merkleRoot;
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
require(isWhitelisted(msg.sender, _maxMintAmount, _proof), "you don't have a whitelist");
_mint(msg.sender, _mintAmount);
}
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
ただこのままだとアローリストを登録している人が無制限に購入できてしまうので、もう少しコントラクトにコードを追加します。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "erc721a/contracts//ERC721A.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTFree is ERC721A("MyToken", "MTK"), Ownable {
enum SalePhase {
Locked,
Presale
}
SalePhase public phase = SalePhase.Locked;
bytes32 public merkleRoot;
mapping(address user => uint256 mintAmount) public presaleMintCount;
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
function setPhase(SalePhase _phase) external onlyOwner {
phase = _phase;
}
function presaleMint(uint256 _mintAmount, uint256 _maxMintAmount, bytes32[] calldata _proof) external {
require(phase == SalePhase.Presale, "presale event is not active");
require(isWhitelisted(msg.sender, _maxMintAmount, _proof), "you don't have a whitelist");
require(presaleMintCount[msg.sender] + _mintAmount <= _maxMintAmount, "exceeds number of earned tokens");
presaleMintCount[msg.sender] += _mintAmount;
_mint(msg.sender, _mintAmount);
}
function isWhitelisted(
address _address,
uint256 _maxMintAmount,
bytes32[] calldata _proof
) public view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_address, _maxMintAmount));
return MerkleProof.verifyCalldata(_proof, merkleRoot, leaf);
}
function _startTokenId() internal view virtual override returns (uint256) {
return 1;
}
}
これでセールの時間にならないと購入できなくて、購入上限に達したらエラーになるようにできました!
有料販売の場合
価格をつけて販売する場合はNFTPayableを参考にしてみてください。
テストコードを書く
上記まででコントラクトのコードは完成したのですが、スマートコントラクトの開発は一つのミスが大惨事になる可能性があるので、作るだけじゃなくてテストしないとダメです。
実際スマートコントラクトの開発をする場合、テストコードを書いている時間の方が長くなると思います😓
例えばfoundryの場合は以下のようにテストしていきます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "murky/Merkle.sol";
abstract contract MerkleTreeHelper is Test, Merkle {
struct MerkleDataSet {
address[] accounts;
uint256[] units;
bytes32[] leaves;
bytes32 root;
}
function generateMerkleData(
address[] memory addresses,
uint256[] memory units
) public pure returns (bytes32[] memory leaves) {
leaves = new bytes32[](addresses.length);
for (uint256 i = 0; i < addresses.length; i++) {
leaves[i] = keccak256(abi.encodePacked(addresses[i], units[i]));
}
return leaves;
}
function createMerkleDataset(uint256 size) internal pure returns (MerkleDataSet memory) {
address[] memory accounts;
uint256[] memory units;
bytes32[] memory leaves;
bytes32 root;
accounts = new address[](size);
units = new uint256[](size);
for (uint256 i = 0; i < accounts.length; i++) {
accounts[i] = vm.addr(i + 1);
units[i] = size + i;
}
leaves = generateMerkleData(accounts, units);
root = getRoot(leaves);
return MerkleDataSet(accounts, units, leaves, root);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "src/NftFree.sol";
import "./util/MerkleTreeHelper.sol";
contract NFTFreeTest is MerkleTreeHelper {
NFTFree public nftContract;
address public owner;
MerkleDataSet internal merkleDataset;
function setUp() public {
nftContract = new NFTFree();
merkleDataset = createMerkleDataset(10);
owner = nftContract.owner();
vm.startPrank(owner);
nftContract.setMerkleRoot(merkleDataset.root);
vm.stopPrank();
}
modifier saleStart() {
vm.startPrank(owner);
nftContract.setPhase(NFTFree.SalePhase.Presale);
vm.stopPrank();
_;
}
// =============================================================
// UNIT
// =============================================================
function testCheckInitValue() public {
assertEq(nftContract.name(), "MyToken");
assertEq(nftContract.symbol(), "MTK");
assertEq(nftContract.owner(), owner);
assertEq(nftContract.merkleRoot(), merkleDataset.root);
}
// access controll
function testFailtNotOwner() public {
vm.startPrank(vm.addr(1));
nftContract.setPhase(NFTFree.SalePhase.Presale);
}
// sale
function testRevertPresaleMintNotStartSale() public {
bytes32[] memory proof = getProof(merkleDataset.leaves, 0);
vm.startPrank(vm.addr(1));
vm.expectRevert("presale event is not active");
nftContract.presaleMint(10, 10, proof);
}
function testRevertPresaleMintNotRoot() external saleStart {
MerkleDataSet memory anotherMerkleDataset = createMerkleDataset(15);
vm.startPrank(owner);
nftContract.setMerkleRoot(anotherMerkleDataset.root);
vm.stopPrank();
bytes32[] memory proof = getProof(merkleDataset.leaves, 0);
vm.startPrank(vm.addr(1));
vm.expectRevert("you don't have a whitelist");
nftContract.presaleMint(1, 10, proof);
}
function testRevertPresaleMintNotAddress() external saleStart {
bytes32[] memory proof = getProof(merkleDataset.leaves, 0);
vm.startPrank(vm.addr(2));
vm.expectRevert("you don't have a whitelist");
nftContract.presaleMint(1, 10, proof);
}
function testRevertPresaleMintNotProof() external saleStart {
bytes32[] memory proof = getProof(merkleDataset.leaves, 1);
vm.startPrank(vm.addr(1));
vm.expectRevert("you don't have a whitelist");
nftContract.presaleMint(1, 10, proof);
}
function testRevertPresaleMintNotAllowtedAmount() external saleStart {
bytes32[] memory proof = getProof(merkleDataset.leaves, 0);
vm.startPrank(vm.addr(1));
vm.expectRevert("you don't have a whitelist");
nftContract.presaleMint(1, 15, proof);
}
// =============================================================
// INTEGRATION
// =============================================================
function testSuccessPresaleMint() external saleStart {
bytes32[] memory proof = getProof(merkleDataset.leaves, 0);
vm.startPrank(vm.addr(1));
nftContract.presaleMint(10, 10, proof);
assertEq(nftContract.balanceOf(vm.addr(1)), 10);
nftContract.safeTransferFrom(vm.addr(1), vm.addr(2), 1);
assertEq(nftContract.balanceOf(vm.addr(1)), 9);
assertEq(nftContract.balanceOf(vm.addr(2)), 1);
vm.expectRevert("exceeds number of earned tokens");
nftContract.presaleMint(10, 10, proof);
vm.stopPrank();
}
}
SolidityでマークルツリーのテストをするためにMurkyというライブラリを使っています。
マークルツリーの生成に必要なものは Helper
として別のファイルに記載しそれをメインの方で継承してテストしています。
申し訳ないですが、ここでテストのやり方を説明してしまうと記事が膨大になってしまうので、詳しい説明は省かせていただきます🙇♂️
また、HardhatのテストはルブライトさんのGitHubが参考になると思います。
テストが完了したら、テストネットにデプロイします。
やり方は以下の記事が参考になると思います。
このコードを使う場合の注意
このコントラクトはあくまで参考です。
使う前に安全かどうか自分でしっかりテストしてください。
マークルツリーの認証をフロントで作る
さてコントラクト側の準備ができたので次はフロント側をやっていきます。
僕はnext.jsでサイトを作ることが多いのでこれを使う前提で解説しますが Reactなら問題ないと思います。
また、通常の購入するサイト(ミントサイト)は使い捨てされるのでハッシュリップスが採用されることが多いのですが、フロントがある程度慣れていると結構使いずらいので僕はプロジェクトでは使っていません。
ただ、フロントがよくわからないまたはサクッと購入するサイトを作りたい方も結構いると思うので、その場合はハッシュリップスで十分だと思います!
ハッシュリップスを使いたい方はNinja Daoのエンジニアサーバーにあるけいすけさんが解説されている「しごジェネ」が参考になると思います!
なお、こちらもマークルツリーに焦点を当てたいので、申し訳ないですがフロントの基本的な解説は省かせていただきますので各自の環境でセットアップをお願いします🙏
準備ができたら、マークルツリーに登録するデータを用意します。
おそらくスプレに登録するアドレスと購入できる枚数が記載されたものを共有される場合が多いと思うので、それを以下のように登録します。
export const alData: [`0x${string}`, number][] = [
["0xa3beb3febf6d38b1612784c4aae0dde6786bf8ea", 1],
["0xbc8a3f437ac6d96ec7bab8c34ad0b8f26e2c4919", 1],
["0xc4704c93c9cd36f3298be1afef8bdc844a19558a", 1],
["0xd03509cbe5aefe2e00d0e354dbb6d8735b56592a", 1],
];
ここで重要なのが必ずユーザーのアドレスをすべて小文字に変換してください。
そうしないとユーザーが購入できないトラブルが起きてしまうので変換ツールですべて小文字にしましょう。
次はユーザーがウォレットに接続したらマークルツリーの proof
を返す仕組みを作ります。
まずはwagmiをインストールまたはセットアップしましょう!
それに加えてmerkletreejsをインストールします。
完了したらファイルを作りマークルツルーのフックを以下のよう感じで作っていきます。
"use client";
import { useAccount } from "wagmi";
import { encodePacked, keccak256, zeroAddress } from "viem";
import MerkleTree from "merkletreejs";
import { alData } from "../../data/al";
const toLowerCaseTyped = <T extends string>(arg: T) => {
return arg.toLowerCase() as Lowercase<T>;
};
export const useGenerateAlData = () => {
const { address } = useAccount();
const lowerAddress = toLowerCaseTyped(address ?? zeroAddress);
const alAddressData = alData.map((list) => {
return list[0];
});
const addressId = alAddressData.indexOf(lowerAddress);
const leafNodes = alData.map((data) => {
return keccak256(
encodePacked(["address", "uint256"], [data[0], BigInt(data[1])])
);
});
const claimLeafNodes = keccak256(
encodePacked(
["address", "uint256"],
[lowerAddress, BigInt(addressId === -1 ? 0 : alData[addressId][1])]
)
);
const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true });
const localRootHash = merkleTree.getRoot();
const hexProof = merkleTree.getHexProof(claimLeafNodes) as `0x${string}`[];
const maxAmount = addressId === -1 ? 0 : alData[addressId][1];
const verify = merkleTree.verify(hexProof, claimLeafNodes, localRootHash);
return { hexProof, maxAmount, verify };
};
順番に見ていきます。
まずはこちらのコードを追加します。
"use client";
import { useAccount } from "wagmi";
import { encodePacked, keccak256, zeroAddress } from "viem";
import MerkleTree from "merkletreejs";
import { alData } from "../../data/al";
先頭にある use client
はNext.jsがアップデートでデフォルトがサーバーコンポーネントになったのでつけています。
Next.jsを使っていないもしくはpagesで開発している方は不要です。
viemというライブラリはwagmiがアプデでether.jsを使うのやめてviemを採用したので使っています。
僕が使ってみた感じだとviemの方が型安全でドキュメントも読みやすいのでこっちの方がいいかなと思います。
詳しくはWhy viemを読んでみてください。
その下の toLowerCaseTyped
は toLowerCase
したときに string型
になるのを防ぐための関数です。
const toLowerCaseTyped = <T extends string>(arg: T) => {
return arg.toLowerCase() as Lowercase<T>;
};
ここは理解できなくても問題ないので飛ばしてください。
次のコードは取得したアドレスを小文字変換して、alデータからアドレスのみのデータを取得しています。
また、アドレスのみのデータに対して indexOf
を使い小文字変換したアドレスが何番目にあるか探しています。
const { address } = useAccount();
const lowerAddress = toLowerCaseTyped(address ?? zeroAddress);
const alAddressData = alData.map((list) => {
return list[0];
});
const addressId = alAddressData.indexOf(lowerAddress);
indexOf
はデータがない場合は -1
を返します。
次のコードを見ると leafNodes
と claimLeafNodes
があると思います。
const leafNodes = alData.map((data) => {
return keccak256(
encodePacked(["address", "uint256"], [data[0], BigInt(data[1])])
);
});
const claimLeafNodes = keccak256(
encodePacked(
["address", "uint256"],
[lowerAddress, BigInt(addressId === -1 ? 0 : alData[addressId][1])]
)
);
ここでは、マークルツリーの作成に必要な leafNodes
と検証するデータの claimLeafNodes
を作成しています。
先ほどのマークルツリーの基礎で説明したようにデータを検証するときは再度マークルツリーのデータをを作成する必要があります。
そして最後のコードを見ていきます。
const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true });
const localRootHash = merkleTree.getRoot();
const hexProof = merkleTree.getHexProof(claimLeafNodes) as `0x${string}`[];
const maxAmount = addressId === -1 ? 0 : alData[addressId][1];
const verify = merkleTree.verify(hexProof, claimLeafNodes, localRootHash);
return { hexProof, maxAmount, verify };
こちらでは MerkleTree
に必要なデータを入れてインスタンスを作成し、そのインスタンスを使い rootHash
、hexProof
、verify
を作っています。
事前に verify
することでミスをなくせますし、アローリストに登録されているかどうか判定するために使えます。
もっと厳格にしたい場合はオンチェーンに設定されている root
を取ってきて verify
するのもありだと思います!
こんな感じでフックを作成していきます。
あくまで一例なので気になるところがありましたら自分で変えてください。
サーバーサイドでやらなくていいの?
マークルツリーは先ほど説明した通りデータを少しでも変更したら最後に出てくるルートも全く違うものになります。
仮に悪い人がデータをいじったとしてもオンチェーンに設定しているものと違うのでエラーになります。
なので特にサーバーサイドでやらなくても大丈夫です。
気になる方はnext.jsの Route Handlers
などを使いサーバーサイドでやることもできます。
ここではフックの方が簡単かなと思いこちらを紹介しました。
次にオンチェーンのマークルツリーの root
の設定がまだなのでやりましょう。
root
はさっき作ったフックに console.log
を使って調べられます。
const localRootHash = merkleTree.getRoot();
console.log("Root Hash: ", "0x" + localRootHash.toString("hex"));
コンソールを確認すると root
の値が取得できているのが確認できると思います。
それをコピーできたら、イーサスキャンにデプロイしたコントラクトアドレスを入力して、write
にある setMerkleRoot
を呼び出し merkleRoot
を設定してください。
設定が終わったら console.log()
は消してください。
次に実際にフロントからコントラクトを呼び出してNFTを購入してみましょう。
フロントからコントラクトを呼び出すにはコントタクトアドレスとabiが必要なのでイーサスキャンからとってきます。
その後フォルダを作りファイルを作成し以下のようにします。
export const NFT_FREE_ABI = [
// abiをここに入れる
] as const
import { NFT_FREE_ABI } from "./abi/nftFreeAbi";
type nftFreeConfigType = {
address: `0x${string}`;
abi: typeof NFT_FREE_ABI;
};
export const nftFreeConfig: nftFreeConfigType = {
address: "0x...",
abi: NFT_FREE_ABI,
};
これで準備ができました。
あとウォレットの繋ぎ方ですがwagmiのConnect Walletにやり方が記載されています。
僕は今回デフォルトで組み込まれているConnectコンポーネントを使いますがアドレスが取れれば大丈夫なので、繋ぎ方は自分の好きなようにしてください。
ウォレットをサイトに繋げるようになったら以下のコードを追加します。
"use client";
import { Connect } from "@/components/Connect";
import { nftFreeConfig } from "@/contract/contractConfig";
import { useGenerateAlData } from "@/utils/merkle-tree copy";
import { BaseError, zeroAddress } from "viem";
import {
useAccount,
useContractRead,
useContractWrite,
useWaitForTransaction,
} from "wagmi";
export default function Page() {
const { isConnected, address } = useAccount();
const { hexProof, maxAmount, verify } = useGenerateAlData();
const { data: phase } = useContractRead({
...nftFreeConfig,
functionName: "phase",
enabled: Boolean(address),
});
const { data: presaleMintCount } = useContractRead({
...nftFreeConfig,
functionName: "presaleMintCount",
args: [address ?? zeroAddress],
enabled: Boolean(address),
});
const {
write: writePresaleMint,
data: presaleMintData,
error: presaleMintError,
isLoading: isPresaleMintLoading,
isError: isPresaleMintError,
} = useContractWrite({
...nftFreeConfig,
functionName: "presaleMint",
});
const { isLoading: isPresaleMintPending, isSuccess: isPresaleMintSuccess } =
useWaitForTransaction({
hash: presaleMintData?.hash,
});
const presaleMint = () => {
if (typeof presaleMintCount === "undefined") {
return;
}
const claimAmount = Math.max(maxAmount - Number(presaleMintCount), 0);
writePresaleMint({
args: [BigInt(claimAmount), BigInt(maxAmount), hexProof ?? []],
});
};
return (
<div>
<h1>NFT購入サイト</h1>
<Connect />
{!isConnected ? (
<p>connect wallet</p>
) : phase === 0 ? (
<p>セールが開始されていません</p>
) : !verify ? (
<p>アローリストに登録されていません</p>
) : isPresaleMintSuccess ? (
<p>成功しました!</p>
) : typeof presaleMintCount != "undefined" &&
maxAmount - Number(presaleMintCount) <= 0 ? (
<p>購入できる上限に到達しました</p>
) : (
<div>
<button
onClick={presaleMint}
disabled={isPresaleMintLoading || isPresaleMintPending}
>
購入する
</button>
{isPresaleMintLoading && <div>ウォレットのチェック中...</div>}
{isPresaleMintPending && <div>トランザクション送信中...</div>}
{isPresaleMintError && (
<div>{(presaleMintError as BaseError)?.shortMessage}</div>
)}
</div>
)}
</div>
);
}
上記はフロントの最適限のコードです。
サイトを見てみると「セールが開始されていません」となっていると思いますが、これは phase
が 0
だからですね。
イーサスキャンの write
から setPhase
を呼び出し 1
に変更しておきましょう。
phaseの注意
実際に販売するときは phase
の変更を直前までしないでください。
そうすると購入するボタンが出てくると思います。
ボタンを押すとメタマスクが立ち上がると思いますので実際にトランザクションを送って見てください。
少し経つとトランザクションが成功すると思います!
これでアローリストに登録した人しか購入できないスマートコントラクトの構築とフロントを作ることができました!
コードの注意
有料販売や購入枚数を選べるようにしたい場合は別途コードを変更する必要があるので注意してください。
Bonus: NFTプロジェクトを作るときにかかる費用
お金がかかる主な部分は以下の二つだと思います。
- デプロイ
- 関数操作
ブロックチェーン見ればわかるので、参考までに僕が担当したプロジェクトのデプロイ費用を掲載いたします。
プロジェクト | デプロイ費用 |
---|---|
ZUTTO MAMORU | 0.3593 ETH |
CNPR | 0.0284 ETH |
これに加えてエアドロや他の関数の操作もするので、僕の場合は余裕を持って 0.5~1ETH
をファウンダーの方からもらうことが多いです。
ただ、規模やすることによって金額も大きく変わるので参考までにです。
hardhatやfoundryならおおよそのかかるガス代を事前に見れるので、それを参考にするといいと思います!
ちなみに mapping
でアローリスト登録しようとする場合は、普通に 1ETH
以上かかったりするので、マークルツリーに感謝ですね。
まとめ
マークルツリーを使えばアローリストを登録するのにガス代をかけず安全にセールをすることができます。
この記事がこれからNFTのセールをするエンジニアの方の参考になれば幸いです。
もし参考になったという方がいましたら、ブックマークかTwitterなどでシェアしていただけると嬉しいです。
また、ブロックチェーンエンジニアをお探しでしたらDMかメールしていただければご相談に乗りますのでお気軽に送っていただければと思います!