Appearance
L2-10: Merkle NFTs(Merkle 白名单铸造)
1. 问题
NFT 项目通常需要白名单机制——只有预先指定的地址才能参与早期铸造。最直接的方案是将白名单存储在合约的 mapping 中,但如果有 10,000 个白名单地址,每个 mapping 写入需要 20,000 gas(冷 SSTORE),总成本高达 200,000,000 gas——这在主网上根本无法承担。
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 的安全性依赖于哈希函数的抗碰撞性。标准实现使用"双哈希"防范二阶原像攻击:在拼接两个子节点时按字典序排序(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 {
// 三层守卫检查
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(存入合约的 constructor)
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 设为可变变量并提供 setter 函数,项目方可以在 mint 中途替换 root——这会改变游戏的公平性
- calldata proof 大小限制:虽然理论上 Merkle Proof 很小(每层 32 字节),但超大树(如 2^32 叶子)的 proof 为 1024 字节,仍在合理范围。但前端可能在构造 proof 时包含多余节点
5. 陷阱的原因
叶子构造的差异源于 abi.encode 和 abi.encodePacked 的不同编码方式。abi.encode(address) 产生 64 个十六进制字符(带 ABI 填充),而 abi.encodePacked(address) 产生 40 个字符(无填充)。如果链下使用 ethers.solidityPacked(对应 abi.encodePacked)而链上使用 abi.encode,编码结果完全不同的哈希。必须两端保持一致——本挑战使用 abi.encodePacked。
sortPairs 的行为基于哈希字节的字典序(unsigned big-endian 比较)。如果链下没有启用 sortPairs(或不排序),兄弟节点按自然顺序拼接——而链上的 MerkleProof 库始终按字典序排序。这导致同样的树,链下 proof 在链上验证失败。
重复铸造绕过利用了交易原子性:在单个交易的执行上下文中,hasClaimed 状态更新和 _safeMint 调用是顺序发生的。如果先 mint 后设状态,且 _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 并在构造函数中赋值——这既节省 gas 又保证了白名单的不可篡改性。项目方在部署合约前确定白名单并生成 root,部署后无法更改。
7. 技术要点
| 要点 | 说明 |
|---|---|
| Merkle Root 存储 | 单个 bytes32,无论白名单多大 |
| 验证复杂度 | O(log2(n)) — 100 万地址仅需 20 次哈希 |
| 叶子构造 | keccak256(abi.encodePacked(msg.sender)) 与链下一致 |
| 二阶原像防御 | 字典序排序兄弟节点(computedHash < proof[i]) |
| Merkle Root 不可变 | 使用 immutable 防止 mint 中途篡改 |
| 重复铸造防护 | 提前设置 hasClaimed[msg.sender] = true(check-effects-interactions) |
| proof 即插即用 | OpenZeppelin MerkleProof.verify() 一行调用 |
| sortPairs | 链上下必须一致(都是字典序或都不是) |