跳转到内容
123xiao | 无名键客

《Web3 中基于智能合约的 NFT 白名单铸造系统实战:Merkle Tree 校验、Gas 优化与安全防护》

字数: 0 阅读时长: 1 分钟

Web3 中基于智能合约的 NFT 白名单铸造系统实战:Merkle Tree 校验、Gas 优化与安全防护

NFT 项目做发售时,白名单铸造几乎是绕不过去的一环。
问题看起来不复杂:只让特定地址在特定时间、按特定额度 mint。但真正落地时,事情会迅速变得“链上工程化”:

  • 白名单名单很长,不能直接把所有地址硬编码进合约
  • 每个地址的可铸造数量可能不同
  • 公开 sale 与白名单 sale 往往并存
  • gas 成本一高,用户体验就崩
  • 机器人抢铸、重入、签名重放、错误 proof 等问题会一起冒出来

这篇文章我换一个更偏架构设计与落地实现的角度,带你把一个可运行的 NFT 白名单铸造系统搭出来,并重点讲清楚三件事:

  1. 为什么 Merkle Tree 是白名单铸造的主流方案
  2. 怎么在智能合约里做 gas 友好的校验
  3. 上线前哪些安全点必须补齐

背景与问题

先看最朴素的需求。

一个 NFT 发售通常包含这些规则:

  • 白名单阶段允许提前 mint
  • 白名单用户有更低价格
  • 每个白名单地址有独立额度,比如 A 可 mint 2 个,B 可 mint 5 个
  • 公售阶段所有人都能 mint,但总量受限
  • 团队希望链上验证,避免后端中心化控制

如果你直接在合约里这样存:

mapping(address => bool) public whitelist;

对小规模名单还能接受,但一旦几千、几万个地址,就会碰到两个现实问题:

1. 部署与写入成本太高

每个地址写入链上存储都要 gas。
如果项目方在部署后再批量导入白名单,成本会非常可观。

2. 灵活性差

只存 bool 无法表达更复杂的数据:

  • 每个地址的额度不同
  • 不同地址的价格不同
  • 不同轮次有不同规则

于是,Merkle Tree 成了更适合的方案:
链上只存一个 merkleRoot,用户 mint 时提交 proof,合约验证该用户是否在白名单集合中。

这是一个典型的“用计算替代存储”的链上设计。


方案对比与取舍分析

在正式写代码前,我先把常见方案放在一起对比一下。很多人上来就写 Merkle,但其实你得知道它解决了什么、又牺牲了什么。

方案链上成本灵活性用户侧复杂度适用场景
链上 mapping 白名单小规模活动
后端签名授权需要动态策略
Merkle Tree大多数 NFT 白名单
零知识证明名单很高隐私要求强

为什么多数 NFT 项目选 Merkle Tree

因为它在这几个维度上比较平衡:

  • 链上只保存一个 root,存储成本低
  • proof 可由前端或后端生成,扩展性好
  • 可把 address + allowance + price + phase 一起编码进叶子节点,规则表达能力强
  • 完全链上验证,不依赖中心化服务在线

它的代价是什么

也别神化它,Merkle Tree 不是银弹:

  • 前端必须正确拿到 proof
  • 叶子编码规则必须前后一致
  • root 一旦更新,旧 proof 立即失效
  • 若 phase 切换设计混乱,容易造成用户体验问题

所以工程上要把“链上合约、前端、名单生成脚本”视为一个整体,而不是单点开发。


核心原理

1. Merkle Tree 在白名单中的角色

项目方离线生成白名单数据,比如:

[
  { address: 0xA..., allowance: 2 },
  { address: 0xB..., allowance: 1 },
  { address: 0xC..., allowance: 3 }
]

每一条记录会被编码成一个叶子节点的哈希。
随后把所有叶子构建成一棵 Merkle Tree,得到唯一的 merkleRoot

  • 合约里只保存 merkleRoot
  • 用户铸造时提交:
    • 自己的地址
    • 自己的 allowance
    • 对应的 Merkle proof
  • 合约重新计算叶子哈希,再沿 proof 向上验证,最终看是否能还原出 root

