【決定版】NFTのアローリスト(ホワイトリスト)をスマートコントラクトで作る方法

2023-06-21

この記事では、NFTのアローリスト(ホワイトリスト)をスマートコントラクトで実装しサイトからNFTを購入する方法を紹介します。

スマートコントラクトのコードは誰でも見れるので、間違ったセールのコードを書いてしまうとハッキングのリスクがあります。

なので、安全なアローリスト登録のコードを書きたい方はぜひ見てください!

NFTのアローリスト登録をする方法は3つある【マークルツリーがおすすめ】

NFTのアローリストの登録をスマートコントラクトで実装する方法は3つあります。

難しそうなものが3つ並んでいますが、タイトルにある通り特別な理由がない限りマークルツリーを採用してください。

なぜかというと、mapping は簡単だけどガス代かなりかかりますし、ECDSA は実装の難易度が高くてハッキングの落とし穴も結構あるからです。

それに比べてマークルツリーは多くのNFTプロジェクトで採用されており、ガス代あまりかかりませんし実装難易度も ECDSA に比べたら難しくありません。

なので今回はマークルツリーの実装方法を詳しくみていきたいと思います!

マークルツリーの基礎

先ほどから単語が出ているマークルツリーについて解説します。

マークルツリーはブロックチェーンにも使われており、主にたくさんのデータを使って何かチェックしたいときに使います。

例えば、以前もらった色付きの箱が複数あって、前と比べて凹んでいないか確認するときは普通に見て確認しますよね。

ただ、これが1000万以上ある場合だと一個一個確認するのきついですよね。

そこで使われるのがマークルツリーです。

確認したい箱をマークルツリーの機械に入れると必ず一定の大きさの文字列を返してくれます。

0xa7a2aae1a4409ccf5351b5a3e66b53990f2e625f8342e7c703e969107e44b643

重さ:10g

重さ:20g

また、最後に出てくる文字列は適当に出てくるのではなくて以下の法則を持っています。

  • 同じ箱を入れたら出てくる文字列は常に一緒
  • 出てきた文字列から入れる前の箱を調べることができない
  • 少しでも入れた箱が違うと全く違う文字列が出てくる

順番に見ていきましょう。

同じ箱を入れたら出てくる文字列は常に一緒

これは同じものを入れたら常に同じデータが返ってくるということです。

change を押した後に reset を押してみてください。

0x の後の値が最初の値と変わっていないのがわかると思います。

0xa7a2aae1a4409ccf5351b5a3e66b53990f2e625f8342e7c703e969107e44b643

重さ:10g

重さ:20g

box data

出てきた文字列から入れる前の箱を調べることができない

こちらはタイトル通り、文字列をどんなに調べてもそこから使われているデータを当てることはできないということです。

先ほどの例は下の値が公開されていたので文字列を作るのに何が使われていたのか分かりましたが、以下の情報だけだとわからないですよね。

0x5efbb9b8805d57832895b92dc7a19351925d731db1d03b5799da0c28030740ef

少しでも入れた箱が違うと全く違う文字列が出てくる

下記のデモで実際に角を変えてみてください。

ちょっとだけしか変えていないのに上のラベルの文字列がかなり変わっているのが確認できると思います。

0xa7a2aae1a4409ccf5351b5a3e66b53990f2e625f8342e7c703e969107e44b643

重さ:10g

重さ:20g

top radius
bottom radius

上記を踏まえて、1000万の箱を以前と比べて凹んでいないか確認したいときは以下の手順でチェックできます。

  1. 箱をもらった時にペアを作り最後の一個になるまでマークルツリーの機械に入れて出てきた文字列をメモしておく

  2. 凹んでいないかチェックする時も同じ処理をして最後に出てきた文字列があっているかチェックする

これだけで1000万の箱が凹んでいないかチェックできます。

一連の流れを画像で説明するとこんな感じです。

マークルツリーのルートの生成図
マークルツリーのルートの再生成

なぜ最後の文字列だけ見ればいいかというと先ほどのデモで見た少しでも入れたデータが違うと全く違う文字列が出てくると言う性質があるので、最後に出てくる値が違ってきてしまうからですね。

メモしたルートと生成したルートが違う

この最後の値だけ確認すればいいと言うのがアローリスト登録で重要です。

