avatar
原创2022/03/08

如何利用 Merkle Tree 节省白名单 Gas 费用

AI 总结

通过对比链上存储白名单与默克尔树两种方案,深度讲解如何利用 Merkle Proof 机制在 NFT 合约中实现低成本白名单验证,包括链下生成默克尔树、链上验证 Merkle Proof 的完整代码实现,以及 Gas 消耗的量化对比。

设置白名单

设置白名单的一种方式是将白名单地址直接存储在合约中。

import "@openzeppelin/contracts/access/Ownable.sol";
 
contract NFTWhite is Ownable {
  mapping(address => bool) whiteLists;
 
  constructor() ERC721('RoboItem', 'RTM') {}
 
  // 添加白名单地址
  function setWhiteList(address _to, bool _state) public onlyOwner {
    whiteLists[_to] = _state;
  }
}

如上代码所示,通过全局变量 whiteLists 存储白名单地址。通过调用 setWhiteList 来设置白名单。但每次只能设置一个地址。假设有 1000 个白名单地址,则需要调用 1000 次 setWhiteList,无疑会耗费巨量的 gas。当然也可以批量设置,但这并不能节省多少 gas 费。此时就可以通过默克尔树来极大的节省 gas 费。

使用默克尔树设置白名单

如果你还不知道什么是 默克尔树,可以直接查看 这里

默克尔证明(Merkle Proof)

假设有下面的默克尔树

merkle-proof

在已知根节点,即Top Hash的值,需要证明 0x02 是否在原始数据中。只需要提供节点 Hash 0-0 和 Hash 1 的值即可。

首先计算出 0x02 的哈希值,然后与已知的 Hash 0-0 经过哈希计算得到 Hash 0, 再将 Hash 0 与 已知的 Hash 1 计算得到 Top Hash。如果此时计算得到的 Top Hash 的值与原先的 Top Hash 一致,则证明 0x02 在原数据中。

这样做的优点在于不需要知道其他节点的值就可以证明数据在原始数据的集合中。

设置白名单

思路:

  • 将白名单地址列表存储在前端

  • 根据白名单地址列表生成默克尔树, 并将根节点的值保存在智能合约中

  • 用户 mint 的时候,调用合约的 mint 方法,并传入用户地址以及用户地址对应的 proof 列表

  • 合约端根据前端传入的信息进行 proof 验证,通过则可以 mint

前端实现

import { MerkleTree } from 'merkletreejs'
import keccak256 from 'keccak256'
import { ethers } from 'ethers'
 
// 白名单列表
const whiteLists = [
  '0xDB85A62a1a9Bb682E0e661e3E7bbEb4947BBBE07',
  '0x9215E27C0800F3fF433A19064a4983325cA62F48',
  '0xb23D56af43A669e2E5E5EB9Dbd4B3Ee3e2309F26',
  '0x56E6aBb93832817f8FAeaDBB0c1aD561A6ea59da',
  '0xE0a070e79e136050cd54d00b1840AA4096d2bD2b',
  '0x32993d42Fc41940453C39F1491f9175cFD955904',
  '0x865F6aBc0A7645F206a419612FE50FDB3c0D7329',
  '0x827ce8028893839dE74cd6Dbf36A7D0DeBA43Dcf',
  '0x3E20869eeC79D20388c0A02a72f255564fcC085c',
  '0x22d3bc725aF70E007D0Fd87afaF1d72094fC3429'
]
const nodes = whiteLists.map((addr) => keccak256(addr))
// 创建 merkle tree
const merkleTree = new MerkleTree(nodes, keccak256, { sortPairs: true })
 
async function mint() {
  const provider = new ethers.providers.Web3Provider(window.ethereum)
 
  // 获取地址
  const [account] = await provider.send('eth_requestAccounts', [])
 
  // 前端验证
  if (!whiteLists.includes(account)) {
    alert('you are not in white list')
    return
  }
  const signer = provider.getSigner()
  // 创建合约实例
  const contract = new ethers.Contract(contractAddress, contractABI, signer)
  // 获取地址对应的 proof 列表
  const proof = merkleTree.getHexProof(keccak256(account))
  // 调用合约的 mint 方法
  const tx = await contract.mint(account, proof, {
    gasLimit: 3000000
  })
 
  tx.wait()
}

合约端

节点验证使用 openzeppelin 的 MerkleProof 库进行验证,具体代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
library MerkleProof {
  // 验证方法
  // leaf:proof 列表
  // root:根节点的值
  // leaf: 需要验证的节点值
  function verify(
    bytes32[] memory proof,
    bytes32 root,
    bytes32 leaf
  ) internal pure returns (bool) {
    // 计算结果与 root 比较
    return processProof(proof, leaf) == root;
  }
 
  // 将 leaf 节点与 proof 中的列表中的第0个元素进行 hash 运算,并将运算结果与第1个元素继续进行 hash 运算,直到运算到 proof 列表的最后一个元素,得到的值就是 根节点的值
  function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
    bytes32 computedHash = leaf;
    // 循环进行 hash 运算
    for (uint256 i = 0; i < proof.length; i++) {
      computedHash = _hashPair(computedHash, proof[i]);
    }
    return computedHash;
  }
 
  function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) {
    return a < b ? _efficientHash(a, b) : _efficientHash(b, a);
  }
 
  // 将 a和b 进行 hash 运算
  function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) {
    /// @solidity memory-safe-assembly
    assembly {
      mstore(0x00, a)
      mstore(0x20, b)
      value := keccak256(0x00, 0x40)
    }
  }
}

在我们的合约中只需要调用 MerkleProof.verify 即可实现验证。

//SPDX-License-Identifier: MIT
 
pragma solidity ^0.8.15;
 
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
 
// 带白名单功能NFT
contract RoboNFT is ERC721URIStorage, Ownable {
  // 存储默克尔树根节点的值
  bytes32 public root;
 
  constructor(bytes32 _root) ERC721('RoboNFT', 'robo') {
    root = _root;
  }
 
  // 设置跟节点的值(防止白名单变化的情况)
  function setRoot(bytes32 _root) external onlyOwner {
    root = _root;
  }
 
  // mint 函数
  function mint(address _to, uint tokenId, bytes32[] calldata proof) external {
    // 验证 _to 地址是否属于白名单
    require(MerkleProof.verify(proof, root, keccak256(abi.encodePacked(_to))), 'not in white list');
    _mint(_to, tokenId);
  }
}

通过默克尔树避免了链上存储大量的白名单地址信息,而只需要存储一个白名单地址列表生成的默克尔树的根节点信息。这样便节省了大量设置白名单的费用。同时这样的方式还可以用来发放代币空投等

点击播放