数字签名与nft白名单

数字签名与nft白名单

数字签名(ECDSA)与 NFT 白名单

一、什么是数字签名?

数字签名是密码学中的一种验证机制。在以太坊中,它可以用来证明某个消息确实是由某个特定地址(私钥持有者)签发的,而无需暴露私钥。

生活中的类比

想象你在银行办业务需要签字。银行通过对比你的签字与预留签字来确认是你本人。数字签名类似——只不过是数学上的”签字”,不可伪造、不可抵赖。

二、核心概念

概念说明
私钥只有你自己知道的秘密数字,用来签名
公钥/地址从私钥推导出来的,可以公开,用来验证签名
消息哈希对要签名的内容取哈希(keccak256),固定长度 32 字节
签名(signature)用私钥对消息哈希进行运算得到的 65 字节数据(r + s + v)
ecrecoverSolidity 内置函数,从签名和消息哈希反推出签名者地址

三、签名的完整流程

┌─────────────────────────────────────────────────────────────┐
│                        链下(前端/钱包)                        │
├─────────────────────────────────────────────────────────────┤
│  1. 构造消息: hash = keccak256(用户地址 + tokenId)             │
│  2. 加前缀:   ethHash = "\x19Ethereum Signed Message:\n32" + hash │
│  3. 用私钥签名 ethHash → 得到 signature (65 bytes)            │
└─────────────────────────────────────────────────────────────┘

                              ▼ 提交 signature 到合约
┌─────────────────────────────────────────────────────────────┐
│                        链上(智能合约)                         │
├─────────────────────────────────────────────────────────────┤
│  1. 重新计算消息哈希(用相同的参数)                              │
│  2. 加上以太坊签名前缀                                         │
│  3. 用 ecrecover(ethHash, v, r, s) 恢复签名者地址              │
│  4. 对比恢复出的地址与项目方预设的 signer 地址                    │
│  5. 一致 → 验证通过 → 允许 mint NFT                           │
└─────────────────────────────────────────────────────────────┘

四、为什么要加以太坊前缀?

"\x19Ethereum Signed Message:\n32"

这是 EIP-191 标准规定的前缀。目的是防止用户签名的消息被恶意利用为真实的以太坊交易。加了这个前缀后,签出来的签名只能用于消息验证,不会被当作转账交易执行。

五、代码详解

5.1 签名工具库

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

library Signature {
    /// @notice 将地址和 tokenId 打包取哈希,作为原始消息
    function getMessageHash(
        address _account,
        uint256 _tokenId
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_account, _tokenId));
    }

    /// @notice 在消息哈希前加以太坊签名前缀(EIP-191)
    function toEthSignedMessageHash(
        bytes32 messageHash
    ) public pure returns (bytes32) {
        return keccak256(
            abi.encodePacked(
                "\x19Ethereum Signed Message:\n32",
                messageHash
            )
        );
    }

    /// @notice 从签名中恢复签名者地址
    function recoverPublicKey(
        bytes32 _messageHash,
        bytes memory _signature
    ) public pure returns (address) {
        require(_signature.length == 65, "invalid signature length");
        bytes32 r;
        bytes32 s;
        uint8 v;
        // 用内联汇编从 signature 中提取 r, s, v
        assembly {
            r := mload(add(_signature, 0x20))  // 前 32 字节
            s := mload(add(_signature, 0x40))  // 中间 32 字节
            v := byte(0, mload(add(_signature, 0x60)))  // 最后 1 字节
        }
        return ecrecover(_messageHash, v, r, s);
    }

    /// @notice 验证签名是否来自指定地址
    function verify(
        bytes32 _messageHash,
        bytes memory _signature,
        address _publicKey
    ) public pure returns (bool) {
        return recoverPublicKey(_messageHash, _signature) == _publicKey;
    }
}

5.2 签名 NFT 白名单合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ERC721.sol";

contract SignatureNFT is ERC721 {
    // 项目方的签名地址(公钥),部署时写入,不可更改
    address public immutable signer;
    // 记录已经 mint 过的地址,防止重复铸造
    mapping(address => bool) public mintedAddress;

    constructor(
        string memory name,
        string memory symbol,
        address _signer
    ) ERC721(name, symbol) {
        signer = _signer;
    }

    /// @notice 用户提交签名来铸造 NFT
    function mint(
        address _account,
        uint256 _tokenId,
        bytes memory _signature
    ) external {
        // 步骤1: 计算消息哈希
        bytes32 _messageHash = getMessageHash(_account, _tokenId);
        // 步骤2: 加以太坊前缀
        bytes32 _ethSignedHash = toEthSignedMessageHash(_messageHash);
        // 步骤3: 验证签名是否来自项目方 signer
        require(verify(_ethSignedHash, _signature), "Invalid Signature!");
        // 步骤4: 防止重复铸造
        require(!mintedAddress[_account], "Already minted!");
        // 步骤5: 铸造 NFT
        _mint(_account, _tokenId);
        mintedAddress[_account] = true;
    }

    // ... 内部辅助函数(同上面的库)
}

六、签名中 r、s、v 是什么?

一个 ECDSA 签名是 65 字节,由三部分组成:

分量长度含义
r32 字节椭圆曲线上随机点的 x 坐标
s32 字节签名计算结果
v1 字节恢复标识符(27 或 28),帮助确定公钥

在内存中的排列:[r (32 bytes)][s (32 bytes)][v (1 byte)]

七、实际使用场景

场景:NFT 白名单(免 Gas 方案)

传统方式(Merkle Tree)需要项目方提前将所有白名单地址上链,有新增或修改就要重新设置根哈希。

签名方案的优势:

  1. 项目方在链下用私钥为每个白名单用户签名
  2. 用户 mint 时提交签名到合约
  3. 合约验证签名是否来自项目方
  4. 无需提前上链任何白名单数据,省 gas、更灵活

前端签名示例(ethers.js)

const { ethers } = require("ethers");

// 项目方私钥(服务器端保管,绝不能暴露!)
const signer = new ethers.Wallet("项目方私钥");

// 为用户生成签名
async function generateSignature(userAddress, tokenId) {
    // 1. 构造消息(与合约中 getMessageHash 逻辑一致)
    const messageHash = ethers.solidityPackedKeccak256(
        ["address", "uint256"],
        [userAddress, tokenId]
    );
    // 2. 签名(ethers.js 会自动加以太坊前缀)
    const signature = await signer.signMessage(ethers.getBytes(messageHash));
    return signature;
}

八、安全注意事项

  1. 私钥保管:签名私钥必须安全存储在服务器端,泄露等于白名单失效
  2. 防重放:合约中用 mintedAddress 映射防止同一地址重复使用签名
  3. 前缀不可省略:不加 EIP-191 前缀,签名可能被恶意用作交易
  4. 签名长度校验:必须检查签名长度为 65 字节

九、总结

对比项Merkle Tree 白名单签名白名单
上链成本需要存储 Merkle Root只需存一个 signer 地址
灵活性修改名单需重新设置根随时可以签发新签名
链下准备需要构造整棵树单独为每个用户签名即可
安全性高(依赖私钥安全)