なぜならイーサリアムではデータを設定する度にガス代がかかるのですが、マークルツリーならどんなにデータが多くても最後の文字列を設定するだけで検証できます。

なのでアローリストの登録をするのにガス代をすごく安くできるので、特定の人しかNFTを購入できないようにしたいときに使えるんですね!

マークルツリーの基礎について分かったと思うので、次の章では実際にアローリストを登録する方法を解説します。

マークルツリーでNFTのアローリスト登録を実装する

さてマークルツリーについて大体わかったところで、実際にマークルツリーでNFTのアローリストを登録していきましょう!

さっきは登録するデータが箱でしたがそれをアドレスと購入できる最大数に変更する感じですね。

アローリストを登録するときはアドレスのみじゃなくて、アドレスと購入できる最大数を登録するのが一般的なのでこの二つを設定する方法を見ていきます!

手順としては以下の通りです。

  1. スマートコントラクトを作る
  2. テストコードを書く
  3. マークルツリーの認証をフロントで作る

では見ていきましょう!

スマートコントラクトを作る

完成系のコードはこちらです。

src/nftFree.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";
 
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は安全なコードを提供してくれるところでたくさんの人が利用しています。

今回は、MerkleProofOwnableを使います。

また、NFTプロジェクトを作る場合はERC721Aが使われることが多いので今回はこちらをベースとして使います。

インストールが完了したら、.sol ファイルを作りライブラリをインポートしてコードを以下のようにします。

src/nftFree.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";

次に contract を作り ERC721AOwnable を継承します。

src/nftFree.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";
 
contract NFT is ERC721A("MyToken", "MTK"), Ownable {
function _startTokenId() internal view virtual override returns (uint256) {
    return 1;
  }
}

MyToken がトークン名で MTK がシンボル名です。

ERC721AはトークンIDが 0 から始まるので 1 から始まるように変更しています。

これで準備ができたのでセールのコードを追加します。

src/nftFree.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";
 
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が送られます。

ただこれだと誰でも実行できてしまうので、マークルツリーで特定の人しか購入できないようにしていきましょう!

src/nftFree.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";
 
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 を誰でも変更できてしまうと悪用されてしまうので setMerkleRootonlyOwner をつけています。

src/nftFree.sol
	function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
		merkleRoot = _merkleRoot;
	}

onlyOwner をつけると関数を実行できるのをコントラクトのオーナーのみに制限してくれます。

また、 presaleMintrequire を追加して isWhitelistedfalse だったらエラーが出るようにしています。

src/nftFree.sol
	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 を作ってマークルツリーの認証をしています。

src/nftFree.sol
	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が読み取れないからですね。

abi.encodePackedを使う場合と使うわない場合の比較図

もう少し詳しくいうとEVMがバイトデータを扱うようにできていてそこで使われる keccak256 の受け付けるデータが バイト型 だから変換しています。

試しに以下のコードを作って呼び出すとコメントアウトのデータが返ってきます。

src/nftFree.sol
	//return: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000000a
	function testAbiEncodePacked() public pure returns (bytes memory) {
		address a = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
		uint256 b = 10;
		return abi.encodePacked(a, b);
	}

さらに詳しく見たい方はこちらのabi.encodePacked()の働きを見てみようがわかりやすいと思います。

ただ、これだと何のデータを入れたかわかってしまうので keccak256 で一定の長さのハッシュ値に変換します。

まぁ要するに入れたデータをわからないようにして悪いことできないようにしている感じです。

keccak256を使うと元のデータを調べることができない

前の章のマークルツリーの基礎で説明した常に一定の大きさの文字列を返して逆から計算することができないというのはこの機能を使っているからです。

試しに先ほどのデータに対して keccak256 を使ってみるとコメントアウトのデータが返ってきます。

src/nftFree.sol
	//return: 0x569ab48c70cc15322c9253243aab005d1c64df7c33031cf8dfb5a8ac071d368d
	function testKeccak256AbiEncode() public pure returns (bytes32) {
		address a = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
		uint256 b = 10;
		return keccak256(abi.encodePacked(a, b));
	}

さっきの文字列に比べてだいぶ短くなったのがわかると思います。

また、Chatgptに値を戻すように指示しても無理ですね。

chatgptに聞いてもkeccak256で生成したデータを復元できない

ちなみに前の 0x は文字列が16進数であることを表しています。