如果能还原,说明这条数据确实属于原始白名单集合。

2. 为什么 proof 能证明成员资格

直观理解是:
Merkle Tree 把一大堆数据“压缩”成了一个根哈希。
proof 就像一条从叶子走到根的“兄弟节点路径”。

flowchart TD
  A[Leaf: keccak256(address, allowance)] --> H1[Parent Hash]
  B[Sibling Leaf] --> H1
  H1 --> H3[Upper Hash]
  C[Sibling Branch] --> H3
  H3 --> R[Merkle Root]

  P[User submits proof] --> V[Contract recomputes path]
  V --> R

3. 叶子节点该包含什么

这是很多项目踩坑最多的地方之一。

一个白名单叶子如果只包含 address

keccak256(abi.encodePacked(msg.sender))

那么你没法表达“这个地址最多 mint 2 个”。

更实用的做法是把业务字段一起编码进去,比如:

keccak256(abi.encodePacked(account, allowance))

如果要支持更细规则,还可以加入:

  • phaseId
  • mintPrice
  • collectionId

但字段越多,前后端越要保证一致,否则 proof 一定验证失败。

4. 白名单额度控制的关键点

Merkle proof 只能证明“你在名单里,且名单里给你的额度是 N”。
它不能自动知道你已经 mint 了多少。

所以合约里还需要一个状态变量:

mapping(address => uint256) public whitelistMinted;

校验流程通常是:

  1. 用 proof 验证 (address, allowance) 在白名单中
  2. 检查 whitelistMinted[address] + quantity <= allowance
  3. 更新已铸造数量
  4. 执行 mint

这一步别漏,不然白名单额度形同虚设。


系统架构设计

从架构上,一个完整的白名单铸造系统一般包含三部分:

  1. 名单生成层:离线脚本生成 Merkle Tree 和 proof
  2. 链上验证层:NFT 合约保存 root 并验证 proof
  3. 交互层:前端根据钱包地址查询 proof,发起 mint
flowchart LR
  D[Whitelist CSV/JSON] --> S[Build Script]
  S --> R[merkleRoot]
  S --> P[proof map]
  R --> C[Smart Contract]
  P --> F[Frontend / API]
  F --> U[User Wallet]
  U --> C

数据流

  • 运营同学整理白名单
  • 开发用脚本生成 root + proofMap
  • 部署合约时写入 root
  • 用户连接钱包后,前端按地址获取 proof
  • 用户调用 whitelistMint(quantity, allowance, proof)

容量估算

假设白名单有 10,000 个地址:

  • 如果用链上 mapping 批量写入,成本非常高
  • 如果用 Merkle Tree,链上只存 1 个 bytes32 root
  • 用户每次额外提交的 proof 长度约为 O(log n),10,000 条数据大概十几层

这也是 Merkle Tree 在大名单场景下特别划算的原因:
把全局成本转为按需验证成本


实战代码(可运行)

下面我给一套可以跑起来的最小实现:

  • 合约:ERC721A + MerkleProof + ReentrancyGuard
  • 脚本:Node.js 生成 Merkle root 和 proof
  • 测试:Hardhat 风格验证白名单 mint

为了突出重点,我会保持代码简洁,但保留关键安全控制。


一、Solidity 合约

依赖:

  • OpenZeppelin Contracts
  • ERC721A(可选,但对批量 mint 更省 gas)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "erc721a/contracts/ERC721A.sol";

