Skip to content
On this page

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.encodeabi.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链上下必须一致(都是字典序或都不是)

Built with AiAda