Web3 中基于智能合约的 NFT 白名单铸造系统实战:Merkle Tree 校验、Gas 优化与安全防护
NFT 项目做发售时,白名单铸造几乎是绕不过去的一环。
问题看起来不复杂:只让特定地址在特定时间、按特定额度 mint。但真正落地时,事情会迅速变得“链上工程化”:
- 白名单名单很长,不能直接把所有地址硬编码进合约
- 每个地址的可铸造数量可能不同
- 公开 sale 与白名单 sale 往往并存
- gas 成本一高,用户体验就崩
- 机器人抢铸、重入、签名重放、错误 proof 等问题会一起冒出来
这篇文章我换一个更偏架构设计与落地实现的角度,带你把一个可运行的 NFT 白名单铸造系统搭出来,并重点讲清楚三件事:
- 为什么 Merkle Tree 是白名单铸造的主流方案
- 怎么在智能合约里做 gas 友好的校验
- 上线前哪些安全点必须补齐
背景与问题
先看最朴素的需求。
一个 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))
如果要支持更细规则,还可以加入:
phaseIdmintPricecollectionId
但字段越多,前后端越要保证一致,否则 proof 一定验证失败。
4. 白名单额度控制的关键点
Merkle proof 只能证明“你在名单里,且名单里给你的额度是 N”。
它不能自动知道你已经 mint 了多少。
所以合约里还需要一个状态变量:
mapping(address => uint256) public whitelistMinted;
校验流程通常是:
- 用 proof 验证
(address, allowance)在白名单中 - 检查
whitelistMinted[address] + quantity <= allowance - 更新已铸造数量
- 执行 mint
这一步别漏,不然白名单额度形同虚设。
系统架构设计
从架构上,一个完整的白名单铸造系统一般包含三部分:
- 名单生成层:离线脚本生成 Merkle Tree 和 proof
- 链上验证层:NFT 合约保存 root 并验证 proof
- 交互层:前端根据钱包地址查询 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 为例,合理顺序应该是:
require各种条件- 更新
whitelistMinted _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”,但真正上线可用,核心是三件事协同:
- 用 Merkle Tree 降低链上存储成本
- 用额度状态记录保证规则真的落地
- 用安全与流程控制避免发售当天翻车
如果你让我给一个中级开发者的落地建议,我会这么总结:
- 规则简单时:叶子先用
address + allowance - 名单规模大时:优先用 Merkle Tree,而不是链上 mapping
- 批量 mint 明显时:优先考虑 ERC721A
- 发售敏感时:root 更新策略要提前定好,不要临场改
- 上线前:一定做“脚本—前端—合约”的端到端测试,不要只测合约单元测试
最后一句经验之谈:
我见过的大多数白名单 mint 问题,不是出在 Merkle 算法本身,而是出在编码不一致、额度状态遗漏、以及 root 更新流程混乱。把这些工程细节守住,整个系统就会稳很多。
如果你准备把这套方案真正投到生产环境,我建议至少再补两件事:
- 完整事件日志
- 更细的 phase 设计与压测验证
这样你得到的,就不是“能跑的 demo”,而是一套更接近真实项目的 NFT 白名单铸造架构。