contract MerkleWhitelistNFT is ERC721A, Ownable, ReentrancyGuard {
    bytes32 public merkleRoot;

    uint256 public constant MAX_SUPPLY = 5000;
    uint256 public whitelistPrice = 0.03 ether;
    uint256 public publicPrice = 0.05 ether;

    bool public whitelistSaleActive;
    bool public publicSaleActive;

    mapping(address => uint256) public whitelistMinted;
    uint256 public maxPublicMintPerTx = 3;

    constructor(bytes32 _merkleRoot) ERC721A("WhitelistNFT", "WNFT") {
        merkleRoot = _merkleRoot;
    }

    function setMerkleRoot(bytes32 _newRoot) external onlyOwner {
        merkleRoot = _newRoot;
    }

    function setSaleState(bool _whitelistSaleActive, bool _publicSaleActive) external onlyOwner {
        whitelistSaleActive = _whitelistSaleActive;
        publicSaleActive = _publicSaleActive;
    }

    function setPrices(uint256 _whitelistPrice, uint256 _publicPrice) external onlyOwner {
        whitelistPrice = _whitelistPrice;
        publicPrice = _publicPrice;
    }

    function whitelistMint(
        uint256 quantity,
        uint256 allowance,
        bytes32[] calldata proof
    ) external payable nonReentrant {
        require(whitelistSaleActive, "Whitelist sale inactive");
        require(quantity > 0, "Quantity must be > 0");
        require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds max supply");
        require(msg.value == whitelistPrice * quantity, "Incorrect ETH amount");

        bytes32 leaf = keccak256(abi.encodePacked(msg.sender, allowance));
        require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

        uint256 minted = whitelistMinted[msg.sender];
        require(minted + quantity <= allowance, "Exceeds whitelist allowance");

        whitelistMinted[msg.sender] = minted + quantity;
        _safeMint(msg.sender, quantity);
    }

    function publicMint(uint256 quantity) external payable nonReentrant {
        require(publicSaleActive, "Public sale inactive");
        require(quantity > 0 && quantity <= maxPublicMintPerTx, "Invalid quantity");
        require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds max supply");
        require(msg.value == publicPrice * quantity, "Incorrect ETH amount");

        _safeMint(msg.sender, quantity);
    }

    function withdraw(address payable to) external onlyOwner nonReentrant {
        require(to != address(0), "Zero address");
        uint256 balance = address(this).balance;
        require(balance > 0, "No balance");

        (bool success, ) = to.call{value: balance}("");
        require(success, "Withdraw failed");
    }
}

二、生成 Merkle Tree 的脚本

下面用 merkletreejs + keccak256 生成 root 和 proof。

先安装依赖:

npm install merkletreejs keccak256 ethers

脚本 scripts/buildWhitelist.js

const { MerkleTree } = require("merkletreejs");
const keccak256 = require("keccak256");
const { ethers } = require("ethers");

const whitelist = [
  { address: "0x1111111111111111111111111111111111111111", allowance: 2 },
  { address: "0x2222222222222222222222222222222222222222", allowance: 1 },
  { address: "0x3333333333333333333333333333333333333333", allowance: 3 },
];

function hashLeaf(address, allowance) {
  return Buffer.from(
    ethers.utils.solidityKeccak256(
      ["address", "uint256"],
      [address, allowance]
    ).slice(2),
    "hex"
  );
}

const leaves = whitelist.map((item) => hashLeaf(item.address, item.allowance));
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const root = tree.getHexRoot();

console.log("Merkle Root:", root);

const proofMap = {};
for (const item of whitelist) {
  const leaf = hashLeaf(item.address, item.allowance);
  proofMap[item.address.toLowerCase()] = {
    allowance: item.allowance,
    proof: tree.getHexProof(leaf),
  };
}

console.log(JSON.stringify(proofMap, null, 2));

这个脚本会输出:

  • merkleRoot
  • 每个地址对应的 allowance + proof

前端只要按钱包地址读取这份数据即可。


三、Hardhat 测试示例

测试文件 test/MerkleWhitelistNFT.js

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { MerkleTree } = require("merkletreejs");
const keccak256 = require("keccak256");