さらに詳しくみたい方はこちらのHashing Functions In Solidity Using Keccak256が参考になると思います。

まとめると leaf を生成するためにやっていることは以下の通りです。

  1. _address_maxMintAmount をくっつけて バイト型にデータを変換
  2. それをハッシュ化して一定の長さの文字列に変換

これだけです。

次に、MerkleProof.verifyCalldata を見ていきます。

ここでは、3つの引数を受け取って root の中にさっき作った leaf があるかどうか確認しています。

src/nftFree.sol
	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);
	}

leafroot の中にあるかどうか確認するためには、leafproof から root をもう一回計算して設定されている root と同じかどうか比較します。

MerkleProof.sol
	function verifyCalldata(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
		return processProofCalldata(proof, leaf) == root;
	}

例えば、青の箱が root の中に含まれているかどうか確認したいときは、水色の枠線のハッシュ値が必要です。

rootを再計算するにはProofとleafが必要

bytes32[] calldata proof とあるように渡すときはそれらを配列に入れて渡します。

これで特定の人しか購入できないNFTのセールのコントラクトを作ることができました!

NFTFree.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";
 
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;
	}
}

ただこのままだとアローリストを登録している人が無制限に購入できてしまうので、もう少しコントラクトにコードを追加します。

NFTFree.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";
 
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;
	}
}

これでセールの時間にならないと購入できなくて、購入上限に達したらエラーになるようにできました!

テストコードを書く

上記まででコントラクトのコードは完成したのですが、スマートコントラクトの開発は一つのミスが大惨事になる可能性があるので、作るだけじゃなくてテストしないとダメです。

実際スマートコントラクトの開発をする場合、テストコードを書いている時間の方が長くなると思います😓

例えばfoundryの場合は以下のようにテストしていきます。

test/util/MerkleTreeHelper.sol
// 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);
	}
}
test/NftFree.t.sol
// 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エンジニアサーバーにあるけいすけさんが解説されている「しごジェネ」が参考になると思います!

なお、こちらもマークルツリーに焦点を当てたいので、申し訳ないですがフロントの基本的な解説は省かせていただきますので各自の環境でセットアップをお願いします🙏

準備ができたら、マークルツリーに登録するデータを用意します。

おそらくスプレに登録するアドレスと購入できる枚数が記載されたものを共有される場合が多いと思うので、それを以下のように登録します。

data/al.ts
export const alData: [`0x${string}`, number][] = [
  ["0xa3beb3febf6d38b1612784c4aae0dde6786bf8ea", 1],
  ["0xbc8a3f437ac6d96ec7bab8c34ad0b8f26e2c4919", 1],
  ["0xc4704c93c9cd36f3298be1afef8bdc844a19558a", 1],
  ["0xd03509cbe5aefe2e00d0e354dbb6d8735b56592a", 1],
];

ここで重要なのが必ずユーザーのアドレスをすべて小文字に変換してください。

そうしないとユーザーが購入できないトラブルが起きてしまうので変換ツールですべて小文字にしましょう。

次はユーザーがウォレットに接続したらマークルツリーの proof を返す仕組みを作ります。

まずはwagmiをインストールまたはセットアップしましょう!

それに加えてmerkletreejsをインストールします。

完了したらファイルを作りマークルツルーのフックを以下のよう感じで作っていきます。

src/util/merkle-tree.ts
"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 };
};
 

順番に見ていきます。

まずはこちらのコードを追加します。

src/util/merkle-tree.ts
"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を読んでみてください。

その下の toLowerCaseTypedtoLowerCase したときに string型 になるのを防ぐための関数です。

src/util/merkle-tree.ts
const toLowerCaseTyped = <T extends string>(arg: T) => {
  return arg.toLowerCase() as Lowercase<T>;
};

ここは理解できなくても問題ないので飛ばしてください。

次のコードは取得したアドレスを小文字変換して、alデータからアドレスのみのデータを取得しています。

また、アドレスのみのデータに対して indexOf を使い小文字変換したアドレスが何番目にあるか探しています。

src/util/merkle-tree.ts
const { address } = useAccount();
 
  const lowerAddress = toLowerCaseTyped(address ?? zeroAddress);
  const alAddressData = alData.map((list) => {
    return list[0];
  });
 
  const addressId = alAddressData.indexOf(lowerAddress);

