批量铸造设计模式:Merkle Tree白名单验证与批量mint合约实现

好,咱们今天聊点硬核的。批量铸造,说白了就是让用户一次mint多个NFT,而不是一个个点。你想想看,如果用户要买10个NFT,却要发起10次交易,gas费直接爆炸。所以,批量mint是必须的。

但这里有个坑——白名单验证。传统的白名单就是把地址存到mapping里,gas费高得吓人。我早期做项目时,白名单地址一多,部署合约直接超限。后来我改用Merkle Tree,嗯,真香。

为什么用Merkle Tree?

Merkle Tree的核心思想是:把白名单地址列表压缩成一个根哈希(root hash),存在合约里。用户mint时,只需要提供自己的地址和对应的证明(proof),合约就能验证你是否在白名单里。

这样做的好处很明显:

  • 存储成本极低:合约里只存一个bytes32的根哈希,而不是整个地址列表
  • 验证高效:链上验证只需要O(log n)的计算量
  • 白名单可更新:换个根哈希,白名单就变了,不用重新部署合约

核心要点:Merkle Tree白名单验证,本质上是「链下计算,链上验证」。白名单列表在链下生成,合约只负责验证。

Merkle Tree白名单验证合约实现

咱们直接上代码。我习惯用OpenZeppelin的MerkleProof库,省心。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MerkleNFT is ERC721 {
    bytes32 public merkleRoot;
    uint256 public nextTokenId;
    mapping(address => bool) public claimed;

    constructor(bytes32 _merkleRoot) ERC721("MerkleNFT", "MNFT") {
        merkleRoot = _merkleRoot;
    }

    // 批量mint函数
    function batchMint(
        uint256 quantity,
        bytes32[] calldata merkleProof
    ) external payable {
        // 验证白名单
        bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
        require(
            MerkleProof.verify(merkleProof, merkleRoot, leaf),
            "Not in whitelist"
        );

        // 防止重复mint
        require(!claimed[msg.sender], "Already claimed");
        claimed[msg.sender] = true;

        // 批量铸造
        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, nextTokenId++);
        }
    }

    // 更新白名单(仅管理员)
    function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
        merkleRoot = _merkleRoot;
    }
}

这段代码里,我用了MerkleProof.verify来验证证明。注意,叶子节点是keccak256(abi.encodePacked(msg.sender)),这个哈希方式要和链下生成Merkle Tree时保持一致。

我曾经踩过的坑:链下生成叶子节点时,用了abi.encode而不是abi.encodePacked,结果链上验证一直失败。后来排查了半天才发现是编码方式不一致。记住,abi.encodePacked会把多个参数紧凑打包,而abi.encode会按32字节对齐,结果完全不同。

链下生成Merkle Tree

链下生成Merkle Tree,我一般用JavaScript的merkletreejs库。来看代码:

const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');

// 白名单地址列表
const whitelistAddresses = [
    '0x123...',
    '0x456...',
    // ... 更多地址
];

// 生成叶子节点
const leafNodes = whitelistAddresses.map(addr => 
    keccak256(addr)
);

// 构建Merkle Tree
const merkleTree = new MerkleTree(leafNodes, keccak256, {
    sortPairs: true  // 重要!必须排序
});

// 获取根哈希
const rootHash = merkleTree.getRoot().toString('hex');

// 为某个地址生成证明
const claimingAddress = '0x123...';
const leaf = keccak256(claimingAddress);
const proof = merkleTree.getHexProof(leaf);

console.log('Root Hash:', rootHash);
console.log('Proof:', proof);

这里有个细节:sortPairs: true。为什么?因为Merkle Tree在合并节点时,如果不排序,同样的叶子节点集合可能会生成不同的树。OpenZeppelin的MerkleProof.verify默认使用排序后的树,所以链下也必须排序。

批量mint的gas优化技巧

批量mint时,gas费是绕不开的话题。我总结了几条经验:

优化点 说明 效果
减少SSTORE操作 批量mint时,尽量把状态变量一次性写入,而不是每次mint都写 可节省30% gas
使用_safeMint代替_mint _safeMint会检查接收方是否为合约,避免NFT被锁死 安全性提升,gas略增
批量更新_owners映射 在循环外先计算好所有tokenId,再一次性写入 减少重复计算
使用unchecked 在循环中,如果确定不会溢出,可以用unchecked跳过溢出检查 每轮循环节省约20 gas

我的个人习惯:在批量mint函数里,我会把nextTokenId的递增放在unchecked块里。因为一次mint最多也就几百个,不可能溢出。但要注意,如果合约允许无限mint,那就不能这么干。

批量mint的完整实现

把上面的优化技巧整合起来,一个更高效的批量mint合约长这样:

function batchMintOptimized(
    uint256 quantity,
    bytes32[] calldata merkleProof
) external payable {
    // 白名单验证
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Not whitelisted");
    require(!claimed[msg.sender], "Already claimed");
    claimed[msg.sender] = true;

    uint256 startTokenId = nextTokenId;
    uint256 endTokenId = startTokenId + quantity;

    // 批量铸造,使用unchecked优化
    for (uint256 i = startTokenId; i < endTokenId; ) {
        _safeMint(msg.sender, i);
        unchecked {
            i++;
        }
    }

    // 一次性更新nextTokenId
    unchecked {
        nextTokenId = endTokenId;
    }
}

你看,这里我把nextTokenId的更新移到了循环外面,避免了每次mint都写一次存储。循环里的i++也用了unchecked。嗯,这些小细节加起来,gas费能省不少。

避坑指南

最后,分享几个我踩过的坑:

  • Merkle Tree的排序问题:链下生成时一定要sortPairs: true,否则验证失败
  • 叶子节点编码:链下和链上必须用完全相同的编码方式,推荐keccak256(abi.encodePacked(address))
  • 重复mint防护:白名单验证通过后,一定要用claimed映射防止同一个地址多次mint
  • 批量mint的数量限制:建议加一个maxBatchSize限制,防止用户一次mint太多导致gas超限

总结一下:Merkle Tree白名单验证 + 批量mint,是NFT项目中最常用的组合拳。它既解决了白名单存储的gas问题,又让用户能一次mint多个NFT。你想想看,如果每个用户都要单独验证白名单、单独mint,那gas费得多吓人?

好了,这一章就到这里。下一章咱们聊聊「ERC-721A的批量mint优化」,那可是个更狠的优化方案,能把gas费再砍掉一半。