describe("MerkleWhitelistNFT", function () {
  function hashLeaf(address, allowance) {
    return Buffer.from(
      ethers.utils.solidityKeccak256(
        ["address", "uint256"],
        [address, allowance]
      ).slice(2),
      "hex"
    );
  }

  it("should allow whitelist user to mint within allowance", async function () {
    const [owner, user1, user2] = await ethers.getSigners();

    const whitelist = [
      { address: user1.address, allowance: 2 },
      { address: user2.address, allowance: 1 },
    ];

    const leaves = whitelist.map((x) => hashLeaf(x.address, x.allowance));
    const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
    const root = tree.getHexRoot();

    const NFT = await ethers.getContractFactory("MerkleWhitelistNFT");
    const nft = await NFT.deploy(root);
    await nft.deployed();

    await nft.setSaleState(true, false);

    const allowance = 2;
    const leaf = hashLeaf(user1.address, allowance);
    const proof = tree.getHexProof(leaf);

    const price = await nft.whitelistPrice();

    await expect(
      nft.connect(user1).whitelistMint(2, allowance, proof, {
        value: price.mul(2),
      })
    ).to.not.be.reverted;

    expect(await nft.totalSupply()).to.equal(2);
  });

  it("should reject invalid proof", async function () {
    const [owner, user1, user2] = await ethers.getSigners();

    const whitelist = [{ address: user1.address, allowance: 1 }];
    const leaves = whitelist.map((x) => hashLeaf(x.address, x.allowance));
    const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
    const root = tree.getHexRoot();

    const NFT = await ethers.getContractFactory("MerkleWhitelistNFT");
    const nft = await NFT.deploy(root);
    await nft.deployed();

    await nft.setSaleState(true, false);

    const fakeAllowance = 2;
    const fakeLeaf = hashLeaf(user2.address, fakeAllowance);
    const fakeProof = tree.getHexProof(fakeLeaf);

    const price = await nft.whitelistPrice();

    await expect(
      nft.connect(user2).whitelistMint(1, fakeAllowance, fakeProof, {
        value: price,
      })
    ).to.be.revertedWith("Invalid proof");
  });
});

四、前端调用思路

前端的核心不是复杂逻辑,而是参数要和链下构建时严格一致

伪代码如下:

async function whitelistMint(contract, walletAddress, quantity, proofData) {
  const { allowance, proof } = proofData[walletAddress.toLowerCase()];
  const price = await contract.whitelistPrice();
  const total = price.mul(quantity);

  const tx = await contract.whitelistMint(quantity, allowance, proof, {
    value: total,
  });
  await tx.wait();
}

如果前端传错 allowance,哪怕 proof 是对的,也会失败。
因为叶子哈希绑定的是 (address, allowance) 这个组合。


白名单校验时序

把整个校验过程串起来看,会更清楚:

sequenceDiagram
  participant U as 用户钱包
  participant F as 前端
  participant A as Proof服务/静态JSON
  participant C as NFT合约

  U->>F: 连接钱包
  F->>A: 按地址查询 allowance/proof
  A-->>F: 返回 proof 数据
  U->>C: whitelistMint(quantity, allowance, proof)
  C->>C: 校验 sale 状态
  C->>C: 校验 ETH 金额
  C->>C: verify(proof, root, leaf)
  C->>C: 检查 minted + quantity <= allowance
  C->>C: 更新 minted
  C->>C: _safeMint
  C-->>U: Mint 成功

常见坑与排查

这一部分我建议你上线前至少过一遍。很多“合约没问题”的线上事故,最后发现是链下构建流程出了问题。

1. abi.encodePacked 与链下编码不一致

这是最常见的坑。

合约里你写的是:

keccak256(abi.encodePacked(msg.sender, allowance))

那链下必须使用完全等价的编码方式,比如:

ethers.utils.solidityKeccak256(["address", "uint256"], [address, allowance])

如果你链下自己拼字符串,或者类型顺序不一致,proof 一定失败。

排查方法:

  • 在脚本里打印 leaf
  • 在测试里打印 leaf
  • 对比链上计算结果是否一致

2. 地址大小写或格式问题

虽然 EVM 地址本质上不区分大小写,但你在做 proofMap[address] 查询时,很容易因为大小写不一致查不到 proof。

建议:

  • 所有地址入库时统一 toLowerCase()
  • 前端查询时也统一小写

3. sortPairs 配置前后不一致

如果链下构建 Merkle Tree 时用了:

new MerkleTree(leaves, keccak256, { sortPairs: true })

那么你要保证验证逻辑兼容这个构建方式。
OpenZeppelin 的 MerkleProof.verify 默认适配“排序后的配对哈希”方案,这是主流做法。