indexOf はデータがない場合は -1 を返します。

次のコードを見ると leafNodesclaimLeafNodes があると思います。

src/util/merkle-tree.ts
  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 を作成しています。

先ほどのマークルツリーの基礎で説明したようにデータを検証するときは再度マークルツリーのデータをを作成する必要があります。

そして最後のコードを見ていきます。

src/util/merkle-tree.ts
  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 に必要なデータを入れてインスタンスを作成し、そのインスタンスを使い rootHashhexProofverify を作っています。

事前に verify することでミスをなくせますし、アローリストに登録されているかどうか判定するために使えます。

もっと厳格にしたい場合はオンチェーンに設定されている root を取ってきて verify するのもありだと思います!

こんな感じでフックを作成していきます。

あくまで一例なので気になるところがありましたら自分で変えてください。

次にオンチェーンのマークルツリーの root の設定がまだなのでやりましょう。

root はさっき作ったフックに console.log を使って調べられます。

src/src/util/merkle-tree.ts
  const localRootHash = merkleTree.getRoot();
  console.log("Root Hash: ", "0x" + localRootHash.toString("hex"));

コンソールを確認すると root の値が取得できているのが確認できると思います。

それをコピーできたら、イーサスキャンにデプロイしたコントラクトアドレスを入力して、write にある setMerkleRoot を呼び出し merkleRoot を設定してください。

設定が終わったら console.log() は消してください。

次に実際にフロントからコントラクトを呼び出してNFTを購入してみましょう。

フロントからコントラクトを呼び出すにはコントタクトアドレスとabiが必要なのでイーサスキャンからとってきます。

その後フォルダを作りファイルを作成し以下のようにします。

src/contract/abi/nftFreeAbi.ts
  export const NFT_FREE_ABI = [
		// abiをここに入れる
	] as const
src/contract/contractConfig.ts
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コンポーネントを使いますがアドレスが取れれば大丈夫なので、繋ぎ方は自分の好きなようにしてください。

ウォレットをサイトに繋げるようになったら以下のコードを追加します。

src/app/page.tsx
"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>
  );
}
 

上記はフロントの最適限のコードです。

サイトを見てみると「セールが開始されていません」となっていると思いますが、これは phase0 だからですね。

イーサスキャンの write から setPhase を呼び出し 1 に変更しておきましょう。

そうすると購入するボタンが出てくると思います。

ボタンを押すとメタマスクが立ち上がると思いますので実際にトランザクションを送って見てください。

少し経つとトランザクションが成功すると思います!

これでアローリストに登録した人しか購入できないスマートコントラクトの構築とフロントを作ることができました!

Bonus: NFTプロジェクトを作るときにかかる費用

お金がかかる主な部分は以下の二つだと思います。

  • デプロイ
  • 関数操作

ブロックチェーン見ればわかるので、参考までに僕が担当したプロジェクトのデプロイ費用を掲載いたします。

プロジェクトデプロイ費用
ZUTTO MAMORU0.3593 ETH
CNPR0.0284 ETH

これに加えてエアドロや他の関数の操作もするので、僕の場合は余裕を持って 0.5~1ETH をファウンダーの方からもらうことが多いです。

ただ、規模やすることによって金額も大きく変わるので参考までにです。

hardhatやfoundryならおおよそのかかるガス代を事前に見れるので、それを参考にするといいと思います!

ちなみに mapping でアローリスト登録しようとする場合は、普通に 1ETH 以上かかったりするので、マークルツリーに感謝ですね。

まとめ

マークルツリーを使えばアローリストを登録するのにガス代をかけず安全にセールをすることができます。

この記事がこれからNFTのセールをするエンジニアの方の参考になれば幸いです。

もし参考になったという方がいましたら、ブックマークかTwitterなどでシェアしていただけると嬉しいです。

また、ブロックチェーンエンジニアをお探しでしたらDMメールしていただければご相談に乗りますのでお気軽に送っていただければと思います!

無料メルマガ

このサイトの目的は、NFTのエンジニアの方やブロックチェーンの技術に興味がある方に、役に立つコンテンツを作ることです。 新しいコンテンツが公開されたらお知らせします。

また、時々メルマガ限定のコンテンツも配信します。

不満足ならいつでも解約できます。

無料登録は 「こちら」 です。
© 2023 ryuji