WTF Solidity 极简入门:Merkle Tree 与 NFT 白名单发放实战
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
Merkle Tree(默克尔树/哈希树)是区块链的底层加密技术,被比特币与以太坊广泛采用。本篇以 WTF-Solidity 教程第 36 讲为骨架,系统讲解 Merkle Tree 的构造原理、Merkle Proof 验证机制、链上MerkleProof库的实现细节,并给出一个完整的 ERC721 白名单合约与 Remix 部署验证流程——读完你即可用一条根哈希(root)在链上验证海量白名单地址,显著降低 gas 成本。
Merkle Tree 是什么
Merkle Tree,也叫默克尔树或哈希树,是一种自下而上构建的加密树:
- 每个叶子节点是对应数据的哈希(例如某个白名单地址的
keccak256哈希); - 每个非叶子节点是其两个子节点哈希再次哈希后的结果;
- 树的顶端是根节点(root / Top Hash),代表整棵树的指纹。
这种结构的核心价值在于:Merkle Tree 允许对大型数据结构的内容进行高效且安全的验证(Merkle Proof)。对于有N个叶子节点的 Merkle Tree,在已知root根值的情况下,验证某个数据是否属于该树(即是否是某个叶子节点),只需要ceil(log₂N)个哈希值(即proof),非常高效。
Merkle Proof 的验证逻辑
看一个直观的例子:假设叶子L1的Merkle proof为Hash 0-1和Hash 1。只要知道这两个值,就能验证L1是否在 Merkle Tree 的叶子中:
- 通过叶子
L1的数据可计算得到Hash 0-0; - 已知
Hash 0-1,将Hash 0-0与Hash 0-1联合哈希得到Hash 0; - 已知
Hash 1,将Hash 0与Hash 1联合哈希得到Top Hash(即 root 节点哈希); - 若最终结果与链上存储的
root相等,则证明通过;如果数据有误或proof错误,则无法还原出root根值。
生成 Merkle Tree:网页工具与 merkletreejs
生成 Merkle Tree 有两种常用方式:在线网页工具(merkletreejs 的示例页面)或 JavaScript 库merkletreejs。
本文使用网页工具生成一棵以4 个地址为叶子节点的 Merkle Tree。叶子节点输入:
[ "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2", "0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db", "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB" ]在菜单中勾选Keccak-256、hashLeaves和sortPairs三个选项,点击Compute,Merkle Tree 即生成完毕。展开如下:
└─ Root: eeefd63003e0e702cb41cd0043015a6e26ddb38073cc6ffeb0ba3e808ba8c097 ├─ 9d997719c0a5b5f6db9b8ac69a988be57cf324cb9fffd51dc2c37544bb520d65 │ ├─ Leaf0:5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229 │ └─ Leaf1:999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb └─ 4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c ├─ Leaf2:04a10bfd00977f54cc3450c9b25c9b3a502a089eba0097ba35fc33c4ea5fcb54 └─ Leaf3:dfbe3e504ac4e35541bebad4d0e7574668e16fefa26cd4172f93e18b59ce9486为什么必须勾选
sortPairs?排序保证哈希运算满足交换律(即H(a,b) == H(b,a))。在链上验证时,_hashPair会先对两个节点哈希排序再哈希,链下生成时也必须用同样的排序规则,否则链下链上的树不一致,验证必然失败。这一点在仓库源码的注释中也明确强调(见 MerkleTree.sol 头部注释)。
通过网页工具还可以拿到地址 0 的proof(即上图中其所有兄弟节点的哈希):
[ "0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb", "0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c" ]链上验证:MerkleProof 库实现剖析
WTF-Solidity 在第 36 讲给出了一个完整的MerkleProof库(与 OpenZeppelin 的经典实现一致),完整源码见 36_MerkleTree/MerkleTree.sol:
library MerkleProof { function verify( bytes32[] memory proof, bytes32 root, bytes32 leaf ) internal pure returns (bool) { return processProof(proof, leaf) == root; } function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) { bytes32 computedHash = leaf; for (uint256 i = 0; i < proof.length; i++) { computedHash = _hashPair(computedHash, proof[i]); } return computedHash; } // Sorted Pair Hash function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) { return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); } }库中包含三个函数,职责逐层递进:
verify()函数:利用proof验证leaf是否属于根为root的 Merkle Tree,属于则返回true。它内部调用processProof()。processProof()函数:利用proof和leaf从叶子向根逐层计算,还原出 Merkle Tree 的root。它内部调用_hashPair()。从源码看,其核心是一个循环:computedHash从leaf出发,每轮与一个 proof 节点做一次配对哈希,循环次数等于proof.length。_hashPair()函数:用keccak256()计算两个子节点的哈希,且先排序再哈希(a < b则keccak256(abi.encodePacked(a, b)),否则反过来),这是保证交换律、消除节点顺序歧义的关键。
验证流程与安全特性
将地址0的叶子哈希、root与对应proof输入verify()函数,将返回true;如果改变了其中任意一个值(叶子数据、proof 中的任一兄弟哈希、或 root),都无法还原出正确的根,返回false。
从源码结构还可以推断两个安全要点:
- 防重入设计:在
mint()中,地址被记录到mintedAddress的赋值发生在_mint之前,这种"先置位、后转账"的顺序可有效阻止重入攻击(WTF-Solidity 在 S01_ReentrancyAttack/readme.md 中有专题讲解)。 - 仅存 root、轻量上链:
leaf和proof由链下(后端)持有,链上合约只存储一个root,因此验证开销与白名单规模无关,极其省 gas。
与 OpenZeppelin 实现的对照
本仓库lib目录下还内置了 OpenZeppelin 的完整版 MerkleProof.sol。对照可以看出:
- 本讲的精简实现与 OZ 的
verify/processProof逻辑完全一致; - OZ 版本额外提供了
verifyCalldata(proof 使用calldata传入,进一步节省 gas)、支持自定义哈希函数的verify(..., hasher)重载,以及用于同时验证多个叶子的multiProofVerify/processMultiProof(需要proofFlags标记合并分支); - OZ 版本还给出了重要的安全警告:应避免使用 64 字节长的叶子值,否则排序后的内部节点对拼接结果可能被误解读为叶子值,构成潜在攻击面。OZ 的 JavaScript 库生成 Merkle 树时默认规避了该风险。
对绝大多数白名单场景,本讲的精简版已足够;若需要批量空投证明,可升级使用 OZ 的 multiproof 能力。
实战:用 Merkle Tree 发放 NFT 白名单
为什么用 Merkle Tree 发白名单
一份拥有800 个地址的白名单,如果直接把地址数组存到链上,更新一次所需的 gas 费用很容易超过1 ETH。而采用 Merkle Tree 后:
leaf(地址哈希)和proof可以存放在后端或客户端;- 链上只需存储一个
root值(32 字节); - 每次白名单更新只改变 root,gas 开销极小。
因此很多ERC721标准的 NFT 与ERC20标准代币的白名单/空投都采用 Merkle Tree 发放(例如 Optimism 的空投)。
MerkleTree 白名单合约
仓库中的完整合约见 36_MerkleTree/MerkleTree.sol,该合约继承ERC721标准(实现见 34_ERC721/ERC721.sol)并利用MerkleProof库:
contract MerkleTree is ERC721 { bytes32 immutable public root; // Merkle树的根 mapping(address => bool) public mintedAddress; // 记录已经mint的地址 // 构造函数,初始化NFT合集的名称、代号、Merkle树的根 constructor(string memory name, string memory symbol, bytes32 merkleroot) ERC721(name, symbol) { root = merkleroot; } // 利用Merkle树验证地址并完成mint function mint(address account, uint256 tokenId, bytes32[] calldata proof) external { require(_verify(_leaf(account), proof), "Invalid merkle proof"); // Merkle检验通过 require(!mintedAddress[account], "Already minted!"); // 地址没有mint过 mintedAddress[account] = true; // 记录mint过的地址 _mint(account, tokenId); // mint } // 计算Merkle树叶子的哈希值 function _leaf(address account) internal pure returns (bytes32) { return keccak256(abi.encodePacked(account)); } // Merkle树验证,调用MerkleProof库的verify()函数 function _verify(bytes32 leaf, bytes32[] memory proof) internal view returns (bool) { return MerkleProof.verify(proof, root, leaf); } }状态变量
合约中有两个状态变量:
root:bytes32 immutable类型,存储 Merkle Tree 的根,部署合约时在构造函数中赋值(immutable意味着部署后不可更改,保证白名单根哈希不可被篡改);mintedAddress:mapping(address => bool),记录已 mint 过的地址,某地址 mint 成功后置为true,用于防止同一地址重复铸造。
四个核心函数
合约中共有 4 个函数:
- 构造函数:初始化 NFT 的名称(
name)、代号(symbol)和 Merkle Tree 的root。ERC721(name, symbol)基类构造函数负责存储名称与代号,本合约只需把merkleroot写入root。 mint()函数:利用白名单铸造 NFT。参数为白名单地址account、铸造的tokenId和proof。执行顺序是:先调用_verify(_leaf(account), proof)验证地址是否在白名单中,再检查mintedAddress确认该地址未铸造过,然后先把地址记录到mintedAddress中(防止重入攻击),最后调用基类_mint(account, tokenId)铸造 NFT。此过程调用了_leaf()和_verify()函数。_leaf()函数:计算 Merkle Tree 叶子地址的哈希,即keccak256(abi.encodePacked(account))——注意此处只对地址本身取哈希,不包含任何字符串前缀或类型标记,因此链下生成树时hashLeaves选项必须与之一致。_verify()函数:调用MerkleProof库的verify()函数进行 Merkle Tree 验证,参数为叶子哈希、proof 与合约内存储的root。
Remix 验证全流程
第一步:部署合约
使用上文例子中的 4 个地址作为白名单并生成 Merkle Tree。部署MerkleTree合约,3 个参数分别为:
name = "WTF MerkleTree" symbol = "WTF" merkleroot = 0xeeefd63003e0e702cb41cd0043015a6e26ddb38073cc6ffeb0ba3e808ba8c097merkleroot正是网页工具生成结果中的根值,与仓库源码 MerkleTree.sol 头部注释中的示例数据一一对应。
第二步:白名单 mint
接下来运行mint函数给地址 0 铸造 NFT,3 个参数分别为:
account = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 tokenId = 0 proof = [ "0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb", "0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c" ]交易成功提交后,可调用 ERC721 基类提供的ownerOf函数验证:tokenId为 0 的 NFT 已经铸造给了地址 0(ownerOf的实现在 34_ERC721/ERC721.sol 中,它从_owners映射读取持有者,若为 0 地址则 revert"token doesn't exist"),合约运行成功。
第三步:验证防重放
此时若再次调用mint函数给同一地址铸造,虽然该地址能够通过 Merkle Proof 验证(它确实在白名单中、proof 也正确),但由于地址已经记录在mintedAddress中,require(!mintedAddress[account], "Already minted!")会触发,交易将被中止并回滚。
总结
这一讲围绕 WTF-Solidity 第 36 讲展开,完成了三件事:
- 理解概念:Merkle Tree 是自下而上构建的哈希树,支持用
log(N)级别的 proof 高效验证数据归属; - 掌握实现:分析了 MerkleProof 库 中
verify/processProof/_hashPair三层函数的调用链,并对照 OpenZeppelin 完整版 说明了calldata变体与 multiproof 的扩展能力; - 完成实战:基于 ERC721 基类 实现并部署了 MerkleTree 白名单合约,在 Remix 中完成了从生成树、部署到白名单 mint、防重放验证的完整闭环。
在实际生产中,复杂的 Merkle Tree 可以利用 JavaScript 库merkletreejs生成和管理,链上只需要存储一个 root 值,非常节省 gas,因此大量项目方选择使用 Merkle Tree 来发放白名单与空投。若想进一步了解白名单之外的安全主题,可继续阅读仓库中的 S01_ReentrancyAttack 等安全专题。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考