我个人建议:统一使用 sortPairs: true
这样树结构更稳定,避免左右顺序引入额外复杂度。


4. 更新 root 后旧 proof 失效

运营修改白名单是常态,但这会带来一个很现实的问题:

  • 用户页面上缓存的是旧 proof
  • 合约里已经切换到新 root
  • 用户一 mint 就报 Invalid proof

解决办法:

  • 前端在 mint 前实时拉一次 proof
  • root 更新时同步刷新静态 proof 文件或 API 缓存
  • 版本号化 proof 数据

5. 额度校验遗漏

有些项目只做了 proof 校验:

require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

却没记录已经 mint 了多少。
结果用户可以反复提交同一个 proof,无限铸造。

必须加:

mapping(address => uint256) public whitelistMinted;

并在 mint 前校验累计数量。


6. msg.value 校验写得太松

错误示例:

require(msg.value >= whitelistPrice * quantity, "Insufficient ETH");

这会让多付 ETH 的用户把钱留在合约里,虽然不一定是漏洞,但会带来对账和用户投诉问题。

更好的做法:

require(msg.value == whitelistPrice * quantity, "Incorrect ETH amount");

边界更清楚。


7. _safeMint 的重入风险认知不足

很多人觉得“只是 mint NFT,哪里来的重入”。
_safeMint 如果接收方是合约,会触发 onERC721Received 回调。

如果你在状态更新顺序上处理不当,理论上可能被利用。

正确习惯:

  • 先检查
  • 再更新状态
  • 最后 _safeMint
  • 对外部敏感函数加 nonReentrant

安全/性能最佳实践

这一节我尽量给“可执行建议”,不是只喊口号。


一、安全最佳实践

1. 采用 Checks-Effects-Interactions 顺序

以白名单 mint 为例,合理顺序应该是:

  1. require 各种条件
  2. 更新 whitelistMinted
  3. _safeMint

这能降低回调类风险。


2. 对管理员操作做最小化设计

管理员通常有这些权限:

  • 修改 merkleRoot
  • 切换 sale 开关
  • 提款

这些函数都应该:

  • onlyOwner
  • 不要做多余外部调用
  • 最好保留事件日志

例如:

event MerkleRootUpdated(bytes32 oldRoot, bytes32 newRoot);
event SaleStateUpdated(bool whitelistSaleActive, bool publicSaleActive);

日志能极大提升排障效率。


3. 谨慎处理 root 更新

如果 root 可随时更改,意味着项目方可以动态修改白名单。
这在产品上是灵活的,但在信任模型上也意味着用户需要相信管理员。

如果项目方强调公平性,可以考虑:

  • 白名单阶段开始后禁止修改 root
  • 或通过 timelock 延迟生效
  • 或把每个 phase 的 root 固定下来

这属于“产品承诺与链上权限边界”问题,不只是代码问题。


4. 提款函数用 call

现代 Solidity 中,提款建议使用:

(bool success, ) = to.call{value: amount}("");
require(success, "Withdraw failed");

不要依赖 transfer 的固定 gas 限制。


5. 别忽略 DoS 与机器人问题

Merkle proof 解决的是“谁能 mint”,
但它并不解决:

  • 抢跑
  • 机器人批量抢购
  • 合约钱包批量 mint
  • 矿工排序

如果发售很热门,还可以补充:

  • 每地址每 tx 限额
  • EOA 限制(仅作为弱防护,不绝对可靠)
  • 分阶段小批量放量
  • 加签名或 commit-reveal 机制

二、Gas 优化最佳实践

1. 优先减少链上存储

这是 Merkle Tree 最大的 gas 优势来源。
一个 bytes32 root 远比成千上万个地址存储便宜。


2. 批量 mint 场景优先用 ERC721A

如果你的白名单经常是一次 mint 2~5 个,ERC721A 的批量铸造成本通常显著低于标准 ERC721 的逐个 mint。

适用边界:

  • 同质化 NFT 批量连续 tokenId 分配
  • 不需要每个 token mint 时做复杂独立逻辑

