Appearance
L2-10: Merkle NFT(Merkle ホワイトリストミント)
1. 課題
NFT プロジェクトでは通常、ホワイトリストメカニズムが必要です。事前に指定されたアドレスのみが早期ミントに参加できます。最も直接的な方法はホワイトリストをコントラクトのマッピングに保存することですが、10,000 のホワイトリストアドレスがあり、各マッピング書き込みに 20,000 ガス(コールド SSTORE)がかかる場合、総コストは 200,000,000 ガスに達します。これはメインネットではまったく負担できません。
Merkle Tree はエレガントな解決策を提供します。ホワイトリスト全体を(そのサイズに関係なく)単一の 32 バイトの Merkle Root に圧縮してコントラクトに保存します。各ユーザーは自分の Merkle Proof(リーフからルートへのハッシュパス)を提供して「自分がホワイトリストに含まれていること」を証明します。検証プロセスは O(log2(n)) 回のハッシュ計算のみを必要とします。100 万アドレスのホワイトリストでも、各 Proof はわずか 20 個の bytes32(640 バイトの calldata)で済みます。
このチャレンジ(src/level2/MerkleNFT.sol を参照)では、ERC-721 ベースの NFT コントラクトを実装し、Merkle Proof 検証を使用したホワイトリストミントに加えて、ミント価格、供給上限、重複ミント防止、引き出し機能もサポートすることが求められます。
2. 理由
Merkle Tree はイーサリアムのスケーラビリティを支える基盤技術です。Rollup の状態ルートコミットメントからエアドロップ請求、NFT ホワイトリストまで、Merkle 証明は至る所で使用されています。Merkle 検証を理解することは、単に MerkleProof.verify() の使い方を学ぶことではなく、「オフチェーン計算 + オンチェーン検証」というイーサリアムスケーリングパラダイムの本質を理解することです。O(n) のオンチェーン計算を O(log n) に、ストレージを O(1) に圧縮するのです。
Merkle Tree のセキュリティはハッシュ関数の衝突耐性に依存しています。標準実装では「ダブルハッシュ」を使用して二次原像攻撃を防ぎます。2 つの子ノードを連結する際に辞書順でソートします(computedHash < proof[i] の場合は computedHash を先に、proof[i] を後に、そうでなければ逆)。ソートしない場合、攻撃者は特定の条件下で、検証を迂回する一見有効な証明を構築できる可能性があります。
MerkleNFT は、複数の ERC-721 標準機能を統合した総合的な演習でもあります。_safeMint は受信者が ERC-721 対応アドレスであることを保証し(NFT がコントラクト内にロックされるのを防ぐ)、hasClaimed は重複ミントを防止し、withdraw は収益引き出しインターフェースを提供します。これらの知識ポイントは個別にはシンプルですが、組み合わさることで本番レベルの NFT ミントシステムに近いものを形成します。
3. 解決策
コアアーキテクチャ
MerkleNFT コントラクト(src/level2/MerkleNFT.sol を参照)は OpenZeppelin の ERC721 を継承し、MerkleProof ライブラリを使用して証明検証を行います:
オフチェーン生成 オンチェーン検証
================ ================
ホワイトリストアドレスリスト bytes32 immutable MERKLE_ROOT
↓
Merkle Tree を生成 bytes32 leaf = keccak256(abi.encodePacked(msg.sender))
↓
各アドレスの proof を抽出 proof.verify(MERKLE_ROOT, leaf) → true/false
↓ ↓
フロントエンドが proof を渡す _safeMint(msg.sender, tokenId)
主要な実装詳細
solidity
contract MerkleNFT is ERC721 {
using MerkleProof for bytes32[];
bytes32 public immutable MERKLE_ROOT;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_SUPPLY = 100;
uint256 private _tokenIdCounter;
mapping(address => bool) public hasClaimed;
address public owner;
function mint(bytes32[] calldata proof) external payable {
// 3 つのガードチェック
if (_tokenIdCounter >= MAX_SUPPLY) revert MaxSupplyReached();
if (hasClaimed[msg.sender]) revert AlreadyClaimed();
if (msg.value < MINT_PRICE) revert InsufficientPayment(msg.value, MINT_PRICE);
// リーフノードを構築(標準的な方法:abi.encodePacked アドレス)
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
// MerkleProof ライブラリを使用して検証
if (!proof.verify(MERKLE_ROOT, leaf)) revert InvalidProof();
// リプレイを防止
hasClaimed[msg.sender] = true;
// ミント
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
}
function withdraw() external {
if (msg.sender != owner) revert Unauthorized();
(bool success,) = owner.call{value: address(this).balance}("");
if (!success) revert WithdrawFailed();
}
}
Merkle 証明検証の核心(OpenZeppelin MerkleProof ライブラリ実装)
solidity
function verify(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
// 二次原像攻撃を防ぐため、兄弟ノードを辞書順でソート
if (computedHash < proof[i]) {
computedHash = keccak256(abi.encodePacked(computedHash, proof[i]));
} else {
computedHash = keccak256(abi.encodePacked(proof[i], computedHash));
}
}
return computedHash == root;
}
辞書順ソート(computedHash < proof[i] の比較)は Merkle 証明のセキュリティの鍵です。ソートしない場合、攻撃者は内部ノードを「リーフ」として使用して証明を偽造する可能性があります(二次原像攻撃)。
オフチェーン Merkle Tree 生成(JavaScript / ethers.js)
javascript
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');
function generateMerkleTree(whitelistAddresses) {
// 1. 各アドレスのリーフハッシュを生成
const leaves = whitelistAddresses.map(addr =>
keccak256(ethers.solidityPacked(['address'], [addr]))
);
// 2. Merkle Tree を構築(sortPairs: true でオンチェーンの辞書順ソートと一致)
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
// 3. root を抽出(コントラクトのコンストラクタに渡す)
const root = tree.getHexRoot();
// 4. 各ユーザーの proof を生成
const proofs = {};
for (const addr of whitelistAddresses) {
const leaf = keccak256(ethers.solidityPacked(['address'], [addr]));
proofs[addr] = tree.getHexProof(leaf);
}
return { root, proofs };
}
4. 遭遇した落とし穴
- リーフ構築方法の不一致:オフチェーンで
keccak256(abi.encodePacked(address))を使用しているのに、オンチェーンでkeccak256(abi.encode(address))を使用すると、生成されるリーフハッシュが異なり、証明が永遠に失敗します - sortPairs 設定の不一致:オフチェーンの MerkleTree の
sortPairsオプションは、オンチェーンの辞書順ソート動作と一致している必要があります。そうでないと証明が検証を通過しません - 重複ミントの迂回:
_safeMintの前にhasClaimed[msg.sender] = trueが設定されていない場合、攻撃者は同じトランザクション内でリエントランシー攻撃を通じて複数回ミントできます(_safeMint自体に ERC-721 のリエントランシー保護がありますが、ベストプラクティスは事前にマークすることです) - Merkle Root の不変性の問題:MERKLE_ROOT を可変変数にしてセッター関数を提供すると、プロジェクトチームがミント途中で root を置き換えることができ、公平性が損なわれます
- calldata の proof サイズ制限:理論的には Merkle Proof は非常に小さいですが(レベルあたり 32 バイト)、巨大なツリー(例:2^32 リーフ)の proof は 1024 バイトで、依然として妥当な範囲です。ただし、フロントエンドが proof を構築する際に余分なノードを含める可能性があります
5. 落とし穴の原因
リーフ構築の違いは、abi.encode と abi.encodePacked の異なるエンコード方式に起因します。abi.encode(address) は 64 文字の 16 進数を生成しますが(ABI パディングあり)、abi.encodePacked(address) は 40 文字を生成します(パディングなし)。オフチェーン側が ethers.solidityPacked(abi.encodePacked に対応)を使用しているのに、オンチェーン側が abi.encode を使用すると、エンコード結果が完全に異なるハッシュになります。両端で一貫性を保つ必要があります。このチャレンジでは abi.encodePacked を使用します。
sortPairs の動作はハッシュバイトの辞書順(符号なしビッグエンディアン比較)に基づきます。オフチェーンで sortPairs が有効になっていない(またはソートがオフになっている)場合、兄弟ノードは自然な順序で連結されます。一方、オンチェーンの MerkleProof ライブラリは常に辞書順でソートします。これにより、同じツリーのオフチェーン proof がオンチェーン検証で失敗します。
重複ミントの迂回はトランザクションの原子性を悪用します。単一のトランザクションの実行コンテキスト内では、hasClaimed 状態更新と _safeMint 呼び出しは順次発生します。ミントが状態設定より先に行われ、_safeMint が受信者の onERC721Received コールバックをトリガーする(これが再度 mint を呼び出す可能性がある)と、状態が更新される前にガードチェックが再度通過してしまいます。hasClaimed 状態を早期に更新すること(check-effects-interactions パターン)が標準的な防御策です。
6. 落とし穴の解決方法
リーフ構築には常に同じエンコード方法を使用します:keccak256(abi.encodePacked(msg.sender))。これは Solidity コントラクトで最も一般的なリーフ構築方法であり、ethers.js の solidityPacked(['address'], [addr]) と互換性があります。より多くの情報(許可されるミント数量など)を含める必要がある場合は、構造化されたリーフを使用します:keccak256(abi.encode(address, uint256))。
オフチェーンの sortPairs: true がオンチェーンと一致していることを確認します。merkletreejs のデフォルトは sortPairs: true で、OpenZeppelin の MerkleProof もデフォルトで辞書順です。カスタム検証ロジックがソートしない場合は、両端でソートをオフにします。
check-effects-interactions パターンに従います。すべての外部呼び出し(_safeMint)の前にすべての状態更新を完了させます:
solidity
// ✅ 正しい順序
hasClaimed[msg.sender] = true; // 1. 状態を更新
uint256 tokenId = _tokenIdCounter; // 2. 取得してインクリメント
_tokenIdCounter++;
_safeMint(msg.sender, tokenId); // 3. 最後に外部インタラクション
// ❌ 誤った順序
_safeMint(msg.sender, tokenId); // 1. 先に外部呼び出し
hasClaimed[msg.sender] = true; // 2. 後に状態更新(リエントランシー脆弱性)
MERKLE_ROOT を immutable に設定し、コンストラクタで代入します。これによりガスが節約され、ホワイトリストの不変性が保証されます。プロジェクトチームはコントラクトのデプロイ前にホワイトリストを確定して root を生成し、デプロイ後に変更することはできません。
7. 技術的要点
| 要点 | 説明 |
|---|---|
| Merkle Root ストレージ | ホワイトリストのサイズに関係なく、単一の bytes32 |
| 検証の計算量 | O(log2(n)) — 100 万アドレスでもわずか 20 回のハッシュ |
| リーフ構築 | keccak256(abi.encodePacked(msg.sender)) をオフチェーンと一致させる |
| 二次原像防御 | 兄弟ノードの辞書順ソート(computedHash < proof[i]) |
| Merkle Root の不変性 | immutable を使用してミント途中の改ざんを防止 |
| 重複ミント防止 | hasClaimed[msg.sender] = true を早期に設定(check-effects-interactions) |
| proof のプラグアンドプレイ | OpenZeppelin MerkleProof.verify() の 1 行呼び出し |
| sortPairs | オンチェーンとオフチェーンで一致させる必要あり(両方ソートするか両方しないか) |