NFT白名单:如何实现单地址可变铸造额度
用离线签名方案实现可变额度白名单的NFT铸造合约
直接用链上mapping(address => uint)存储每个地址的铸造额度,确实会因为批量写入的gas成本过高(以太坊上每写一个存储slot要几千gas,几百条就是几十万甚至上百万gas)而不划算。离线签名授权是目前最常用的替代方案,能同时降低项目方和铸造者的成本,具体实现思路如下:
核心逻辑
项目方不需要提前把所有白名单地址和额度写到链上,而是离线为每个白名单地址生成专属的授权签名,用户铸造时携带这个签名给合约验证,合约只需要验证签名有效性、用户当前铸造数量是否超出签名中的额度即可。
关键步骤
项目方离线生成签名
- 为每个白名单地址生成包含以下信息的授权内容:用户地址、可铸造最大额度、签名过期时间(可选,防止旧签名滥用)、合约地址(可选,防止签名被其他合约复用)。
- 用项目方的私钥对这些内容的哈希值进行签名,得到签名数据。
- 把签名和对应的授权信息分发给白名单用户(比如通过前端页面、邮件等)。
用户铸造时验证签名
- 用户调用铸造函数时,传入自己要铸造的数量、授权信息、签名数据。
- 合约先验证签名是否由项目方的私钥生成,再检查当前用户已铸造数量 + 本次铸造数量是否不超过签名中的额度,最后执行铸造逻辑。
合约代码示例
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract WhitelistNFT is ERC721 { // 项目方的签名地址(预存在合约中) address public immutable signer; // 记录每个用户已铸造的数量(仅当用户实际铸造时才写入,成本极低) mapping(address => uint256) public mintedCount; // 下一个要铸造的tokenID uint256 public nextTokenId; // 授权信息结构体 struct MintAllowance { address user; uint256 maxAmount; uint256 deadline; // 签名过期时间(Unix时间戳) address contractAddr; // 绑定合约地址,防止签名跨合约复用 } constructor(address _signer) ERC721("WhitelistNFT", "WNFT") { signer = _signer; } // 验证签名有效性 function verifySignature(MintAllowance calldata allowance, bytes calldata signature) public view returns (bool) { // 生成消息哈希,包含所有关键信息防止篡改 bytes32 messageHash = keccak256(abi.encode( allowance.user, allowance.maxAmount, allowance.deadline, allowance.contractAddr )); // 转换为以太坊签名标准的哈希(添加前缀) bytes32 ethSignedHash = keccak256(abi.encodePacked( "\x19Ethereum Signed Message:\n32", messageHash )); // 恢复签名者地址 address recovered = ecrecover(ethSignedHash, signature[64], bytes32(signature[0:32]), bytes32(signature[32:64])); // 验证签名者是项目方、签名未过期、用户匹配、合约地址匹配 return recovered == signer && block.timestamp <= allowance.deadline && allowance.user == msg.sender && allowance.contractAddr == address(this); } // 铸造函数 function mint(uint256 amount, MintAllowance calldata allowance, bytes calldata signature) external payable { // 1. 验证签名 require(verifySignature(allowance, signature), "Invalid/expired signature"); // 2. 检查是否超过额度 require(mintedCount[msg.sender] + amount <= allowance.maxAmount, "Exceeds mint limit"); // 3. 检查支付金额(如果是付费铸造) // require(msg.value == amount * PRICE_PER_NFT, "Incorrect ETH amount"); // 执行铸造 for (uint256 i = 0; i < amount; i++) { _safeMint(msg.sender, nextTokenId); nextTokenId++; } // 更新已铸造数量 mintedCount[msg.sender] += amount; } }
成本优势
- 项目方:不需要执行任何链上批量写入操作,只需要离线生成签名(本地用脚本即可完成,比如用ethers.js或web3.js),几乎零gas成本,不管白名单有几百还是几千条都能轻松处理。
- 用户:铸造时只需要多传递几个参数和签名,gas成本仅比普通铸造略高一点,远低于项目方提前批量写入白名单带来的间接成本(比如项目方可能会把成本转嫁到用户身上)。
注意事项
- 必须保留
mintedCount的mapping:用来记录用户已铸造的数量,防止用户重复使用同一个签名多次铸造,超出额度。这个mapping只有用户实际铸造时才会写入,所以整体gas成本比预先批量写入所有白名单地址低很多。 - 签名要加入过期时间:避免项目方取消白名单后,用户还能用旧签名铸造。
- 签名要绑定合约地址:防止攻击者把签名拿到其他同逻辑的合约上复用。
- 项目方要妥善保管私钥:一旦私钥泄露,攻击者可以伪造任意地址的授权签名,造成损失。
内容的提问来源于stack exchange,提问作者agonist_
相关产品推荐
相关产品推荐