如果你的铸造逻辑很个性化,ERC721A 的收益可能没那么大。


3. 减少重复读取状态变量

例如:

uint256 minted = whitelistMinted[msg.sender];
require(minted + quantity <= allowance, "Exceeds whitelist allowance");
whitelistMinted[msg.sender] = minted + quantity;

比多次直接访问 mapping 更省一点 gas,也更清晰。


4. 常量和不可变量优先使用 constant / immutable

MAX_SUPPLY 这种固定值,用 constant
部署后不变的地址或参数,可考虑 immutable


5. 自定义错误可进一步省 gas

如果你在意极致优化,可以把字符串错误改为自定义错误:

error InvalidProof();
error ExceedsAllowance();

然后写成:

if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert InvalidProof();

这样部署与运行成本通常更优。


一个更稳的扩展设计:按阶段区分白名单

真实项目里,常见情况不是“只有一个白名单阶段”,而是:

  • OG 阶段:最多 mint 2,价格更低
  • WL 阶段:最多 mint 1
  • Public 阶段:开放 mint

这时可以把 phaseId 编进叶子:

keccak256(abi.encodePacked(account, phaseId, allowance, price))

好处是:

  • 不同阶段规则天然隔离
  • 同一个地址可以在不同阶段有不同权益
  • proof 泄露后也不能跨阶段复用

但代价也明确:

  • 链下生成与前端参数更复杂
  • root 管理难度增加
  • 测试覆盖必须更完整

如果你的项目规则不复杂,没必要一上来就做这么重。
中等规模项目里,我更建议先用 address + allowance,阶段单独切 root。


排查 checklist:上线前我会手动过的一遍

这部分给你一个实操清单,尤其适合发售前最后验收。

合约侧

  • 白名单 mint 是否验证 saleActive
  • 是否验证 msg.value == price * quantity
  • 是否验证 totalSupply + quantity <= MAX_SUPPLY
  • 是否记录并校验已 mint 数量
  • 是否加 nonReentrant
  • withdraw 是否只允许 owner 调用
  • root 更新是否有事件日志

脚本侧

  • 链下叶子编码是否与 Solidity 完全一致
  • 地址是否统一大小写
  • 是否固定 sortPairs: true
  • root 是否与部署参数一致
  • proofMap 是否覆盖全部白名单地址

前端侧

  • 钱包地址查询不到 proof 时是否有明确提示
  • root 更新后是否会刷新 proof 缓存
  • mint 前是否读取最新价格
  • 用户数量输入是否受 allowance 限制
  • 错误信息是否能区分 proof 错误、金额错误、额度超限

总结

NFT 白名单铸造,表面上只是“让部分用户提前 mint”,但真正上线可用,核心是三件事协同:

  1. 用 Merkle Tree 降低链上存储成本
  2. 用额度状态记录保证规则真的落地
  3. 用安全与流程控制避免发售当天翻车

如果你让我给一个中级开发者的落地建议,我会这么总结:

  • 规则简单时:叶子先用 address + allowance
  • 名单规模大时:优先用 Merkle Tree,而不是链上 mapping
  • 批量 mint 明显时:优先考虑 ERC721A
  • 发售敏感时:root 更新策略要提前定好,不要临场改
  • 上线前:一定做“脚本—前端—合约”的端到端测试,不要只测合约单元测试

最后一句经验之谈:
我见过的大多数白名单 mint 问题,不是出在 Merkle 算法本身,而是出在编码不一致、额度状态遗漏、以及 root 更新流程混乱。把这些工程细节守住,整个系统就会稳很多。

如果你准备把这套方案真正投到生产环境,我建议至少再补两件事:

  • 完整事件日志
  • 更细的 phase 设计与压测验证

这样你得到的,就不是“能跑的 demo”,而是一套更接近真实项目的 NFT 白名单铸造架构。


分享到:

上一篇
《Java 中基于 CompletableFuture 的异步编排实战:提升接口聚合性能与可维护性-172》
下一篇
《AI 智能体实战:基于 RAG 构建企业知识库问答系统的架构设计与性能优化》